aboutsummaryrefslogtreecommitdiffstats
path: root/activerecord/test/cases
diff options
context:
space:
mode:
Diffstat (limited to 'activerecord/test/cases')
-rw-r--r--activerecord/test/cases/adapter_test.rb513
-rw-r--r--activerecord/test/cases/adapters/mysql2/active_schema_test.rb202
-rw-r--r--activerecord/test/cases/adapters/mysql2/auto_increment_test.rb34
-rw-r--r--activerecord/test/cases/adapters/mysql2/bind_parameter_test.rb52
-rw-r--r--activerecord/test/cases/adapters/mysql2/boolean_test.rb102
-rw-r--r--activerecord/test/cases/adapters/mysql2/case_sensitivity_test.rb65
-rw-r--r--activerecord/test/cases/adapters/mysql2/charset_collation_test.rb56
-rw-r--r--activerecord/test/cases/adapters/mysql2/connection_test.rb215
-rw-r--r--activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb55
-rw-r--r--activerecord/test/cases/adapters/mysql2/enum_test.rb23
-rw-r--r--activerecord/test/cases/adapters/mysql2/explain_test.rb23
-rw-r--r--activerecord/test/cases/adapters/mysql2/json_test.rb24
-rw-r--r--activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb135
-rw-r--r--activerecord/test/cases/adapters/mysql2/schema_migrations_test.rb65
-rw-r--r--activerecord/test/cases/adapters/mysql2/schema_test.rb128
-rw-r--r--activerecord/test/cases/adapters/mysql2/sp_test.rb38
-rw-r--r--activerecord/test/cases/adapters/mysql2/sql_types_test.rb16
-rw-r--r--activerecord/test/cases/adapters/mysql2/table_options_test.rb119
-rw-r--r--activerecord/test/cases/adapters/mysql2/transaction_test.rb149
-rw-r--r--activerecord/test/cases/adapters/mysql2/unsigned_type_test.rb68
-rw-r--r--activerecord/test/cases/adapters/mysql2/virtual_column_test.rb63
-rw-r--r--activerecord/test/cases/adapters/postgresql/active_schema_test.rb111
-rw-r--r--activerecord/test/cases/adapters/postgresql/array_test.rb390
-rw-r--r--activerecord/test/cases/adapters/postgresql/bit_string_test.rb84
-rw-r--r--activerecord/test/cases/adapters/postgresql/bytea_test.rb137
-rw-r--r--activerecord/test/cases/adapters/postgresql/case_insensitive_test.rb28
-rw-r--r--activerecord/test/cases/adapters/postgresql/change_schema_test.rb40
-rw-r--r--activerecord/test/cases/adapters/postgresql/cidr_test.rb27
-rw-r--r--activerecord/test/cases/adapters/postgresql/citext_test.rb78
-rw-r--r--activerecord/test/cases/adapters/postgresql/collation_test.rb55
-rw-r--r--activerecord/test/cases/adapters/postgresql/composite_test.rb134
-rw-r--r--activerecord/test/cases/adapters/postgresql/connection_test.rb258
-rw-r--r--activerecord/test/cases/adapters/postgresql/create_unlogged_tables_test.rb74
-rw-r--r--activerecord/test/cases/adapters/postgresql/datatype_test.rb93
-rw-r--r--activerecord/test/cases/adapters/postgresql/date_test.rb42
-rw-r--r--activerecord/test/cases/adapters/postgresql/domain_test.rb49
-rw-r--r--activerecord/test/cases/adapters/postgresql/enum_test.rb93
-rw-r--r--activerecord/test/cases/adapters/postgresql/explain_test.rb22
-rw-r--r--activerecord/test/cases/adapters/postgresql/extension_migration_test.rb61
-rw-r--r--activerecord/test/cases/adapters/postgresql/foreign_table_test.rb109
-rw-r--r--activerecord/test/cases/adapters/postgresql/full_text_test.rb46
-rw-r--r--activerecord/test/cases/adapters/postgresql/geometric_test.rb373
-rw-r--r--activerecord/test/cases/adapters/postgresql/hstore_test.rb378
-rw-r--r--activerecord/test/cases/adapters/postgresql/infinity_test.rb108
-rw-r--r--activerecord/test/cases/adapters/postgresql/integer_test.rb27
-rw-r--r--activerecord/test/cases/adapters/postgresql/json_test.rb52
-rw-r--r--activerecord/test/cases/adapters/postgresql/ltree_test.rb55
-rw-r--r--activerecord/test/cases/adapters/postgresql/money_test.rb101
-rw-r--r--activerecord/test/cases/adapters/postgresql/network_test.rb96
-rw-r--r--activerecord/test/cases/adapters/postgresql/numbers_test.rb51
-rw-r--r--activerecord/test/cases/adapters/postgresql/partitions_test.rb22
-rw-r--r--activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb446
-rw-r--r--activerecord/test/cases/adapters/postgresql/prepared_statements_disabled_test.rb27
-rw-r--r--activerecord/test/cases/adapters/postgresql/quoting_test.rb50
-rw-r--r--activerecord/test/cases/adapters/postgresql/range_test.rb418
-rw-r--r--activerecord/test/cases/adapters/postgresql/referential_integrity_test.rb113
-rw-r--r--activerecord/test/cases/adapters/postgresql/rename_table_test.rb36
-rw-r--r--activerecord/test/cases/adapters/postgresql/schema_authorization_test.rb110
-rw-r--r--activerecord/test/cases/adapters/postgresql/schema_test.rb660
-rw-r--r--activerecord/test/cases/adapters/postgresql/serial_test.rb156
-rw-r--r--activerecord/test/cases/adapters/postgresql/statement_pool_test.rb43
-rw-r--r--activerecord/test/cases/adapters/postgresql/timestamp_test.rb92
-rw-r--r--activerecord/test/cases/adapters/postgresql/transaction_test.rb189
-rw-r--r--activerecord/test/cases/adapters/postgresql/type_lookup_test.rb35
-rw-r--r--activerecord/test/cases/adapters/postgresql/utils_test.rb64
-rw-r--r--activerecord/test/cases/adapters/postgresql/uuid_test.rb383
-rw-r--r--activerecord/test/cases/adapters/postgresql/xml_test.rb56
-rw-r--r--activerecord/test/cases/adapters/sqlite3/bind_parameter_test.rb20
-rw-r--r--activerecord/test/cases/adapters/sqlite3/collation_test.rb55
-rw-r--r--activerecord/test/cases/adapters/sqlite3/copy_table_test.rb101
-rw-r--r--activerecord/test/cases/adapters/sqlite3/explain_test.rb23
-rw-r--r--activerecord/test/cases/adapters/sqlite3/json_test.rb29
-rw-r--r--activerecord/test/cases/adapters/sqlite3/quoting_test.rb91
-rw-r--r--activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb652
-rw-r--r--activerecord/test/cases/adapters/sqlite3/sqlite3_create_folder_test.rb24
-rw-r--r--activerecord/test/cases/adapters/sqlite3/statement_pool_test.rb21
-rw-r--r--activerecord/test/cases/aggregations_test.rb170
-rw-r--r--activerecord/test/cases/ar_schema_test.rb145
-rw-r--r--activerecord/test/cases/arel/attributes/attribute_test.rb1015
-rw-r--r--activerecord/test/cases/arel/attributes/math_test.rb83
-rw-r--r--activerecord/test/cases/arel/attributes_test.rb68
-rw-r--r--activerecord/test/cases/arel/collectors/bind_test.rb40
-rw-r--r--activerecord/test/cases/arel/collectors/composite_test.rb47
-rw-r--r--activerecord/test/cases/arel/collectors/sql_string_test.rb41
-rw-r--r--activerecord/test/cases/arel/collectors/substitute_bind_collector_test.rb48
-rw-r--r--activerecord/test/cases/arel/crud_test.rb65
-rw-r--r--activerecord/test/cases/arel/delete_manager_test.rb53
-rw-r--r--activerecord/test/cases/arel/factory_methods_test.rb46
-rw-r--r--activerecord/test/cases/arel/helper.rb45
-rw-r--r--activerecord/test/cases/arel/insert_manager_test.rb242
-rw-r--r--activerecord/test/cases/arel/nodes/and_test.rb21
-rw-r--r--activerecord/test/cases/arel/nodes/as_test.rb36
-rw-r--r--activerecord/test/cases/arel/nodes/ascending_test.rb46
-rw-r--r--activerecord/test/cases/arel/nodes/bin_test.rb35
-rw-r--r--activerecord/test/cases/arel/nodes/binary_test.rb29
-rw-r--r--activerecord/test/cases/arel/nodes/bind_param_test.rb22
-rw-r--r--activerecord/test/cases/arel/nodes/case_test.rb86
-rw-r--r--activerecord/test/cases/arel/nodes/casted_test.rb18
-rw-r--r--activerecord/test/cases/arel/nodes/count_test.rb35
-rw-r--r--activerecord/test/cases/arel/nodes/delete_statement_test.rb36
-rw-r--r--activerecord/test/cases/arel/nodes/descending_test.rb46
-rw-r--r--activerecord/test/cases/arel/nodes/distinct_test.rb21
-rw-r--r--activerecord/test/cases/arel/nodes/equality_test.rb86
-rw-r--r--activerecord/test/cases/arel/nodes/extract_test.rb43
-rw-r--r--activerecord/test/cases/arel/nodes/false_test.rb21
-rw-r--r--activerecord/test/cases/arel/nodes/grouping_test.rb26
-rw-r--r--activerecord/test/cases/arel/nodes/infix_operation_test.rb42
-rw-r--r--activerecord/test/cases/arel/nodes/insert_statement_test.rb44
-rw-r--r--activerecord/test/cases/arel/nodes/named_function_test.rb48
-rw-r--r--activerecord/test/cases/arel/nodes/node_test.rb41
-rw-r--r--activerecord/test/cases/arel/nodes/not_test.rb31
-rw-r--r--activerecord/test/cases/arel/nodes/or_test.rb36
-rw-r--r--activerecord/test/cases/arel/nodes/over_test.rb69
-rw-r--r--activerecord/test/cases/arel/nodes/select_core_test.rb71
-rw-r--r--activerecord/test/cases/arel/nodes/select_statement_test.rb51
-rw-r--r--activerecord/test/cases/arel/nodes/sql_literal_test.rb75
-rw-r--r--activerecord/test/cases/arel/nodes/sum_test.rb35
-rw-r--r--activerecord/test/cases/arel/nodes/table_alias_test.rb29
-rw-r--r--activerecord/test/cases/arel/nodes/true_test.rb21
-rw-r--r--activerecord/test/cases/arel/nodes/unary_operation_test.rb41
-rw-r--r--activerecord/test/cases/arel/nodes/update_statement_test.rb60
-rw-r--r--activerecord/test/cases/arel/nodes/window_test.rb81
-rw-r--r--activerecord/test/cases/arel/nodes_test.rb34
-rw-r--r--activerecord/test/cases/arel/select_manager_test.rb1225
-rw-r--r--activerecord/test/cases/arel/support/fake_record.rb129
-rw-r--r--activerecord/test/cases/arel/table_test.rb216
-rw-r--r--activerecord/test/cases/arel/update_manager_test.rb126
-rw-r--r--activerecord/test/cases/arel/visitors/depth_first_test.rb270
-rw-r--r--activerecord/test/cases/arel/visitors/dispatch_contamination_test.rb72
-rw-r--r--activerecord/test/cases/arel/visitors/dot_test.rb83
-rw-r--r--activerecord/test/cases/arel/visitors/ibm_db_test.rb73
-rw-r--r--activerecord/test/cases/arel/visitors/informix_test.rb98
-rw-r--r--activerecord/test/cases/arel/visitors/mssql_test.rb138
-rw-r--r--activerecord/test/cases/arel/visitors/mysql_test.rb109
-rw-r--r--activerecord/test/cases/arel/visitors/oracle12_test.rb100
-rw-r--r--activerecord/test/cases/arel/visitors/oracle_test.rb236
-rw-r--r--activerecord/test/cases/arel/visitors/postgres_test.rb320
-rw-r--r--activerecord/test/cases/arel/visitors/sqlite_test.rb75
-rw-r--r--activerecord/test/cases/arel/visitors/to_sql_test.rb713
-rw-r--r--activerecord/test/cases/associations/belongs_to_associations_test.rb1376
-rw-r--r--activerecord/test/cases/associations/bidirectional_destroy_dependencies_test.rb43
-rw-r--r--activerecord/test/cases/associations/callbacks_test.rb191
-rw-r--r--activerecord/test/cases/associations/cascaded_eager_loading_test.rb193
-rw-r--r--activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb88
-rw-r--r--activerecord/test/cases/associations/eager_load_nested_include_test.rb126
-rw-r--r--activerecord/test/cases/associations/eager_singularization_test.rb148
-rw-r--r--activerecord/test/cases/associations/eager_test.rb1625
-rw-r--r--activerecord/test/cases/associations/extension_test.rb94
-rw-r--r--activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb1024
-rw-r--r--activerecord/test/cases/associations/has_many_associations_test.rb2932
-rw-r--r--activerecord/test/cases/associations/has_many_through_associations_test.rb1481
-rw-r--r--activerecord/test/cases/associations/has_one_associations_test.rb786
-rw-r--r--activerecord/test/cases/associations/has_one_through_associations_test.rb429
-rw-r--r--activerecord/test/cases/associations/inner_join_association_test.rb159
-rw-r--r--activerecord/test/cases/associations/inverse_associations_test.rb762
-rw-r--r--activerecord/test/cases/associations/join_model_test.rb778
-rw-r--r--activerecord/test/cases/associations/left_outer_join_association_test.rb91
-rw-r--r--activerecord/test/cases/associations/nested_through_associations_test.rb622
-rw-r--r--activerecord/test/cases/associations/required_test.rb128
-rw-r--r--activerecord/test/cases/associations_test.rb370
-rw-r--r--activerecord/test/cases/attribute_decorators_test.rb127
-rw-r--r--activerecord/test/cases/attribute_methods/read_test.rb61
-rw-r--r--activerecord/test/cases/attribute_methods_test.rb1046
-rw-r--r--activerecord/test/cases/attributes_test.rb285
-rw-r--r--activerecord/test/cases/autosave_association_test.rb1824
-rw-r--r--activerecord/test/cases/base_test.rb1551
-rw-r--r--activerecord/test/cases/batches_test.rb663
-rw-r--r--activerecord/test/cases/binary_test.rb46
-rw-r--r--activerecord/test/cases/bind_parameter_test.rb118
-rw-r--r--activerecord/test/cases/boolean_test.rb43
-rw-r--r--activerecord/test/cases/cache_key_test.rb131
-rw-r--r--activerecord/test/cases/calculations_test.rb975
-rw-r--r--activerecord/test/cases/callbacks_test.rb506
-rw-r--r--activerecord/test/cases/clone_test.rb42
-rw-r--r--activerecord/test/cases/coders/json_test.rb17
-rw-r--r--activerecord/test/cases/coders/yaml_column_test.rb66
-rw-r--r--activerecord/test/cases/collection_cache_key_test.rb175
-rw-r--r--activerecord/test/cases/column_alias_test.rb19
-rw-r--r--activerecord/test/cases/column_definition_test.rb34
-rw-r--r--activerecord/test/cases/comment_test.rb168
-rw-r--r--activerecord/test/cases/connection_adapters/adapter_leasing_test.rb58
-rw-r--r--activerecord/test/cases/connection_adapters/connection_handler_test.rb388
-rw-r--r--activerecord/test/cases/connection_adapters/connection_handlers_multi_db_test.rb343
-rw-r--r--activerecord/test/cases/connection_adapters/connection_specification_test.rb14
-rw-r--r--activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb260
-rw-r--r--activerecord/test/cases/connection_adapters/mysql_type_lookup_test.rb78
-rw-r--r--activerecord/test/cases/connection_adapters/schema_cache_test.rb101
-rw-r--r--activerecord/test/cases/connection_adapters/type_lookup_test.rb120
-rw-r--r--activerecord/test/cases/connection_management_test.rb114
-rw-r--r--activerecord/test/cases/connection_pool_test.rb708
-rw-r--r--activerecord/test/cases/connection_specification/resolver_test.rb141
-rw-r--r--activerecord/test/cases/core_test.rb125
-rw-r--r--activerecord/test/cases/counter_cache_test.rb367
-rw-r--r--activerecord/test/cases/custom_locking_test.rb19
-rw-r--r--activerecord/test/cases/database_statements_test.rb37
-rw-r--r--activerecord/test/cases/date_test.rb46
-rw-r--r--activerecord/test/cases/date_time_precision_test.rb107
-rw-r--r--activerecord/test/cases/date_time_test.rb76
-rw-r--r--activerecord/test/cases/defaults_test.rb226
-rw-r--r--activerecord/test/cases/dirty_test.rb922
-rw-r--r--activerecord/test/cases/disconnected_test.rb32
-rw-r--r--activerecord/test/cases/dup_test.rb177
-rw-r--r--activerecord/test/cases/enum_test.rb563
-rw-r--r--activerecord/test/cases/errors_test.rb16
-rw-r--r--activerecord/test/cases/explain_subscriber_test.rb66
-rw-r--r--activerecord/test/cases/explain_test.rb88
-rw-r--r--activerecord/test/cases/filter_attributes_test.rb132
-rw-r--r--activerecord/test/cases/finder_respond_to_test.rb61
-rw-r--r--activerecord/test/cases/finder_test.rb1422
-rw-r--r--activerecord/test/cases/fixture_set/file_test.rb158
-rw-r--r--activerecord/test/cases/fixtures_test.rb1364
-rw-r--r--activerecord/test/cases/forbidden_attributes_protection_test.rb166
-rw-r--r--activerecord/test/cases/habtm_destroy_order_test.rb61
-rw-r--r--activerecord/test/cases/helper.rb194
-rw-r--r--activerecord/test/cases/hot_compatibility_test.rb144
-rw-r--r--activerecord/test/cases/i18n_test.rb46
-rw-r--r--activerecord/test/cases/inheritance_test.rb660
-rw-r--r--activerecord/test/cases/instrumentation_test.rb76
-rw-r--r--activerecord/test/cases/integration_test.rb258
-rw-r--r--activerecord/test/cases/invalid_connection_test.rb26
-rw-r--r--activerecord/test/cases/invertible_migration_test.rb425
-rw-r--r--activerecord/test/cases/json_attribute_test.rb35
-rw-r--r--activerecord/test/cases/json_serialization_test.rb311
-rw-r--r--activerecord/test/cases/json_shared_test_cases.rb269
-rw-r--r--activerecord/test/cases/legacy_configurations_test.rb43
-rw-r--r--activerecord/test/cases/locking_test.rb738
-rw-r--r--activerecord/test/cases/log_subscriber_test.rb251
-rw-r--r--activerecord/test/cases/migration/change_schema_test.rb478
-rw-r--r--activerecord/test/cases/migration/change_table_test.rb266
-rw-r--r--activerecord/test/cases/migration/column_attributes_test.rb188
-rw-r--r--activerecord/test/cases/migration/column_positioning_test.rb68
-rw-r--r--activerecord/test/cases/migration/columns_test.rb323
-rw-r--r--activerecord/test/cases/migration/command_recorder_test.rb375
-rw-r--r--activerecord/test/cases/migration/compatibility_test.rb383
-rw-r--r--activerecord/test/cases/migration/create_join_table_test.rb168
-rw-r--r--activerecord/test/cases/migration/foreign_key_test.rb497
-rw-r--r--activerecord/test/cases/migration/helper.rb41
-rw-r--r--activerecord/test/cases/migration/index_test.rb219
-rw-r--r--activerecord/test/cases/migration/logger_test.rb38
-rw-r--r--activerecord/test/cases/migration/pending_migrations_test.rb42
-rw-r--r--activerecord/test/cases/migration/references_foreign_key_test.rb255
-rw-r--r--activerecord/test/cases/migration/references_index_test.rb103
-rw-r--r--activerecord/test/cases/migration/references_statements_test.rb138
-rw-r--r--activerecord/test/cases/migration/rename_table_test.rb118
-rw-r--r--activerecord/test/cases/migration_test.rb1180
-rw-r--r--activerecord/test/cases/migrator_test.rb508
-rw-r--r--activerecord/test/cases/mixin_test.rb64
-rw-r--r--activerecord/test/cases/modules_test.rb174
-rw-r--r--activerecord/test/cases/multiparameter_attributes_test.rb399
-rw-r--r--activerecord/test/cases/multiple_db_test.rb117
-rw-r--r--activerecord/test/cases/nested_attributes_test.rb1120
-rw-r--r--activerecord/test/cases/nested_attributes_with_callbacks_test.rb146
-rw-r--r--activerecord/test/cases/null_relation_test.rb85
-rw-r--r--activerecord/test/cases/numeric_data_test.rb93
-rw-r--r--activerecord/test/cases/persistence_test.rb1082
-rw-r--r--activerecord/test/cases/pooled_connections_test.rb79
-rw-r--r--activerecord/test/cases/primary_keys_test.rb473
-rw-r--r--activerecord/test/cases/query_cache_test.rb634
-rw-r--r--activerecord/test/cases/quoting_test.rb297
-rw-r--r--activerecord/test/cases/readonly_test.rb120
-rw-r--r--activerecord/test/cases/reaper_test.rb93
-rw-r--r--activerecord/test/cases/reflection_test.rb518
-rw-r--r--activerecord/test/cases/relation/delegation_test.rb85
-rw-r--r--activerecord/test/cases/relation/delete_all_test.rb104
-rw-r--r--activerecord/test/cases/relation/merging_test.rb181
-rw-r--r--activerecord/test/cases/relation/mutation_test.rb150
-rw-r--r--activerecord/test/cases/relation/or_test.rb137
-rw-r--r--activerecord/test/cases/relation/predicate_builder_test.rb18
-rw-r--r--activerecord/test/cases/relation/record_fetch_warning_test.rb42
-rw-r--r--activerecord/test/cases/relation/select_test.rb15
-rw-r--r--activerecord/test/cases/relation/update_all_test.rb237
-rw-r--r--activerecord/test/cases/relation/where_chain_test.rb107
-rw-r--r--activerecord/test/cases/relation/where_clause_test.rb245
-rw-r--r--activerecord/test/cases/relation/where_test.rb366
-rw-r--r--activerecord/test/cases/relation_test.rb372
-rw-r--r--activerecord/test/cases/relations_test.rb1931
-rw-r--r--activerecord/test/cases/reload_models_test.rb26
-rw-r--r--activerecord/test/cases/reserved_word_test.rb141
-rw-r--r--activerecord/test/cases/result_test.rb105
-rw-r--r--activerecord/test/cases/sanitize_test.rb185
-rw-r--r--activerecord/test/cases/schema_dumper_test.rb521
-rw-r--r--activerecord/test/cases/schema_loading_test.rb54
-rw-r--r--activerecord/test/cases/scoping/default_scoping_test.rb575
-rw-r--r--activerecord/test/cases/scoping/named_scoping_test.rb601
-rw-r--r--activerecord/test/cases/scoping/relation_scoping_test.rb426
-rw-r--r--activerecord/test/cases/secure_token_test.rb34
-rw-r--r--activerecord/test/cases/serialization_test.rb106
-rw-r--r--activerecord/test/cases/serialized_attribute_test.rb400
-rw-r--r--activerecord/test/cases/statement_cache_test.rb134
-rw-r--r--activerecord/test/cases/statement_invalid_test.rb42
-rw-r--r--activerecord/test/cases/store_test.rb251
-rw-r--r--activerecord/test/cases/suppressor_test.rb77
-rw-r--r--activerecord/test/cases/tasks/database_tasks_test.rb1137
-rw-r--r--activerecord/test/cases/tasks/mysql_rake_test.rb409
-rw-r--r--activerecord/test/cases/tasks/postgresql_rake_test.rb539
-rw-r--r--activerecord/test/cases/tasks/sqlite_rake_test.rb264
-rw-r--r--activerecord/test/cases/test_case.rb140
-rw-r--r--activerecord/test/cases/test_fixtures_test.rb20
-rw-r--r--activerecord/test/cases/time_precision_test.rb103
-rw-r--r--activerecord/test/cases/timestamp_test.rb488
-rw-r--r--activerecord/test/cases/touch_later_test.rb123
-rw-r--r--activerecord/test/cases/transaction_callbacks_test.rb665
-rw-r--r--activerecord/test/cases/transaction_isolation_test.rb106
-rw-r--r--activerecord/test/cases/transactions_test.rb1139
-rw-r--r--activerecord/test/cases/type/adapter_specific_registry_test.rb135
-rw-r--r--activerecord/test/cases/type/date_time_test.rb16
-rw-r--r--activerecord/test/cases/type/integer_test.rb29
-rw-r--r--activerecord/test/cases/type/string_test.rb24
-rw-r--r--activerecord/test/cases/type/type_map_test.rb178
-rw-r--r--activerecord/test/cases/type/unsigned_integer_test.rb19
-rw-r--r--activerecord/test/cases/type_test.rb41
-rw-r--r--activerecord/test/cases/types_test.rb26
-rw-r--r--activerecord/test/cases/unconnected_test.rb35
-rw-r--r--activerecord/test/cases/unsafe_raw_sql_test.rb319
-rw-r--r--activerecord/test/cases/validations/absence_validation_test.rb75
-rw-r--r--activerecord/test/cases/validations/association_validation_test.rb99
-rw-r--r--activerecord/test/cases/validations/i18n_generate_message_validation_test.rb86
-rw-r--r--activerecord/test/cases/validations/i18n_validation_test.rb85
-rw-r--r--activerecord/test/cases/validations/length_validation_test.rb80
-rw-r--r--activerecord/test/cases/validations/presence_validation_test.rb105
-rw-r--r--activerecord/test/cases/validations/uniqueness_validation_test.rb557
-rw-r--r--activerecord/test/cases/validations_repair_helper.rb21
-rw-r--r--activerecord/test/cases/validations_test.rb219
-rw-r--r--activerecord/test/cases/view_test.rb222
-rw-r--r--activerecord/test/cases/yaml_serialization_test.rb141
325 files changed, 76310 insertions, 0 deletions
diff --git a/activerecord/test/cases/adapter_test.rb b/activerecord/test/cases/adapter_test.rb
new file mode 100644
index 0000000000..66e594d771
--- /dev/null
+++ b/activerecord/test/cases/adapter_test.rb
@@ -0,0 +1,513 @@
+# 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_current_database
+ if @connection.respond_to?(:current_database)
+ assert_equal ARTest.connection_config["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
+ ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations["arunit"].except(:database))
+
+ config = ARTest.connection_config
+ ActiveRecord::Base.connection.execute(
+ "SELECT #{config['arunit']['database']}.pirates.*, #{config['arunit2']['database']}.courses.* " \
+ "FROM #{config['arunit']['database']}.pirates, #{config['arunit2']['database']}.courses"
+ )
+ end
+ ensure
+ ActiveRecord::Base.establish_connection :arunit
+ end
+ end
+
+ 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_errors_when_an_insert_query_is_called_while_preventing_writes
+ assert_no_queries do
+ assert_raises(ActiveRecord::ReadOnlyError) do
+ @connection.while_preventing_writes do
+ @connection.transaction do
+ @connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')", nil, false)
+ end
+ end
+ end
+ end
+ end
+
+ def test_errors_when_an_update_query_is_called_while_preventing_writes
+ @connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')")
+
+ assert_no_queries do
+ assert_raises(ActiveRecord::ReadOnlyError) do
+ @connection.while_preventing_writes do
+ @connection.transaction do
+ @connection.update("UPDATE subscribers SET nick = '9989' WHERE nick = '138853948594'")
+ end
+ end
+ end
+ end
+ end
+
+ def test_errors_when_a_delete_query_is_called_while_preventing_writes
+ @connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')")
+
+ assert_no_queries do
+ assert_raises(ActiveRecord::ReadOnlyError) do
+ @connection.while_preventing_writes do
+ @connection.transaction do
+ @connection.delete("DELETE FROM subscribers WHERE nick = '138853948594'")
+ end
+ end
+ end
+ end
+ end
+
+ def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes
+ @connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')")
+
+ @connection.while_preventing_writes do
+ result = @connection.select_all("SELECT subscribers.* FROM subscribers WHERE nick = '138853948594'")
+ assert_equal 1, result.length
+ end
+ end
+
+ def test_uniqueness_violations_are_translated_to_specific_exception
+ @connection.execute "INSERT INTO subscribers(nick) VALUES('me')"
+ error = assert_raises(ActiveRecord::RecordNotUnique) do
+ @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_with_legacy_binds
+ post = Post.create!(title: "foo", body: "bar")
+ expected = @connection.select_all("SELECT * FROM posts WHERE id = #{post.id}")
+ result = @connection.select_all("SELECT * FROM posts WHERE id = #{Arel::Nodes::BindParam.new(nil).to_sql}", nil, [[nil, post.id]])
+ assert_equal expected.to_a, result.to_a
+ end
+
+ def test_insert_update_delete_with_legacy_binds
+ binds = [[nil, 1]]
+ bind_param = Arel::Nodes::BindParam.new(nil)
+
+ id = @connection.insert("INSERT INTO events(id) VALUES (#{bind_param.to_sql})", nil, nil, nil, nil, binds)
+ assert_equal 1, id
+
+ @connection.update("UPDATE events SET title = 'foo' WHERE id = #{bind_param.to_sql}", nil, binds)
+ result = @connection.select_all("SELECT * FROM events WHERE id = #{bind_param.to_sql}", nil, binds)
+ assert_equal({ "id" => 1, "title" => "foo" }, result.first)
+
+ @connection.delete("DELETE FROM events WHERE id = #{bind_param.to_sql}", nil, binds)
+ result = @connection.select_all("SELECT * FROM events WHERE id = #{bind_param.to_sql}", nil, binds)
+ assert_nil result.first
+ end
+
+ def test_insert_update_delete_with_binds
+ binds = [Relation::QueryAttribute.new("id", 1, Type.default_value)]
+ bind_param = Arel::Nodes::BindParam.new(nil)
+
+ id = @connection.insert("INSERT INTO events(id) VALUES (#{bind_param.to_sql})", nil, nil, nil, nil, binds)
+ assert_equal 1, id
+
+ @connection.update("UPDATE events SET title = 'foo' WHERE id = #{bind_param.to_sql}", nil, binds)
+ result = @connection.select_all("SELECT * FROM events WHERE id = #{bind_param.to_sql}", nil, binds)
+ assert_equal({ "id" => 1, "title" => "foo" }, result.first)
+
+ @connection.delete("DELETE FROM events WHERE id = #{bind_param.to_sql}", nil, binds)
+ result = @connection.select_all("SELECT * FROM events WHERE id = #{bind_param.to_sql}", nil, binds)
+ assert_nil result.first
+ end
+ end
+
+ def test_select_methods_passing_a_association_relation
+ author = Author.create!(name: "john")
+ Post.create!(author: author, title: "foo", body: "bar")
+ query = author.posts.where(title: "foo").select(:title)
+ assert_equal({ "title" => "foo" }, @connection.select_one(query))
+ 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
+
+ def test_supports_multi_insert_is_deprecated
+ assert_deprecated { @connection.supports_multi_insert? }
+ end
+
+ def test_column_name_length_is_deprecated
+ assert_deprecated { @connection.column_name_length }
+ end
+
+ def test_table_name_length_is_deprecated
+ assert_deprecated { @connection.table_name_length }
+ end
+
+ def test_columns_per_table_is_deprecated
+ assert_deprecated { @connection.columns_per_table }
+ end
+
+ def test_indexes_per_table_is_deprecated
+ assert_deprecated { @connection.indexes_per_table }
+ end
+
+ def test_columns_per_multicolumn_index_is_deprecated
+ assert_deprecated { @connection.columns_per_multicolumn_index }
+ end
+
+ def test_sql_query_length_is_deprecated
+ assert_deprecated { @connection.sql_query_length }
+ end
+
+ def test_joins_per_query_is_deprecated
+ assert_deprecated { @connection.joins_per_query }
+ end
+ end
+
+ class AdapterForeignKeyTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ fixtures :fk_test_has_pk
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ end
+
+ 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
+
+ class Klass < ActiveRecord::Base
+ end
+
+ def setup
+ Klass.establish_connection :arunit
+ @connection = Klass.connection
+ end
+
+ teardown do
+ Klass.remove_connection
+ end
+
+ unless in_memory_db?
+ test "transaction state is reset after a reconnect" do
+ @connection.begin_transaction
+ assert_predicate @connection, :transaction_open?
+ @connection.reconnect!
+ assert_not_predicate @connection, :transaction_open?
+ end
+
+ test "transaction state is reset after a disconnect" do
+ @connection.begin_transaction
+ assert_predicate @connection, :transaction_open?
+ @connection.disconnect!
+ assert_not_predicate @connection, :transaction_open?
+ end
+ end
+
+ # test resetting sequences in odd tables in PostgreSQL
+ if ActiveRecord::Base.connection.respond_to?(:reset_pk_sequence!)
+ require "models/movie"
+ require "models/subscriber"
+
+ def test_reset_empty_table_with_custom_pk
+ Movie.delete_all
+ Movie.connection.reset_pk_sequence! "movies"
+ assert_equal 1, Movie.create(name: "fight club").id
+ end
+
+ def test_reset_table_with_non_integer_pk
+ Subscriber.delete_all
+ Subscriber.connection.reset_pk_sequence! "subscribers"
+ sub = Subscriber.new(name: "robert drake")
+ sub.id = "bob drake"
+ assert_nothing_raised { sub.save! }
+ end
+ end
+ end
+end
+
+if ActiveRecord::Base.connection.supports_advisory_locks?
+ class AdvisoryLocksEnabledTest < ActiveRecord::TestCase
+ include ConnectionHelper
+
+ def test_advisory_locks_enabled?
+ assert ActiveRecord::Base.connection.advisory_locks_enabled?
+
+ run_without_connection do |orig_connection|
+ ActiveRecord::Base.establish_connection(
+ orig_connection.merge(advisory_locks: false)
+ )
+
+ assert_not ActiveRecord::Base.connection.advisory_locks_enabled?
+
+ ActiveRecord::Base.establish_connection(
+ orig_connection.merge(advisory_locks: true)
+ )
+
+ assert ActiveRecord::Base.connection.advisory_locks_enabled?
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/active_schema_test.rb b/activerecord/test/cases/adapters/mysql2/active_schema_test.rb
new file mode 100644
index 0000000000..2d71ee2f15
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/active_schema_test.rb
@@ -0,0 +1,202 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/connection_helper"
+
+class Mysql2ActiveSchemaTest < ActiveRecord::Mysql2TestCase
+ include ConnectionHelper
+
+ def setup
+ ActiveRecord::Base.connection.send(:default_row_format)
+ ActiveRecord::Base.connection.singleton_class.class_eval do
+ alias_method :execute_without_stub, :execute
+ def execute(sql, name = nil) sql end
+ end
+ end
+
+ teardown do
+ reset_connection
+ end
+
+ def test_add_index
+ # add_index calls data_source_exists? and index_name_exists? which can't work since execute is stubbed
+ def (ActiveRecord::Base.connection).data_source_exists?(*); true; end
+ def (ActiveRecord::Base.connection).index_name_exists?(*); false; end
+
+ expected = "CREATE INDEX `index_people_on_last_name` ON `people` (`last_name`) "
+ assert_equal expected, add_index(:people, :last_name, length: nil)
+
+ expected = "CREATE INDEX `index_people_on_last_name` ON `people` (`last_name`(10)) "
+ assert_equal expected, add_index(:people, :last_name, length: 10)
+
+ expected = "CREATE INDEX `index_people_on_last_name_and_first_name` ON `people` (`last_name`(15), `first_name`(15)) "
+ assert_equal expected, add_index(:people, [:last_name, :first_name], length: 15)
+ assert_equal expected, add_index(:people, ["last_name", "first_name"], length: 15)
+
+ expected = "CREATE INDEX `index_people_on_last_name_and_first_name` ON `people` (`last_name`(15), `first_name`) "
+ assert_equal expected, add_index(:people, [:last_name, :first_name], length: { last_name: 15 })
+ assert_equal expected, add_index(:people, ["last_name", "first_name"], length: { last_name: 15 })
+
+ expected = "CREATE INDEX `index_people_on_last_name_and_first_name` ON `people` (`last_name`(15), `first_name`(10)) "
+ assert_equal expected, add_index(:people, [:last_name, :first_name], length: { last_name: 15, first_name: 10 })
+ assert_equal expected, add_index(:people, ["last_name", :first_name], length: { last_name: 15, "first_name" => 10 })
+
+ %w(SPATIAL FULLTEXT UNIQUE).each do |type|
+ expected = "CREATE #{type} INDEX `index_people_on_last_name` ON `people` (`last_name`) "
+ assert_equal expected, add_index(:people, :last_name, type: type)
+ end
+
+ %w(btree hash).each do |using|
+ expected = "CREATE INDEX `index_people_on_last_name` USING #{using} ON `people` (`last_name`) "
+ assert_equal expected, add_index(:people, :last_name, using: using)
+ end
+
+ expected = "CREATE INDEX `index_people_on_last_name` USING btree ON `people` (`last_name`(10)) "
+ assert_equal expected, add_index(:people, :last_name, length: 10, using: :btree)
+
+ expected = "CREATE INDEX `index_people_on_last_name` USING btree ON `people` (`last_name`(10)) ALGORITHM = COPY"
+ assert_equal expected, add_index(:people, :last_name, length: 10, using: :btree, algorithm: :copy)
+
+ assert_raise ArgumentError do
+ add_index(:people, :last_name, algorithm: :coyp)
+ end
+
+ expected = "CREATE INDEX `index_people_on_last_name_and_first_name` USING btree ON `people` (`last_name`(15), `first_name`(15)) "
+ assert_equal expected, add_index(:people, [:last_name, :first_name], length: 15, using: :btree)
+ end
+
+ def test_index_in_create
+ def (ActiveRecord::Base.connection).data_source_exists?(*); false; end
+
+ %w(SPATIAL FULLTEXT UNIQUE).each do |type|
+ expected = /\ACREATE TABLE `people` \(#{type} INDEX `index_people_on_last_name` \(`last_name`\)\)/
+ actual = ActiveRecord::Base.connection.create_table(:people, id: false) do |t|
+ t.index :last_name, type: type
+ end
+ assert_match expected, actual
+ end
+
+ expected = /\ACREATE TABLE `people` \( INDEX `index_people_on_last_name` USING btree \(`last_name`\(10\)\)\)/
+ actual = ActiveRecord::Base.connection.create_table(:people, id: false) do |t|
+ t.index :last_name, length: 10, using: :btree
+ end
+ assert_match expected, actual
+ end
+
+ def test_index_in_bulk_change
+ def (ActiveRecord::Base.connection).data_source_exists?(*); true; end
+ def (ActiveRecord::Base.connection).index_name_exists?(*); false; end
+
+ %w(SPATIAL FULLTEXT UNIQUE).each do |type|
+ expected = "ALTER TABLE `people` ADD #{type} INDEX `index_people_on_last_name` (`last_name`)"
+ actual = ActiveRecord::Base.connection.change_table(:people, bulk: true) do |t|
+ t.index :last_name, type: type
+ end
+ assert_equal expected, actual
+ end
+
+ expected = "ALTER TABLE `people` ADD INDEX `index_people_on_last_name` USING btree (`last_name`(10)), ALGORITHM = COPY"
+ actual = ActiveRecord::Base.connection.change_table(:people, bulk: true) do |t|
+ t.index :last_name, length: 10, using: :btree, algorithm: :copy
+ end
+ assert_equal expected, actual
+ end
+
+ def test_drop_table
+ assert_equal "DROP TABLE `people`", drop_table(:people)
+ end
+
+ def test_create_mysql_database_with_encoding
+ if row_format_dynamic_by_default?
+ assert_equal "CREATE DATABASE `matt` DEFAULT CHARACTER SET `utf8mb4`", create_database(:matt)
+ else
+ error = assert_raises(RuntimeError) { create_database(:matt) }
+ expected = "Configure a supported :charset and ensure innodb_large_prefix is enabled to support indexes on varchar(255) string columns."
+ assert_equal expected, error.message
+ end
+ assert_equal "CREATE DATABASE `aimonetti` DEFAULT CHARACTER SET `latin1`", create_database(:aimonetti, charset: "latin1")
+ assert_equal "CREATE DATABASE `matt_aimonetti` DEFAULT COLLATE `utf8mb4_bin`", create_database(:matt_aimonetti, collation: "utf8mb4_bin")
+ end
+
+ def test_recreate_mysql_database_with_encoding
+ create_database(:luca, charset: "latin1")
+ assert_equal "CREATE DATABASE `luca` DEFAULT CHARACTER SET `latin1`", recreate_database(:luca, charset: "latin1")
+ end
+
+ def test_add_column
+ assert_equal "ALTER TABLE `people` ADD `last_name` varchar(255)", add_column(:people, :last_name, :string)
+ end
+
+ def test_add_column_with_limit
+ assert_equal "ALTER TABLE `people` ADD `key` varchar(32)", add_column(:people, :key, :string, limit: 32)
+ end
+
+ def test_drop_table_with_specific_database
+ assert_equal "DROP TABLE `otherdb`.`people`", drop_table("otherdb.people")
+ end
+
+ def test_add_timestamps
+ with_real_execute do
+ ActiveRecord::Base.connection.create_table :delete_me
+ ActiveRecord::Base.connection.add_timestamps :delete_me, null: true
+ assert column_present?("delete_me", "updated_at", "datetime")
+ assert column_present?("delete_me", "created_at", "datetime")
+ ensure
+ ActiveRecord::Base.connection.drop_table :delete_me rescue nil
+ end
+ end
+
+ def test_remove_timestamps
+ with_real_execute do
+ ActiveRecord::Base.connection.create_table :delete_me do |t|
+ t.timestamps null: true
+ end
+ ActiveRecord::Base.connection.remove_timestamps :delete_me, null: true
+ assert_not column_present?("delete_me", "updated_at", "datetime")
+ assert_not column_present?("delete_me", "created_at", "datetime")
+ ensure
+ ActiveRecord::Base.connection.drop_table :delete_me rescue nil
+ end
+ end
+
+ def test_indexes_in_create
+ assert_called_with(
+ ActiveRecord::Base.connection,
+ :data_source_exists?,
+ [:temp],
+ returns: false
+ ) do
+ expected = /\ACREATE TEMPORARY TABLE `temp` \( INDEX `index_temp_on_zip` \(`zip`\)\)(?: ROW_FORMAT=DYNAMIC)? AS SELECT id, name, zip FROM a_really_complicated_query/
+ actual = ActiveRecord::Base.connection.create_table(:temp, temporary: true, as: "SELECT id, name, zip FROM a_really_complicated_query") do |t|
+ t.index :zip
+ end
+
+ assert_match expected, actual
+ end
+ end
+
+ private
+ def with_real_execute
+ ActiveRecord::Base.connection.singleton_class.class_eval do
+ alias_method :execute_with_stub, :execute
+ remove_method :execute
+ alias_method :execute, :execute_without_stub
+ end
+
+ yield
+ ensure
+ ActiveRecord::Base.connection.singleton_class.class_eval do
+ remove_method :execute
+ alias_method :execute, :execute_with_stub
+ end
+ end
+
+ def method_missing(method_symbol, *arguments)
+ ActiveRecord::Base.connection.send(method_symbol, *arguments)
+ end
+
+ def column_present?(table_name, column_name, type)
+ results = ActiveRecord::Base.connection.select_all("SHOW FIELDS FROM #{table_name} LIKE '#{column_name}'")
+ results.first && results.first["Type"] == type
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/auto_increment_test.rb b/activerecord/test/cases/adapters/mysql2/auto_increment_test.rb
new file mode 100644
index 0000000000..4c67633946
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/auto_increment_test.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+class Mysql2AutoIncrementTest < ActiveRecord::Mysql2TestCase
+ include SchemaDumpingHelper
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ end
+
+ def teardown
+ @connection.drop_table :auto_increments, if_exists: true
+ end
+
+ def test_auto_increment_without_primary_key
+ @connection.create_table :auto_increments, id: false, force: true do |t|
+ t.integer :id, null: false, auto_increment: true
+ t.index :id
+ end
+ output = dump_table_schema("auto_increments")
+ assert_match(/t\.integer\s+"id",\s+null: false,\s+auto_increment: true$/, output)
+ end
+
+ def test_auto_increment_with_composite_primary_key
+ @connection.create_table :auto_increments, primary_key: [:id, :created_at], force: true do |t|
+ t.integer :id, null: false, auto_increment: true
+ t.datetime :created_at, null: false
+ end
+ output = dump_table_schema("auto_increments")
+ assert_match(/t\.integer\s+"id",\s+null: false,\s+auto_increment: true$/, output)
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/bind_parameter_test.rb b/activerecord/test/cases/adapters/mysql2/bind_parameter_test.rb
new file mode 100644
index 0000000000..825bddfb73
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/bind_parameter_test.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class Mysql2Adapter
+ class BindParameterTest < ActiveRecord::Mysql2TestCase
+ fixtures :topics
+
+ def test_update_question_marks
+ str = "foo?bar"
+ x = Topic.first
+ x.title = str
+ x.content = str
+ x.save!
+ x.reload
+ assert_equal str, x.title
+ assert_equal str, x.content
+ end
+
+ def test_create_question_marks
+ str = "foo?bar"
+ x = Topic.create!(title: str, content: str)
+ x.reload
+ assert_equal str, x.title
+ assert_equal str, x.content
+ end
+
+ def test_update_null_bytes
+ str = "foo\0bar"
+ x = Topic.first
+ x.title = str
+ x.content = str
+ x.save!
+ x.reload
+ assert_equal str, x.title
+ assert_equal str, x.content
+ end
+
+ def test_create_null_bytes
+ str = "foo\0bar"
+ x = Topic.create!(title: str, content: str)
+ x.reload
+ assert_equal str, x.title
+ assert_equal str, x.content
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/boolean_test.rb b/activerecord/test/cases/adapters/mysql2/boolean_test.rb
new file mode 100644
index 0000000000..db09b30361
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/boolean_test.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class Mysql2BooleanTest < ActiveRecord::Mysql2TestCase
+ self.use_transactional_tests = false
+
+ class BooleanType < ActiveRecord::Base
+ self.table_name = "mysql_booleans"
+ end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.clear_cache!
+ @connection.create_table("mysql_booleans") do |t|
+ t.boolean "archived"
+ t.string "published", limit: 1
+ end
+ BooleanType.reset_column_information
+
+ @emulate_booleans = ActiveRecord::ConnectionAdapters::Mysql2Adapter.emulate_booleans
+ end
+
+ teardown do
+ emulate_booleans @emulate_booleans
+ @connection.drop_table "mysql_booleans"
+ end
+
+ test "column type with emulated booleans" do
+ emulate_booleans true
+
+ assert_equal :boolean, boolean_column.type
+ assert_equal :string, string_column.type
+ end
+
+ test "column type without emulated booleans" do
+ emulate_booleans false
+
+ assert_equal :integer, boolean_column.type
+ assert_equal :string, string_column.type
+ end
+
+ test "type casting with emulated booleans" do
+ emulate_booleans true
+
+ boolean = BooleanType.create!(archived: true, published: true)
+ attributes = boolean.reload.attributes_before_type_cast
+ assert_equal 1, attributes["archived"]
+ assert_equal "1", attributes["published"]
+
+ boolean = BooleanType.create!(archived: false, published: false)
+ attributes = boolean.reload.attributes_before_type_cast
+ assert_equal 0, attributes["archived"]
+ assert_equal "0", attributes["published"]
+
+ assert_equal 1, @connection.type_cast(true)
+ assert_equal 0, @connection.type_cast(false)
+ end
+
+ test "type casting without emulated booleans" do
+ emulate_booleans false
+
+ boolean = BooleanType.create!(archived: true, published: true)
+ attributes = boolean.reload.attributes_before_type_cast
+ assert_equal 1, attributes["archived"]
+ assert_equal "1", attributes["published"]
+
+ boolean = BooleanType.create!(archived: false, published: false)
+ attributes = boolean.reload.attributes_before_type_cast
+ assert_equal 0, attributes["archived"]
+ assert_equal "0", attributes["published"]
+
+ assert_equal 1, @connection.type_cast(true)
+ assert_equal 0, @connection.type_cast(false)
+ end
+
+ test "with booleans stored as 1 and 0" do
+ @connection.execute "INSERT INTO mysql_booleans(archived, published) VALUES(1, '1')"
+ boolean = BooleanType.first
+ assert_equal true, boolean.archived
+ assert_equal "1", boolean.published
+ end
+
+ test "with booleans stored as t" do
+ @connection.execute "INSERT INTO mysql_booleans(published) VALUES('t')"
+ boolean = BooleanType.first
+ assert_equal "t", boolean.published
+ end
+
+ def boolean_column
+ BooleanType.columns.find { |c| c.name == "archived" }
+ end
+
+ def string_column
+ BooleanType.columns.find { |c| c.name == "published" }
+ end
+
+ def emulate_booleans(value)
+ ActiveRecord::ConnectionAdapters::Mysql2Adapter.emulate_booleans = value
+ BooleanType.reset_column_information
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/case_sensitivity_test.rb b/activerecord/test/cases/adapters/mysql2/case_sensitivity_test.rb
new file mode 100644
index 0000000000..c32475c683
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/case_sensitivity_test.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class Mysql2CaseSensitivityTest < ActiveRecord::Mysql2TestCase
+ class CollationTest < ActiveRecord::Base
+ end
+
+ repair_validations(CollationTest)
+
+ def test_columns_include_collation_different_from_table
+ assert_equal "utf8mb4_bin", CollationTest.columns_hash["string_cs_column"].collation
+ assert_equal "utf8mb4_general_ci", CollationTest.columns_hash["string_ci_column"].collation
+ end
+
+ def test_case_sensitive
+ assert_not_predicate CollationTest.columns_hash["string_ci_column"], :case_sensitive?
+ assert_predicate CollationTest.columns_hash["string_cs_column"], :case_sensitive?
+ end
+
+ def test_case_insensitive_comparison_for_ci_column
+ CollationTest.validates_uniqueness_of(:string_ci_column, case_sensitive: false)
+ CollationTest.create!(string_ci_column: "A")
+ invalid = CollationTest.new(string_ci_column: "a")
+ queries = assert_sql { invalid.save }
+ ci_uniqueness_query = queries.detect { |q| q.match(/string_ci_column/) }
+ assert_no_match(/lower/i, ci_uniqueness_query)
+ end
+
+ def test_case_insensitive_comparison_for_cs_column
+ CollationTest.validates_uniqueness_of(:string_cs_column, case_sensitive: false)
+ CollationTest.create!(string_cs_column: "A")
+ invalid = CollationTest.new(string_cs_column: "a")
+ queries = assert_sql { invalid.save }
+ cs_uniqueness_query = queries.detect { |q| q.match(/string_cs_column/) }
+ assert_match(/lower/i, cs_uniqueness_query)
+ end
+
+ def test_case_sensitive_comparison_for_ci_column
+ CollationTest.validates_uniqueness_of(:string_ci_column, case_sensitive: true)
+ CollationTest.create!(string_ci_column: "A")
+ invalid = CollationTest.new(string_ci_column: "A")
+ queries = assert_sql { invalid.save }
+ ci_uniqueness_query = queries.detect { |q| q.match(/string_ci_column/) }
+ assert_match(/binary/i, ci_uniqueness_query)
+ end
+
+ def test_case_sensitive_comparison_for_cs_column
+ CollationTest.validates_uniqueness_of(:string_cs_column, case_sensitive: true)
+ CollationTest.create!(string_cs_column: "A")
+ invalid = CollationTest.new(string_cs_column: "A")
+ queries = assert_sql { invalid.save }
+ cs_uniqueness_query = queries.detect { |q| q.match(/string_cs_column/) }
+ assert_no_match(/binary/i, cs_uniqueness_query)
+ end
+
+ def test_case_sensitive_comparison_for_binary_column
+ CollationTest.validates_uniqueness_of(:binary_column, case_sensitive: true)
+ CollationTest.create!(binary_column: "A")
+ invalid = CollationTest.new(binary_column: "A")
+ queries = assert_sql { invalid.save }
+ bin_uniqueness_query = queries.detect { |q| q.match(/binary_column/) }
+ assert_no_match(/\bBINARY\b/, bin_uniqueness_query)
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/charset_collation_test.rb b/activerecord/test/cases/adapters/mysql2/charset_collation_test.rb
new file mode 100644
index 0000000000..0bdbefdfb9
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/charset_collation_test.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+class Mysql2CharsetCollationTest < ActiveRecord::Mysql2TestCase
+ include SchemaDumpingHelper
+ self.use_transactional_tests = false
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table :charset_collations, force: true do |t|
+ t.string :string_ascii_bin, charset: "ascii", collation: "ascii_bin"
+ t.text :text_ucs2_unicode_ci, charset: "ucs2", collation: "ucs2_unicode_ci"
+ end
+ end
+
+ teardown do
+ @connection.drop_table :charset_collations, if_exists: true
+ end
+
+ test "string column with charset and collation" do
+ column = @connection.columns(:charset_collations).find { |c| c.name == "string_ascii_bin" }
+ assert_equal :string, column.type
+ assert_equal "ascii_bin", column.collation
+ end
+
+ test "text column with charset and collation" do
+ column = @connection.columns(:charset_collations).find { |c| c.name == "text_ucs2_unicode_ci" }
+ assert_equal :text, column.type
+ assert_equal "ucs2_unicode_ci", column.collation
+ end
+
+ test "add column with charset and collation" do
+ @connection.add_column :charset_collations, :title, :string, charset: "utf8mb4", collation: "utf8mb4_bin"
+
+ column = @connection.columns(:charset_collations).find { |c| c.name == "title" }
+ assert_equal :string, column.type
+ assert_equal "utf8mb4_bin", column.collation
+ end
+
+ test "change column with charset and collation" do
+ @connection.add_column :charset_collations, :description, :string, charset: "utf8mb4", collation: "utf8mb4_unicode_ci"
+ @connection.change_column :charset_collations, :description, :text, charset: "utf8mb4", collation: "utf8mb4_general_ci"
+
+ column = @connection.columns(:charset_collations).find { |c| c.name == "description" }
+ assert_equal :text, column.type
+ assert_equal "utf8mb4_general_ci", column.collation
+ end
+
+ test "schema dump includes collation" do
+ output = dump_table_schema("charset_collations")
+ assert_match %r{t\.string\s+"string_ascii_bin",\s+collation: "ascii_bin"$}, output
+ assert_match %r{t\.text\s+"text_ucs2_unicode_ci",\s+collation: "ucs2_unicode_ci"$}, output
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/connection_test.rb b/activerecord/test/cases/adapters/mysql2/connection_test.rb
new file mode 100644
index 0000000000..3103589186
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/connection_test.rb
@@ -0,0 +1,215 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/connection_helper"
+
+class Mysql2ConnectionTest < ActiveRecord::Mysql2TestCase
+ include ConnectionHelper
+
+ fixtures :comments
+
+ def setup
+ super
+ @subscriber = SQLSubscriber.new
+ @subscription = ActiveSupport::Notifications.subscribe("sql.active_record", @subscriber)
+ @connection = ActiveRecord::Base.connection
+ end
+
+ def teardown
+ ActiveSupport::Notifications.unsubscribe(@subscription)
+ super
+ end
+
+ def test_bad_connection
+ assert_raise ActiveRecord::NoDatabaseError do
+ configuration = ActiveRecord::Base.configurations["arunit"].merge(database: "inexistent_activerecord_unittest")
+ connection = ActiveRecord::Base.mysql2_connection(configuration)
+ connection.drop_table "ex", if_exists: true
+ end
+ end
+
+ def test_truncate
+ rows = ActiveRecord::Base.connection.exec_query("select count(*) from comments")
+ count = rows.first.values.first
+ assert_operator count, :>, 0
+
+ ActiveRecord::Base.connection.truncate("comments")
+ rows = ActiveRecord::Base.connection.exec_query("select count(*) from comments")
+ count = rows.first.values.first
+ assert_equal 0, count
+ end
+
+ def test_no_automatic_reconnection_after_timeout
+ assert_predicate @connection, :active?
+ @connection.update("set @@wait_timeout=1")
+ sleep 2
+ assert_not_predicate @connection, :active?
+ ensure
+ # Repair all fixture connections so other tests won't break.
+ @fixture_connections.each(&:verify!)
+ end
+
+ def test_successful_reconnection_after_timeout_with_manual_reconnect
+ assert_predicate @connection, :active?
+ @connection.update("set @@wait_timeout=1")
+ sleep 2
+ @connection.reconnect!
+ assert_predicate @connection, :active?
+ end
+
+ def test_successful_reconnection_after_timeout_with_verify
+ assert_predicate @connection, :active?
+ @connection.update("set @@wait_timeout=1")
+ sleep 2
+ @connection.verify!
+ assert_predicate @connection, :active?
+ end
+
+ def test_execute_after_disconnect
+ @connection.disconnect!
+
+ error = assert_raise(ActiveRecord::StatementInvalid) do
+ @connection.execute("SELECT 1")
+ end
+ assert_kind_of Mysql2::Error, error.cause
+ end
+
+ def test_quote_after_disconnect
+ @connection.disconnect!
+
+ assert_raise(Mysql2::Error) do
+ @connection.quote("string")
+ end
+ end
+
+ def test_active_after_disconnect
+ @connection.disconnect!
+ assert_equal false, @connection.active?
+ end
+
+ def test_wait_timeout_as_string
+ run_without_connection do |orig_connection|
+ ActiveRecord::Base.establish_connection(orig_connection.merge(wait_timeout: "60"))
+ result = ActiveRecord::Base.connection.select_value("SELECT @@SESSION.wait_timeout")
+ assert_equal 60, result
+ end
+ end
+
+ def test_wait_timeout_as_url
+ run_without_connection do |orig_connection|
+ ActiveRecord::Base.establish_connection(orig_connection.merge("url" => "mysql2:///?wait_timeout=60"))
+ result = ActiveRecord::Base.connection.select_value("SELECT @@SESSION.wait_timeout")
+ assert_equal 60, result
+ end
+ end
+
+ def test_mysql_connection_collation_is_configured
+ assert_equal "utf8mb4_unicode_ci", @connection.show_variable("collation_connection")
+ assert_equal "utf8mb4_general_ci", ARUnit2Model.connection.show_variable("collation_connection")
+ end
+
+ def test_mysql_default_in_strict_mode
+ result = @connection.select_value("SELECT @@SESSION.sql_mode")
+ assert_match %r(STRICT_ALL_TABLES), result
+ end
+
+ def test_mysql_strict_mode_disabled
+ run_without_connection do |orig_connection|
+ ActiveRecord::Base.establish_connection(orig_connection.merge(strict: false))
+ result = ActiveRecord::Base.connection.select_value("SELECT @@SESSION.sql_mode")
+ assert_no_match %r(STRICT_ALL_TABLES), result
+ end
+ end
+
+ def test_mysql_strict_mode_specified_default
+ run_without_connection do |orig_connection|
+ ActiveRecord::Base.establish_connection(orig_connection.merge(strict: :default))
+ global_sql_mode = ActiveRecord::Base.connection.select_value("SELECT @@GLOBAL.sql_mode")
+ session_sql_mode = ActiveRecord::Base.connection.select_value("SELECT @@SESSION.sql_mode")
+ assert_equal global_sql_mode, session_sql_mode
+ end
+ end
+
+ def test_mysql_sql_mode_variable_overrides_strict_mode
+ run_without_connection do |orig_connection|
+ ActiveRecord::Base.establish_connection(orig_connection.deep_merge(variables: { "sql_mode" => "ansi" }))
+ result = ActiveRecord::Base.connection.select_value("SELECT @@SESSION.sql_mode")
+ assert_no_match %r(STRICT_ALL_TABLES), result
+ end
+ end
+
+ def test_passing_arbitrary_flags_to_adapter
+ run_without_connection do |orig_connection|
+ ActiveRecord::Base.establish_connection(orig_connection.merge(flags: Mysql2::Client::COMPRESS))
+ assert_equal (Mysql2::Client::COMPRESS | Mysql2::Client::FOUND_ROWS), ActiveRecord::Base.connection.raw_connection.query_options[:flags]
+ end
+ end
+
+ def test_passing_flags_by_array_to_adapter
+ run_without_connection do |orig_connection|
+ ActiveRecord::Base.establish_connection(orig_connection.merge(flags: ["COMPRESS"]))
+ assert_equal ["COMPRESS", "FOUND_ROWS"], ActiveRecord::Base.connection.raw_connection.query_options[:flags]
+ end
+ end
+
+ def test_mysql_set_session_variable
+ run_without_connection do |orig_connection|
+ ActiveRecord::Base.establish_connection(orig_connection.deep_merge(variables: { default_week_format: 3 }))
+ session_mode = ActiveRecord::Base.connection.exec_query "SELECT @@SESSION.DEFAULT_WEEK_FORMAT"
+ assert_equal 3, session_mode.rows.first.first.to_i
+ end
+ end
+
+ def test_mysql_set_session_variable_to_default
+ run_without_connection do |orig_connection|
+ ActiveRecord::Base.establish_connection(orig_connection.deep_merge(variables: { default_week_format: :default }))
+ global_mode = ActiveRecord::Base.connection.exec_query "SELECT @@GLOBAL.DEFAULT_WEEK_FORMAT"
+ session_mode = ActiveRecord::Base.connection.exec_query "SELECT @@SESSION.DEFAULT_WEEK_FORMAT"
+ assert_equal global_mode.rows, session_mode.rows
+ end
+ end
+
+ def test_logs_name_show_variable
+ ActiveRecord::Base.connection.materialize_transactions
+ @subscriber.logged.clear
+ @connection.show_variable "foo"
+ assert_equal "SCHEMA", @subscriber.logged[0][1]
+ end
+
+ def test_logs_name_rename_column_for_alter
+ @connection.execute "CREATE TABLE `bar_baz` (`foo` varchar(255))"
+ @subscriber.logged.clear
+ @connection.send(:rename_column_for_alter, "bar_baz", "foo", "foo2")
+ assert_equal "SCHEMA", @subscriber.logged[0][1]
+ ensure
+ @connection.execute "DROP TABLE `bar_baz`"
+ end
+
+ def test_get_and_release_advisory_lock
+ lock_name = "test lock'n'name"
+
+ got_lock = @connection.get_advisory_lock(lock_name)
+ assert got_lock, "get_advisory_lock should have returned true but it didn't"
+
+ assert_equal test_lock_free(lock_name), false,
+ "expected the test advisory lock to be held but it wasn't"
+
+ released_lock = @connection.release_advisory_lock(lock_name)
+ assert released_lock, "expected release_advisory_lock to return true but it didn't"
+
+ assert test_lock_free(lock_name), "expected the test lock to be available after releasing"
+ end
+
+ def test_release_non_existent_advisory_lock
+ lock_name = "fake lock'n'name"
+ released_non_existent_lock = @connection.release_advisory_lock(lock_name)
+ assert_equal released_non_existent_lock, false,
+ "expected release_advisory_lock to return false when there was no lock to release"
+ end
+
+ private
+
+ def test_lock_free(lock_name)
+ @connection.select_value("SELECT IS_FREE_LOCK(#{@connection.quote(lock_name)})") == 1
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb b/activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb
new file mode 100644
index 0000000000..00a075e063
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class Mysql2DatetimePrecisionQuotingTest < ActiveRecord::Mysql2TestCase
+ setup do
+ @connection = ActiveRecord::Base.connection
+ end
+
+ test "microsecond precision for MySQL gte 5.6.4" do
+ stub_version "5.6.4" do
+ assert_microsecond_precision
+ end
+ end
+
+ test "no microsecond precision for MySQL lt 5.6.4" do
+ stub_version "5.6.3" do
+ assert_no_microsecond_precision
+ end
+ end
+
+ test "microsecond precision for MariaDB gte 5.3.0" do
+ stub_version "5.5.5-10.1.8-MariaDB-log" do
+ assert_microsecond_precision
+ end
+ end
+
+ test "no microsecond precision for MariaDB lt 5.3.0" do
+ stub_version "5.2.9-MariaDB" do
+ assert_no_microsecond_precision
+ end
+ end
+
+ private
+ def assert_microsecond_precision
+ assert_match_quoted_microsecond_datetime(/\.000001\z/)
+ end
+
+ def assert_no_microsecond_precision
+ assert_match_quoted_microsecond_datetime(/\d\z/)
+ end
+
+ def assert_match_quoted_microsecond_datetime(match)
+ assert_match match, @connection.quoted_date(Time.now.change(usec: 1))
+ end
+
+ def stub_version(full_version_string)
+ @connection.stub(:full_version, full_version_string) do
+ @connection.remove_instance_variable(:@version) if @connection.instance_variable_defined?(:@version)
+ yield
+ end
+ ensure
+ @connection.remove_instance_variable(:@version) if @connection.instance_variable_defined?(:@version)
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/enum_test.rb b/activerecord/test/cases/adapters/mysql2/enum_test.rb
new file mode 100644
index 0000000000..832f5d61d1
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/enum_test.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class Mysql2EnumTest < ActiveRecord::Mysql2TestCase
+ class EnumTest < ActiveRecord::Base
+ end
+
+ def test_enum_limit
+ column = EnumTest.columns_hash["enum_column"]
+ assert_equal 8, column.limit
+ end
+
+ def test_should_not_be_unsigned
+ column = EnumTest.columns_hash["enum_column"]
+ assert_not_predicate column, :unsigned?
+ end
+
+ def test_should_not_be_bigint
+ column = EnumTest.columns_hash["enum_column"]
+ assert_not_predicate column, :bigint?
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/explain_test.rb b/activerecord/test/cases/adapters/mysql2/explain_test.rb
new file mode 100644
index 0000000000..b8e778f0b0
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/explain_test.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/author"
+require "models/post"
+
+class Mysql2ExplainTest < ActiveRecord::Mysql2TestCase
+ fixtures :authors
+
+ def test_explain_for_one_query
+ explain = Author.where(id: 1).explain
+ assert_match %(EXPLAIN for: SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 1), explain
+ assert_match %r(authors |.* const), explain
+ end
+
+ def test_explain_with_eager_loading
+ explain = Author.where(id: 1).includes(:posts).explain
+ assert_match %(EXPLAIN for: SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 1), explain
+ assert_match %r(authors |.* const), explain
+ assert_match %(EXPLAIN for: SELECT `posts`.* FROM `posts` WHERE `posts`.`author_id` = 1), explain
+ assert_match %r(posts |.* ALL), explain
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/json_test.rb b/activerecord/test/cases/adapters/mysql2/json_test.rb
new file mode 100644
index 0000000000..de78ba91f5
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/json_test.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "cases/json_shared_test_cases"
+
+if ActiveRecord::Base.connection.supports_json?
+ class Mysql2JSONTest < ActiveRecord::Mysql2TestCase
+ include JSONSharedTestCases
+ self.use_transactional_tests = false
+
+ def setup
+ super
+ @connection.create_table("json_data_type") do |t|
+ t.json "payload"
+ t.json "settings"
+ end
+ end
+
+ private
+ def column_type
+ :json
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb b/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb
new file mode 100644
index 0000000000..7bc86476b6
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb
@@ -0,0 +1,135 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/ddl_helper"
+
+class Mysql2AdapterTest < ActiveRecord::Mysql2TestCase
+ include DdlHelper
+
+ def setup
+ @conn = ActiveRecord::Base.connection
+ end
+
+ def test_exec_query_nothing_raises_with_no_result_queries
+ assert_nothing_raised do
+ with_example_table do
+ @conn.exec_query("INSERT INTO ex (number) VALUES (1)")
+ @conn.exec_query("DELETE FROM ex WHERE number = 1")
+ end
+ end
+ end
+
+ def test_columns_for_distinct_zero_orders
+ assert_equal "posts.id",
+ @conn.columns_for_distinct("posts.id", [])
+ end
+
+ def test_columns_for_distinct_one_order
+ assert_equal "posts.created_at AS alias_0, posts.id",
+ @conn.columns_for_distinct("posts.id", ["posts.created_at desc"])
+ end
+
+ def test_columns_for_distinct_few_orders
+ assert_equal "posts.created_at AS alias_0, posts.position AS alias_1, posts.id",
+ @conn.columns_for_distinct("posts.id", ["posts.created_at desc", "posts.position asc"])
+ end
+
+ def test_columns_for_distinct_with_case
+ assert_equal(
+ "CASE WHEN author.is_active THEN UPPER(author.name) ELSE UPPER(author.email) END AS alias_0, posts.id",
+ @conn.columns_for_distinct("posts.id",
+ ["CASE WHEN author.is_active THEN UPPER(author.name) ELSE UPPER(author.email) END"])
+ )
+ end
+
+ def test_columns_for_distinct_blank_not_nil_orders
+ assert_equal "posts.created_at AS alias_0, posts.id",
+ @conn.columns_for_distinct("posts.id", ["posts.created_at desc", "", " "])
+ end
+
+ def test_columns_for_distinct_with_arel_order
+ order = Object.new
+ def order.to_sql
+ "posts.created_at desc"
+ end
+ assert_equal "posts.created_at AS alias_0, posts.id",
+ @conn.columns_for_distinct("posts.id", [order])
+ end
+
+ def test_errors_for_bigint_fks_on_integer_pk_table
+ # table old_cars has primary key of integer
+
+ error = assert_raises(ActiveRecord::MismatchedForeignKey) do
+ @conn.add_reference :engines, :old_car
+ @conn.add_foreign_key :engines, :old_cars
+ end
+
+ assert_match "Column `old_car_id` on table `engines` has a type of `bigint(20)`", error.message
+ assert_not_nil error.cause
+ @conn.exec_query("ALTER TABLE engines DROP COLUMN old_car_id")
+ end
+
+ def test_errors_when_an_insert_query_is_called_while_preventing_writes
+ assert_raises(ActiveRecord::ReadOnlyError) do
+ @conn.while_preventing_writes do
+ @conn.insert("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')")
+ end
+ end
+ end
+
+ def test_errors_when_an_update_query_is_called_while_preventing_writes
+ @conn.insert("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')")
+
+ assert_raises(ActiveRecord::ReadOnlyError) do
+ @conn.while_preventing_writes do
+ @conn.update("UPDATE `engines` SET `engines`.`car_id` = '9989' WHERE `engines`.`car_id` = '138853948594'")
+ end
+ end
+ end
+
+ def test_errors_when_a_delete_query_is_called_while_preventing_writes
+ @conn.execute("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')")
+
+ assert_raises(ActiveRecord::ReadOnlyError) do
+ @conn.while_preventing_writes do
+ @conn.execute("DELETE FROM `engines` where `engines`.`car_id` = '138853948594'")
+ end
+ end
+ end
+
+ def test_errors_when_a_replace_query_is_called_while_preventing_writes
+ @conn.execute("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')")
+
+ assert_raises(ActiveRecord::ReadOnlyError) do
+ @conn.while_preventing_writes do
+ @conn.execute("REPLACE INTO `engines` SET `engines`.`car_id` = '249823948'")
+ end
+ end
+ end
+
+ def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes
+ @conn.execute("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')")
+
+ @conn.while_preventing_writes do
+ assert_equal 1, @conn.execute("SELECT `engines`.* FROM `engines` WHERE `engines`.`car_id` = '138853948594'").entries.count
+ end
+ end
+
+ def test_doesnt_error_when_a_show_query_is_called_while_preventing_writes
+ @conn.while_preventing_writes do
+ assert_equal 2, @conn.execute("SHOW FULL FIELDS FROM `engines`").entries.count
+ end
+ end
+
+ def test_doesnt_error_when_a_set_query_is_called_while_preventing_writes
+ @conn.while_preventing_writes do
+ assert_nil @conn.execute("SET NAMES utf8")
+ end
+ end
+
+ private
+
+ def with_example_table(definition = "id int auto_increment primary key, number int, data varchar(255)", &block)
+ super(@conn, "ex", definition, &block)
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/schema_migrations_test.rb b/activerecord/test/cases/adapters/mysql2/schema_migrations_test.rb
new file mode 100644
index 0000000000..d7d9a2d732
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/schema_migrations_test.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class SchemaMigrationsTest < ActiveRecord::Mysql2TestCase
+ self.use_transactional_tests = false
+
+ def test_renaming_index_on_foreign_key
+ connection.add_index "engines", "car_id"
+ connection.add_foreign_key :engines, :cars, name: "fk_engines_cars"
+
+ connection.rename_index("engines", "index_engines_on_car_id", "idx_renamed")
+ assert_equal ["idx_renamed"], connection.indexes("engines").map(&:name)
+ ensure
+ connection.remove_foreign_key :engines, name: "fk_engines_cars"
+ end
+
+ def test_initializes_schema_migrations_for_encoding_utf8mb4
+ with_encoding_utf8mb4 do
+ table_name = ActiveRecord::SchemaMigration.table_name
+ connection.drop_table table_name, if_exists: true
+
+ ActiveRecord::SchemaMigration.create_table
+
+ assert connection.column_exists?(table_name, :version, :string)
+ end
+ end
+
+ def test_initializes_internal_metadata_for_encoding_utf8mb4
+ with_encoding_utf8mb4 do
+ table_name = ActiveRecord::InternalMetadata.table_name
+ connection.drop_table table_name, if_exists: true
+
+ ActiveRecord::InternalMetadata.create_table
+
+ assert connection.column_exists?(table_name, :key, :string)
+ end
+ ensure
+ ActiveRecord::InternalMetadata[:environment] = connection.migration_context.current_environment
+ end
+
+ private
+
+ def with_encoding_utf8mb4
+ database_name = connection.current_database
+ database_info = connection.select_one("SELECT * FROM information_schema.schemata WHERE schema_name = '#{database_name}'")
+
+ original_charset = database_info["DEFAULT_CHARACTER_SET_NAME"]
+ original_collation = database_info["DEFAULT_COLLATION_NAME"]
+
+ execute("ALTER DATABASE #{database_name} DEFAULT CHARACTER SET utf8mb4")
+
+ yield
+ ensure
+ execute("ALTER DATABASE #{database_name} DEFAULT CHARACTER SET #{original_charset} COLLATE #{original_collation}")
+ end
+
+ def connection
+ @connection ||= ActiveRecord::Base.connection
+ end
+
+ def execute(sql)
+ connection.execute(sql)
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/schema_test.rb b/activerecord/test/cases/adapters/mysql2/schema_test.rb
new file mode 100644
index 0000000000..1283b0642c
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/schema_test.rb
@@ -0,0 +1,128 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+require "models/comment"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class Mysql2SchemaTest < ActiveRecord::Mysql2TestCase
+ fixtures :posts
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ db = Post.connection_pool.spec.config[:database]
+ table = Post.table_name
+ @db_name = db
+
+ @omgpost = Class.new(ActiveRecord::Base) do
+ self.inheritance_column = :disabled
+ self.table_name = "#{db}.#{table}"
+ def self.name; "Post"; end
+ end
+ end
+
+ def test_float_limits
+ @connection.create_table :mysql_doubles do |t|
+ t.float :float_no_limit
+ t.float :float_short, limit: 5
+ t.float :float_long, limit: 53
+
+ t.float :float_23, limit: 23
+ t.float :float_24, limit: 24
+ t.float :float_25, limit: 25
+ end
+
+ column_no_limit = @connection.columns(:mysql_doubles).find { |c| c.name == "float_no_limit" }
+ column_short = @connection.columns(:mysql_doubles).find { |c| c.name == "float_short" }
+ column_long = @connection.columns(:mysql_doubles).find { |c| c.name == "float_long" }
+
+ column_23 = @connection.columns(:mysql_doubles).find { |c| c.name == "float_23" }
+ column_24 = @connection.columns(:mysql_doubles).find { |c| c.name == "float_24" }
+ column_25 = @connection.columns(:mysql_doubles).find { |c| c.name == "float_25" }
+
+ # Mysql floats are precision 0..24, Mysql doubles are precision 25..53
+ assert_equal 24, column_no_limit.limit
+ assert_equal 24, column_short.limit
+ assert_equal 53, column_long.limit
+
+ assert_equal 24, column_23.limit
+ assert_equal 24, column_24.limit
+ assert_equal 53, column_25.limit
+ ensure
+ @connection.drop_table "mysql_doubles", if_exists: true
+ end
+
+ def test_schema
+ assert @omgpost.first
+ end
+
+ def test_primary_key
+ assert_equal "id", @omgpost.primary_key
+ end
+
+ def test_data_source_exists?
+ name = @omgpost.table_name
+ assert @connection.data_source_exists?(name), "#{name} data_source should exist"
+ end
+
+ def test_data_source_exists_wrong_schema
+ assert_not(@connection.data_source_exists?("#{@db_name}.zomg"), "data_source should not exist")
+ end
+
+ def test_dump_indexes
+ index_a_name = "index_key_tests_on_snack"
+ index_b_name = "index_key_tests_on_pizza"
+ index_c_name = "index_key_tests_on_awesome"
+
+ table = "key_tests"
+
+ indexes = @connection.indexes(table).sort_by(&:name)
+ assert_equal 3, indexes.size
+
+ index_a = indexes.select { |i| i.name == index_a_name }[0]
+ index_b = indexes.select { |i| i.name == index_b_name }[0]
+ index_c = indexes.select { |i| i.name == index_c_name }[0]
+ assert_equal :btree, index_a.using
+ assert_nil index_a.type
+ assert_equal :btree, index_b.using
+ assert_nil index_b.type
+
+ assert_nil index_c.using
+ assert_equal :fulltext, index_c.type
+ end
+
+ unless mysql_enforcing_gtid_consistency?
+ def test_drop_temporary_table
+ @connection.transaction do
+ @connection.create_table(:temp_table, temporary: true)
+ # if it doesn't properly say DROP TEMPORARY TABLE, the transaction commit
+ # will complain that no transaction is active
+ @connection.drop_table(:temp_table, temporary: true)
+ end
+ end
+ end
+ end
+ end
+end
+
+class Mysql2AnsiQuotesTest < ActiveRecord::Mysql2TestCase
+ def setup
+ @connection = ActiveRecord::Base.connection
+ @connection.execute("SET SESSION sql_mode='ANSI_QUOTES'")
+ end
+
+ def teardown
+ @connection.reconnect!
+ end
+
+ def test_primary_key_method_with_ansi_quotes
+ assert_equal "id", @connection.primary_key("topics")
+ end
+
+ def test_foreign_keys_method_with_ansi_quotes
+ fks = @connection.foreign_keys("lessons_students")
+ assert_equal([["lessons_students", "students", :cascade]],
+ fks.map { |fk| [fk.from_table, fk.to_table, fk.on_delete] })
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/sp_test.rb b/activerecord/test/cases/adapters/mysql2/sp_test.rb
new file mode 100644
index 0000000000..7b6dce71e9
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/sp_test.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+require "models/reply"
+
+class Mysql2StoredProcedureTest < ActiveRecord::Mysql2TestCase
+ fixtures :topics
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ unless ActiveRecord::Base.connection.version >= "5.6.0"
+ skip("no stored procedure support")
+ end
+ end
+
+ # Test that MySQL allows multiple results for stored procedures
+ #
+ # In MySQL 5.6, CLIENT_MULTI_RESULTS is enabled by default.
+ # https://dev.mysql.com/doc/refman/5.6/en/call.html
+ def test_multi_results
+ rows = @connection.select_rows("CALL ten();")
+ assert_equal 10, rows[0][0].to_i, "ten() did not return 10 as expected: #{rows.inspect}"
+ assert @connection.active?, "Bad connection use by 'Mysql2Adapter.select_rows'"
+ end
+
+ def test_multi_results_from_select_one
+ row = @connection.select_one("CALL topics(1);")
+ assert_equal "David", row["author_name"]
+ assert @connection.active?, "Bad connection use by 'Mysql2Adapter.select_one'"
+ end
+
+ def test_multi_results_from_find_by_sql
+ topics = Topic.find_by_sql "CALL topics(3);"
+ assert_equal 3, topics.size
+ assert @connection.active?, "Bad connection use by 'Mysql2Adapter.select'"
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/sql_types_test.rb b/activerecord/test/cases/adapters/mysql2/sql_types_test.rb
new file mode 100644
index 0000000000..e10642cbb4
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/sql_types_test.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class Mysql2SqlTypesTest < ActiveRecord::Mysql2TestCase
+ def test_binary_types
+ assert_equal "varbinary(64)", type_to_sql(:binary, 64)
+ assert_equal "varbinary(4095)", type_to_sql(:binary, 4095)
+ assert_equal "blob", type_to_sql(:binary, 4096)
+ assert_equal "blob", type_to_sql(:binary)
+ end
+
+ def type_to_sql(type, limit = nil)
+ ActiveRecord::Base.connection.type_to_sql(type, limit: limit)
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/table_options_test.rb b/activerecord/test/cases/adapters/mysql2/table_options_test.rb
new file mode 100644
index 0000000000..1c92df940f
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/table_options_test.rb
@@ -0,0 +1,119 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+class Mysql2TableOptionsTest < ActiveRecord::Mysql2TestCase
+ include SchemaDumpingHelper
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ end
+
+ def teardown
+ @connection.drop_table "mysql_table_options", if_exists: true
+ end
+
+ test "table options with ENGINE" do
+ @connection.create_table "mysql_table_options", force: true, options: "ENGINE=MyISAM"
+ output = dump_table_schema("mysql_table_options")
+ options = %r{create_table "mysql_table_options", options: "(?<options>.*)"}.match(output)[:options]
+ assert_match %r{ENGINE=MyISAM}, options
+ end
+
+ test "table options with ROW_FORMAT" do
+ @connection.create_table "mysql_table_options", force: true, options: "ROW_FORMAT=REDUNDANT"
+ output = dump_table_schema("mysql_table_options")
+ options = %r{create_table "mysql_table_options", options: "(?<options>.*)"}.match(output)[:options]
+ assert_match %r{ROW_FORMAT=REDUNDANT}, options
+ end
+
+ test "table options with CHARSET" do
+ @connection.create_table "mysql_table_options", force: true, options: "CHARSET=utf8mb4"
+ output = dump_table_schema("mysql_table_options")
+ options = %r{create_table "mysql_table_options", options: "(?<options>.*)"}.match(output)[:options]
+ assert_match %r{CHARSET=utf8mb4}, options
+ end
+
+ test "table options with COLLATE" do
+ @connection.create_table "mysql_table_options", force: true, options: "COLLATE=utf8mb4_bin"
+ output = dump_table_schema("mysql_table_options")
+ options = %r{create_table "mysql_table_options", options: "(?<options>.*)"}.match(output)[:options]
+ assert_match %r{COLLATE=utf8mb4_bin}, options
+ end
+end
+
+class Mysql2DefaultEngineOptionSchemaDumpTest < ActiveRecord::Mysql2TestCase
+ include SchemaDumpingHelper
+ self.use_transactional_tests = false
+
+ def setup
+ @verbose_was = ActiveRecord::Migration.verbose
+ ActiveRecord::Migration.verbose = false
+ end
+
+ def teardown
+ ActiveRecord::Base.connection.drop_table "mysql_table_options", if_exists: true
+ ActiveRecord::Migration.verbose = @verbose_was
+ ActiveRecord::SchemaMigration.delete_all rescue nil
+ end
+
+ test "schema dump includes ENGINE=InnoDB if not provided" do
+ ActiveRecord::Base.connection.create_table "mysql_table_options", force: true
+
+ output = dump_table_schema("mysql_table_options")
+ options = %r{create_table "mysql_table_options", options: "(?<options>.*)"}.match(output)[:options]
+ assert_match %r{ENGINE=InnoDB}, options
+ end
+
+ test "schema dump includes ENGINE=InnoDB in legacy migrations" do
+ migration = Class.new(ActiveRecord::Migration[5.1]) do
+ def migrate(x)
+ create_table "mysql_table_options", force: true
+ end
+ end.new
+
+ ActiveRecord::Migrator.new(:up, [migration]).migrate
+
+ output = dump_table_schema("mysql_table_options")
+ options = %r{create_table "mysql_table_options", options: "(?<options>.*)"}.match(output)[:options]
+ assert_match %r{ENGINE=InnoDB}, options
+ end
+end
+
+class Mysql2DefaultEngineOptionSqlOutputTest < ActiveRecord::Mysql2TestCase
+ self.use_transactional_tests = false
+
+ def setup
+ @logger_was = ActiveRecord::Base.logger
+ @log = StringIO.new
+ @verbose_was = ActiveRecord::Migration.verbose
+ ActiveRecord::Base.logger = ActiveSupport::Logger.new(@log)
+ ActiveRecord::Migration.verbose = false
+ end
+
+ def teardown
+ ActiveRecord::Base.logger = @logger_was
+ ActiveRecord::Migration.verbose = @verbose_was
+ ActiveRecord::Base.connection.drop_table "mysql_table_options", if_exists: true
+ ActiveRecord::SchemaMigration.delete_all rescue nil
+ end
+
+ test "new migrations do not contain default ENGINE=InnoDB option" do
+ ActiveRecord::Base.connection.create_table "mysql_table_options", force: true
+
+ assert_no_match %r{ENGINE=InnoDB}, @log.string
+ end
+
+ test "legacy migrations contain default ENGINE=InnoDB option" do
+ migration = Class.new(ActiveRecord::Migration[5.1]) do
+ def migrate(x)
+ create_table "mysql_table_options", force: true
+ end
+ end.new
+
+ ActiveRecord::Migrator.new(:up, [migration]).migrate
+
+ assert_match %r{ENGINE=InnoDB}, @log.string
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/transaction_test.rb b/activerecord/test/cases/adapters/mysql2/transaction_test.rb
new file mode 100644
index 0000000000..52e283f247
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/transaction_test.rb
@@ -0,0 +1,149 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/connection_helper"
+
+module ActiveRecord
+ class Mysql2TransactionTest < ActiveRecord::Mysql2TestCase
+ self.use_transactional_tests = false
+
+ class Sample < ActiveRecord::Base
+ self.table_name = "samples"
+ end
+
+ setup do
+ @abort, Thread.abort_on_exception = Thread.abort_on_exception, false
+ Thread.report_on_exception, @original_report_on_exception = false, Thread.report_on_exception
+
+ @connection = ActiveRecord::Base.connection
+ @connection.clear_cache!
+
+ @connection.transaction do
+ @connection.drop_table "samples", if_exists: true
+ @connection.create_table("samples") do |t|
+ t.integer "value"
+ end
+ end
+
+ Sample.reset_column_information
+ end
+
+ teardown do
+ @connection.drop_table "samples", if_exists: true
+
+ Thread.abort_on_exception = @abort
+ Thread.report_on_exception = @original_report_on_exception
+ end
+
+ test "raises Deadlocked when a deadlock is encountered" do
+ assert_raises(ActiveRecord::Deadlocked) do
+ barrier = Concurrent::CyclicBarrier.new(2)
+
+ s1 = Sample.create value: 1
+ s2 = Sample.create value: 2
+
+ thread = Thread.new do
+ Sample.transaction do
+ s1.lock!
+ barrier.wait
+ s2.update value: 1
+ end
+ end
+
+ begin
+ Sample.transaction do
+ s2.lock!
+ barrier.wait
+ s1.update value: 2
+ end
+ ensure
+ thread.join
+ end
+ end
+ end
+
+ test "raises LockWaitTimeout when lock wait timeout exceeded" do
+ assert_raises(ActiveRecord::LockWaitTimeout) do
+ s = Sample.create!(value: 1)
+ latch1 = Concurrent::CountDownLatch.new
+ latch2 = Concurrent::CountDownLatch.new
+
+ thread = Thread.new do
+ Sample.transaction do
+ Sample.lock.find(s.id)
+ latch1.count_down
+ latch2.wait
+ end
+ end
+
+ begin
+ Sample.transaction do
+ latch1.wait
+ Sample.connection.execute("SET innodb_lock_wait_timeout = 1")
+ Sample.lock.find(s.id)
+ end
+ ensure
+ Sample.connection.execute("SET innodb_lock_wait_timeout = DEFAULT")
+ latch2.count_down
+ thread.join
+ end
+ end
+ end
+
+ test "raises StatementTimeout when statement timeout exceeded" do
+ skip unless ActiveRecord::Base.connection.show_variable("max_execution_time")
+ assert_raises(ActiveRecord::StatementTimeout) do
+ s = Sample.create!(value: 1)
+ latch1 = Concurrent::CountDownLatch.new
+ latch2 = Concurrent::CountDownLatch.new
+
+ thread = Thread.new do
+ Sample.transaction do
+ Sample.lock.find(s.id)
+ latch1.count_down
+ latch2.wait
+ end
+ end
+
+ begin
+ Sample.transaction do
+ latch1.wait
+ Sample.connection.execute("SET max_execution_time = 1")
+ Sample.lock.find(s.id)
+ end
+ ensure
+ Sample.connection.execute("SET max_execution_time = DEFAULT")
+ latch2.count_down
+ thread.join
+ end
+ end
+ end
+
+ test "raises QueryCanceled when canceling statement due to user request" do
+ assert_raises(ActiveRecord::QueryCanceled) do
+ s = Sample.create!(value: 1)
+ latch = Concurrent::CountDownLatch.new
+
+ thread = Thread.new do
+ Sample.transaction do
+ Sample.lock.find(s.id)
+ latch.count_down
+ sleep(0.5)
+ conn = Sample.connection
+ pid = conn.query_value("SELECT id FROM information_schema.processlist WHERE info LIKE '% FOR UPDATE'")
+ conn.execute("KILL QUERY #{pid}")
+ end
+ end
+
+ begin
+ Sample.transaction do
+ latch.wait
+ Sample.lock.find(s.id)
+ end
+ ensure
+ thread.join
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/unsigned_type_test.rb b/activerecord/test/cases/adapters/mysql2/unsigned_type_test.rb
new file mode 100644
index 0000000000..97da96003d
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/unsigned_type_test.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+class Mysql2UnsignedTypeTest < ActiveRecord::Mysql2TestCase
+ include SchemaDumpingHelper
+ self.use_transactional_tests = false
+
+ class UnsignedType < ActiveRecord::Base
+ end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table("unsigned_types", force: true) do |t|
+ t.integer :unsigned_integer, unsigned: true
+ t.bigint :unsigned_bigint, unsigned: true
+ t.float :unsigned_float, unsigned: true
+ t.decimal :unsigned_decimal, unsigned: true, precision: 10, scale: 2
+ t.column :unsigned_zerofill, "int unsigned zerofill"
+ end
+ end
+
+ teardown do
+ @connection.drop_table "unsigned_types", if_exists: true
+ end
+
+ test "unsigned int max value is in range" do
+ assert expected = UnsignedType.create(unsigned_integer: 4294967295)
+ assert_equal expected, UnsignedType.find_by(unsigned_integer: 4294967295)
+ end
+
+ test "minus value is out of range" do
+ assert_raise(ActiveModel::RangeError) do
+ UnsignedType.create(unsigned_integer: -10)
+ end
+ assert_raise(ActiveModel::RangeError) do
+ UnsignedType.create(unsigned_bigint: -10)
+ end
+ assert_raise(ActiveRecord::RangeError) do
+ UnsignedType.create(unsigned_float: -10.0)
+ end
+ assert_raise(ActiveRecord::RangeError) do
+ UnsignedType.create(unsigned_decimal: -10.0)
+ end
+ end
+
+ test "schema definition can use unsigned as the type" do
+ @connection.change_table("unsigned_types") do |t|
+ t.unsigned_integer :unsigned_integer_t
+ t.unsigned_bigint :unsigned_bigint_t
+ t.unsigned_float :unsigned_float_t
+ t.unsigned_decimal :unsigned_decimal_t, precision: 10, scale: 2
+ end
+
+ @connection.columns("unsigned_types").select { |c| /^unsigned_/.match?(c.name) }.each do |column|
+ assert_predicate column, :unsigned?
+ end
+ end
+
+ test "schema dump includes unsigned option" do
+ schema = dump_table_schema "unsigned_types"
+ assert_match %r{t\.integer\s+"unsigned_integer",\s+unsigned: true$}, schema
+ assert_match %r{t\.bigint\s+"unsigned_bigint",\s+unsigned: true$}, schema
+ assert_match %r{t\.float\s+"unsigned_float",\s+unsigned: true$}, schema
+ assert_match %r{t\.decimal\s+"unsigned_decimal",\s+precision: 10,\s+scale: 2,\s+unsigned: true$}, schema
+ end
+end
diff --git a/activerecord/test/cases/adapters/mysql2/virtual_column_test.rb b/activerecord/test/cases/adapters/mysql2/virtual_column_test.rb
new file mode 100644
index 0000000000..8494acee3b
--- /dev/null
+++ b/activerecord/test/cases/adapters/mysql2/virtual_column_test.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+if ActiveRecord::Base.connection.supports_virtual_columns?
+ class Mysql2VirtualColumnTest < ActiveRecord::Mysql2TestCase
+ include SchemaDumpingHelper
+
+ self.use_transactional_tests = false
+
+ class VirtualColumn < ActiveRecord::Base
+ end
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table :virtual_columns, force: true do |t|
+ t.string :name
+ t.virtual :upper_name, type: :string, as: "UPPER(`name`)"
+ t.virtual :name_length, type: :integer, as: "LENGTH(`name`)", stored: true
+ t.virtual :name_octet_length, type: :integer, as: "OCTET_LENGTH(`name`)", stored: true
+ end
+ VirtualColumn.create(name: "Rails")
+ end
+
+ def teardown
+ @connection.drop_table :virtual_columns, if_exists: true
+ VirtualColumn.reset_column_information
+ end
+
+ def test_virtual_column
+ column = VirtualColumn.columns_hash["upper_name"]
+ assert_predicate column, :virtual?
+ assert_match %r{\bVIRTUAL\b}, column.extra
+ assert_equal "RAILS", VirtualColumn.take.upper_name
+ end
+
+ def test_stored_column
+ column = VirtualColumn.columns_hash["name_length"]
+ assert_predicate column, :virtual?
+ assert_match %r{\b(?:STORED|PERSISTENT)\b}, column.extra
+ assert_equal 5, VirtualColumn.take.name_length
+ end
+
+ def test_change_table
+ @connection.change_table :virtual_columns do |t|
+ t.virtual :lower_name, type: :string, as: "LOWER(name)"
+ end
+ VirtualColumn.reset_column_information
+ column = VirtualColumn.columns_hash["lower_name"]
+ assert_predicate column, :virtual?
+ assert_match %r{\bVIRTUAL\b}, column.extra
+ assert_equal "rails", VirtualColumn.take.lower_name
+ end
+
+ def test_schema_dumping
+ output = dump_table_schema("virtual_columns")
+ assert_match(/t\.virtual\s+"upper_name",\s+type: :string,\s+as: "(?:UPPER|UCASE)\(`name`\)"$/i, output)
+ assert_match(/t\.virtual\s+"name_length",\s+type: :integer,\s+as: "(?:octet_length|length)\(`name`\)",\s+stored: true$/i, output)
+ assert_match(/t\.virtual\s+"name_octet_length",\s+type: :integer,\s+as: "(?:octet_length|length)\(`name`\)",\s+stored: true$/i, output)
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/active_schema_test.rb b/activerecord/test/cases/adapters/postgresql/active_schema_test.rb
new file mode 100644
index 0000000000..62efaf3bfe
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/active_schema_test.rb
@@ -0,0 +1,111 @@
+# 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
+ # add_index calls index_name_exists? which can't work since execute is stubbed
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.define_method(:index_name_exists?) { |*| false }
+
+ expected = %(CREATE UNIQUE INDEX "index_people_on_last_name" ON "people" ("last_name") WHERE state = 'active')
+ assert_equal expected, add_index(:people, :last_name, unique: true, where: "state = 'active'")
+
+ 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 "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")
+
+ assert_raise ArgumentError do
+ add_index(:people, :last_name, algorithm: :copy)
+ end
+
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.remove_method :index_name_exists?
+ end
+
+ def test_remove_index
+ # remove_index calls index_name_for_remove which can't work since execute is stubbed
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.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(method_symbol, *arguments)
+ ActiveRecord::Base.connection.send(method_symbol, *arguments)
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/array_test.rb b/activerecord/test/cases/adapters/postgresql/array_test.rb
new file mode 100644
index 0000000000..42618c2ec3
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/array_test.rb
@@ -0,0 +1,390 @@
+# 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") 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_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_insert_fixtures
+ tag_values = ["val1", "val2", "val3_with_'_multiple_quote_'_chars"]
+ assert_deprecated do
+ @connection.insert_fixtures([{ "tags" => tag_values }], "pg_arrays")
+ end
+ assert_equal(PgArray.last.tags, tag_values)
+ end
+
+ def test_attribute_for_inspect_for_array_field
+ record = PgArray.new { |a| a.ratings = (1..10).to_a }
+ assert_equal("[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]", record.attribute_for_inspect(:ratings))
+ 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
diff --git a/activerecord/test/cases/adapters/postgresql/bit_string_test.rb b/activerecord/test/cases/adapters/postgresql/bit_string_test.rb
new file mode 100644
index 0000000000..c8e728bbb6
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/bit_string_test.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/connection_helper"
+require "support/schema_dumping_helper"
+
+class PostgresqlBitStringTest < ActiveRecord::PostgreSQLTestCase
+ include ConnectionHelper
+ include SchemaDumpingHelper
+
+ class PostgresqlBitString < ActiveRecord::Base; end
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table("postgresql_bit_strings", force: true) do |t|
+ t.bit :a_bit, default: "00000011", limit: 8
+ t.bit_varying :a_bit_varying, default: "0011", limit: 4
+ t.bit :another_bit
+ t.bit_varying :another_bit_varying
+ end
+ end
+
+ def teardown
+ return unless @connection
+ @connection.drop_table "postgresql_bit_strings", if_exists: true
+ end
+
+ def test_bit_string_column
+ column = PostgresqlBitString.columns_hash["a_bit"]
+ assert_equal :bit, column.type
+ assert_equal "bit(8)", column.sql_type
+ assert_not_predicate column, :array?
+
+ type = PostgresqlBitString.type_for_attribute("a_bit")
+ assert_not_predicate type, :binary?
+ end
+
+ def test_bit_string_varying_column
+ column = PostgresqlBitString.columns_hash["a_bit_varying"]
+ assert_equal :bit_varying, column.type
+ assert_equal "bit varying(4)", column.sql_type
+ assert_not_predicate column, :array?
+
+ type = PostgresqlBitString.type_for_attribute("a_bit_varying")
+ assert_not_predicate type, :binary?
+ end
+
+ def test_default
+ assert_equal "00000011", PostgresqlBitString.column_defaults["a_bit"]
+ assert_equal "00000011", PostgresqlBitString.new.a_bit
+
+ assert_equal "0011", PostgresqlBitString.column_defaults["a_bit_varying"]
+ assert_equal "0011", PostgresqlBitString.new.a_bit_varying
+ end
+
+ def test_schema_dumping
+ output = dump_table_schema("postgresql_bit_strings")
+ assert_match %r{t\.bit\s+"a_bit",\s+limit: 8,\s+default: "00000011"$}, output
+ assert_match %r{t\.bit_varying\s+"a_bit_varying",\s+limit: 4,\s+default: "0011"$}, output
+ end
+
+ if ActiveRecord::Base.connection.prepared_statements
+ def test_assigning_invalid_hex_string_raises_exception
+ assert_raises(ActiveRecord::StatementInvalid) { PostgresqlBitString.create! a_bit: "FF" }
+ assert_raises(ActiveRecord::StatementInvalid) { PostgresqlBitString.create! a_bit_varying: "F" }
+ end
+ end
+
+ def test_roundtrip
+ record = PostgresqlBitString.create!(a_bit: "00001010", a_bit_varying: "0101")
+ assert_equal "00001010", record.a_bit
+ assert_equal "0101", record.a_bit_varying
+ assert_nil record.another_bit
+ assert_nil record.another_bit_varying
+
+ record.a_bit = "11111111"
+ record.a_bit_varying = "0xF"
+ record.save!
+
+ assert record.reload
+ assert_equal "11111111", record.a_bit
+ assert_equal "1111", record.a_bit_varying
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/bytea_test.rb b/activerecord/test/cases/adapters/postgresql/bytea_test.rb
new file mode 100644
index 0000000000..3988c2adca
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/bytea_test.rb
@@ -0,0 +1,137 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+class PostgresqlByteaTest < ActiveRecord::PostgreSQLTestCase
+ include SchemaDumpingHelper
+
+ class ByteaDataType < ActiveRecord::Base
+ self.table_name = "bytea_data_type"
+ end
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ begin
+ @connection.transaction do
+ @connection.create_table("bytea_data_type") do |t|
+ t.binary "payload"
+ t.binary "serialized"
+ end
+ end
+ end
+ @column = ByteaDataType.columns_hash["payload"]
+ @type = ByteaDataType.type_for_attribute("payload")
+ end
+
+ teardown do
+ @connection.drop_table "bytea_data_type", if_exists: true
+ end
+
+ def test_column
+ assert @column.is_a?(ActiveRecord::ConnectionAdapters::PostgreSQLColumn)
+ assert_equal :binary, @column.type
+ end
+
+ def test_binary_columns_are_limitless_the_upper_limit_is_one_GB
+ assert_equal "bytea", @connection.type_to_sql(:binary, limit: 100_000)
+ assert_raise ActiveRecord::ActiveRecordError do
+ @connection.type_to_sql(:binary, limit: 4294967295)
+ end
+ end
+
+ def test_type_cast_binary_converts_the_encoding
+ assert @column
+
+ data = "\u001F\x8B"
+ assert_equal("UTF-8", data.encoding.name)
+ assert_equal("ASCII-8BIT", @type.deserialize(data).encoding.name)
+ end
+
+ def test_type_cast_binary_value
+ data = (+"\u001F\x8B").force_encoding("BINARY")
+ assert_equal(data, @type.deserialize(data))
+ end
+
+ def test_type_case_nil
+ assert_nil(@type.deserialize(nil))
+ end
+
+ def test_read_value
+ data = "\u001F"
+ @connection.execute "insert into bytea_data_type (payload) VALUES ('#{data}')"
+ record = ByteaDataType.first
+ assert_equal(data, record.payload)
+ record.delete
+ end
+
+ def test_read_nil_value
+ @connection.execute "insert into bytea_data_type (payload) VALUES (null)"
+ record = ByteaDataType.first
+ assert_nil(record.payload)
+ record.delete
+ end
+
+ def test_write_value
+ data = "\u001F"
+ record = ByteaDataType.create(payload: data)
+ assert_not_predicate record, :new_record?
+ assert_equal(data, record.payload)
+ end
+
+ def test_via_to_sql
+ data = "'\u001F\\"
+ ByteaDataType.create(payload: data)
+ sql = ByteaDataType.where(payload: data).select(:payload).to_sql
+ result = @connection.query(sql)
+ assert_equal([[data]], result)
+ end
+
+ def test_via_to_sql_with_complicating_connection
+ Thread.new do
+ other_conn = ActiveRecord::Base.connection
+ other_conn.execute("SET standard_conforming_strings = off")
+ other_conn.execute("SET escape_string_warning = off")
+ end.join
+
+ test_via_to_sql
+ end
+
+ def test_write_binary
+ data = File.read(File.join(__dir__, "..", "..", "..", "assets", "example.log"))
+ assert(data.size > 1)
+ record = ByteaDataType.create(payload: data)
+ assert_not_predicate record, :new_record?
+ assert_equal(data, record.payload)
+ assert_equal(data, ByteaDataType.where(id: record.id).first.payload)
+ end
+
+ def test_write_nil
+ record = ByteaDataType.create(payload: nil)
+ assert_not_predicate record, :new_record?
+ assert_nil(record.payload)
+ assert_nil(ByteaDataType.where(id: record.id).first.payload)
+ end
+
+ class Serializer
+ def load(str); str; end
+ def dump(str); str; end
+ end
+
+ def test_serialize
+ klass = Class.new(ByteaDataType) {
+ serialize :serialized, Serializer.new
+ }
+ obj = klass.new
+ obj.serialized = "hello world"
+ obj.save!
+ obj.reload
+ assert_equal "hello world", obj.serialized
+ end
+
+ def test_schema_dumping
+ output = dump_table_schema("bytea_data_type")
+ assert_match %r{t\.binary\s+"payload"$}, output
+ assert_match %r{t\.binary\s+"serialized"$}, output
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/case_insensitive_test.rb b/activerecord/test/cases/adapters/postgresql/case_insensitive_test.rb
new file mode 100644
index 0000000000..305e033642
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/case_insensitive_test.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class PostgresqlCaseInsensitiveTest < ActiveRecord::PostgreSQLTestCase
+ class Default < ActiveRecord::Base; end
+
+ def test_case_insensitiveness
+ connection = ActiveRecord::Base.connection
+ table = Default.arel_table
+
+ column = Default.columns_hash["char1"]
+ comparison = connection.case_insensitive_comparison table, :char1, column, nil
+ assert_match(/lower/i, comparison.to_sql)
+
+ column = Default.columns_hash["char2"]
+ comparison = connection.case_insensitive_comparison table, :char2, column, nil
+ assert_match(/lower/i, comparison.to_sql)
+
+ column = Default.columns_hash["char3"]
+ comparison = connection.case_insensitive_comparison table, :char3, column, nil
+ assert_match(/lower/i, comparison.to_sql)
+
+ column = Default.columns_hash["multiline_default"]
+ comparison = connection.case_insensitive_comparison table, :multiline_default, column, nil
+ assert_match(/lower/i, comparison.to_sql)
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/change_schema_test.rb b/activerecord/test/cases/adapters/postgresql/change_schema_test.rb
new file mode 100644
index 0000000000..6dba4f3e14
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/change_schema_test.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ class Migration
+ class PGChangeSchemaTest < ActiveRecord::PostgreSQLTestCase
+ attr_reader :connection
+
+ def setup
+ super
+ @connection = ActiveRecord::Base.connection
+ connection.create_table(:strings) do |t|
+ t.string :somedate
+ end
+ end
+
+ def teardown
+ connection.drop_table :strings
+ end
+
+ def test_change_string_to_date
+ connection.change_column :strings, :somedate, :timestamp, using: 'CAST("somedate" AS timestamp)'
+ assert_equal :datetime, connection.columns(:strings).find { |c| c.name == "somedate" }.type
+ end
+
+ def test_change_type_with_symbol
+ connection.change_column :strings, :somedate, :timestamp, cast_as: :timestamp
+ assert_equal :datetime, connection.columns(:strings).find { |c| c.name == "somedate" }.type
+ end
+
+ def test_change_type_with_array
+ connection.change_column :strings, :somedate, :timestamp, array: true, cast_as: :timestamp
+ column = connection.columns(:strings).find { |c| c.name == "somedate" }
+ assert_equal :datetime, column.type
+ assert_predicate column, :array?
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/cidr_test.rb b/activerecord/test/cases/adapters/postgresql/cidr_test.rb
new file mode 100644
index 0000000000..f20958fbd2
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/cidr_test.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "ipaddr"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class PostgreSQLAdapter < AbstractAdapter
+ class CidrTest < ActiveRecord::PostgreSQLTestCase
+ test "type casting IPAddr for database" do
+ type = OID::Cidr.new
+ ip = IPAddr.new("255.0.0.0/8")
+ ip2 = IPAddr.new("127.0.0.1")
+
+ assert_equal "255.0.0.0/8", type.serialize(ip)
+ assert_equal "127.0.0.1/32", type.serialize(ip2)
+ end
+
+ test "casting does nothing with non-IPAddr objects" do
+ type = OID::Cidr.new
+
+ assert_equal "foo", type.serialize("foo")
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/citext_test.rb b/activerecord/test/cases/adapters/postgresql/citext_test.rb
new file mode 100644
index 0000000000..9eb0b7d99c
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/citext_test.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+class PostgresqlCitextTest < ActiveRecord::PostgreSQLTestCase
+ include SchemaDumpingHelper
+ class Citext < ActiveRecord::Base
+ self.table_name = "citexts"
+ end
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+
+ enable_extension!("citext", @connection)
+
+ @connection.create_table("citexts") do |t|
+ t.citext "cival"
+ end
+ end
+
+ teardown do
+ @connection.drop_table "citexts", if_exists: true
+ disable_extension!("citext", @connection)
+ end
+
+ def test_citext_enabled
+ assert @connection.extension_enabled?("citext")
+ end
+
+ def test_column
+ column = Citext.columns_hash["cival"]
+ assert_equal :citext, column.type
+ assert_equal "citext", column.sql_type
+ assert_not_predicate column, :array?
+
+ type = Citext.type_for_attribute("cival")
+ assert_not_predicate type, :binary?
+ end
+
+ def test_change_table_supports_json
+ @connection.transaction do
+ @connection.change_table("citexts") do |t|
+ t.citext "username"
+ end
+ Citext.reset_column_information
+ column = Citext.columns_hash["username"]
+ assert_equal :citext, column.type
+
+ raise ActiveRecord::Rollback # reset the schema change
+ end
+ ensure
+ Citext.reset_column_information
+ end
+
+ def test_write
+ x = Citext.new(cival: "Some CI Text")
+ x.save!
+ citext = Citext.first
+ assert_equal "Some CI Text", citext.cival
+
+ citext.cival = "Some NEW CI Text"
+ citext.save!
+
+ assert_equal "Some NEW CI Text", citext.reload.cival
+ end
+
+ def test_select_case_insensitive
+ @connection.execute "insert into citexts (cival) values('Cased Text')"
+ x = Citext.where(cival: "cased text").first
+ assert_equal "Cased Text", x.cival
+ end
+
+ def test_schema_dump_with_shorthand
+ output = dump_table_schema("citexts")
+ assert_match %r[t\.citext "cival"], output
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/collation_test.rb b/activerecord/test/cases/adapters/postgresql/collation_test.rb
new file mode 100644
index 0000000000..7468f4c4f8
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/collation_test.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+class PostgresqlCollationTest < ActiveRecord::PostgreSQLTestCase
+ include SchemaDumpingHelper
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table :postgresql_collations, force: true do |t|
+ t.string :string_c, collation: "C"
+ t.text :text_posix, collation: "POSIX"
+ end
+ end
+
+ def teardown
+ @connection.drop_table :postgresql_collations, if_exists: true
+ end
+
+ test "string column with collation" do
+ column = @connection.columns(:postgresql_collations).find { |c| c.name == "string_c" }
+ assert_equal :string, column.type
+ assert_equal "C", column.collation
+ end
+
+ test "text column with collation" do
+ column = @connection.columns(:postgresql_collations).find { |c| c.name == "text_posix" }
+ assert_equal :text, column.type
+ assert_equal "POSIX", column.collation
+ end
+
+ test "add column with collation" do
+ @connection.add_column :postgresql_collations, :title, :string, collation: "C"
+
+ column = @connection.columns(:postgresql_collations).find { |c| c.name == "title" }
+ assert_equal :string, column.type
+ assert_equal "C", column.collation
+ end
+
+ test "change column with collation" do
+ @connection.add_column :postgresql_collations, :description, :string
+ @connection.change_column :postgresql_collations, :description, :text, collation: "POSIX"
+
+ column = @connection.columns(:postgresql_collations).find { |c| c.name == "description" }
+ assert_equal :text, column.type
+ assert_equal "POSIX", column.collation
+ end
+
+ test "schema dump includes collation" do
+ output = dump_table_schema("postgresql_collations")
+ assert_match %r{t\.string\s+"string_c",\s+collation: "C"$}, output
+ assert_match %r{t\.text\s+"text_posix",\s+collation: "POSIX"$}, output
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/composite_test.rb b/activerecord/test/cases/adapters/postgresql/composite_test.rb
new file mode 100644
index 0000000000..683066cdb3
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/composite_test.rb
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/connection_helper"
+
+module PostgresqlCompositeBehavior
+ include ConnectionHelper
+
+ class PostgresqlComposite < ActiveRecord::Base
+ self.table_name = "postgresql_composites"
+ end
+
+ def setup
+ super
+
+ @connection = ActiveRecord::Base.connection
+ @connection.transaction do
+ @connection.execute <<~SQL
+ CREATE TYPE full_address AS
+ (
+ city VARCHAR(90),
+ street VARCHAR(90)
+ );
+ SQL
+ @connection.create_table("postgresql_composites") do |t|
+ t.column :address, :full_address
+ end
+ end
+ end
+
+ def teardown
+ super
+
+ @connection.drop_table "postgresql_composites", if_exists: true
+ @connection.execute "DROP TYPE IF EXISTS full_address"
+ reset_connection
+ PostgresqlComposite.reset_column_information
+ end
+end
+
+# Composites are mapped to `OID::Identity` by default. The user is informed by a warning like:
+# "unknown OID 5653508: failed to recognize type of 'address'. It will be treated as String."
+# To take full advantage of composite types, we suggest you register your own +OID::Type+.
+# See PostgresqlCompositeWithCustomOIDTest
+class PostgresqlCompositeTest < ActiveRecord::PostgreSQLTestCase
+ include PostgresqlCompositeBehavior
+
+ def test_column
+ ensure_warning_is_issued
+
+ column = PostgresqlComposite.columns_hash["address"]
+ assert_nil column.type
+ assert_equal "full_address", column.sql_type
+ assert_not_predicate column, :array?
+
+ type = PostgresqlComposite.type_for_attribute("address")
+ assert_not_predicate type, :binary?
+ end
+
+ def test_composite_mapping
+ ensure_warning_is_issued
+
+ @connection.execute "INSERT INTO postgresql_composites VALUES (1, ROW('Paris', 'Champs-Élysées'));"
+ composite = PostgresqlComposite.first
+ assert_equal "(Paris,Champs-Élysées)", composite.address
+
+ composite.address = "(Paris,Rue Basse)"
+ composite.save!
+
+ assert_equal '(Paris,"Rue Basse")', composite.reload.address
+ end
+
+ private
+ def ensure_warning_is_issued
+ warning = capture(:stderr) do
+ PostgresqlComposite.columns_hash
+ end
+ assert_match(/unknown OID \d+: failed to recognize type of 'address'\. It will be treated as String\./, warning)
+ end
+end
+
+class PostgresqlCompositeWithCustomOIDTest < ActiveRecord::PostgreSQLTestCase
+ include PostgresqlCompositeBehavior
+
+ class FullAddressType < ActiveRecord::Type::Value
+ def type; :full_address end
+
+ def deserialize(value)
+ if value =~ /\("?([^",]*)"?,"?([^",]*)"?\)/
+ FullAddress.new($1, $2)
+ end
+ end
+
+ def cast(value)
+ value
+ end
+
+ def serialize(value)
+ return if value.nil?
+ "(#{value.city},#{value.street})"
+ end
+ end
+
+ FullAddress = Struct.new(:city, :street)
+
+ def setup
+ super
+
+ @connection.send(:type_map).register_type "full_address", FullAddressType.new
+ end
+
+ def test_column
+ column = PostgresqlComposite.columns_hash["address"]
+ assert_equal :full_address, column.type
+ assert_equal "full_address", column.sql_type
+ assert_not_predicate column, :array?
+
+ type = PostgresqlComposite.type_for_attribute("address")
+ assert_not_predicate type, :binary?
+ end
+
+ def test_composite_mapping
+ @connection.execute "INSERT INTO postgresql_composites VALUES (1, ROW('Paris', 'Champs-Élysées'));"
+ composite = PostgresqlComposite.first
+ assert_equal "Paris", composite.address.city
+ assert_equal "Champs-Élysées", composite.address.street
+
+ composite.address = FullAddress.new("Paris", "Rue Basse")
+ composite.save!
+
+ assert_equal "Paris", composite.reload.address.city
+ assert_equal "Rue Basse", composite.reload.address.street
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/connection_test.rb b/activerecord/test/cases/adapters/postgresql/connection_test.rb
new file mode 100644
index 0000000000..40ab158c05
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/connection_test.rb
@@ -0,0 +1,258 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/connection_helper"
+
+module ActiveRecord
+ class PostgresqlConnectionTest < ActiveRecord::PostgreSQLTestCase
+ include ConnectionHelper
+
+ class NonExistentTable < ActiveRecord::Base
+ end
+
+ fixtures :comments
+
+ def setup
+ super
+ @subscriber = SQLSubscriber.new
+ @connection = ActiveRecord::Base.connection
+ @connection.materialize_transactions
+ @subscription = ActiveSupport::Notifications.subscribe("sql.active_record", @subscriber)
+ end
+
+ def teardown
+ ActiveSupport::Notifications.unsubscribe(@subscription)
+ super
+ end
+
+ def test_truncate
+ count = ActiveRecord::Base.connection.execute("select count(*) from comments").first["count"].to_i
+ assert_operator count, :>, 0
+ ActiveRecord::Base.connection.truncate("comments")
+ count = ActiveRecord::Base.connection.execute("select count(*) from comments").first["count"].to_i
+ assert_equal 0, count
+ end
+
+ def test_encoding
+ assert_queries(1) do
+ assert_not_nil @connection.encoding
+ end
+ end
+
+ def test_collation
+ assert_queries(1) do
+ assert_not_nil @connection.collation
+ end
+ end
+
+ def test_ctype
+ assert_queries(1) do
+ assert_not_nil @connection.ctype
+ end
+ end
+
+ def test_default_client_min_messages
+ assert_equal "warning", @connection.client_min_messages
+ end
+
+ # Ensure, we can set connection params using the example of Generic
+ # Query Optimizer (geqo). It is 'on' per default.
+ def test_connection_options
+ params = ActiveRecord::Base.connection_config.dup
+ params[:options] = "-c geqo=off"
+ NonExistentTable.establish_connection(params)
+
+ # Verify the connection param has been applied.
+ expect = NonExistentTable.connection.query("show geqo").first.first
+ assert_equal "off", expect
+ end
+
+ def test_reset
+ @connection.query("ROLLBACK")
+ @connection.query("SET geqo TO off")
+
+ # Verify the setting has been applied.
+ expect = @connection.query("show geqo").first.first
+ assert_equal "off", expect
+
+ @connection.reset!
+
+ # Verify the setting has been cleared.
+ expect = @connection.query("show geqo").first.first
+ assert_equal "on", expect
+ end
+
+ def test_reset_with_transaction
+ @connection.query("ROLLBACK")
+ @connection.query("SET geqo TO off")
+
+ # Verify the setting has been applied.
+ expect = @connection.query("show geqo").first.first
+ assert_equal "off", expect
+
+ @connection.query("BEGIN")
+ @connection.reset!
+
+ # Verify the setting has been cleared.
+ expect = @connection.query("show geqo").first.first
+ assert_equal "on", expect
+ end
+
+ def test_tables_logs_name
+ @connection.tables
+ assert_equal "SCHEMA", @subscriber.logged[0][1]
+ end
+
+ def test_indexes_logs_name
+ @connection.indexes("items")
+ assert_equal "SCHEMA", @subscriber.logged[0][1]
+ end
+
+ def test_table_exists_logs_name
+ @connection.table_exists?("items")
+ assert_equal "SCHEMA", @subscriber.logged[0][1]
+ end
+
+ def test_table_alias_length_logs_name
+ @connection.instance_variable_set("@max_identifier_length", nil)
+ @connection.table_alias_length
+ assert_equal "SCHEMA", @subscriber.logged[0][1]
+ end
+
+ def test_current_database_logs_name
+ @connection.current_database
+ assert_equal "SCHEMA", @subscriber.logged[0][1]
+ end
+
+ def test_encoding_logs_name
+ @connection.encoding
+ assert_equal "SCHEMA", @subscriber.logged[0][1]
+ end
+
+ def test_schema_names_logs_name
+ @connection.schema_names
+ assert_equal "SCHEMA", @subscriber.logged[0][1]
+ end
+
+ if ActiveRecord::Base.connection.prepared_statements
+ def test_statement_key_is_logged
+ bind = Relation::QueryAttribute.new(nil, 1, Type::Value.new)
+ @connection.exec_query("SELECT $1::integer", "SQL", [bind], prepare: true)
+ name = @subscriber.payloads.last[:statement_name]
+ assert name
+ res = @connection.exec_query("EXPLAIN (FORMAT JSON) EXECUTE #{name}(1)")
+ plan = res.column_types["QUERY PLAN"].deserialize res.rows.first.first
+ assert_operator plan.length, :>, 0
+ end
+ end
+
+ def test_reconnection_after_actual_disconnection_with_verify
+ original_connection_pid = @connection.query("select pg_backend_pid()")
+
+ # Sanity check.
+ assert_predicate @connection, :active?
+
+ secondary_connection = ActiveRecord::Base.connection_pool.checkout
+ secondary_connection.query("select pg_terminate_backend(#{original_connection_pid.first.first})")
+ ActiveRecord::Base.connection_pool.checkin(secondary_connection)
+
+ @connection.verify!
+
+ assert_predicate @connection, :active?
+
+ # If we get no exception here, then either we re-connected successfully, or
+ # we never actually got disconnected.
+ new_connection_pid = @connection.query("select pg_backend_pid()")
+
+ assert_not_equal original_connection_pid, new_connection_pid,
+ "umm -- looks like you didn't break the connection, because we're still " \
+ "successfully querying with the same connection pid."
+ ensure
+ # Repair all fixture connections so other tests won't break.
+ @fixture_connections.each(&:verify!)
+ end
+
+ def test_set_session_variable_true
+ run_without_connection do |orig_connection|
+ ActiveRecord::Base.establish_connection(orig_connection.deep_merge(variables: { debug_print_plan: true }))
+ set_true = ActiveRecord::Base.connection.exec_query "SHOW DEBUG_PRINT_PLAN"
+ assert_equal set_true.rows, [["on"]]
+ end
+ end
+
+ def test_set_session_variable_false
+ run_without_connection do |orig_connection|
+ ActiveRecord::Base.establish_connection(orig_connection.deep_merge(variables: { debug_print_plan: false }))
+ set_false = ActiveRecord::Base.connection.exec_query "SHOW DEBUG_PRINT_PLAN"
+ assert_equal set_false.rows, [["off"]]
+ end
+ end
+
+ def test_set_session_variable_nil
+ run_without_connection do |orig_connection|
+ # This should be a no-op that does not raise an error
+ ActiveRecord::Base.establish_connection(orig_connection.deep_merge(variables: { debug_print_plan: nil }))
+ end
+ end
+
+ def test_set_session_variable_default
+ run_without_connection do |orig_connection|
+ # This should execute a query that does not raise an error
+ ActiveRecord::Base.establish_connection(orig_connection.deep_merge(variables: { debug_print_plan: :default }))
+ end
+ end
+
+ def test_set_session_timezone
+ run_without_connection do |orig_connection|
+ ActiveRecord::Base.establish_connection(orig_connection.deep_merge(variables: { timezone: "America/New_York" }))
+ assert_equal "America/New_York", ActiveRecord::Base.connection.query_value("SHOW TIME ZONE")
+ end
+ end
+
+ def test_get_and_release_advisory_lock
+ lock_id = 5295901941911233559
+ list_advisory_locks = <<~SQL
+ SELECT locktype,
+ (classid::bigint << 32) | objid::bigint AS lock_id
+ FROM pg_locks
+ WHERE locktype = 'advisory'
+ SQL
+
+ got_lock = @connection.get_advisory_lock(lock_id)
+ assert got_lock, "get_advisory_lock should have returned true but it didn't"
+
+ advisory_lock = @connection.query(list_advisory_locks).find { |l| l[1] == lock_id }
+ assert advisory_lock,
+ "expected to find an advisory lock with lock_id #{lock_id} but there wasn't one"
+
+ released_lock = @connection.release_advisory_lock(lock_id)
+ assert released_lock, "expected release_advisory_lock to return true but it didn't"
+
+ advisory_locks = @connection.query(list_advisory_locks).select { |l| l[1] == lock_id }
+ assert_empty advisory_locks,
+ "expected to have released advisory lock with lock_id #{lock_id} but it was still held"
+ end
+
+ def test_release_non_existent_advisory_lock
+ fake_lock_id = 2940075057017742022
+ with_warning_suppression do
+ released_non_existent_lock = @connection.release_advisory_lock(fake_lock_id)
+ assert_equal released_non_existent_lock, false,
+ "expected release_advisory_lock to return false when there was no lock to release"
+ end
+ end
+
+ def test_supports_ranges_is_deprecated
+ assert_deprecated { @connection.supports_ranges? }
+ end
+
+ private
+
+ def with_warning_suppression
+ log_level = @connection.client_min_messages
+ @connection.client_min_messages = "error"
+ yield
+ @connection.client_min_messages = log_level
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/create_unlogged_tables_test.rb b/activerecord/test/cases/adapters/postgresql/create_unlogged_tables_test.rb
new file mode 100644
index 0000000000..a02bae1453
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/create_unlogged_tables_test.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+class UnloggedTablesTest < ActiveRecord::PostgreSQLTestCase
+ include SchemaDumpingHelper
+
+ TABLE_NAME = "things"
+ LOGGED_FIELD = "relpersistence"
+ LOGGED_QUERY = "SELECT #{LOGGED_FIELD} FROM pg_class WHERE relname = '#{TABLE_NAME}'"
+ LOGGED = "p"
+ UNLOGGED = "u"
+ TEMPORARY = "t"
+
+ class Thing < ActiveRecord::Base
+ self.table_name = TABLE_NAME
+ end
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = false
+ end
+
+ teardown do
+ @connection.drop_table TABLE_NAME, if_exists: true
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = false
+ end
+
+ def test_logged_by_default
+ @connection.create_table(TABLE_NAME) do |t|
+ end
+ assert_equal @connection.execute(LOGGED_QUERY).first[LOGGED_FIELD], LOGGED
+ end
+
+ def test_unlogged_in_test_environment_when_unlogged_setting_enabled
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = true
+
+ @connection.create_table(TABLE_NAME) do |t|
+ end
+ assert_equal @connection.execute(LOGGED_QUERY).first[LOGGED_FIELD], UNLOGGED
+ end
+
+ def test_not_included_in_schema_dump
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = true
+
+ @connection.create_table(TABLE_NAME) do |t|
+ end
+ assert_no_match(/unlogged/i, dump_table_schema(TABLE_NAME))
+ end
+
+ def test_not_changed_in_change_table
+ @connection.create_table(TABLE_NAME) do |t|
+ end
+
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = true
+
+ @connection.change_table(TABLE_NAME) do |t|
+ t.column :name, :string
+ end
+ assert_equal @connection.execute(LOGGED_QUERY).first[LOGGED_FIELD], LOGGED
+ end
+
+ def test_gracefully_handles_temporary_tables
+ @connection.create_table(TABLE_NAME, temporary: true) do |t|
+ end
+
+ # Temporary tables are already unlogged, though this query results in a
+ # different result ("t" vs. "u"). This test is really just checking that we
+ # didn't try to run `CREATE TEMPORARY UNLOGGED TABLE`, which would result in
+ # a PostgreSQL error.
+ assert_equal @connection.execute(LOGGED_QUERY).first[LOGGED_FIELD], TEMPORARY
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/datatype_test.rb b/activerecord/test/cases/adapters/postgresql/datatype_test.rb
new file mode 100644
index 0000000000..b7535d5c9a
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/datatype_test.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/ddl_helper"
+
+class PostgresqlTime < ActiveRecord::Base
+end
+
+class PostgresqlOid < ActiveRecord::Base
+end
+
+class PostgresqlLtree < ActiveRecord::Base
+end
+
+class PostgresqlDataTypeTest < ActiveRecord::PostgreSQLTestCase
+ self.use_transactional_tests = false
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+
+ @connection.execute("INSERT INTO postgresql_times (id, time_interval, scaled_time_interval) VALUES (1, '1 year 2 days ago', '3 weeks ago')")
+ @first_time = PostgresqlTime.find(1)
+
+ @connection.execute("INSERT INTO postgresql_oids (id, obj_id) VALUES (1, 1234)")
+ @first_oid = PostgresqlOid.find(1)
+ end
+
+ teardown do
+ [PostgresqlTime, PostgresqlOid].each(&:delete_all)
+ end
+
+ def test_data_type_of_time_types
+ assert_equal :interval, @first_time.column_for_attribute(:time_interval).type
+ assert_equal :interval, @first_time.column_for_attribute(:scaled_time_interval).type
+ end
+
+ def test_data_type_of_oid_types
+ assert_equal :oid, @first_oid.column_for_attribute(:obj_id).type
+ end
+
+ def test_time_values
+ assert_equal "-1 years -2 days", @first_time.time_interval
+ assert_equal "-21 days", @first_time.scaled_time_interval
+ end
+
+ def test_oid_values
+ assert_equal 1234, @first_oid.obj_id
+ end
+
+ def test_update_time
+ @first_time.time_interval = "2 years 3 minutes"
+ assert @first_time.save
+ assert @first_time.reload
+ assert_equal "2 years 00:03:00", @first_time.time_interval
+ end
+
+ def test_update_oid
+ new_value = 567890
+ @first_oid.obj_id = new_value
+ assert @first_oid.save
+ assert @first_oid.reload
+ assert_equal new_value, @first_oid.obj_id
+ end
+
+ def test_text_columns_are_limitless_the_upper_limit_is_one_GB
+ assert_equal "text", @connection.type_to_sql(:text, limit: 100_000)
+ assert_raise ActiveRecord::ActiveRecordError do
+ @connection.type_to_sql(:text, limit: 4294967295)
+ end
+ end
+end
+
+class PostgresqlInternalDataTypeTest < ActiveRecord::PostgreSQLTestCase
+ include DdlHelper
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ end
+
+ def test_name_column_type
+ with_example_table @connection, "ex", "data name" do
+ column = @connection.columns("ex").find { |col| col.name == "data" }
+ assert_equal :string, column.type
+ end
+ end
+
+ def test_char_column_type
+ with_example_table @connection, "ex", 'data "char"' do
+ column = @connection.columns("ex").find { |col| col.name == "data" }
+ assert_equal :string, column.type
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/date_test.rb b/activerecord/test/cases/adapters/postgresql/date_test.rb
new file mode 100644
index 0000000000..a86abac2be
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/date_test.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+
+class PostgresqlDateTest < ActiveRecord::PostgreSQLTestCase
+ def test_load_infinity_and_beyond
+ topic = Topic.find_by_sql("SELECT 'infinity'::date AS last_read").first
+ assert topic.last_read.infinite?, "timestamp should be infinite"
+ assert_operator topic.last_read, :>, 0
+
+ topic = Topic.find_by_sql("SELECT '-infinity'::date AS last_read").first
+ assert topic.last_read.infinite?, "timestamp should be infinite"
+ assert_operator topic.last_read, :<, 0
+ end
+
+ def test_save_infinity_and_beyond
+ topic = Topic.create!(last_read: 1.0 / 0.0)
+ assert_equal(1.0 / 0.0, topic.last_read)
+
+ topic = Topic.create!(last_read: -1.0 / 0.0)
+ assert_equal(-1.0 / 0.0, topic.last_read)
+ end
+
+ def test_bc_date
+ date = Date.new(0) - 1.week
+ topic = Topic.create!(last_read: date)
+ assert_equal date, Topic.find(topic.id).last_read
+ end
+
+ def test_bc_date_leap_year
+ date = Time.utc(-4, 2, 29).to_date
+ topic = Topic.create!(last_read: date)
+ assert_equal date, Topic.find(topic.id).last_read
+ end
+
+ def test_bc_date_year_zero
+ date = Time.utc(0, 4, 7).to_date
+ topic = Topic.create!(last_read: date)
+ assert_equal date, Topic.find(topic.id).last_read
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/domain_test.rb b/activerecord/test/cases/adapters/postgresql/domain_test.rb
new file mode 100644
index 0000000000..eeaad94c27
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/domain_test.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/connection_helper"
+
+class PostgresqlDomainTest < ActiveRecord::PostgreSQLTestCase
+ include ConnectionHelper
+
+ class PostgresqlDomain < ActiveRecord::Base
+ self.table_name = "postgresql_domains"
+ end
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ @connection.transaction do
+ @connection.execute "CREATE DOMAIN custom_money as numeric(8,2)"
+ @connection.create_table("postgresql_domains") do |t|
+ t.column :price, :custom_money
+ end
+ end
+ end
+
+ teardown do
+ @connection.drop_table "postgresql_domains", if_exists: true
+ @connection.execute "DROP DOMAIN IF EXISTS custom_money"
+ reset_connection
+ end
+
+ def test_column
+ column = PostgresqlDomain.columns_hash["price"]
+ assert_equal :decimal, column.type
+ assert_equal "custom_money", column.sql_type
+ assert_not_predicate column, :array?
+
+ type = PostgresqlDomain.type_for_attribute("price")
+ assert_not_predicate type, :binary?
+ end
+
+ def test_domain_acts_like_basetype
+ PostgresqlDomain.create price: ""
+ record = PostgresqlDomain.first
+ assert_nil record.price
+
+ record.price = "34.15"
+ record.save!
+
+ assert_equal BigDecimal("34.15"), record.reload.price
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/enum_test.rb b/activerecord/test/cases/adapters/postgresql/enum_test.rb
new file mode 100644
index 0000000000..416a2b141b
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/enum_test.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/connection_helper"
+
+class PostgresqlEnumTest < ActiveRecord::PostgreSQLTestCase
+ include ConnectionHelper
+
+ class PostgresqlEnum < ActiveRecord::Base
+ self.table_name = "postgresql_enums"
+ end
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ @connection.transaction do
+ @connection.execute <<~SQL
+ CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy');
+ SQL
+ @connection.create_table("postgresql_enums") do |t|
+ t.column :current_mood, :mood
+ end
+ end
+ end
+
+ teardown do
+ @connection.drop_table "postgresql_enums", if_exists: true
+ @connection.execute "DROP TYPE IF EXISTS mood"
+ reset_connection
+ end
+
+ def test_column
+ column = PostgresqlEnum.columns_hash["current_mood"]
+ assert_equal :enum, column.type
+ assert_equal "mood", column.sql_type
+ assert_not_predicate column, :array?
+
+ type = PostgresqlEnum.type_for_attribute("current_mood")
+ assert_not_predicate type, :binary?
+ end
+
+ def test_enum_defaults
+ @connection.add_column "postgresql_enums", "good_mood", :mood, default: "happy"
+ PostgresqlEnum.reset_column_information
+
+ assert_equal "happy", PostgresqlEnum.column_defaults["good_mood"]
+ assert_equal "happy", PostgresqlEnum.new.good_mood
+ ensure
+ PostgresqlEnum.reset_column_information
+ end
+
+ def test_enum_mapping
+ @connection.execute "INSERT INTO postgresql_enums VALUES (1, 'sad');"
+ enum = PostgresqlEnum.first
+ assert_equal "sad", enum.current_mood
+
+ enum.current_mood = "happy"
+ enum.save!
+
+ assert_equal "happy", enum.reload.current_mood
+ end
+
+ def test_invalid_enum_update
+ @connection.execute "INSERT INTO postgresql_enums VALUES (1, 'sad');"
+ enum = PostgresqlEnum.first
+ enum.current_mood = "angry"
+
+ assert_raise ActiveRecord::StatementInvalid do
+ enum.save
+ end
+ end
+
+ def test_no_oid_warning
+ @connection.execute "INSERT INTO postgresql_enums VALUES (1, 'sad');"
+ stderr_output = capture(:stderr) { PostgresqlEnum.first }
+
+ assert_predicate stderr_output, :blank?
+ end
+
+ def test_enum_type_cast
+ enum = PostgresqlEnum.new
+ enum.current_mood = :happy
+
+ assert_equal "happy", enum.current_mood
+ end
+
+ def test_assigning_enum_to_nil
+ model = PostgresqlEnum.new(current_mood: nil)
+
+ assert_nil model.current_mood
+ assert model.save
+ assert_nil model.reload.current_mood
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/explain_test.rb b/activerecord/test/cases/adapters/postgresql/explain_test.rb
new file mode 100644
index 0000000000..be525383e9
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/explain_test.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/author"
+require "models/post"
+
+class PostgreSQLExplainTest < ActiveRecord::PostgreSQLTestCase
+ fixtures :authors
+
+ def test_explain_for_one_query
+ explain = Author.where(id: 1).explain
+ assert_match %r(EXPLAIN for: SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\$1 \[\["id", 1\]\]|1)), explain
+ assert_match %(QUERY PLAN), explain
+ end
+
+ def test_explain_with_eager_loading
+ explain = Author.where(id: 1).includes(:posts).explain
+ assert_match %(QUERY PLAN), explain
+ assert_match %r(EXPLAIN for: SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\$1 \[\["id", 1\]\]|1)), explain
+ assert_match %r(EXPLAIN for: SELECT "posts"\.\* FROM "posts" WHERE "posts"\."author_id" = (?:\$1 \[\["author_id", 1\]\]|1)), explain
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb b/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb
new file mode 100644
index 0000000000..df97ab11e7
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class PostgresqlExtensionMigrationTest < ActiveRecord::PostgreSQLTestCase
+ self.use_transactional_tests = false
+
+ class EnableHstore < ActiveRecord::Migration::Current
+ def change
+ enable_extension "hstore"
+ end
+ end
+
+ class DisableHstore < ActiveRecord::Migration::Current
+ def change
+ disable_extension "hstore"
+ end
+ end
+
+ def setup
+ super
+
+ @connection = ActiveRecord::Base.connection
+
+ @old_schema_migration_table_name = ActiveRecord::SchemaMigration.table_name
+ @old_table_name_prefix = ActiveRecord::Base.table_name_prefix
+ @old_table_name_suffix = ActiveRecord::Base.table_name_suffix
+
+ ActiveRecord::Base.table_name_prefix = "p_"
+ ActiveRecord::Base.table_name_suffix = "_s"
+ ActiveRecord::SchemaMigration.delete_all rescue nil
+ ActiveRecord::SchemaMigration.table_name = "p_schema_migrations_s"
+ ActiveRecord::Migration.verbose = false
+ end
+
+ def teardown
+ ActiveRecord::Base.table_name_prefix = @old_table_name_prefix
+ ActiveRecord::Base.table_name_suffix = @old_table_name_suffix
+ ActiveRecord::SchemaMigration.delete_all rescue nil
+ ActiveRecord::Migration.verbose = true
+ ActiveRecord::SchemaMigration.table_name = @old_schema_migration_table_name
+
+ super
+ end
+
+ def test_enable_extension_migration_ignores_prefix_and_suffix
+ @connection.disable_extension("hstore")
+
+ migrations = [EnableHstore.new(nil, 1)]
+ ActiveRecord::Migrator.new(:up, migrations).migrate
+ assert @connection.extension_enabled?("hstore"), "extension hstore should be enabled"
+ end
+
+ def test_disable_extension_migration_ignores_prefix_and_suffix
+ @connection.enable_extension("hstore")
+
+ migrations = [DisableHstore.new(nil, 1)]
+ ActiveRecord::Migrator.new(:up, migrations).migrate
+ assert_not @connection.extension_enabled?("hstore"), "extension hstore should not be enabled"
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/foreign_table_test.rb b/activerecord/test/cases/adapters/postgresql/foreign_table_test.rb
new file mode 100644
index 0000000000..69339c8a31
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/foreign_table_test.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/professor"
+
+if ActiveRecord::Base.connection.supports_foreign_tables?
+ class ForeignTableTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ class ForeignProfessor < ActiveRecord::Base
+ self.table_name = "foreign_professors"
+ end
+
+ class ForeignProfessorWithPk < ForeignProfessor
+ self.primary_key = "id"
+ end
+
+ def setup
+ @professor = Professor.create(name: "Nicola")
+
+ @connection = ActiveRecord::Base.connection
+ enable_extension!("postgres_fdw", @connection)
+
+ foreign_db_config = ARTest.connection_config["arunit2"]
+ @connection.execute <<~SQL
+ CREATE SERVER foreign_server
+ FOREIGN DATA WRAPPER postgres_fdw
+ OPTIONS (dbname '#{foreign_db_config["database"]}')
+ SQL
+
+ @connection.execute <<~SQL
+ CREATE USER MAPPING FOR CURRENT_USER
+ SERVER foreign_server
+ SQL
+
+ @connection.execute <<~SQL
+ CREATE FOREIGN TABLE foreign_professors (
+ id int,
+ name character varying NOT NULL
+ ) SERVER foreign_server OPTIONS (
+ table_name 'professors'
+ )
+ SQL
+ end
+
+ def teardown
+ disable_extension!("postgres_fdw", @connection)
+ @connection.execute <<~SQL
+ DROP SERVER IF EXISTS foreign_server CASCADE
+ SQL
+ end
+
+ def test_table_exists
+ table_name = ForeignProfessor.table_name
+ assert_not ActiveRecord::Base.connection.table_exists?(table_name)
+ end
+
+ def test_foreign_tables_are_valid_data_sources
+ table_name = ForeignProfessor.table_name
+ assert @connection.data_source_exists?(table_name), "'#{table_name}' should be a data source"
+ end
+
+ def test_foreign_tables
+ assert_equal ["foreign_professors"], @connection.foreign_tables
+ end
+
+ def test_foreign_table_exists
+ assert @connection.foreign_table_exists?("foreign_professors")
+ assert @connection.foreign_table_exists?(:foreign_professors)
+ assert_not @connection.foreign_table_exists?("nonexistingtable")
+ assert_not @connection.foreign_table_exists?("'")
+ assert_not @connection.foreign_table_exists?(nil)
+ end
+
+ def test_attribute_names
+ assert_equal ["id", "name"], ForeignProfessor.attribute_names
+ end
+
+ def test_attributes
+ professor = ForeignProfessorWithPk.find(@professor.id)
+ assert_equal @professor.attributes, professor.attributes
+ end
+
+ def test_does_not_have_a_primary_key
+ assert_nil ForeignProfessor.primary_key
+ end
+
+ def test_insert_record
+ # Explicit `id` here to avoid complex configurations to implicitly work with remote table
+ ForeignProfessorWithPk.create!(id: 100, name: "Leonardo")
+
+ professor = ForeignProfessorWithPk.last
+ assert_equal "Leonardo", professor.name
+ end
+
+ def test_update_record
+ professor = ForeignProfessorWithPk.find(@professor.id)
+ professor.name = "Albert"
+ professor.save!
+ professor.reload
+ assert_equal "Albert", professor.name
+ end
+
+ def test_delete_record
+ professor = ForeignProfessorWithPk.find(@professor.id)
+ assert_difference("ForeignProfessor.count", -1) { professor.destroy }
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/full_text_test.rb b/activerecord/test/cases/adapters/postgresql/full_text_test.rb
new file mode 100644
index 0000000000..95dee3bf44
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/full_text_test.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+class PostgresqlFullTextTest < ActiveRecord::PostgreSQLTestCase
+ include SchemaDumpingHelper
+ class Tsvector < ActiveRecord::Base; end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table("tsvectors") do |t|
+ t.tsvector "text_vector"
+ end
+ end
+
+ teardown do
+ @connection.drop_table "tsvectors", if_exists: true
+ end
+
+ def test_tsvector_column
+ column = Tsvector.columns_hash["text_vector"]
+ assert_equal :tsvector, column.type
+ assert_equal "tsvector", column.sql_type
+ assert_not_predicate column, :array?
+
+ type = Tsvector.type_for_attribute("text_vector")
+ assert_not_predicate type, :binary?
+ end
+
+ def test_update_tsvector
+ Tsvector.create text_vector: "'text' 'vector'"
+ tsvector = Tsvector.first
+ assert_equal "'text' 'vector'", tsvector.text_vector
+
+ tsvector.text_vector = "'new' 'text' 'vector'"
+ tsvector.save!
+ assert tsvector.reload
+ assert_equal "'new' 'text' 'vector'", tsvector.text_vector
+ end
+
+ def test_schema_dump_with_shorthand
+ output = dump_table_schema("tsvectors")
+ assert_match %r{t\.tsvector "text_vector"}, output
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/geometric_test.rb b/activerecord/test/cases/adapters/postgresql/geometric_test.rb
new file mode 100644
index 0000000000..8c6f046553
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/geometric_test.rb
@@ -0,0 +1,373 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/connection_helper"
+require "support/schema_dumping_helper"
+
+class PostgresqlPointTest < ActiveRecord::PostgreSQLTestCase
+ include ConnectionHelper
+ include SchemaDumpingHelper
+
+ class PostgresqlPoint < ActiveRecord::Base
+ attribute :x, :point
+ attribute :y, :point
+ attribute :z, :point
+ attribute :array_of_points, :point, array: true
+ attribute :legacy_x, :legacy_point
+ attribute :legacy_y, :legacy_point
+ attribute :legacy_z, :legacy_point
+ end
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table("postgresql_points") do |t|
+ t.point :x
+ t.point :y, default: [12.2, 13.3]
+ t.point :z, default: "(14.4,15.5)"
+ t.point :array_of_points, array: true
+ t.point :legacy_x
+ t.point :legacy_y, default: [12.2, 13.3]
+ t.point :legacy_z, default: "(14.4,15.5)"
+ end
+ end
+
+ teardown do
+ @connection.drop_table "postgresql_points", if_exists: true
+ end
+
+ def test_column
+ column = PostgresqlPoint.columns_hash["x"]
+ assert_equal :point, column.type
+ assert_equal "point", column.sql_type
+ assert_not_predicate column, :array?
+
+ type = PostgresqlPoint.type_for_attribute("x")
+ assert_not_predicate type, :binary?
+ end
+
+ def test_default
+ assert_equal ActiveRecord::Point.new(12.2, 13.3), PostgresqlPoint.column_defaults["y"]
+ assert_equal ActiveRecord::Point.new(12.2, 13.3), PostgresqlPoint.new.y
+
+ assert_equal ActiveRecord::Point.new(14.4, 15.5), PostgresqlPoint.column_defaults["z"]
+ assert_equal ActiveRecord::Point.new(14.4, 15.5), PostgresqlPoint.new.z
+ end
+
+ def test_schema_dumping
+ output = dump_table_schema("postgresql_points")
+ assert_match %r{t\.point\s+"x"$}, output
+ assert_match %r{t\.point\s+"y",\s+default: \[12\.2, 13\.3\]$}, output
+ assert_match %r{t\.point\s+"z",\s+default: \[14\.4, 15\.5\]$}, output
+ end
+
+ def test_roundtrip
+ PostgresqlPoint.create! x: [10, 25.2]
+ record = PostgresqlPoint.first
+ assert_equal ActiveRecord::Point.new(10, 25.2), record.x
+
+ record.x = ActiveRecord::Point.new(1.1, 2.2)
+ record.save!
+ assert record.reload
+ assert_equal ActiveRecord::Point.new(1.1, 2.2), record.x
+ end
+
+ def test_mutation
+ p = PostgresqlPoint.create! x: ActiveRecord::Point.new(10, 20)
+
+ p.x.y = 25
+ p.save!
+ p.reload
+
+ assert_equal ActiveRecord::Point.new(10.0, 25.0), p.x
+ assert_not_predicate p, :changed?
+ end
+
+ def test_array_assignment
+ p = PostgresqlPoint.new(x: [1, 2])
+
+ assert_equal ActiveRecord::Point.new(1, 2), p.x
+ end
+
+ def test_string_assignment
+ p = PostgresqlPoint.new(x: "(1, 2)")
+
+ assert_equal ActiveRecord::Point.new(1, 2), p.x
+ end
+
+ def test_empty_string_assignment
+ p = PostgresqlPoint.new(x: "")
+ assert_nil p.x
+ end
+
+ def test_array_of_points_round_trip
+ expected_value = [
+ ActiveRecord::Point.new(1, 2),
+ ActiveRecord::Point.new(2, 3),
+ ActiveRecord::Point.new(3, 4),
+ ]
+ p = PostgresqlPoint.new(array_of_points: expected_value)
+
+ assert_equal expected_value, p.array_of_points
+ p.save!
+ p.reload
+ assert_equal expected_value, p.array_of_points
+ end
+
+ def test_legacy_column
+ column = PostgresqlPoint.columns_hash["legacy_x"]
+ assert_equal :point, column.type
+ assert_equal "point", column.sql_type
+ assert_not_predicate column, :array?
+
+ type = PostgresqlPoint.type_for_attribute("legacy_x")
+ assert_not_predicate type, :binary?
+ end
+
+ def test_legacy_default
+ assert_equal [12.2, 13.3], PostgresqlPoint.column_defaults["legacy_y"]
+ assert_equal [12.2, 13.3], PostgresqlPoint.new.legacy_y
+
+ assert_equal [14.4, 15.5], PostgresqlPoint.column_defaults["legacy_z"]
+ assert_equal [14.4, 15.5], PostgresqlPoint.new.legacy_z
+ end
+
+ def test_legacy_schema_dumping
+ output = dump_table_schema("postgresql_points")
+ assert_match %r{t\.point\s+"legacy_x"$}, output
+ assert_match %r{t\.point\s+"legacy_y",\s+default: \[12\.2, 13\.3\]$}, output
+ assert_match %r{t\.point\s+"legacy_z",\s+default: \[14\.4, 15\.5\]$}, output
+ end
+
+ def test_legacy_roundtrip
+ PostgresqlPoint.create! legacy_x: [10, 25.2]
+ record = PostgresqlPoint.first
+ assert_equal [10, 25.2], record.legacy_x
+
+ record.legacy_x = [1.1, 2.2]
+ record.save!
+ assert record.reload
+ assert_equal [1.1, 2.2], record.legacy_x
+ end
+
+ def test_legacy_mutation
+ p = PostgresqlPoint.create! legacy_x: [10, 20]
+
+ p.legacy_x[1] = 25
+ p.save!
+ p.reload
+
+ assert_equal [10.0, 25.0], p.legacy_x
+ assert_not_predicate p, :changed?
+ end
+end
+
+class PostgresqlGeometricTest < ActiveRecord::PostgreSQLTestCase
+ include SchemaDumpingHelper
+
+ class PostgresqlGeometric < ActiveRecord::Base; end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table("postgresql_geometrics") do |t|
+ t.lseg :a_line_segment
+ t.box :a_box
+ t.path :a_path
+ t.polygon :a_polygon
+ t.circle :a_circle
+ end
+ end
+
+ teardown do
+ @connection.drop_table "postgresql_geometrics", if_exists: true
+ end
+
+ def test_geometric_types
+ g = PostgresqlGeometric.new(
+ a_line_segment: "(2.0, 3), (5.5, 7.0)",
+ a_box: "2.0, 3, 5.5, 7.0",
+ a_path: "[(2.0, 3), (5.5, 7.0), (8.5, 11.0)]",
+ a_polygon: "((2.0, 3), (5.5, 7.0), (8.5, 11.0))",
+ a_circle: "<(5.3, 10.4), 2>"
+ )
+
+ g.save!
+
+ h = PostgresqlGeometric.find(g.id)
+
+ assert_equal "[(2,3),(5.5,7)]", h.a_line_segment
+ assert_equal "(5.5,7),(2,3)", h.a_box # reordered to store upper right corner then bottom left corner
+ assert_equal "[(2,3),(5.5,7),(8.5,11)]", h.a_path
+ assert_equal "((2,3),(5.5,7),(8.5,11))", h.a_polygon
+ assert_equal "<(5.3,10.4),2>", h.a_circle
+ end
+
+ def test_alternative_format
+ g = PostgresqlGeometric.new(
+ a_line_segment: "((2.0, 3), (5.5, 7.0))",
+ a_box: "(2.0, 3), (5.5, 7.0)",
+ a_path: "((2.0, 3), (5.5, 7.0), (8.5, 11.0))",
+ a_polygon: "2.0, 3, 5.5, 7.0, 8.5, 11.0",
+ a_circle: "((5.3, 10.4), 2)"
+ )
+
+ g.save!
+
+ h = PostgresqlGeometric.find(g.id)
+ assert_equal "[(2,3),(5.5,7)]", h.a_line_segment
+ assert_equal "(5.5,7),(2,3)", h.a_box # reordered to store upper right corner then bottom left corner
+ assert_equal "((2,3),(5.5,7),(8.5,11))", h.a_path
+ assert_equal "((2,3),(5.5,7),(8.5,11))", h.a_polygon
+ assert_equal "<(5.3,10.4),2>", h.a_circle
+ end
+
+ def test_geometric_function
+ PostgresqlGeometric.create! a_path: "[(2.0, 3), (5.5, 7.0), (8.5, 11.0)]" # [ ] is an open path
+ PostgresqlGeometric.create! a_path: "((2.0, 3), (5.5, 7.0), (8.5, 11.0))" # ( ) is a closed path
+
+ objs = PostgresqlGeometric.find_by_sql "SELECT isopen(a_path) FROM postgresql_geometrics ORDER BY id ASC"
+ assert_equal [true, false], objs.map(&:isopen)
+
+ objs = PostgresqlGeometric.find_by_sql "SELECT isclosed(a_path) FROM postgresql_geometrics ORDER BY id ASC"
+ assert_equal [false, true], objs.map(&:isclosed)
+ end
+
+ def test_schema_dumping
+ output = dump_table_schema("postgresql_geometrics")
+ assert_match %r{t\.lseg\s+"a_line_segment"$}, output
+ assert_match %r{t\.box\s+"a_box"$}, output
+ assert_match %r{t\.path\s+"a_path"$}, output
+ assert_match %r{t\.polygon\s+"a_polygon"$}, output
+ assert_match %r{t\.circle\s+"a_circle"$}, output
+ end
+end
+
+class PostgreSQLGeometricLineTest < ActiveRecord::PostgreSQLTestCase
+ include SchemaDumpingHelper
+
+ class PostgresqlLine < ActiveRecord::Base; end
+
+ setup do
+ unless ActiveRecord::Base.connection.send(:postgresql_version) >= 90400
+ skip("line type is not fully implemented")
+ end
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table("postgresql_lines") do |t|
+ t.line :a_line
+ end
+ end
+
+ teardown do
+ if defined?(@connection)
+ @connection.drop_table "postgresql_lines", if_exists: true
+ end
+ end
+
+ def test_geometric_line_type
+ g = PostgresqlLine.new(
+ a_line: "{2.0, 3, 5.5}"
+ )
+ g.save!
+
+ h = PostgresqlLine.find(g.id)
+ assert_equal "{2,3,5.5}", h.a_line
+ end
+
+ def test_alternative_format_line_type
+ g = PostgresqlLine.new(
+ a_line: "(2.0, 3), (4.0, 6.0)"
+ )
+ g.save!
+
+ h = PostgresqlLine.find(g.id)
+ assert_equal "{1.5,-1,0}", h.a_line
+ end
+
+ def test_schema_dumping_for_line_type
+ output = dump_table_schema("postgresql_lines")
+ assert_match %r{t\.line\s+"a_line"$}, output
+ end
+end
+
+class PostgreSQLGeometricTypesTest < ActiveRecord::PostgreSQLTestCase
+ attr_reader :connection, :table_name
+
+ def setup
+ super
+ @connection = ActiveRecord::Base.connection
+ @table_name = :testings
+ end
+
+ def test_creating_column_with_point_type
+ connection.create_table(table_name) do |t|
+ t.point :foo_point
+ end
+
+ assert_column_exists(:foo_point)
+ assert_type_correct(:foo_point, :point)
+ end
+
+ def test_creating_column_with_line_type
+ connection.create_table(table_name) do |t|
+ t.line :foo_line
+ end
+
+ assert_column_exists(:foo_line)
+ assert_type_correct(:foo_line, :line)
+ end
+
+ def test_creating_column_with_lseg_type
+ connection.create_table(table_name) do |t|
+ t.lseg :foo_lseg
+ end
+
+ assert_column_exists(:foo_lseg)
+ assert_type_correct(:foo_lseg, :lseg)
+ end
+
+ def test_creating_column_with_box_type
+ connection.create_table(table_name) do |t|
+ t.box :foo_box
+ end
+
+ assert_column_exists(:foo_box)
+ assert_type_correct(:foo_box, :box)
+ end
+
+ def test_creating_column_with_path_type
+ connection.create_table(table_name) do |t|
+ t.path :foo_path
+ end
+
+ assert_column_exists(:foo_path)
+ assert_type_correct(:foo_path, :path)
+ end
+
+ def test_creating_column_with_polygon_type
+ connection.create_table(table_name) do |t|
+ t.polygon :foo_polygon
+ end
+
+ assert_column_exists(:foo_polygon)
+ assert_type_correct(:foo_polygon, :polygon)
+ end
+
+ def test_creating_column_with_circle_type
+ connection.create_table(table_name) do |t|
+ t.circle :foo_circle
+ end
+
+ assert_column_exists(:foo_circle)
+ assert_type_correct(:foo_circle, :circle)
+ end
+
+ private
+
+ def assert_column_exists(column_name)
+ assert connection.column_exists?(table_name, column_name)
+ end
+
+ def assert_type_correct(column_name, type)
+ column = connection.columns(table_name).find { |c| c.name == column_name.to_s }
+ assert_equal type, column.type
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/hstore_test.rb b/activerecord/test/cases/adapters/postgresql/hstore_test.rb
new file mode 100644
index 0000000000..4b061a9375
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/hstore_test.rb
@@ -0,0 +1,378 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+class PostgresqlHstoreTest < ActiveRecord::PostgreSQLTestCase
+ include SchemaDumpingHelper
+ class Hstore < ActiveRecord::Base
+ self.table_name = "hstores"
+
+ store_accessor :settings, :language, :timezone
+ end
+
+ class FakeParameters
+ def to_unsafe_h
+ { "hi" => "hi" }
+ end
+ end
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+
+ enable_extension!("hstore", @connection)
+
+ @connection.transaction do
+ @connection.create_table("hstores") do |t|
+ t.hstore "tags", default: ""
+ t.hstore "payload", array: true
+ t.hstore "settings"
+ end
+ end
+ Hstore.reset_column_information
+ @column = Hstore.columns_hash["tags"]
+ @type = Hstore.type_for_attribute("tags")
+ end
+
+ teardown do
+ @connection.drop_table "hstores", if_exists: true
+ disable_extension!("hstore", @connection)
+ end
+
+ def test_hstore_included_in_extensions
+ assert_respond_to @connection, :extensions
+ assert_includes @connection.extensions, "hstore", "extension list should include hstore"
+ end
+
+ def test_disable_enable_hstore
+ assert @connection.extension_enabled?("hstore")
+ @connection.disable_extension "hstore"
+ assert_not @connection.extension_enabled?("hstore")
+ @connection.enable_extension "hstore"
+ assert @connection.extension_enabled?("hstore")
+ ensure
+ # Restore column(s) dropped by `drop extension hstore cascade;`
+ load_schema
+ end
+
+ def test_column
+ assert_equal :hstore, @column.type
+ assert_equal "hstore", @column.sql_type
+ assert_not_predicate @column, :array?
+
+ assert_not_predicate @type, :binary?
+ end
+
+ def test_default
+ @connection.add_column "hstores", "permissions", :hstore, default: '"users"=>"read", "articles"=>"write"'
+ Hstore.reset_column_information
+
+ assert_equal({ "users" => "read", "articles" => "write" }, Hstore.column_defaults["permissions"])
+ assert_equal({ "users" => "read", "articles" => "write" }, Hstore.new.permissions)
+ ensure
+ Hstore.reset_column_information
+ end
+
+ def test_change_table_supports_hstore
+ @connection.transaction do
+ @connection.change_table("hstores") do |t|
+ t.hstore "users", default: ""
+ end
+ Hstore.reset_column_information
+ column = Hstore.columns_hash["users"]
+ assert_equal :hstore, column.type
+
+ raise ActiveRecord::Rollback # reset the schema change
+ end
+ ensure
+ Hstore.reset_column_information
+ end
+
+ def test_hstore_migration
+ hstore_migration = Class.new(ActiveRecord::Migration::Current) do
+ def change
+ change_table("hstores") do |t|
+ t.hstore :keys
+ end
+ end
+ end
+
+ hstore_migration.new.suppress_messages do
+ hstore_migration.migrate(:up)
+ assert_includes @connection.columns(:hstores).map(&:name), "keys"
+ hstore_migration.migrate(:down)
+ assert_not_includes @connection.columns(:hstores).map(&:name), "keys"
+ end
+ end
+
+ def test_cast_value_on_write
+ x = Hstore.new tags: { "bool" => true, "number" => 5 }
+ assert_equal({ "bool" => true, "number" => 5 }, x.tags_before_type_cast)
+ assert_equal({ "bool" => "true", "number" => "5" }, x.tags)
+ x.save
+ assert_equal({ "bool" => "true", "number" => "5" }, x.reload.tags)
+ end
+
+ def test_type_cast_hstore
+ assert_equal({ "1" => "2" }, @type.deserialize("\"1\"=>\"2\""))
+ assert_equal({}, @type.deserialize(""))
+ assert_equal({ "key" => nil }, @type.deserialize("key => NULL"))
+ assert_equal({ "c" => "}", '"a"' => 'b "a b' }, @type.deserialize(%q(c=>"}", "\"a\""=>"b \"a b")))
+ end
+
+ def test_with_store_accessors
+ x = Hstore.new(language: "fr", timezone: "GMT")
+ assert_equal "fr", x.language
+ assert_equal "GMT", x.timezone
+
+ x.save!
+ x = Hstore.first
+ assert_equal "fr", x.language
+ assert_equal "GMT", x.timezone
+
+ x.language = "de"
+ x.save!
+
+ x = Hstore.first
+ assert_equal "de", x.language
+ assert_equal "GMT", x.timezone
+ end
+
+ def test_duplication_with_store_accessors
+ x = Hstore.new(language: "fr", timezone: "GMT")
+ assert_equal "fr", x.language
+ assert_equal "GMT", x.timezone
+
+ y = x.dup
+ assert_equal "fr", y.language
+ assert_equal "GMT", y.timezone
+ end
+
+ def test_yaml_round_trip_with_store_accessors
+ x = Hstore.new(language: "fr", timezone: "GMT")
+ assert_equal "fr", x.language
+ assert_equal "GMT", x.timezone
+
+ y = YAML.load(YAML.dump(x))
+ assert_equal "fr", y.language
+ assert_equal "GMT", y.timezone
+ end
+
+ def test_changes_in_place
+ hstore = Hstore.create!(settings: { "one" => "two" })
+ hstore.settings["three"] = "four"
+ hstore.save!
+ hstore.reload
+
+ assert_equal "four", hstore.settings["three"]
+ assert_not_predicate hstore, :changed?
+ end
+
+ def test_dirty_from_user_equal
+ settings = { "alongkey" => "anything", "key" => "value" }
+ hstore = Hstore.create!(settings: settings)
+
+ hstore.settings = { "key" => "value", "alongkey" => "anything" }
+ assert_equal settings, hstore.settings
+ assert_not_predicate hstore, :changed?
+ end
+
+ def test_hstore_dirty_from_database_equal
+ settings = { "alongkey" => "anything", "key" => "value" }
+ hstore = Hstore.create!(settings: settings)
+ hstore.reload
+
+ assert_equal settings, hstore.settings
+ hstore.settings = settings
+ assert_not_predicate hstore, :changed?
+ end
+
+ def test_gen1
+ assert_equal('" "=>""', @type.serialize(" " => ""))
+ end
+
+ def test_gen2
+ assert_equal('","=>""', @type.serialize("," => ""))
+ end
+
+ def test_gen3
+ assert_equal('"="=>""', @type.serialize("=" => ""))
+ end
+
+ def test_gen4
+ assert_equal('">"=>""', @type.serialize(">" => ""))
+ end
+
+ def test_parse1
+ assert_equal({ "a" => nil, "b" => nil, "c" => "NuLl", "null" => "c" }, @type.deserialize('a=>null,b=>NuLl,c=>"NuLl",null=>c'))
+ end
+
+ def test_parse2
+ assert_equal({ " " => " " }, @type.deserialize("\\ =>\\ "))
+ end
+
+ def test_parse3
+ assert_equal({ "=" => ">" }, @type.deserialize("==>>"))
+ end
+
+ def test_parse4
+ assert_equal({ "=a" => "q=w" }, @type.deserialize('\=a=>q=w'))
+ end
+
+ def test_parse5
+ assert_equal({ "=a" => "q=w" }, @type.deserialize('"=a"=>q\=w'))
+ end
+
+ def test_parse6
+ assert_equal({ "\"a" => "q>w" }, @type.deserialize('"\"a"=>q>w'))
+ end
+
+ def test_parse7
+ assert_equal({ "\"a" => "q\"w" }, @type.deserialize('\"a=>q"w'))
+ end
+
+ def test_rewrite
+ @connection.execute "insert into hstores (tags) VALUES ('1=>2')"
+ x = Hstore.first
+ x.tags = { '"a\'' => "b" }
+ assert x.save!
+ end
+
+ def test_select
+ @connection.execute "insert into hstores (tags) VALUES ('1=>2')"
+ x = Hstore.first
+ assert_equal({ "1" => "2" }, x.tags)
+ end
+
+ def test_array_cycle
+ assert_array_cycle([{ "AA" => "BB", "CC" => "DD" }, { "AA" => nil }])
+ end
+
+ def test_array_strings_with_quotes
+ assert_array_cycle([{ "this has" => 'some "s that need to be escaped"' }])
+ end
+
+ def test_array_strings_with_commas
+ assert_array_cycle([{ "this,has" => "many,values" }])
+ end
+
+ def test_array_strings_with_array_delimiters
+ assert_array_cycle(["{" => "}"])
+ end
+
+ def test_array_strings_with_null_strings
+ assert_array_cycle([{ "NULL" => "NULL" }])
+ end
+
+ def test_contains_nils
+ assert_array_cycle([{ "NULL" => nil }])
+ end
+
+ def test_select_multikey
+ @connection.execute "insert into hstores (tags) VALUES ('1=>2,2=>3')"
+ x = Hstore.first
+ assert_equal({ "1" => "2", "2" => "3" }, x.tags)
+ end
+
+ def test_create
+ assert_cycle("a" => "b", "1" => "2")
+ end
+
+ def test_nil
+ assert_cycle("a" => nil)
+ end
+
+ def test_quotes
+ assert_cycle("a" => 'b"ar', '1"foo' => "2")
+ end
+
+ def test_whitespace
+ assert_cycle("a b" => "b ar", '1"foo' => "2")
+ end
+
+ def test_backslash
+ assert_cycle('a\\b' => 'b\\ar', '1"foo' => "2")
+ end
+
+ def test_comma
+ assert_cycle("a, b" => "bar", '1"foo' => "2")
+ end
+
+ def test_arrow
+ assert_cycle("a=>b" => "bar", '1"foo' => "2")
+ end
+
+ def test_quoting_special_characters
+ assert_cycle("ca" => "cà", "ac" => "àc")
+ end
+
+ def test_multiline
+ assert_cycle("a\nb" => "c\nd")
+ end
+
+ class TagCollection
+ def initialize(hash); @hash = hash end
+ def to_hash; @hash end
+ def self.load(hash); new(hash) end
+ def self.dump(object); object.to_hash end
+ end
+
+ class HstoreWithSerialize < Hstore
+ serialize :tags, TagCollection
+ end
+
+ def test_hstore_with_serialized_attributes
+ HstoreWithSerialize.create! tags: TagCollection.new("one" => "two")
+ record = HstoreWithSerialize.first
+ assert_instance_of TagCollection, record.tags
+ assert_equal({ "one" => "two" }, record.tags.to_hash)
+ record.tags = TagCollection.new("three" => "four")
+ record.save!
+ assert_equal({ "three" => "four" }, HstoreWithSerialize.first.tags.to_hash)
+ end
+
+ def test_clone_hstore_with_serialized_attributes
+ HstoreWithSerialize.create! tags: TagCollection.new("one" => "two")
+ record = HstoreWithSerialize.first
+ dupe = record.dup
+ assert_equal({ "one" => "two" }, dupe.tags.to_hash)
+ end
+
+ def test_schema_dump_with_shorthand
+ output = dump_table_schema("hstores")
+ assert_match %r[t\.hstore "tags",\s+default: {}], output
+ end
+
+ def test_supports_to_unsafe_h_values
+ assert_equal("\"hi\"=>\"hi\"", @type.serialize(FakeParameters.new))
+ end
+
+ private
+ def assert_array_cycle(array)
+ # test creation
+ x = Hstore.create!(payload: array)
+ x.reload
+ assert_equal(array, x.payload)
+
+ # test updating
+ x = Hstore.create!(payload: [])
+ x.payload = array
+ x.save!
+ x.reload
+ assert_equal(array, x.payload)
+ end
+
+ def assert_cycle(hash)
+ # test creation
+ x = Hstore.create!(tags: hash)
+ x.reload
+ assert_equal(hash, x.tags)
+
+ # test updating
+ x = Hstore.create!(tags: {})
+ x.tags = hash
+ x.save!
+ x.reload
+ assert_equal(hash, x.tags)
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/infinity_test.rb b/activerecord/test/cases/adapters/postgresql/infinity_test.rb
new file mode 100644
index 0000000000..b1bf06d9e9
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/infinity_test.rb
@@ -0,0 +1,108 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class PostgresqlInfinityTest < ActiveRecord::PostgreSQLTestCase
+ include InTimeZone
+
+ class PostgresqlInfinity < ActiveRecord::Base
+ end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table(:postgresql_infinities) do |t|
+ t.float :float
+ t.datetime :datetime
+ t.date :date
+ end
+ end
+
+ teardown do
+ @connection.drop_table "postgresql_infinities", if_exists: true
+ end
+
+ test "type casting infinity on a float column" do
+ record = PostgresqlInfinity.create!(float: Float::INFINITY)
+ record.reload
+ assert_equal Float::INFINITY, record.float
+ end
+
+ test "type casting string on a float column" do
+ record = PostgresqlInfinity.new(float: "Infinity")
+ assert_equal Float::INFINITY, record.float
+ record = PostgresqlInfinity.new(float: "-Infinity")
+ assert_equal(-Float::INFINITY, record.float)
+ record = PostgresqlInfinity.new(float: "NaN")
+ assert record.float.nan?, "Expected #{record.float} to be NaN"
+ end
+
+ test "update_all with infinity on a float column" do
+ record = PostgresqlInfinity.create!
+ PostgresqlInfinity.update_all(float: Float::INFINITY)
+ record.reload
+ assert_equal Float::INFINITY, record.float
+ end
+
+ test "type casting infinity on a datetime column" do
+ record = PostgresqlInfinity.create!(datetime: "infinity")
+ record.reload
+ assert_equal Float::INFINITY, record.datetime
+
+ record = PostgresqlInfinity.create!(datetime: Float::INFINITY)
+ record.reload
+ assert_equal Float::INFINITY, record.datetime
+ end
+
+ test "type casting infinity on a date column" do
+ record = PostgresqlInfinity.create!(date: "infinity")
+ record.reload
+ assert_equal Float::INFINITY, record.date
+
+ record = PostgresqlInfinity.create!(date: Float::INFINITY)
+ record.reload
+ assert_equal Float::INFINITY, record.date
+ end
+
+ test "update_all with infinity on a datetime column" do
+ record = PostgresqlInfinity.create!
+ PostgresqlInfinity.update_all(datetime: Float::INFINITY)
+ record.reload
+ assert_equal Float::INFINITY, record.datetime
+ end
+
+ test "assigning 'infinity' on a datetime column with TZ aware attributes" do
+ in_time_zone "Pacific Time (US & Canada)" do
+ record = PostgresqlInfinity.create!(datetime: "infinity")
+ assert_equal Float::INFINITY, record.datetime
+ assert_equal record.datetime, record.reload.datetime
+ end
+ ensure
+ # setting time_zone_aware_attributes causes the types to change.
+ # There is no way to do this automatically since it can be set on a superclass
+ PostgresqlInfinity.reset_column_information
+ end
+
+ test "where clause with infinite range on a datetime column" do
+ record = PostgresqlInfinity.create!(datetime: Time.current)
+
+ string = PostgresqlInfinity.where(datetime: "-infinity".."infinity")
+ assert_equal record, string.take
+
+ infinity = PostgresqlInfinity.where(datetime: -::Float::INFINITY..::Float::INFINITY)
+ assert_equal record, infinity.take
+
+ assert_equal infinity.to_sql, string.to_sql
+ end
+
+ test "where clause with infinite range on a date column" do
+ record = PostgresqlInfinity.create!(date: Date.current)
+
+ string = PostgresqlInfinity.where(date: "-infinity".."infinity")
+ assert_equal record, string.take
+
+ infinity = PostgresqlInfinity.where(date: -::Float::INFINITY..::Float::INFINITY)
+ assert_equal record, infinity.take
+
+ assert_equal infinity.to_sql, string.to_sql
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/integer_test.rb b/activerecord/test/cases/adapters/postgresql/integer_test.rb
new file mode 100644
index 0000000000..3e45b057ff
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/integer_test.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "active_support/core_ext/numeric/bytes"
+
+class PostgresqlIntegerTest < ActiveRecord::PostgreSQLTestCase
+ class PgInteger < ActiveRecord::Base
+ end
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+
+ @connection.transaction do
+ @connection.create_table "pg_integers", force: true do |t|
+ t.integer :quota, limit: 8, default: 2.gigabytes
+ end
+ end
+ end
+
+ teardown do
+ @connection.drop_table "pg_integers", if_exists: true
+ end
+
+ test "schema properly respects bigint ranges" do
+ assert_equal 2.gigabytes, PgInteger.new.quota
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/json_test.rb b/activerecord/test/cases/adapters/postgresql/json_test.rb
new file mode 100644
index 0000000000..ee08841eb3
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/json_test.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "cases/json_shared_test_cases"
+
+module PostgresqlJSONSharedTestCases
+ include JSONSharedTestCases
+
+ def setup
+ super
+ @connection.create_table("json_data_type") do |t|
+ t.public_send column_type, "payload", default: {} # t.json 'payload', default: {}
+ t.public_send column_type, "settings" # t.json 'settings'
+ t.public_send column_type, "objects", array: true # t.json 'objects', array: true
+ end
+ rescue ActiveRecord::StatementInvalid
+ skip "do not test on PostgreSQL without #{column_type} type."
+ end
+
+ def test_default
+ @connection.add_column "json_data_type", "permissions", column_type, default: { "users": "read", "posts": ["read", "write"] }
+ klass.reset_column_information
+
+ assert_equal({ "users" => "read", "posts" => ["read", "write"] }, klass.column_defaults["permissions"])
+ assert_equal({ "users" => "read", "posts" => ["read", "write"] }, klass.new.permissions)
+ end
+
+ def test_deserialize_with_array
+ x = klass.new(objects: ["foo" => "bar"])
+ assert_equal ["foo" => "bar"], x.objects
+ x.save!
+ assert_equal ["foo" => "bar"], x.objects
+ x.reload
+ assert_equal ["foo" => "bar"], x.objects
+ end
+end
+
+class PostgresqlJSONTest < ActiveRecord::PostgreSQLTestCase
+ include PostgresqlJSONSharedTestCases
+
+ def column_type
+ :json
+ end
+end
+
+class PostgresqlJSONBTest < ActiveRecord::PostgreSQLTestCase
+ include PostgresqlJSONSharedTestCases
+
+ def column_type
+ :jsonb
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/ltree_test.rb b/activerecord/test/cases/adapters/postgresql/ltree_test.rb
new file mode 100644
index 0000000000..8349ee6ee2
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/ltree_test.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+class PostgresqlLtreeTest < ActiveRecord::PostgreSQLTestCase
+ include SchemaDumpingHelper
+ class Ltree < ActiveRecord::Base
+ self.table_name = "ltrees"
+ end
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+
+ enable_extension!("ltree", @connection)
+
+ @connection.transaction do
+ @connection.create_table("ltrees") do |t|
+ t.ltree "path"
+ end
+ end
+ rescue ActiveRecord::StatementInvalid
+ skip "do not test on PG without ltree"
+ end
+
+ teardown do
+ @connection.drop_table "ltrees", if_exists: true
+ end
+
+ def test_column
+ column = Ltree.columns_hash["path"]
+ assert_equal :ltree, column.type
+ assert_equal "ltree", column.sql_type
+ assert_not_predicate column, :array?
+
+ type = Ltree.type_for_attribute("path")
+ assert_not_predicate type, :binary?
+ end
+
+ def test_write
+ ltree = Ltree.new(path: "1.2.3.4")
+ assert ltree.save!
+ end
+
+ def test_select
+ @connection.execute "insert into ltrees (path) VALUES ('1.2.3')"
+ ltree = Ltree.first
+ assert_equal "1.2.3", ltree.path
+ end
+
+ def test_schema_dump_with_shorthand
+ output = dump_table_schema("ltrees")
+ assert_match %r[t\.ltree "path"], output
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/money_test.rb b/activerecord/test/cases/adapters/postgresql/money_test.rb
new file mode 100644
index 0000000000..1aa0348879
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/money_test.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+class PostgresqlMoneyTest < ActiveRecord::PostgreSQLTestCase
+ include SchemaDumpingHelper
+
+ class PostgresqlMoney < ActiveRecord::Base
+ validates :depth, numericality: true
+ end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.execute("set lc_monetary = 'C'")
+ @connection.create_table("postgresql_moneys", force: true) do |t|
+ t.money "wealth"
+ t.money "depth", default: "150.55"
+ end
+ end
+
+ teardown do
+ @connection.drop_table "postgresql_moneys", if_exists: true
+ end
+
+ def test_column
+ column = PostgresqlMoney.columns_hash["wealth"]
+ assert_equal :money, column.type
+ assert_equal "money", column.sql_type
+ assert_equal 2, column.scale
+ assert_not_predicate column, :array?
+
+ type = PostgresqlMoney.type_for_attribute("wealth")
+ assert_not_predicate type, :binary?
+ end
+
+ def test_default
+ assert_equal BigDecimal("150.55"), PostgresqlMoney.column_defaults["depth"]
+ assert_equal BigDecimal("150.55"), PostgresqlMoney.new.depth
+ assert_equal "150.55", PostgresqlMoney.new.depth_before_type_cast
+ end
+
+ def test_money_values
+ @connection.execute("INSERT INTO postgresql_moneys (id, wealth) VALUES (1, '567.89'::money)")
+ @connection.execute("INSERT INTO postgresql_moneys (id, wealth) VALUES (2, '-567.89'::money)")
+
+ first_money = PostgresqlMoney.find(1)
+ second_money = PostgresqlMoney.find(2)
+ assert_equal 567.89, first_money.wealth
+ assert_equal(-567.89, second_money.wealth)
+ end
+
+ def test_money_type_cast
+ type = PostgresqlMoney.type_for_attribute("wealth")
+ assert_equal(12345678.12, type.cast(+"$12,345,678.12"))
+ assert_equal(12345678.12, type.cast(+"$12.345.678,12"))
+ assert_equal(-1.15, type.cast(+"-$1.15"))
+ assert_equal(-2.25, type.cast(+"($2.25)"))
+ end
+
+ def test_schema_dumping
+ output = dump_table_schema("postgresql_moneys")
+ assert_match %r{t\.money\s+"wealth",\s+scale: 2$}, output
+ assert_match %r{t\.money\s+"depth",\s+scale: 2,\s+default: "150\.55"$}, output
+ end
+
+ def test_create_and_update_money
+ money = PostgresqlMoney.create(wealth: +"987.65")
+ assert_equal 987.65, money.wealth
+
+ new_value = BigDecimal("123.45")
+ money.wealth = new_value
+ money.save!
+ money.reload
+ assert_equal new_value, money.wealth
+ end
+
+ def test_update_all_with_money_string
+ money = PostgresqlMoney.create!
+ PostgresqlMoney.update_all(wealth: "987.65")
+ money.reload
+
+ assert_equal 987.65, money.wealth
+ end
+
+ def test_update_all_with_money_big_decimal
+ money = PostgresqlMoney.create!
+ PostgresqlMoney.update_all(wealth: "123.45".to_d)
+ money.reload
+
+ assert_equal 123.45, money.wealth
+ end
+
+ def test_update_all_with_money_numeric
+ money = PostgresqlMoney.create!
+ PostgresqlMoney.update_all(wealth: 123.45)
+ money.reload
+
+ assert_equal 123.45, money.wealth
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/network_test.rb b/activerecord/test/cases/adapters/postgresql/network_test.rb
new file mode 100644
index 0000000000..736570451b
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/network_test.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+class PostgresqlNetworkTest < ActiveRecord::PostgreSQLTestCase
+ include SchemaDumpingHelper
+ class PostgresqlNetworkAddress < ActiveRecord::Base; end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table("postgresql_network_addresses", force: true) do |t|
+ t.inet "inet_address", default: "192.168.1.1"
+ t.cidr "cidr_address", default: "192.168.1.0/24"
+ t.macaddr "mac_address", default: "ff:ff:ff:ff:ff:ff"
+ end
+ end
+
+ teardown do
+ @connection.drop_table "postgresql_network_addresses", if_exists: true
+ end
+
+ def test_cidr_column
+ column = PostgresqlNetworkAddress.columns_hash["cidr_address"]
+ assert_equal :cidr, column.type
+ assert_equal "cidr", column.sql_type
+ assert_not_predicate column, :array?
+
+ type = PostgresqlNetworkAddress.type_for_attribute("cidr_address")
+ assert_not_predicate type, :binary?
+ end
+
+ def test_inet_column
+ column = PostgresqlNetworkAddress.columns_hash["inet_address"]
+ assert_equal :inet, column.type
+ assert_equal "inet", column.sql_type
+ assert_not_predicate column, :array?
+
+ type = PostgresqlNetworkAddress.type_for_attribute("inet_address")
+ assert_not_predicate type, :binary?
+ end
+
+ def test_macaddr_column
+ column = PostgresqlNetworkAddress.columns_hash["mac_address"]
+ assert_equal :macaddr, column.type
+ assert_equal "macaddr", column.sql_type
+ assert_not_predicate column, :array?
+
+ type = PostgresqlNetworkAddress.type_for_attribute("mac_address")
+ assert_not_predicate type, :binary?
+ end
+
+ def test_network_types
+ PostgresqlNetworkAddress.create(cidr_address: "192.168.0.0/24",
+ inet_address: "172.16.1.254/32",
+ mac_address: "01:23:45:67:89:0a")
+
+ address = PostgresqlNetworkAddress.first
+ assert_equal IPAddr.new("192.168.0.0/24"), address.cidr_address
+ assert_equal IPAddr.new("172.16.1.254"), address.inet_address
+ assert_equal "01:23:45:67:89:0a", address.mac_address
+
+ address.cidr_address = "10.1.2.3/32"
+ address.inet_address = "10.0.0.0/8"
+ address.mac_address = "bc:de:f0:12:34:56"
+
+ address.save!
+ assert address.reload
+ assert_equal IPAddr.new("10.1.2.3/32"), address.cidr_address
+ assert_equal IPAddr.new("10.0.0.0/8"), address.inet_address
+ assert_equal "bc:de:f0:12:34:56", address.mac_address
+ end
+
+ def test_invalid_network_address
+ invalid_address = PostgresqlNetworkAddress.new(cidr_address: "invalid addr",
+ inet_address: "invalid addr")
+ assert_nil invalid_address.cidr_address
+ assert_nil invalid_address.inet_address
+ assert_equal "invalid addr", invalid_address.cidr_address_before_type_cast
+ assert_equal "invalid addr", invalid_address.inet_address_before_type_cast
+ assert invalid_address.save
+
+ invalid_address.reload
+ assert_nil invalid_address.cidr_address
+ assert_nil invalid_address.inet_address
+ assert_nil invalid_address.cidr_address_before_type_cast
+ assert_nil invalid_address.inet_address_before_type_cast
+ end
+
+ def test_schema_dump_with_shorthand
+ output = dump_table_schema("postgresql_network_addresses")
+ assert_match %r{t\.inet\s+"inet_address",\s+default: "192\.168\.1\.1"}, output
+ assert_match %r{t\.cidr\s+"cidr_address",\s+default: "192\.168\.1\.0/24"}, output
+ assert_match %r{t\.macaddr\s+"mac_address",\s+default: "ff:ff:ff:ff:ff:ff"}, output
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/numbers_test.rb b/activerecord/test/cases/adapters/postgresql/numbers_test.rb
new file mode 100644
index 0000000000..b53a12254d
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/numbers_test.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class PostgresqlNumberTest < ActiveRecord::PostgreSQLTestCase
+ class PostgresqlNumber < ActiveRecord::Base; end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table("postgresql_numbers", force: true) do |t|
+ t.column "single", "REAL"
+ t.column "double", "DOUBLE PRECISION"
+ end
+ end
+
+ teardown do
+ @connection.drop_table "postgresql_numbers", if_exists: true
+ end
+
+ def test_data_type
+ assert_equal :float, PostgresqlNumber.columns_hash["single"].type
+ assert_equal :float, PostgresqlNumber.columns_hash["double"].type
+ end
+
+ def test_values
+ @connection.execute("INSERT INTO postgresql_numbers (id, single, double) VALUES (1, 123.456, 123456.789)")
+ @connection.execute("INSERT INTO postgresql_numbers (id, single, double) VALUES (2, '-Infinity', 'Infinity')")
+ @connection.execute("INSERT INTO postgresql_numbers (id, single, double) VALUES (3, 123.456, 'NaN')")
+
+ first, second, third = PostgresqlNumber.find(1, 2, 3)
+
+ assert_equal 123.456, first.single
+ assert_equal 123456.789, first.double
+ assert_equal(-::Float::INFINITY, second.single)
+ assert_equal ::Float::INFINITY, second.double
+ assert third.double.nan?, "Expected #{third.double} to be NaN"
+ end
+
+ def test_update
+ record = PostgresqlNumber.create! single: "123.456", double: "123456.789"
+ new_single = 789.012
+ new_double = 789012.345
+ record.single = new_single
+ record.double = new_double
+ record.save!
+
+ record.reload
+ assert_equal new_single, record.single
+ assert_equal new_double, record.double
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/partitions_test.rb b/activerecord/test/cases/adapters/postgresql/partitions_test.rb
new file mode 100644
index 0000000000..0ac9ca1200
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/partitions_test.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class PostgreSQLPartitionsTest < ActiveRecord::PostgreSQLTestCase
+ def setup
+ @connection = ActiveRecord::Base.connection
+ end
+
+ def teardown
+ @connection.drop_table "partitioned_events", if_exists: true
+ end
+
+ def test_partitions_table_exists
+ skip unless ActiveRecord::Base.connection.postgresql_version >= 100000
+ @connection.create_table :partitioned_events, force: true, id: false,
+ options: "partition by range (issued_at)" do |t|
+ t.timestamp :issued_at
+ end
+ assert @connection.table_exists?("partitioned_events")
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb b/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb
new file mode 100644
index 0000000000..34b4fc344b
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb
@@ -0,0 +1,446 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/ddl_helper"
+require "support/connection_helper"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class PostgreSQLAdapterTest < ActiveRecord::PostgreSQLTestCase
+ self.use_transactional_tests = false
+ include DdlHelper
+ include ConnectionHelper
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ end
+
+ def test_bad_connection
+ assert_raise ActiveRecord::NoDatabaseError do
+ configuration = ActiveRecord::Base.configurations["arunit"].merge(database: "should_not_exist-cinco-dog-db")
+ connection = ActiveRecord::Base.postgresql_connection(configuration)
+ connection.exec_query("SELECT 1")
+ end
+ end
+
+ def test_primary_key
+ with_example_table do
+ assert_equal "id", @connection.primary_key("ex")
+ end
+ end
+
+ def test_primary_key_works_tables_containing_capital_letters
+ assert_equal "id", @connection.primary_key("CamelCase")
+ end
+
+ def test_non_standard_primary_key
+ with_example_table "data character varying(255) primary key" do
+ assert_equal "data", @connection.primary_key("ex")
+ end
+ end
+
+ def test_primary_key_returns_nil_for_no_pk
+ with_example_table "id integer" do
+ assert_nil @connection.primary_key("ex")
+ end
+ end
+
+ def test_exec_insert_with_returning_disabled
+ connection = connection_without_insert_returning
+ result = connection.exec_insert("insert into postgresql_partitioned_table_parent (number) VALUES (1)", nil, [], "id", "postgresql_partitioned_table_parent_id_seq")
+ expect = connection.query("select max(id) from postgresql_partitioned_table_parent").first.first
+ assert_equal expect.to_i, result.rows.first.first
+ end
+
+ def test_exec_insert_with_returning_disabled_and_no_sequence_name_given
+ connection = connection_without_insert_returning
+ result = connection.exec_insert("insert into postgresql_partitioned_table_parent (number) VALUES (1)", nil, [], "id")
+ expect = connection.query("select max(id) from postgresql_partitioned_table_parent").first.first
+ assert_equal expect.to_i, result.rows.first.first
+ end
+
+ def test_exec_insert_default_values_with_returning_disabled_and_no_sequence_name_given
+ connection = connection_without_insert_returning
+ result = connection.exec_insert("insert into postgresql_partitioned_table_parent DEFAULT VALUES", nil, [], "id")
+ expect = connection.query("select max(id) from postgresql_partitioned_table_parent").first.first
+ assert_equal expect.to_i, result.rows.first.first
+ end
+
+ def test_exec_insert_default_values_quoted_schema_with_returning_disabled_and_no_sequence_name_given
+ connection = connection_without_insert_returning
+ result = connection.exec_insert('insert into "public"."postgresql_partitioned_table_parent" DEFAULT VALUES', nil, [], "id")
+ expect = connection.query("select max(id) from postgresql_partitioned_table_parent").first.first
+ assert_equal expect.to_i, result.rows.first.first
+ end
+
+ def test_serial_sequence
+ assert_equal "public.accounts_id_seq",
+ @connection.serial_sequence("accounts", "id")
+
+ assert_raises(ActiveRecord::StatementInvalid) do
+ @connection.serial_sequence("zomg", "id")
+ end
+ end
+
+ def test_default_sequence_name
+ assert_equal "public.accounts_id_seq",
+ @connection.default_sequence_name("accounts", "id")
+
+ assert_equal "public.accounts_id_seq",
+ @connection.default_sequence_name("accounts")
+ end
+
+ def test_default_sequence_name_bad_table
+ assert_equal "zomg_id_seq",
+ @connection.default_sequence_name("zomg", "id")
+
+ assert_equal "zomg_id_seq",
+ @connection.default_sequence_name("zomg")
+ end
+
+ def test_pk_and_sequence_for
+ with_example_table do
+ pk, seq = @connection.pk_and_sequence_for("ex")
+ assert_equal "id", pk
+ assert_equal @connection.default_sequence_name("ex", "id"), seq.to_s
+ end
+ end
+
+ def test_pk_and_sequence_for_with_non_standard_primary_key
+ with_example_table "code serial primary key" do
+ pk, seq = @connection.pk_and_sequence_for("ex")
+ assert_equal "code", pk
+ assert_equal @connection.default_sequence_name("ex", "code"), seq.to_s
+ end
+ end
+
+ def test_pk_and_sequence_for_returns_nil_if_no_seq
+ with_example_table "id integer primary key" do
+ assert_nil @connection.pk_and_sequence_for("ex")
+ end
+ end
+
+ def test_pk_and_sequence_for_returns_nil_if_no_pk
+ with_example_table "id integer" do
+ assert_nil @connection.pk_and_sequence_for("ex")
+ end
+ end
+
+ def test_pk_and_sequence_for_returns_nil_if_table_not_found
+ assert_nil @connection.pk_and_sequence_for("unobtainium")
+ end
+
+ def test_pk_and_sequence_for_with_collision_pg_class_oid
+ @connection.exec_query("create table ex(id serial primary key)")
+ @connection.exec_query("create table ex2(id serial primary key)")
+
+ correct_depend_record = [
+ "'pg_class'::regclass",
+ "'ex_id_seq'::regclass",
+ "0",
+ "'pg_class'::regclass",
+ "'ex'::regclass",
+ "1",
+ "'a'"
+ ]
+
+ collision_depend_record = [
+ "'pg_attrdef'::regclass",
+ "'ex2_id_seq'::regclass",
+ "0",
+ "'pg_class'::regclass",
+ "'ex'::regclass",
+ "1",
+ "'a'"
+ ]
+
+ @connection.exec_query(
+ "DELETE FROM pg_depend WHERE objid = 'ex_id_seq'::regclass AND refobjid = 'ex'::regclass AND deptype = 'a'"
+ )
+ @connection.exec_query(
+ "INSERT INTO pg_depend VALUES(#{collision_depend_record.join(',')})"
+ )
+ @connection.exec_query(
+ "INSERT INTO pg_depend VALUES(#{correct_depend_record.join(',')})"
+ )
+
+ seq = @connection.pk_and_sequence_for("ex").last
+ assert_equal PostgreSQL::Name.new("public", "ex_id_seq"), seq
+
+ @connection.exec_query(
+ "DELETE FROM pg_depend WHERE objid = 'ex2_id_seq'::regclass AND refobjid = 'ex'::regclass AND deptype = 'a'"
+ )
+ ensure
+ @connection.drop_table "ex", if_exists: true
+ @connection.drop_table "ex2", if_exists: true
+ end
+
+ def test_table_alias_length
+ assert_nothing_raised do
+ @connection.table_alias_length
+ end
+ end
+
+ def test_exec_no_binds
+ with_example_table do
+ result = @connection.exec_query("SELECT id, data FROM ex")
+ assert_equal 0, result.rows.length
+ assert_equal 2, result.columns.length
+ assert_equal %w{ id data }, result.columns
+
+ string = @connection.quote("foo")
+ @connection.exec_query("INSERT INTO ex (id, data) VALUES (1, #{string})")
+ result = @connection.exec_query("SELECT id, data FROM ex")
+ assert_equal 1, result.rows.length
+ assert_equal 2, result.columns.length
+
+ assert_equal [[1, "foo"]], result.rows
+ end
+ end
+
+ if ActiveRecord::Base.connection.prepared_statements
+ def test_exec_with_binds
+ with_example_table do
+ string = @connection.quote("foo")
+ @connection.exec_query("INSERT INTO ex (id, data) VALUES (1, #{string})")
+
+ bind = Relation::QueryAttribute.new("id", 1, Type::Value.new)
+ result = @connection.exec_query("SELECT id, data FROM ex WHERE id = $1", nil, [bind])
+
+ assert_equal 1, result.rows.length
+ assert_equal 2, result.columns.length
+
+ assert_equal [[1, "foo"]], result.rows
+ end
+ end
+
+ def test_exec_typecasts_bind_vals
+ with_example_table do
+ string = @connection.quote("foo")
+ @connection.exec_query("INSERT INTO ex (id, data) VALUES (1, #{string})")
+
+ bind = Relation::QueryAttribute.new("id", "1-fuu", Type::Integer.new)
+ result = @connection.exec_query("SELECT id, data FROM ex WHERE id = $1", nil, [bind])
+
+ assert_equal 1, result.rows.length
+ assert_equal 2, result.columns.length
+
+ assert_equal [[1, "foo"]], result.rows
+ end
+ end
+ end
+
+ def test_partial_index
+ with_example_table do
+ @connection.add_index "ex", %w{ id number }, name: "partial", where: "number > 100"
+ index = @connection.indexes("ex").find { |idx| idx.name == "partial" }
+ assert_equal "(number > 100)", index.where
+ end
+ end
+
+ def test_expression_index
+ with_example_table do
+ @connection.add_index "ex", "mod(id, 10), abs(number)", name: "expression"
+ index = @connection.indexes("ex").find { |idx| idx.name == "expression" }
+ assert_equal "mod(id, 10), abs(number)", index.columns
+ end
+ end
+
+ def test_index_with_opclass
+ with_example_table do
+ @connection.add_index "ex", "data", opclass: "varchar_pattern_ops"
+ index = @connection.indexes("ex").find { |idx| idx.name == "index_ex_on_data" }
+ assert_equal ["data"], index.columns
+
+ @connection.remove_index "ex", "data"
+ assert_not @connection.indexes("ex").find { |idx| idx.name == "index_ex_on_data" }
+ end
+ end
+
+ def test_columns_for_distinct_zero_orders
+ assert_equal "posts.id",
+ @connection.columns_for_distinct("posts.id", [])
+ end
+
+ def test_columns_for_distinct_one_order
+ assert_equal "posts.created_at AS alias_0, posts.id",
+ @connection.columns_for_distinct("posts.id", ["posts.created_at desc"])
+ end
+
+ def test_columns_for_distinct_few_orders
+ assert_equal "posts.created_at AS alias_0, posts.position AS alias_1, posts.id",
+ @connection.columns_for_distinct("posts.id", ["posts.created_at desc", "posts.position asc"])
+ end
+
+ def test_columns_for_distinct_with_case
+ assert_equal(
+ "CASE WHEN author.is_active THEN UPPER(author.name) ELSE UPPER(author.email) END AS alias_0, posts.id",
+ @connection.columns_for_distinct("posts.id",
+ ["CASE WHEN author.is_active THEN UPPER(author.name) ELSE UPPER(author.email) END"])
+ )
+ end
+
+ def test_columns_for_distinct_blank_not_nil_orders
+ assert_equal "posts.created_at AS alias_0, posts.id",
+ @connection.columns_for_distinct("posts.id", ["posts.created_at desc", "", " "])
+ end
+
+ def test_columns_for_distinct_with_arel_order
+ order = Object.new
+ def order.to_sql
+ "posts.created_at desc"
+ end
+ assert_equal "posts.created_at AS alias_0, posts.id",
+ @connection.columns_for_distinct("posts.id", [order])
+ end
+
+ def test_columns_for_distinct_with_nulls
+ assert_equal "posts.updater_id AS alias_0, posts.title", @connection.columns_for_distinct("posts.title", ["posts.updater_id desc nulls first"])
+ assert_equal "posts.updater_id AS alias_0, posts.title", @connection.columns_for_distinct("posts.title", ["posts.updater_id desc nulls last"])
+ end
+
+ def test_columns_for_distinct_without_order_specifiers
+ assert_equal "posts.updater_id AS alias_0, posts.title",
+ @connection.columns_for_distinct("posts.title", ["posts.updater_id"])
+
+ assert_equal "posts.updater_id AS alias_0, posts.title",
+ @connection.columns_for_distinct("posts.title", ["posts.updater_id nulls last"])
+
+ assert_equal "posts.updater_id AS alias_0, posts.title",
+ @connection.columns_for_distinct("posts.title", ["posts.updater_id nulls first"])
+ end
+
+ def test_raise_error_when_cannot_translate_exception
+ assert_raise TypeError do
+ @connection.send(:log, nil) { @connection.execute(nil) }
+ end
+ end
+
+ def test_reload_type_map_for_newly_defined_types
+ @connection.execute "CREATE TYPE feeling AS ENUM ('good', 'bad')"
+ result = @connection.select_all "SELECT 'good'::feeling"
+ assert_instance_of(PostgreSQLAdapter::OID::Enum,
+ result.column_types["feeling"])
+ ensure
+ @connection.execute "DROP TYPE IF EXISTS feeling"
+ reset_connection
+ end
+
+ def test_only_reload_type_map_once_for_every_unrecognized_type
+ reset_connection
+ connection = ActiveRecord::Base.connection
+
+ silence_warnings do
+ assert_queries 2, ignore_none: true do
+ connection.select_all "select 'pg_catalog.pg_class'::regclass"
+ end
+ assert_queries 1, ignore_none: true do
+ connection.select_all "select 'pg_catalog.pg_class'::regclass"
+ end
+ assert_queries 2, ignore_none: true do
+ connection.select_all "SELECT NULL::anyarray"
+ end
+ end
+ ensure
+ reset_connection
+ end
+
+ def test_only_warn_on_first_encounter_of_unrecognized_oid
+ reset_connection
+ connection = ActiveRecord::Base.connection
+
+ warning = capture(:stderr) {
+ connection.select_all "select 'pg_catalog.pg_class'::regclass"
+ connection.select_all "select 'pg_catalog.pg_class'::regclass"
+ connection.select_all "select 'pg_catalog.pg_class'::regclass"
+ }
+ assert_match(/\Aunknown OID \d+: failed to recognize type of 'regclass'\. It will be treated as String\.\n\z/, warning)
+ ensure
+ reset_connection
+ end
+
+ def test_unparsed_defaults_are_at_least_set_when_saving
+ with_example_table "id SERIAL PRIMARY KEY, number INTEGER NOT NULL DEFAULT (4 + 4) * 2 / 4" do
+ number_klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "ex"
+ end
+ column = number_klass.columns_hash["number"]
+ assert_nil column.default
+ assert_nil column.default_function
+
+ first_number = number_klass.new
+ assert_nil first_number.number
+
+ first_number.save!
+ assert_equal 4, first_number.reload.number
+ end
+ end
+
+ def test_errors_when_an_insert_query_is_called_while_preventing_writes
+ with_example_table do
+ assert_raises(ActiveRecord::ReadOnlyError) do
+ @connection.while_preventing_writes do
+ @connection.execute("INSERT INTO ex (data) VALUES ('138853948594')")
+ end
+ end
+ end
+ end
+
+ def test_errors_when_an_update_query_is_called_while_preventing_writes
+ with_example_table do
+ @connection.execute("INSERT INTO ex (data) VALUES ('138853948594')")
+
+ assert_raises(ActiveRecord::ReadOnlyError) do
+ @connection.while_preventing_writes do
+ @connection.execute("UPDATE ex SET data = '9989' WHERE data = '138853948594'")
+ end
+ end
+ end
+ end
+
+ def test_errors_when_a_delete_query_is_called_while_preventing_writes
+ with_example_table do
+ @connection.execute("INSERT INTO ex (data) VALUES ('138853948594')")
+
+ assert_raises(ActiveRecord::ReadOnlyError) do
+ @connection.while_preventing_writes do
+ @connection.execute("DELETE FROM ex where data = '138853948594'")
+ end
+ end
+ end
+ end
+
+ def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes
+ with_example_table do
+ @connection.execute("INSERT INTO ex (data) VALUES ('138853948594')")
+
+ @connection.while_preventing_writes do
+ assert_equal 1, @connection.execute("SELECT * FROM ex WHERE data = '138853948594'").entries.count
+ end
+ end
+ end
+
+ def test_doesnt_error_when_a_show_query_is_called_while_preventing_writes
+ @connection.while_preventing_writes do
+ assert_equal 1, @connection.execute("SHOW TIME ZONE").entries.count
+ end
+ end
+
+ def test_doesnt_error_when_a_set_query_is_called_while_preventing_writes
+ @connection.while_preventing_writes do
+ assert_equal [], @connection.execute("SET standard_conforming_strings = on").entries
+ end
+ end
+
+ private
+
+ def with_example_table(definition = "id serial primary key, number integer, data character varying(255)", &block)
+ super(@connection, "ex", definition, &block)
+ end
+
+ def connection_without_insert_returning
+ ActiveRecord::Base.postgresql_connection(ActiveRecord::Base.configurations["arunit"].merge(insert_returning: false))
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/prepared_statements_disabled_test.rb b/activerecord/test/cases/adapters/postgresql/prepared_statements_disabled_test.rb
new file mode 100644
index 0000000000..f7478b50c3
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/prepared_statements_disabled_test.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/computer"
+require "models/developer"
+
+class PreparedStatementsDisabledTest < ActiveRecord::PostgreSQLTestCase
+ fixtures :developers
+
+ def setup
+ @conn = ActiveRecord::Base.establish_connection :arunit_without_prepared_statements
+ end
+
+ def teardown
+ @conn.release_connection
+ ActiveRecord::Base.establish_connection :arunit
+ end
+
+ def test_select_query_works_even_when_prepared_statements_are_disabled
+ assert_not Developer.connection.prepared_statements
+
+ david = developers(:david)
+
+ assert_equal david, Developer.where(name: "David").last # With Binds
+ assert_operator Developer.count, :>, 0 # Without Binds
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/quoting_test.rb b/activerecord/test/cases/adapters/postgresql/quoting_test.rb
new file mode 100644
index 0000000000..d571355a9c
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/quoting_test.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class PostgreSQLAdapter
+ class QuotingTest < ActiveRecord::PostgreSQLTestCase
+ def setup
+ @conn = ActiveRecord::Base.connection
+ end
+
+ def test_type_cast_true
+ assert_equal true, @conn.type_cast(true)
+ end
+
+ def test_type_cast_false
+ assert_equal false, @conn.type_cast(false)
+ end
+
+ def test_quote_float_nan
+ nan = 0.0 / 0
+ assert_equal "'NaN'", @conn.quote(nan)
+ end
+
+ def test_quote_float_infinity
+ infinity = 1.0 / 0
+ assert_equal "'Infinity'", @conn.quote(infinity)
+ end
+
+ def test_quote_range
+ range = "1,2]'; SELECT * FROM users; --".."a"
+ type = OID::Range.new(Type::Integer.new, :int8range)
+ assert_equal "'[1,0]'", @conn.quote(type.serialize(range))
+ end
+
+ def test_quote_bit_string
+ value = "'); SELECT * FROM users; /*\n01\n*/--"
+ type = OID::Bit.new
+ assert_nil @conn.quote(type.serialize(value))
+ end
+
+ def test_quote_table_name_with_spaces
+ value = "user posts"
+ assert_equal "\"user posts\"", @conn.quote_table_name(value)
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/range_test.rb b/activerecord/test/cases/adapters/postgresql/range_test.rb
new file mode 100644
index 0000000000..478cd5aa76
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/range_test.rb
@@ -0,0 +1,418 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/connection_helper"
+
+class PostgresqlRange < ActiveRecord::Base
+ self.table_name = "postgresql_ranges"
+ self.time_zone_aware_types += [:tsrange, :tstzrange]
+end
+
+class PostgresqlRangeTest < ActiveRecord::PostgreSQLTestCase
+ self.use_transactional_tests = false
+ include ConnectionHelper
+ include InTimeZone
+
+ def setup
+ @connection = PostgresqlRange.connection
+ begin
+ @connection.transaction do
+ @connection.execute <<~SQL
+ CREATE TYPE floatrange AS RANGE (
+ subtype = float8,
+ subtype_diff = float8mi
+ );
+ SQL
+
+ @connection.create_table("postgresql_ranges") do |t|
+ t.daterange :date_range
+ t.numrange :num_range
+ t.tsrange :ts_range
+ t.tstzrange :tstz_range
+ t.int4range :int4_range
+ t.int8range :int8_range
+ end
+
+ @connection.add_column "postgresql_ranges", "float_range", "floatrange"
+ end
+ PostgresqlRange.reset_column_information
+ rescue ActiveRecord::StatementInvalid
+ skip "do not test on PG without range"
+ end
+
+ insert_range(id: 101,
+ date_range: "[''2012-01-02'', ''2012-01-04'']",
+ num_range: "[0.1, 0.2]",
+ ts_range: "[''2010-01-01 14:30'', ''2011-01-01 14:30'']",
+ tstz_range: "[''2010-01-01 14:30:00+05'', ''2011-01-01 14:30:00-03'']",
+ int4_range: "[1, 10]",
+ int8_range: "[10, 100]",
+ float_range: "[0.5, 0.7]")
+
+ insert_range(id: 102,
+ date_range: "[''2012-01-02'', ''2012-01-04'')",
+ num_range: "[0.1, 0.2)",
+ ts_range: "[''2010-01-01 14:30'', ''2011-01-01 14:30'')",
+ tstz_range: "[''2010-01-01 14:30:00+05'', ''2011-01-01 14:30:00-03'')",
+ int4_range: "[1, 10)",
+ int8_range: "[10, 100)",
+ float_range: "[0.5, 0.7)")
+
+ insert_range(id: 103,
+ date_range: "[''2012-01-02'',]",
+ num_range: "[0.1,]",
+ ts_range: "[''2010-01-01 14:30'',]",
+ tstz_range: "[''2010-01-01 14:30:00+05'',]",
+ int4_range: "[1,]",
+ int8_range: "[10,]",
+ float_range: "[0.5,]")
+
+ insert_range(id: 104,
+ date_range: "[,]",
+ num_range: "[,]",
+ ts_range: "[,]",
+ tstz_range: "[,]",
+ int4_range: "[,]",
+ int8_range: "[,]",
+ float_range: "[,]")
+
+ insert_range(id: 105,
+ date_range: "[''2012-01-02'', ''2012-01-02'')",
+ num_range: "[0.1, 0.1)",
+ ts_range: "[''2010-01-01 14:30'', ''2010-01-01 14:30'')",
+ tstz_range: "[''2010-01-01 14:30:00+05'', ''2010-01-01 06:30:00-03'')",
+ int4_range: "[1, 1)",
+ int8_range: "[10, 10)",
+ float_range: "[0.5, 0.5)")
+
+ @new_range = PostgresqlRange.new
+ @first_range = PostgresqlRange.find(101)
+ @second_range = PostgresqlRange.find(102)
+ @third_range = PostgresqlRange.find(103)
+ @fourth_range = PostgresqlRange.find(104)
+ @empty_range = PostgresqlRange.find(105)
+ end
+
+ teardown do
+ @connection.drop_table "postgresql_ranges", if_exists: true
+ @connection.execute "DROP TYPE IF EXISTS floatrange"
+ reset_connection
+ end
+
+ def test_data_type_of_range_types
+ assert_equal :daterange, @first_range.column_for_attribute(:date_range).type
+ assert_equal :numrange, @first_range.column_for_attribute(:num_range).type
+ assert_equal :tsrange, @first_range.column_for_attribute(:ts_range).type
+ assert_equal :tstzrange, @first_range.column_for_attribute(:tstz_range).type
+ assert_equal :int4range, @first_range.column_for_attribute(:int4_range).type
+ assert_equal :int8range, @first_range.column_for_attribute(:int8_range).type
+ end
+
+ def test_int4range_values
+ assert_equal 1...11, @first_range.int4_range
+ assert_equal 1...10, @second_range.int4_range
+ assert_equal 1...Float::INFINITY, @third_range.int4_range
+ assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.int4_range)
+ assert_nil @empty_range.int4_range
+ end
+
+ def test_int8range_values
+ assert_equal 10...101, @first_range.int8_range
+ assert_equal 10...100, @second_range.int8_range
+ assert_equal 10...Float::INFINITY, @third_range.int8_range
+ assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.int8_range)
+ assert_nil @empty_range.int8_range
+ end
+
+ def test_daterange_values
+ assert_equal Date.new(2012, 1, 2)...Date.new(2012, 1, 5), @first_range.date_range
+ assert_equal Date.new(2012, 1, 2)...Date.new(2012, 1, 4), @second_range.date_range
+ assert_equal Date.new(2012, 1, 2)...Float::INFINITY, @third_range.date_range
+ assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.date_range)
+ assert_nil @empty_range.date_range
+ end
+
+ def test_numrange_values
+ assert_equal BigDecimal("0.1")..BigDecimal("0.2"), @first_range.num_range
+ assert_equal BigDecimal("0.1")...BigDecimal("0.2"), @second_range.num_range
+ assert_equal BigDecimal("0.1")...BigDecimal("Infinity"), @third_range.num_range
+ assert_equal BigDecimal("-Infinity")...BigDecimal("Infinity"), @fourth_range.num_range
+ assert_nil @empty_range.num_range
+ end
+
+ def test_tsrange_values
+ tz = ::ActiveRecord::Base.default_timezone
+ assert_equal Time.send(tz, 2010, 1, 1, 14, 30, 0)..Time.send(tz, 2011, 1, 1, 14, 30, 0), @first_range.ts_range
+ assert_equal Time.send(tz, 2010, 1, 1, 14, 30, 0)...Time.send(tz, 2011, 1, 1, 14, 30, 0), @second_range.ts_range
+ assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.ts_range)
+ assert_nil @empty_range.ts_range
+ end
+
+ def test_tstzrange_values
+ assert_equal Time.parse("2010-01-01 09:30:00 UTC")..Time.parse("2011-01-01 17:30:00 UTC"), @first_range.tstz_range
+ assert_equal Time.parse("2010-01-01 09:30:00 UTC")...Time.parse("2011-01-01 17:30:00 UTC"), @second_range.tstz_range
+ assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.tstz_range)
+ assert_nil @empty_range.tstz_range
+ end
+
+ def test_custom_range_values
+ assert_equal 0.5..0.7, @first_range.float_range
+ assert_equal 0.5...0.7, @second_range.float_range
+ assert_equal 0.5...Float::INFINITY, @third_range.float_range
+ assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.float_range)
+ assert_nil @empty_range.float_range
+ end
+
+ def test_timezone_awareness_tzrange
+ tz = "Pacific Time (US & Canada)"
+
+ in_time_zone tz do
+ PostgresqlRange.reset_column_information
+ time_string = Time.current.to_s
+ time = Time.zone.parse(time_string)
+
+ record = PostgresqlRange.new(tstz_range: time_string..time_string)
+ assert_equal time..time, record.tstz_range
+ assert_equal ActiveSupport::TimeZone[tz], record.tstz_range.begin.time_zone
+
+ record.save!
+ record.reload
+
+ assert_equal time..time, record.tstz_range
+ assert_equal ActiveSupport::TimeZone[tz], record.tstz_range.begin.time_zone
+ end
+ end
+
+ def test_create_tstzrange
+ tstzrange = Time.parse("2010-01-01 14:30:00 +0100")...Time.parse("2011-02-02 14:30:00 CDT")
+ round_trip(@new_range, :tstz_range, tstzrange)
+ assert_equal @new_range.tstz_range, tstzrange
+ assert_equal @new_range.tstz_range, Time.parse("2010-01-01 13:30:00 UTC")...Time.parse("2011-02-02 19:30:00 UTC")
+ end
+
+ def test_update_tstzrange
+ assert_equal_round_trip(@first_range, :tstz_range,
+ Time.parse("2010-01-01 14:30:00 CDT")...Time.parse("2011-02-02 14:30:00 CET"))
+ assert_nil_round_trip(@first_range, :tstz_range,
+ Time.parse("2010-01-01 14:30:00 +0100")...Time.parse("2010-01-01 13:30:00 +0000"))
+ end
+
+ def test_create_tsrange
+ tz = ::ActiveRecord::Base.default_timezone
+ assert_equal_round_trip(@new_range, :ts_range,
+ Time.send(tz, 2010, 1, 1, 14, 30, 0)...Time.send(tz, 2011, 2, 2, 14, 30, 0))
+ end
+
+ def test_update_tsrange
+ tz = ::ActiveRecord::Base.default_timezone
+ assert_equal_round_trip(@first_range, :ts_range,
+ Time.send(tz, 2010, 1, 1, 14, 30, 0)...Time.send(tz, 2011, 2, 2, 14, 30, 0))
+ assert_nil_round_trip(@first_range, :ts_range,
+ Time.send(tz, 2010, 1, 1, 14, 30, 0)...Time.send(tz, 2010, 1, 1, 14, 30, 0))
+ end
+
+ def test_timezone_awareness_tsrange
+ tz = "Pacific Time (US & Canada)"
+
+ in_time_zone tz do
+ PostgresqlRange.reset_column_information
+ time_string = Time.current.to_s
+ time = Time.zone.parse(time_string)
+
+ record = PostgresqlRange.new(ts_range: time_string..time_string)
+ assert_equal time..time, record.ts_range
+ assert_equal ActiveSupport::TimeZone[tz], record.ts_range.begin.time_zone
+
+ record.save!
+ record.reload
+
+ assert_equal time..time, record.ts_range
+ assert_equal ActiveSupport::TimeZone[tz], record.ts_range.begin.time_zone
+ end
+ end
+
+ def test_create_tstzrange_preserve_usec
+ tstzrange = Time.parse("2010-01-01 14:30:00.670277 +0100")...Time.parse("2011-02-02 14:30:00.745125 CDT")
+ round_trip(@new_range, :tstz_range, tstzrange)
+ assert_equal @new_range.tstz_range, tstzrange
+ assert_equal @new_range.tstz_range, Time.parse("2010-01-01 13:30:00.670277 UTC")...Time.parse("2011-02-02 19:30:00.745125 UTC")
+ end
+
+ def test_update_tstzrange_preserve_usec
+ assert_equal_round_trip(@first_range, :tstz_range,
+ Time.parse("2010-01-01 14:30:00.245124 CDT")...Time.parse("2011-02-02 14:30:00.451274 CET"))
+ assert_nil_round_trip(@first_range, :tstz_range,
+ Time.parse("2010-01-01 14:30:00.245124 +0100")...Time.parse("2010-01-01 13:30:00.245124 +0000"))
+ end
+
+ def test_create_tsrange_preseve_usec
+ tz = ::ActiveRecord::Base.default_timezone
+ assert_equal_round_trip(@new_range, :ts_range,
+ Time.send(tz, 2010, 1, 1, 14, 30, 0, 125435)...Time.send(tz, 2011, 2, 2, 14, 30, 0, 225435))
+ end
+
+ def test_update_tsrange_preserve_usec
+ tz = ::ActiveRecord::Base.default_timezone
+ assert_equal_round_trip(@first_range, :ts_range,
+ Time.send(tz, 2010, 1, 1, 14, 30, 0, 142432)...Time.send(tz, 2011, 2, 2, 14, 30, 0, 224242))
+ assert_nil_round_trip(@first_range, :ts_range,
+ Time.send(tz, 2010, 1, 1, 14, 30, 0, 142432)...Time.send(tz, 2010, 1, 1, 14, 30, 0, 142432))
+ end
+
+ def test_timezone_awareness_tsrange_preserve_usec
+ tz = "Pacific Time (US & Canada)"
+
+ in_time_zone tz do
+ PostgresqlRange.reset_column_information
+ time_string = "2017-09-26 07:30:59.132451 -0700"
+ time = Time.zone.parse(time_string)
+ assert time.usec > 0
+
+ record = PostgresqlRange.new(ts_range: time_string..time_string)
+ assert_equal time..time, record.ts_range
+ assert_equal ActiveSupport::TimeZone[tz], record.ts_range.begin.time_zone
+ assert_equal time.usec, record.ts_range.begin.usec
+
+ record.save!
+ record.reload
+
+ assert_equal time..time, record.ts_range
+ assert_equal ActiveSupport::TimeZone[tz], record.ts_range.begin.time_zone
+ assert_equal time.usec, record.ts_range.begin.usec
+ end
+ end
+
+ def test_create_numrange
+ assert_equal_round_trip(@new_range, :num_range,
+ BigDecimal("0.5")...BigDecimal("1"))
+ end
+
+ def test_update_numrange
+ assert_equal_round_trip(@first_range, :num_range,
+ BigDecimal("0.5")...BigDecimal("1"))
+ assert_nil_round_trip(@first_range, :num_range,
+ BigDecimal("0.5")...BigDecimal("0.5"))
+ end
+
+ def test_create_daterange
+ assert_equal_round_trip(@new_range, :date_range,
+ Range.new(Date.new(2012, 1, 1), Date.new(2013, 1, 1), true))
+ end
+
+ def test_update_daterange
+ assert_equal_round_trip(@first_range, :date_range,
+ Date.new(2012, 2, 3)...Date.new(2012, 2, 10))
+ assert_nil_round_trip(@first_range, :date_range,
+ Date.new(2012, 2, 3)...Date.new(2012, 2, 3))
+ end
+
+ def test_create_int4range
+ assert_equal_round_trip(@new_range, :int4_range, Range.new(3, 50, true))
+ end
+
+ def test_update_int4range
+ assert_equal_round_trip(@first_range, :int4_range, 6...10)
+ assert_nil_round_trip(@first_range, :int4_range, 3...3)
+ end
+
+ def test_create_int8range
+ assert_equal_round_trip(@new_range, :int8_range, Range.new(30, 50, true))
+ end
+
+ def test_update_int8range
+ assert_equal_round_trip(@first_range, :int8_range, 60000...10000000)
+ assert_nil_round_trip(@first_range, :int8_range, 39999...39999)
+ end
+
+ def test_exclude_beginning_for_subtypes_without_succ_method_is_not_supported
+ assert_raises(ArgumentError) { PostgresqlRange.create!(num_range: "(0.1, 0.2]") }
+ assert_raises(ArgumentError) { PostgresqlRange.create!(float_range: "(0.5, 0.7]") }
+ assert_raises(ArgumentError) { PostgresqlRange.create!(int4_range: "(1, 10]") }
+ assert_raises(ArgumentError) { PostgresqlRange.create!(int8_range: "(10, 100]") }
+ assert_raises(ArgumentError) { PostgresqlRange.create!(date_range: "(''2012-01-02'', ''2012-01-04'']") }
+ assert_raises(ArgumentError) { PostgresqlRange.create!(ts_range: "(''2010-01-01 14:30'', ''2011-01-01 14:30'']") }
+ assert_raises(ArgumentError) { PostgresqlRange.create!(tstz_range: "(''2010-01-01 14:30:00+05'', ''2011-01-01 14:30:00-03'']") }
+ end
+
+ def test_where_by_attribute_with_range
+ range = 1..100
+ record = PostgresqlRange.create!(int4_range: range)
+ assert_equal record, PostgresqlRange.where(int4_range: range).take
+ end
+
+ def test_where_by_attribute_with_range_in_array
+ range = 1..100
+ record = PostgresqlRange.create!(int4_range: range)
+ assert_equal record, PostgresqlRange.where(int4_range: [range]).take
+ end
+
+ def test_update_all_with_ranges
+ PostgresqlRange.create!
+
+ PostgresqlRange.update_all(int8_range: 1..100)
+
+ assert_equal 1...101, PostgresqlRange.first.int8_range
+ end
+
+ def test_ranges_correctly_escape_input
+ range = "-1,2]'; DROP TABLE postgresql_ranges; --".."a"
+ PostgresqlRange.update_all(int8_range: range)
+
+ assert_nothing_raised do
+ PostgresqlRange.first
+ end
+ end
+
+ def test_infinity_values
+ PostgresqlRange.create!(int4_range: 1..Float::INFINITY,
+ int8_range: -Float::INFINITY..0,
+ float_range: -Float::INFINITY..Float::INFINITY)
+
+ record = PostgresqlRange.first
+
+ assert_equal(1...Float::INFINITY, record.int4_range)
+ assert_equal(-Float::INFINITY...1, record.int8_range)
+ assert_equal(-Float::INFINITY...Float::INFINITY, record.float_range)
+ end
+
+ private
+ def assert_equal_round_trip(range, attribute, value)
+ round_trip(range, attribute, value)
+ assert_equal value, range.public_send(attribute)
+ end
+
+ def assert_nil_round_trip(range, attribute, value)
+ round_trip(range, attribute, value)
+ assert_nil range.public_send(attribute)
+ end
+
+ def round_trip(range, attribute, value)
+ range.public_send "#{attribute}=", value
+ assert range.save
+ assert range.reload
+ end
+
+ def insert_range(values)
+ @connection.execute <<~SQL
+ INSERT INTO postgresql_ranges (
+ id,
+ date_range,
+ num_range,
+ ts_range,
+ tstz_range,
+ int4_range,
+ int8_range,
+ float_range
+ ) VALUES (
+ #{values[:id]},
+ '#{values[:date_range]}',
+ '#{values[:num_range]}',
+ '#{values[:ts_range]}',
+ '#{values[:tstz_range]}',
+ '#{values[:int4_range]}',
+ '#{values[:int8_range]}',
+ '#{values[:float_range]}'
+ )
+ SQL
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/referential_integrity_test.rb b/activerecord/test/cases/adapters/postgresql/referential_integrity_test.rb
new file mode 100644
index 0000000000..ba477c63f4
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/referential_integrity_test.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/connection_helper"
+
+class PostgreSQLReferentialIntegrityTest < ActiveRecord::PostgreSQLTestCase
+ self.use_transactional_tests = false
+
+ include ConnectionHelper
+
+ IS_REFERENTIAL_INTEGRITY_SQL = lambda do |sql|
+ sql.match(/DISABLE TRIGGER ALL/) || sql.match(/ENABLE TRIGGER ALL/)
+ end
+
+ module MissingSuperuserPrivileges
+ def execute(sql)
+ if IS_REFERENTIAL_INTEGRITY_SQL.call(sql)
+ super "BROKEN;" rescue nil # put transaction in broken state
+ raise ActiveRecord::StatementInvalid, "PG::InsufficientPrivilege"
+ else
+ super
+ end
+ end
+ end
+
+ module ProgrammerMistake
+ def execute(sql)
+ if IS_REFERENTIAL_INTEGRITY_SQL.call(sql)
+ raise ArgumentError, "something is not right."
+ else
+ super
+ end
+ end
+ end
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ end
+
+ def teardown
+ reset_connection
+ if ActiveRecord::Base.connection.is_a?(MissingSuperuserPrivileges)
+ raise "MissingSuperuserPrivileges patch was not removed"
+ end
+ end
+
+ def test_should_reraise_invalid_foreign_key_exception_and_show_warning
+ @connection.extend MissingSuperuserPrivileges
+
+ warning = capture(:stderr) do
+ e = assert_raises(ActiveRecord::InvalidForeignKey) do
+ @connection.disable_referential_integrity do
+ raise ActiveRecord::InvalidForeignKey, "Should be re-raised"
+ end
+ end
+ assert_equal "Should be re-raised", e.message
+ end
+ assert_match (/WARNING: Rails was not able to disable referential integrity/), warning
+ assert_match (/cause: PG::InsufficientPrivilege/), warning
+ end
+
+ def test_does_not_print_warning_if_no_invalid_foreign_key_exception_was_raised
+ @connection.extend MissingSuperuserPrivileges
+
+ warning = capture(:stderr) do
+ e = assert_raises(ActiveRecord::StatementInvalid) do
+ @connection.disable_referential_integrity do
+ raise ActiveRecord::StatementInvalid, "Should be re-raised"
+ end
+ end
+ assert_equal "Should be re-raised", e.message
+ end
+ assert warning.blank?, "expected no warnings but got:\n#{warning}"
+ end
+
+ def test_does_not_break_transactions
+ @connection.extend MissingSuperuserPrivileges
+
+ @connection.transaction do
+ @connection.disable_referential_integrity do
+ assert_transaction_is_not_broken
+ end
+ assert_transaction_is_not_broken
+ end
+ end
+
+ def test_does_not_break_nested_transactions
+ @connection.extend MissingSuperuserPrivileges
+
+ @connection.transaction do
+ @connection.transaction(requires_new: true) do
+ @connection.disable_referential_integrity do
+ assert_transaction_is_not_broken
+ end
+ end
+ assert_transaction_is_not_broken
+ end
+ end
+
+ def test_only_catch_active_record_errors_others_bubble_up
+ @connection.extend ProgrammerMistake
+
+ assert_raises ArgumentError do
+ @connection.disable_referential_integrity { }
+ end
+ end
+
+ private
+
+ def assert_transaction_is_not_broken
+ assert_equal 1, @connection.select_value("SELECT 1")
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/rename_table_test.rb b/activerecord/test/cases/adapters/postgresql/rename_table_test.rb
new file mode 100644
index 0000000000..7eccaf4aa2
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/rename_table_test.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class PostgresqlRenameTableTest < ActiveRecord::PostgreSQLTestCase
+ def setup
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table :before_rename, force: true
+ end
+
+ def teardown
+ @connection.drop_table "before_rename", if_exists: true
+ @connection.drop_table "after_rename", if_exists: true
+ end
+
+ test "renaming a table also renames the primary key index" do
+ # sanity check
+ assert_equal 1, num_indices_named("before_rename_pkey")
+ assert_equal 0, num_indices_named("after_rename_pkey")
+
+ @connection.rename_table :before_rename, :after_rename
+
+ assert_equal 0, num_indices_named("before_rename_pkey")
+ assert_equal 1, num_indices_named("after_rename_pkey")
+ end
+
+ private
+
+ def num_indices_named(name)
+ @connection.execute(<<~SQL).values.length
+ SELECT 1 FROM "pg_index"
+ JOIN "pg_class" ON "pg_index"."indexrelid" = "pg_class"."oid"
+ WHERE "pg_class"."relname" = '#{name}'
+ SQL
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/schema_authorization_test.rb b/activerecord/test/cases/adapters/postgresql/schema_authorization_test.rb
new file mode 100644
index 0000000000..fcb0aec81b
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/schema_authorization_test.rb
@@ -0,0 +1,110 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class SchemaThing < ActiveRecord::Base
+end
+
+class SchemaAuthorizationTest < ActiveRecord::PostgreSQLTestCase
+ self.use_transactional_tests = false
+
+ TABLE_NAME = "schema_things"
+ COLUMNS = [
+ "id serial primary key",
+ "name character varying(50)"
+ ]
+ USERS = ["rails_pg_schema_user1", "rails_pg_schema_user2"]
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ @connection.execute "SET search_path TO '$user',public"
+ set_session_auth
+ USERS.each do |u|
+ @connection.execute "CREATE USER #{u}" rescue nil
+ @connection.execute "CREATE SCHEMA AUTHORIZATION #{u}" rescue nil
+ set_session_auth u
+ @connection.execute "CREATE TABLE #{TABLE_NAME} (#{COLUMNS.join(',')})"
+ @connection.execute "INSERT INTO #{TABLE_NAME} (name) VALUES ('#{u}')"
+ set_session_auth
+ end
+ end
+
+ teardown do
+ set_session_auth
+ @connection.execute "RESET search_path"
+ USERS.each do |u|
+ @connection.drop_schema u
+ @connection.execute "DROP USER #{u}"
+ end
+ end
+
+ def test_schema_invisible
+ assert_raise(ActiveRecord::StatementInvalid) do
+ set_session_auth
+ @connection.execute "SELECT * FROM #{TABLE_NAME}"
+ end
+ end
+
+ def test_session_auth=
+ assert_raise(ActiveRecord::StatementInvalid) do
+ @connection.session_auth = "DEFAULT"
+ @connection.execute "SELECT * FROM #{TABLE_NAME}"
+ end
+ end
+
+ def test_setting_auth_clears_stmt_cache
+ assert_nothing_raised do
+ set_session_auth
+ USERS.each do |u|
+ set_session_auth u
+ assert_equal u, @connection.select_value("SELECT name FROM #{TABLE_NAME} WHERE id = 1")
+ set_session_auth
+ end
+ end
+ end
+
+ if ActiveRecord::Base.connection.prepared_statements
+ def test_auth_with_bind
+ assert_nothing_raised do
+ set_session_auth
+ USERS.each do |u|
+ @connection.clear_cache!
+ set_session_auth u
+ assert_equal u, @connection.select_value("SELECT name FROM #{TABLE_NAME} WHERE id = $1", "SQL", [bind_param(1)])
+ set_session_auth
+ end
+ end
+ end
+ end
+
+ def test_sequence_schema_caching
+ assert_nothing_raised do
+ USERS.each do |u|
+ set_session_auth u
+ st = SchemaThing.new name: "TEST1"
+ st.save!
+ st = SchemaThing.new id: 5, name: "TEST2"
+ st.save!
+ set_session_auth
+ end
+ end
+ end
+
+ def test_tables_in_current_schemas
+ assert_not_includes @connection.tables, TABLE_NAME
+ USERS.each do |u|
+ set_session_auth u
+ assert_includes @connection.tables, TABLE_NAME
+ set_session_auth
+ end
+ end
+
+ private
+ def set_session_auth(auth = nil)
+ @connection.session_auth = auth || "default"
+ end
+
+ def bind_param(value)
+ ActiveRecord::Relation::QueryAttribute.new(nil, value, ActiveRecord::Type::Value.new)
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/schema_test.rb b/activerecord/test/cases/adapters/postgresql/schema_test.rb
new file mode 100644
index 0000000000..336cec30ca
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/schema_test.rb
@@ -0,0 +1,660 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/default"
+require "support/schema_dumping_helper"
+
+module PGSchemaHelper
+ def with_schema_search_path(schema_search_path)
+ @connection.schema_search_path = schema_search_path
+ @connection.schema_cache.clear!
+ yield if block_given?
+ ensure
+ @connection.schema_search_path = "'$user', public"
+ @connection.schema_cache.clear!
+ end
+end
+
+class SchemaTest < ActiveRecord::PostgreSQLTestCase
+ include PGSchemaHelper
+ self.use_transactional_tests = false
+
+ SCHEMA_NAME = "test_schema"
+ SCHEMA2_NAME = "test_schema2"
+ TABLE_NAME = "things"
+ CAPITALIZED_TABLE_NAME = "Things"
+ INDEX_A_NAME = "a_index_things_on_name"
+ INDEX_B_NAME = "b_index_things_on_different_columns_in_each_schema"
+ INDEX_C_NAME = "c_index_full_text_search"
+ INDEX_D_NAME = "d_index_things_on_description_desc"
+ INDEX_E_NAME = "e_index_things_on_name_vector"
+ INDEX_A_COLUMN = "name"
+ INDEX_B_COLUMN_S1 = "email"
+ INDEX_B_COLUMN_S2 = "moment"
+ INDEX_C_COLUMN = "(to_tsvector('english', coalesce(things.name, '')))"
+ INDEX_D_COLUMN = "description"
+ INDEX_E_COLUMN = "name_vector"
+ COLUMNS = [
+ "id integer",
+ "name character varying(50)",
+ "email character varying(50)",
+ "description character varying(100)",
+ "name_vector tsvector",
+ "moment timestamp without time zone default now()"
+ ]
+ PK_TABLE_NAME = "table_with_pk"
+ UNMATCHED_SEQUENCE_NAME = "unmatched_primary_key_default_value_seq"
+ UNMATCHED_PK_TABLE_NAME = "table_with_unmatched_sequence_for_pk"
+
+ class Thing1 < ActiveRecord::Base
+ self.table_name = "test_schema.things"
+ end
+
+ class Thing2 < ActiveRecord::Base
+ self.table_name = "test_schema2.things"
+ end
+
+ class Thing3 < ActiveRecord::Base
+ self.table_name = 'test_schema."things.table"'
+ end
+
+ class Thing4 < ActiveRecord::Base
+ self.table_name = 'test_schema."Things"'
+ end
+
+ class Thing5 < ActiveRecord::Base
+ self.table_name = "things"
+ end
+
+ class Song < ActiveRecord::Base
+ self.table_name = "music.songs"
+ has_and_belongs_to_many :albums
+ end
+
+ class Album < ActiveRecord::Base
+ self.table_name = "music.albums"
+ has_and_belongs_to_many :songs
+ end
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ @connection.execute "CREATE SCHEMA #{SCHEMA_NAME} CREATE TABLE #{TABLE_NAME} (#{COLUMNS.join(',')})"
+ @connection.execute "CREATE TABLE #{SCHEMA_NAME}.\"#{TABLE_NAME}.table\" (#{COLUMNS.join(',')})"
+ @connection.execute "CREATE TABLE #{SCHEMA_NAME}.\"#{CAPITALIZED_TABLE_NAME}\" (#{COLUMNS.join(',')})"
+ @connection.execute "CREATE SCHEMA #{SCHEMA2_NAME} CREATE TABLE #{TABLE_NAME} (#{COLUMNS.join(',')})"
+ @connection.execute "CREATE INDEX #{INDEX_A_NAME} ON #{SCHEMA_NAME}.#{TABLE_NAME} USING btree (#{INDEX_A_COLUMN});"
+ @connection.execute "CREATE INDEX #{INDEX_A_NAME} ON #{SCHEMA2_NAME}.#{TABLE_NAME} USING btree (#{INDEX_A_COLUMN});"
+ @connection.execute "CREATE INDEX #{INDEX_B_NAME} ON #{SCHEMA_NAME}.#{TABLE_NAME} USING btree (#{INDEX_B_COLUMN_S1});"
+ @connection.execute "CREATE INDEX #{INDEX_B_NAME} ON #{SCHEMA2_NAME}.#{TABLE_NAME} USING btree (#{INDEX_B_COLUMN_S2});"
+ @connection.execute "CREATE INDEX #{INDEX_C_NAME} ON #{SCHEMA_NAME}.#{TABLE_NAME} USING gin (#{INDEX_C_COLUMN});"
+ @connection.execute "CREATE INDEX #{INDEX_C_NAME} ON #{SCHEMA2_NAME}.#{TABLE_NAME} USING gin (#{INDEX_C_COLUMN});"
+ @connection.execute "CREATE INDEX #{INDEX_D_NAME} ON #{SCHEMA_NAME}.#{TABLE_NAME} USING btree (#{INDEX_D_COLUMN} DESC);"
+ @connection.execute "CREATE INDEX #{INDEX_D_NAME} ON #{SCHEMA2_NAME}.#{TABLE_NAME} USING btree (#{INDEX_D_COLUMN} DESC);"
+ @connection.execute "CREATE INDEX #{INDEX_E_NAME} ON #{SCHEMA_NAME}.#{TABLE_NAME} USING gin (#{INDEX_E_COLUMN});"
+ @connection.execute "CREATE INDEX #{INDEX_E_NAME} ON #{SCHEMA2_NAME}.#{TABLE_NAME} USING gin (#{INDEX_E_COLUMN});"
+ @connection.execute "CREATE TABLE #{SCHEMA_NAME}.#{PK_TABLE_NAME} (id serial primary key)"
+ @connection.execute "CREATE TABLE #{SCHEMA2_NAME}.#{PK_TABLE_NAME} (id serial primary key)"
+ @connection.execute "CREATE SEQUENCE #{SCHEMA_NAME}.#{UNMATCHED_SEQUENCE_NAME}"
+ @connection.execute "CREATE TABLE #{SCHEMA_NAME}.#{UNMATCHED_PK_TABLE_NAME} (id integer NOT NULL DEFAULT nextval('#{SCHEMA_NAME}.#{UNMATCHED_SEQUENCE_NAME}'::regclass), CONSTRAINT unmatched_pkey PRIMARY KEY (id))"
+ end
+
+ teardown do
+ @connection.drop_schema SCHEMA2_NAME, if_exists: true
+ @connection.drop_schema SCHEMA_NAME, if_exists: true
+ end
+
+ def test_schema_names
+ assert_equal ["public", "test_schema", "test_schema2"], @connection.schema_names
+ end
+
+ def test_create_schema
+ @connection.create_schema "test_schema3"
+ assert @connection.schema_names.include? "test_schema3"
+ ensure
+ @connection.drop_schema "test_schema3"
+ end
+
+ def test_raise_create_schema_with_existing_schema
+ @connection.create_schema "test_schema3"
+ assert_raises(ActiveRecord::StatementInvalid) do
+ @connection.create_schema "test_schema3"
+ end
+ ensure
+ @connection.drop_schema "test_schema3"
+ end
+
+ def test_drop_schema
+ begin
+ @connection.create_schema "test_schema3"
+ ensure
+ @connection.drop_schema "test_schema3"
+ end
+ assert_not_includes @connection.schema_names, "test_schema3"
+ end
+
+ def test_drop_schema_if_exists
+ @connection.create_schema "some_schema"
+ assert_includes @connection.schema_names, "some_schema"
+ @connection.drop_schema "some_schema", if_exists: true
+ assert_not_includes @connection.schema_names, "some_schema"
+ end
+
+ def test_habtm_table_name_with_schema
+ ActiveRecord::Base.connection.drop_schema "music", if_exists: true
+ ActiveRecord::Base.connection.create_schema "music"
+ ActiveRecord::Base.connection.execute <<~SQL
+ CREATE TABLE music.albums (id serial primary key);
+ CREATE TABLE music.songs (id serial primary key);
+ CREATE TABLE music.albums_songs (album_id integer, song_id integer);
+ SQL
+
+ song = Song.create
+ Album.create
+ assert_equal song, Song.includes(:albums).references(:albums).first
+ ensure
+ ActiveRecord::Base.connection.drop_schema "music", if_exists: true
+ end
+
+ def test_drop_schema_with_nonexisting_schema
+ assert_raises(ActiveRecord::StatementInvalid) do
+ @connection.drop_schema "idontexist"
+ end
+
+ assert_nothing_raised do
+ @connection.drop_schema "idontexist", if_exists: true
+ end
+ end
+
+ def test_raise_wrapped_exception_on_bad_prepare
+ assert_raises(ActiveRecord::StatementInvalid) do
+ @connection.exec_query "select * from developers where id = ?", "sql", [bind_param(1)]
+ end
+ end
+
+ if ActiveRecord::Base.connection.prepared_statements
+ def test_schema_change_with_prepared_stmt
+ altered = false
+ @connection.exec_query "select * from developers where id = $1", "sql", [bind_param(1)]
+ @connection.exec_query "alter table developers add column zomg int", "sql", []
+ altered = true
+ @connection.exec_query "select * from developers where id = $1", "sql", [bind_param(1)]
+ ensure
+ # We are not using DROP COLUMN IF EXISTS because that syntax is only
+ # supported by pg 9.X
+ @connection.exec_query("alter table developers drop column zomg", "sql", []) if altered
+ end
+ end
+
+ def test_data_source_exists?
+ [Thing1, Thing2, Thing3, Thing4].each do |klass|
+ name = klass.table_name
+ assert @connection.data_source_exists?(name), "'#{name}' data_source should exist"
+ end
+ end
+
+ def test_data_source_exists_when_on_schema_search_path
+ with_schema_search_path(SCHEMA_NAME) do
+ assert(@connection.data_source_exists?(TABLE_NAME), "data_source should exist and be found")
+ end
+ end
+
+ def test_data_source_exists_when_not_on_schema_search_path
+ with_schema_search_path("PUBLIC") do
+ assert_not(@connection.data_source_exists?(TABLE_NAME), "data_source exists but should not be found")
+ end
+ end
+
+ def test_data_source_exists_wrong_schema
+ assert_not(@connection.data_source_exists?("foo.things"), "data_source should not exist")
+ end
+
+ def test_data_source_exists_quoted_names
+ [ %("#{SCHEMA_NAME}"."#{TABLE_NAME}"), %(#{SCHEMA_NAME}."#{TABLE_NAME}"), %(#{SCHEMA_NAME}."#{TABLE_NAME}")].each do |given|
+ assert(@connection.data_source_exists?(given), "data_source should exist when specified as #{given}")
+ end
+ with_schema_search_path(SCHEMA_NAME) do
+ given = %("#{TABLE_NAME}")
+ assert(@connection.data_source_exists?(given), "data_source should exist when specified as #{given}")
+ end
+ end
+
+ def test_data_source_exists_quoted_table
+ with_schema_search_path(SCHEMA_NAME) do
+ assert(@connection.data_source_exists?('"things.table"'), "data_source should exist")
+ end
+ end
+
+ def test_with_schema_prefixed_table_name
+ assert_nothing_raised do
+ assert_equal COLUMNS, columns("#{SCHEMA_NAME}.#{TABLE_NAME}")
+ end
+ end
+
+ def test_with_schema_prefixed_capitalized_table_name
+ assert_nothing_raised do
+ assert_equal COLUMNS, columns("#{SCHEMA_NAME}.#{CAPITALIZED_TABLE_NAME}")
+ end
+ end
+
+ def test_with_schema_search_path
+ assert_nothing_raised do
+ with_schema_search_path(SCHEMA_NAME) do
+ assert_equal COLUMNS, columns(TABLE_NAME)
+ end
+ end
+ end
+
+ def test_proper_encoding_of_table_name
+ assert_equal '"table_name"', @connection.quote_table_name("table_name")
+ assert_equal '"table.name"', @connection.quote_table_name('"table.name"')
+ assert_equal '"schema_name"."table_name"', @connection.quote_table_name("schema_name.table_name")
+ assert_equal '"schema_name"."table.name"', @connection.quote_table_name('schema_name."table.name"')
+ assert_equal '"schema.name"."table_name"', @connection.quote_table_name('"schema.name".table_name')
+ assert_equal '"schema.name"."table.name"', @connection.quote_table_name('"schema.name"."table.name"')
+ end
+
+ def test_classes_with_qualified_schema_name
+ assert_equal 0, Thing1.count
+ assert_equal 0, Thing2.count
+ assert_equal 0, Thing3.count
+ assert_equal 0, Thing4.count
+
+ Thing1.create(id: 1, name: "thing1", email: "thing1@localhost", moment: Time.now)
+ assert_equal 1, Thing1.count
+ assert_equal 0, Thing2.count
+ assert_equal 0, Thing3.count
+ assert_equal 0, Thing4.count
+
+ Thing2.create(id: 1, name: "thing1", email: "thing1@localhost", moment: Time.now)
+ assert_equal 1, Thing1.count
+ assert_equal 1, Thing2.count
+ assert_equal 0, Thing3.count
+ assert_equal 0, Thing4.count
+
+ Thing3.create(id: 1, name: "thing1", email: "thing1@localhost", moment: Time.now)
+ assert_equal 1, Thing1.count
+ assert_equal 1, Thing2.count
+ assert_equal 1, Thing3.count
+ assert_equal 0, Thing4.count
+
+ Thing4.create(id: 1, name: "thing1", email: "thing1@localhost", moment: Time.now)
+ assert_equal 1, Thing1.count
+ assert_equal 1, Thing2.count
+ assert_equal 1, Thing3.count
+ assert_equal 1, Thing4.count
+ end
+
+ def test_raise_on_unquoted_schema_name
+ assert_raises(ActiveRecord::StatementInvalid) do
+ with_schema_search_path "$user,public"
+ end
+ end
+
+ def test_without_schema_search_path
+ assert_raises(ActiveRecord::StatementInvalid) { columns(TABLE_NAME) }
+ end
+
+ def test_ignore_nil_schema_search_path
+ assert_nothing_raised { with_schema_search_path nil }
+ end
+
+ def test_index_name_exists
+ with_schema_search_path(SCHEMA_NAME) do
+ assert @connection.index_name_exists?(TABLE_NAME, INDEX_A_NAME)
+ assert @connection.index_name_exists?(TABLE_NAME, INDEX_B_NAME)
+ assert @connection.index_name_exists?(TABLE_NAME, INDEX_C_NAME)
+ assert @connection.index_name_exists?(TABLE_NAME, INDEX_D_NAME)
+ assert @connection.index_name_exists?(TABLE_NAME, INDEX_E_NAME)
+ assert @connection.index_name_exists?(TABLE_NAME, INDEX_E_NAME)
+ assert_not @connection.index_name_exists?(TABLE_NAME, "missing_index")
+ end
+ end
+
+ def test_dump_indexes_for_schema_one
+ do_dump_index_tests_for_schema(SCHEMA_NAME, INDEX_A_COLUMN, INDEX_B_COLUMN_S1, INDEX_D_COLUMN, INDEX_E_COLUMN)
+ end
+
+ def test_dump_indexes_for_schema_two
+ do_dump_index_tests_for_schema(SCHEMA2_NAME, INDEX_A_COLUMN, INDEX_B_COLUMN_S2, INDEX_D_COLUMN, INDEX_E_COLUMN)
+ end
+
+ def test_dump_indexes_for_schema_multiple_schemas_in_search_path
+ do_dump_index_tests_for_schema("public, #{SCHEMA_NAME}", INDEX_A_COLUMN, INDEX_B_COLUMN_S1, INDEX_D_COLUMN, INDEX_E_COLUMN)
+ end
+
+ def test_dump_indexes_for_table_with_scheme_specified_in_name
+ indexes = @connection.indexes("#{SCHEMA_NAME}.#{TABLE_NAME}")
+ assert_equal 5, indexes.size
+ end
+
+ def test_with_uppercase_index_name
+ @connection.execute "CREATE INDEX \"things_Index\" ON #{SCHEMA_NAME}.things (name)"
+
+ with_schema_search_path SCHEMA_NAME do
+ assert_nothing_raised { @connection.remove_index "things", name: "things_Index" }
+ end
+ end
+
+ def test_remove_index_when_schema_specified
+ @connection.execute "CREATE INDEX \"things_Index\" ON #{SCHEMA_NAME}.things (name)"
+ assert_nothing_raised { @connection.remove_index "things", name: "#{SCHEMA_NAME}.things_Index" }
+
+ @connection.execute "CREATE INDEX \"things_Index\" ON #{SCHEMA_NAME}.things (name)"
+ assert_nothing_raised { @connection.remove_index "#{SCHEMA_NAME}.things", name: "things_Index" }
+
+ @connection.execute "CREATE INDEX \"things_Index\" ON #{SCHEMA_NAME}.things (name)"
+ assert_nothing_raised { @connection.remove_index "#{SCHEMA_NAME}.things", name: "#{SCHEMA_NAME}.things_Index" }
+
+ @connection.execute "CREATE INDEX \"things_Index\" ON #{SCHEMA_NAME}.things (name)"
+ assert_raises(ArgumentError) { @connection.remove_index "#{SCHEMA2_NAME}.things", name: "#{SCHEMA_NAME}.things_Index" }
+ end
+
+ def test_primary_key_with_schema_specified
+ [
+ %("#{SCHEMA_NAME}"."#{PK_TABLE_NAME}"),
+ %(#{SCHEMA_NAME}."#{PK_TABLE_NAME}"),
+ %(#{SCHEMA_NAME}.#{PK_TABLE_NAME})
+ ].each do |given|
+ assert_equal "id", @connection.primary_key(given), "primary key should be found when table referenced as #{given}"
+ end
+ end
+
+ def test_primary_key_assuming_schema_search_path
+ with_schema_search_path("#{SCHEMA_NAME}, #{SCHEMA2_NAME}") do
+ assert_equal "id", @connection.primary_key(PK_TABLE_NAME), "primary key should be found"
+ end
+ end
+
+ def test_pk_and_sequence_for_with_schema_specified
+ pg_name = ActiveRecord::ConnectionAdapters::PostgreSQL::Name
+ [
+ %("#{SCHEMA_NAME}"."#{PK_TABLE_NAME}"),
+ %("#{SCHEMA_NAME}"."#{UNMATCHED_PK_TABLE_NAME}")
+ ].each do |given|
+ pk, seq = @connection.pk_and_sequence_for(given)
+ assert_equal "id", pk, "primary key should be found when table referenced as #{given}"
+ assert_equal pg_name.new(SCHEMA_NAME, "#{PK_TABLE_NAME}_id_seq"), seq, "sequence name should be found when table referenced as #{given}" if given == %("#{SCHEMA_NAME}"."#{PK_TABLE_NAME}")
+ assert_equal pg_name.new(SCHEMA_NAME, UNMATCHED_SEQUENCE_NAME), seq, "sequence name should be found when table referenced as #{given}" if given == %("#{SCHEMA_NAME}"."#{UNMATCHED_PK_TABLE_NAME}")
+ end
+ end
+
+ def test_current_schema
+ {
+ %('$user',public) => "public",
+ SCHEMA_NAME => SCHEMA_NAME,
+ %(#{SCHEMA2_NAME},#{SCHEMA_NAME},public) => SCHEMA2_NAME,
+ %(public,#{SCHEMA2_NAME},#{SCHEMA_NAME}) => "public"
+ }.each do |given, expect|
+ with_schema_search_path(given) { assert_equal expect, @connection.current_schema }
+ end
+ end
+
+ def test_prepared_statements_with_multiple_schemas
+ [SCHEMA_NAME, SCHEMA2_NAME].each do |schema_name|
+ with_schema_search_path schema_name do
+ Thing5.create(id: 1, name: "thing inside #{SCHEMA_NAME}", email: "thing1@localhost", moment: Time.now)
+ end
+ end
+
+ [SCHEMA_NAME, SCHEMA2_NAME].each do |schema_name|
+ with_schema_search_path schema_name do
+ assert_equal 1, Thing5.count
+ end
+ end
+ end
+
+ def test_schema_exists?
+ {
+ "public" => true,
+ SCHEMA_NAME => true,
+ SCHEMA2_NAME => true,
+ "darkside" => false
+ }.each do |given, expect|
+ assert_equal expect, @connection.schema_exists?(given)
+ end
+ end
+
+ def test_reset_pk_sequence
+ sequence_name = "#{SCHEMA_NAME}.#{UNMATCHED_SEQUENCE_NAME}"
+ @connection.execute "SELECT setval('#{sequence_name}', 123)"
+ assert_equal 124, @connection.select_value("SELECT nextval('#{sequence_name}')")
+ @connection.reset_pk_sequence!("#{SCHEMA_NAME}.#{UNMATCHED_PK_TABLE_NAME}")
+ assert_equal 1, @connection.select_value("SELECT nextval('#{sequence_name}')")
+ end
+
+ def test_set_pk_sequence
+ table_name = "#{SCHEMA_NAME}.#{PK_TABLE_NAME}"
+ _, sequence_name = @connection.pk_and_sequence_for table_name
+ @connection.set_pk_sequence! table_name, 123
+ assert_equal 124, @connection.select_value("SELECT nextval('#{sequence_name}')")
+ @connection.reset_pk_sequence! table_name
+ end
+
+ private
+ def columns(table_name)
+ @connection.send(:column_definitions, table_name).map do |name, type, default|
+ "#{name} #{type}" + (default ? " default #{default}" : "")
+ end
+ end
+
+ def do_dump_index_tests_for_schema(this_schema_name, first_index_column_name, second_index_column_name, third_index_column_name, fourth_index_column_name)
+ with_schema_search_path(this_schema_name) do
+ indexes = @connection.indexes(TABLE_NAME).sort_by(&:name)
+ assert_equal 5, indexes.size
+
+ index_a, index_b, index_c, index_d, index_e = indexes
+
+ do_dump_index_assertions_for_one_index(index_a, INDEX_A_NAME, first_index_column_name)
+ do_dump_index_assertions_for_one_index(index_b, INDEX_B_NAME, second_index_column_name)
+ do_dump_index_assertions_for_one_index(index_d, INDEX_D_NAME, third_index_column_name)
+ do_dump_index_assertions_for_one_index(index_e, INDEX_E_NAME, fourth_index_column_name)
+
+ assert_equal :btree, index_a.using
+ assert_equal :btree, index_b.using
+ assert_equal :gin, index_c.using
+ assert_equal :btree, index_d.using
+ assert_equal :gin, index_e.using
+
+ assert_equal :desc, index_d.orders
+ end
+ end
+
+ def do_dump_index_assertions_for_one_index(this_index, this_index_name, this_index_column)
+ assert_equal TABLE_NAME, this_index.table
+ assert_equal 1, this_index.columns.size
+ assert_equal this_index_column, this_index.columns[0]
+ assert_equal this_index_name, this_index.name
+ end
+
+ def bind_param(value)
+ ActiveRecord::Relation::QueryAttribute.new(nil, value, ActiveRecord::Type::Value.new)
+ end
+end
+
+class SchemaForeignKeyTest < ActiveRecord::PostgreSQLTestCase
+ include SchemaDumpingHelper
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ end
+
+ def test_dump_foreign_key_targeting_different_schema
+ @connection.create_schema "my_schema"
+ @connection.create_table "my_schema.trains" do |t|
+ t.string :name
+ end
+ @connection.create_table "wagons" do |t|
+ t.integer :train_id
+ end
+ @connection.add_foreign_key "wagons", "my_schema.trains", column: "train_id"
+ output = dump_table_schema "wagons"
+ assert_match %r{\s+add_foreign_key "wagons", "my_schema\.trains", column: "train_id"$}, output
+ ensure
+ @connection.drop_table "wagons", if_exists: true
+ @connection.drop_table "my_schema.trains", if_exists: true
+ @connection.drop_schema "my_schema", if_exists: true
+ end
+end
+
+class SchemaIndexOpclassTest < ActiveRecord::PostgreSQLTestCase
+ include SchemaDumpingHelper
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table "trains" do |t|
+ t.string :name
+ t.string :position
+ t.text :description
+ end
+ end
+
+ teardown do
+ @connection.drop_table "trains", if_exists: true
+ end
+
+ def test_string_opclass_is_dumped
+ @connection.execute "CREATE INDEX trains_name_and_description ON trains USING btree(name text_pattern_ops, description text_pattern_ops)"
+
+ output = dump_table_schema "trains"
+
+ assert_match(/opclass: :text_pattern_ops/, output)
+ end
+
+ def test_non_default_opclass_is_dumped
+ @connection.execute "CREATE INDEX trains_name_and_description ON trains USING btree(name, description text_pattern_ops)"
+
+ output = dump_table_schema "trains"
+
+ assert_match(/opclass: \{ description: :text_pattern_ops \}/, output)
+ end
+
+ def test_opclass_class_parsing_on_non_reserved_and_cannot_be_function_or_type_keyword
+ @connection.enable_extension("pg_trgm")
+ @connection.execute "CREATE INDEX trains_position ON trains USING gin(position gin_trgm_ops)"
+ @connection.execute "CREATE INDEX trains_name_and_position ON trains USING btree(name, position text_pattern_ops)"
+
+ output = dump_table_schema "trains"
+
+ assert_match(/opclass: :gin_trgm_ops/, output)
+ assert_match(/opclass: \{ position: :text_pattern_ops \}/, output)
+ end
+end
+
+class SchemaIndexNullsOrderTest < ActiveRecord::PostgreSQLTestCase
+ include SchemaDumpingHelper
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table "trains" do |t|
+ t.string :name
+ t.text :description
+ end
+ end
+
+ teardown do
+ @connection.drop_table "trains", if_exists: true
+ end
+
+ def test_nulls_order_is_dumped
+ @connection.execute "CREATE INDEX trains_name_and_description ON trains USING btree(name NULLS FIRST, description)"
+ output = dump_table_schema "trains"
+ assert_match(/order: \{ name: "NULLS FIRST" \}/, output)
+ end
+
+ def test_non_default_order_with_nulls_is_dumped
+ @connection.execute "CREATE INDEX trains_name_and_desc ON trains USING btree(name DESC NULLS LAST, description)"
+ output = dump_table_schema "trains"
+ assert_match(/order: \{ name: "DESC NULLS LAST" \}/, output)
+ end
+end
+
+class DefaultsUsingMultipleSchemasAndDomainTest < ActiveRecord::PostgreSQLTestCase
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.drop_schema "schema_1", if_exists: true
+ @connection.execute "CREATE SCHEMA schema_1"
+ @connection.execute "CREATE DOMAIN schema_1.text AS text"
+ @connection.execute "CREATE DOMAIN schema_1.varchar AS varchar"
+ @connection.execute "CREATE DOMAIN schema_1.bpchar AS bpchar"
+
+ @old_search_path = @connection.schema_search_path
+ @connection.schema_search_path = "schema_1, pg_catalog"
+ @connection.create_table "defaults" do |t|
+ t.text "text_col", default: "some value"
+ t.string "string_col", default: "some value"
+ t.decimal "decimal_col", default: "3.14159265358979323846"
+ end
+ Default.reset_column_information
+ end
+
+ teardown do
+ @connection.schema_search_path = @old_search_path
+ @connection.drop_schema "schema_1", if_exists: true
+ Default.reset_column_information
+ end
+
+ def test_text_defaults_in_new_schema_when_overriding_domain
+ assert_equal "some value", Default.new.text_col, "Default of text column was not correctly parsed"
+ end
+
+ def test_string_defaults_in_new_schema_when_overriding_domain
+ assert_equal "some value", Default.new.string_col, "Default of string column was not correctly parsed"
+ end
+
+ def test_decimal_defaults_in_new_schema_when_overriding_domain
+ assert_equal BigDecimal("3.14159265358979323846"), Default.new.decimal_col, "Default of decimal column was not correctly parsed"
+ end
+
+ def test_bpchar_defaults_in_new_schema_when_overriding_domain
+ @connection.execute "ALTER TABLE defaults ADD bpchar_col bpchar DEFAULT 'some value'"
+ Default.reset_column_information
+ assert_equal "some value", Default.new.bpchar_col, "Default of bpchar column was not correctly parsed"
+ end
+
+ def test_text_defaults_after_updating_column_default
+ @connection.execute "ALTER TABLE defaults ALTER COLUMN text_col SET DEFAULT 'some text'::schema_1.text"
+ assert_equal "some text", Default.new.text_col, "Default of text column was not correctly parsed after updating default using '::text' since postgreSQL will add parens to the default in db"
+ end
+
+ def test_default_containing_quote_and_colons
+ @connection.execute "ALTER TABLE defaults ALTER COLUMN string_col SET DEFAULT 'foo''::bar'"
+ assert_equal "foo'::bar", Default.new.string_col
+ end
+end
+
+class SchemaWithDotsTest < ActiveRecord::PostgreSQLTestCase
+ include PGSchemaHelper
+ self.use_transactional_tests = false
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_schema "my.schema"
+ end
+
+ teardown do
+ @connection.drop_schema "my.schema", if_exists: true
+ end
+
+ test "rename_table" do
+ with_schema_search_path('"my.schema"') do
+ @connection.create_table :posts
+ @connection.rename_table :posts, :articles
+ assert_equal ["articles"], @connection.tables
+ end
+ end
+
+ test "Active Record basics" do
+ with_schema_search_path('"my.schema"') do
+ @connection.create_table :articles do |t|
+ t.string :title
+ end
+ article_class = Class.new(ActiveRecord::Base) do
+ self.table_name = '"my.schema".articles'
+ end
+
+ article_class.create!(title: "zOMG, welcome to my blorgh!")
+ welcome_article = article_class.last
+ assert_equal "zOMG, welcome to my blorgh!", welcome_article.title
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/serial_test.rb b/activerecord/test/cases/adapters/postgresql/serial_test.rb
new file mode 100644
index 0000000000..83ea86be6d
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/serial_test.rb
@@ -0,0 +1,156 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+class PostgresqlSerialTest < ActiveRecord::PostgreSQLTestCase
+ include SchemaDumpingHelper
+
+ class PostgresqlSerial < ActiveRecord::Base; end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table "postgresql_serials", force: true do |t|
+ t.serial :seq
+ t.integer :serials_id, default: -> { "nextval('postgresql_serials_id_seq')" }
+ end
+ end
+
+ teardown do
+ @connection.drop_table "postgresql_serials", if_exists: true
+ end
+
+ def test_serial_column
+ column = PostgresqlSerial.columns_hash["seq"]
+ assert_equal :integer, column.type
+ assert_equal "integer", column.sql_type
+ assert_predicate column, :serial?
+ end
+
+ def test_not_serial_column
+ column = PostgresqlSerial.columns_hash["serials_id"]
+ assert_equal :integer, column.type
+ assert_equal "integer", column.sql_type
+ assert_not_predicate column, :serial?
+ end
+
+ def test_schema_dump_with_shorthand
+ output = dump_table_schema "postgresql_serials"
+ assert_match %r{t\.serial\s+"seq",\s+null: false$}, output
+ end
+
+ def test_schema_dump_with_not_serial
+ output = dump_table_schema "postgresql_serials"
+ assert_match %r{t\.integer\s+"serials_id",\s+default: -> \{ "nextval\('postgresql_serials_id_seq'::regclass\)" \}$}, output
+ end
+end
+
+class PostgresqlBigSerialTest < ActiveRecord::PostgreSQLTestCase
+ include SchemaDumpingHelper
+
+ class PostgresqlBigSerial < ActiveRecord::Base; end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table "postgresql_big_serials", force: true do |t|
+ t.bigserial :seq
+ t.bigint :serials_id, default: -> { "nextval('postgresql_big_serials_id_seq')" }
+ end
+ end
+
+ teardown do
+ @connection.drop_table "postgresql_big_serials", if_exists: true
+ end
+
+ def test_bigserial_column
+ column = PostgresqlBigSerial.columns_hash["seq"]
+ assert_equal :integer, column.type
+ assert_equal "bigint", column.sql_type
+ assert_predicate column, :serial?
+ end
+
+ def test_not_bigserial_column
+ column = PostgresqlBigSerial.columns_hash["serials_id"]
+ assert_equal :integer, column.type
+ assert_equal "bigint", column.sql_type
+ assert_not_predicate column, :serial?
+ end
+
+ def test_schema_dump_with_shorthand
+ output = dump_table_schema "postgresql_big_serials"
+ assert_match %r{t\.bigserial\s+"seq",\s+null: false$}, output
+ end
+
+ def test_schema_dump_with_not_bigserial
+ output = dump_table_schema "postgresql_big_serials"
+ assert_match %r{t\.bigint\s+"serials_id",\s+default: -> \{ "nextval\('postgresql_big_serials_id_seq'::regclass\)" \}$}, output
+ end
+end
+
+module SequenceNameDetectionTestCases
+ class CollidedSequenceNameTest < ActiveRecord::PostgreSQLTestCase
+ include SchemaDumpingHelper
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table :foo_bar, force: true do |t|
+ t.serial :baz_id
+ end
+ @connection.create_table :foo, force: true do |t|
+ t.serial :bar_id
+ t.bigserial :bar_baz_id
+ end
+ end
+
+ def teardown
+ @connection.drop_table :foo_bar, if_exists: true
+ @connection.drop_table :foo, if_exists: true
+ end
+
+ def test_serial_columns
+ columns = @connection.columns(:foo)
+ columns.each do |column|
+ assert_equal :integer, column.type
+ assert_predicate column, :serial?
+ end
+ end
+
+ def test_schema_dump_with_collided_sequence_name
+ output = dump_table_schema "foo"
+ assert_match %r{t\.serial\s+"bar_id",\s+null: false$}, output
+ assert_match %r{t\.bigserial\s+"bar_baz_id",\s+null: false$}, output
+ end
+ end
+
+ class LongerSequenceNameDetectionTest < ActiveRecord::PostgreSQLTestCase
+ include SchemaDumpingHelper
+
+ def setup
+ @table_name = "long_table_name_to_test_sequence_name_detection_for_serial_cols"
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table @table_name, force: true do |t|
+ t.serial :seq
+ t.bigserial :bigseq
+ end
+ end
+
+ def teardown
+ @connection.drop_table @table_name, if_exists: true
+ end
+
+ def test_serial_columns
+ columns = @connection.columns(@table_name)
+ columns.each do |column|
+ assert_equal :integer, column.type
+ assert_predicate column, :serial?
+ end
+ end
+
+ def test_schema_dump_with_long_table_name
+ output = dump_table_schema @table_name
+ assert_match %r{create_table "#{@table_name}", force: :cascade}, output
+ assert_match %r{t\.serial\s+"seq",\s+null: false$}, output
+ assert_match %r{t\.bigserial\s+"bigseq",\s+null: false$}, output
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/statement_pool_test.rb b/activerecord/test/cases/adapters/postgresql/statement_pool_test.rb
new file mode 100644
index 0000000000..fef4b02b04
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/statement_pool_test.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class PostgreSQLAdapter < AbstractAdapter
+ class InactivePgConnection
+ def query(*args)
+ raise PG::Error
+ end
+
+ def status
+ PG::CONNECTION_BAD
+ end
+ end
+
+ class StatementPoolTest < ActiveRecord::PostgreSQLTestCase
+ if Process.respond_to?(:fork)
+ def test_cache_is_per_pid
+ cache = StatementPool.new nil, 10
+ cache["foo"] = "bar"
+ assert_equal "bar", cache["foo"]
+
+ pid = fork {
+ lookup = cache["foo"]
+ exit!(!lookup)
+ }
+
+ Process.waitpid pid
+ assert $?.success?, "process should exit successfully"
+ end
+ end
+
+ def test_dealloc_does_not_raise_on_inactive_connection
+ cache = StatementPool.new InactivePgConnection.new, 10
+ cache["foo"] = "bar"
+ assert_nothing_raised { cache.clear }
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/timestamp_test.rb b/activerecord/test/cases/adapters/postgresql/timestamp_test.rb
new file mode 100644
index 0000000000..b7f213efc8
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/timestamp_test.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/developer"
+require "models/topic"
+
+class PostgresqlTimestampTest < ActiveRecord::PostgreSQLTestCase
+ class PostgresqlTimestampWithZone < ActiveRecord::Base; end
+
+ self.use_transactional_tests = false
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.execute("INSERT INTO postgresql_timestamp_with_zones (id, time) VALUES (1, '2010-01-01 10:00:00-1')")
+ end
+
+ teardown do
+ PostgresqlTimestampWithZone.delete_all
+ end
+
+ def test_timestamp_with_zone_values_with_rails_time_zone_support
+ with_timezone_config default: :utc, aware_attributes: true do
+ @connection.reconnect!
+
+ timestamp = PostgresqlTimestampWithZone.find(1)
+ assert_equal Time.utc(2010, 1, 1, 11, 0, 0), timestamp.time
+ assert_instance_of Time, timestamp.time
+ end
+ ensure
+ @connection.reconnect!
+ end
+
+ def test_timestamp_with_zone_values_without_rails_time_zone_support
+ with_timezone_config default: :local, aware_attributes: false do
+ @connection.reconnect!
+ # make sure to use a non-UTC time zone
+ @connection.execute("SET time zone 'America/Jamaica'", "SCHEMA")
+
+ timestamp = PostgresqlTimestampWithZone.find(1)
+ assert_equal Time.utc(2010, 1, 1, 11, 0, 0), timestamp.time
+ assert_instance_of Time, timestamp.time
+ end
+ ensure
+ @connection.reconnect!
+ end
+end
+
+class PostgresqlTimestampFixtureTest < ActiveRecord::PostgreSQLTestCase
+ fixtures :topics
+
+ def test_group_by_date
+ keys = Topic.group("date_trunc('month', created_at)").count.keys
+ assert_operator keys.length, :>, 0
+ keys.each { |k| assert_kind_of Time, k }
+ end
+
+ def test_load_infinity_and_beyond
+ d = Developer.find_by_sql("select 'infinity'::timestamp as updated_at")
+ assert d.first.updated_at.infinite?, "timestamp should be infinite"
+
+ d = Developer.find_by_sql("select '-infinity'::timestamp as updated_at")
+ time = d.first.updated_at
+ assert time.infinite?, "timestamp should be infinite"
+ assert_operator time, :<, 0
+ end
+
+ def test_save_infinity_and_beyond
+ d = Developer.create!(name: "aaron", updated_at: 1.0 / 0.0)
+ assert_equal(1.0 / 0.0, d.updated_at)
+
+ d = Developer.create!(name: "aaron", updated_at: -1.0 / 0.0)
+ assert_equal(-1.0 / 0.0, d.updated_at)
+ end
+
+ def test_bc_timestamp
+ date = Date.new(0) - 1.week
+ Developer.create!(name: "aaron", updated_at: date)
+ assert_equal date, Developer.find_by_name("aaron").updated_at
+ end
+
+ def test_bc_timestamp_leap_year
+ date = Time.utc(-4, 2, 29)
+ Developer.create!(name: "taihou", updated_at: date)
+ assert_equal date, Developer.find_by_name("taihou").updated_at
+ end
+
+ def test_bc_timestamp_year_zero
+ date = Time.utc(0, 4, 7)
+ Developer.create!(name: "yahagi", updated_at: date)
+ assert_equal date, Developer.find_by_name("yahagi").updated_at
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/transaction_test.rb b/activerecord/test/cases/adapters/postgresql/transaction_test.rb
new file mode 100644
index 0000000000..919ff3d158
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/transaction_test.rb
@@ -0,0 +1,189 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/connection_helper"
+require "concurrent/atomic/cyclic_barrier"
+
+module ActiveRecord
+ class PostgresqlTransactionTest < ActiveRecord::PostgreSQLTestCase
+ self.use_transactional_tests = false
+
+ class Sample < ActiveRecord::Base
+ self.table_name = "samples"
+ end
+
+ setup do
+ @abort, Thread.abort_on_exception = Thread.abort_on_exception, false
+ Thread.report_on_exception, @original_report_on_exception = false, Thread.report_on_exception
+
+ @connection = ActiveRecord::Base.connection
+
+ @connection.transaction do
+ @connection.drop_table "samples", if_exists: true
+ @connection.create_table("samples") do |t|
+ t.integer "value"
+ end
+ end
+
+ Sample.reset_column_information
+ end
+
+ teardown do
+ @connection.drop_table "samples", if_exists: true
+
+ Thread.abort_on_exception = @abort
+ Thread.report_on_exception = @original_report_on_exception
+ end
+
+ test "raises SerializationFailure when a serialization failure occurs" do
+ assert_raises(ActiveRecord::SerializationFailure) do
+ before = Concurrent::CyclicBarrier.new(2)
+ after = Concurrent::CyclicBarrier.new(2)
+
+ thread = Thread.new do
+ with_warning_suppression do
+ Sample.transaction isolation: :serializable do
+ before.wait
+ Sample.create value: Sample.sum(:value)
+ after.wait
+ end
+ end
+ end
+
+ begin
+ with_warning_suppression do
+ Sample.transaction isolation: :serializable do
+ before.wait
+ Sample.create value: Sample.sum(:value)
+ after.wait
+ end
+ end
+ ensure
+ thread.join
+ end
+ end
+ end
+
+ test "raises Deadlocked when a deadlock is encountered" do
+ with_warning_suppression do
+ assert_raises(ActiveRecord::Deadlocked) do
+ barrier = Concurrent::CyclicBarrier.new(2)
+
+ s1 = Sample.create value: 1
+ s2 = Sample.create value: 2
+
+ thread = Thread.new do
+ Sample.transaction do
+ s1.lock!
+ barrier.wait
+ s2.update value: 1
+ end
+ end
+
+ begin
+ Sample.transaction do
+ s2.lock!
+ barrier.wait
+ s1.update value: 2
+ end
+ ensure
+ thread.join
+ end
+ end
+ end
+ end
+
+ test "raises LockWaitTimeout when lock wait timeout exceeded" do
+ assert_raises(ActiveRecord::LockWaitTimeout) do
+ s = Sample.create!(value: 1)
+ latch1 = Concurrent::CountDownLatch.new
+ latch2 = Concurrent::CountDownLatch.new
+
+ thread = Thread.new do
+ Sample.transaction do
+ Sample.lock.find(s.id)
+ latch1.count_down
+ latch2.wait
+ end
+ end
+
+ begin
+ Sample.transaction do
+ latch1.wait
+ Sample.connection.execute("SET lock_timeout = 1")
+ Sample.lock.find(s.id)
+ end
+ ensure
+ Sample.connection.execute("SET lock_timeout = DEFAULT")
+ latch2.count_down
+ thread.join
+ end
+ end
+ end
+
+ test "raises QueryCanceled when statement timeout exceeded" do
+ assert_raises(ActiveRecord::QueryCanceled) do
+ s = Sample.create!(value: 1)
+ latch1 = Concurrent::CountDownLatch.new
+ latch2 = Concurrent::CountDownLatch.new
+
+ thread = Thread.new do
+ Sample.transaction do
+ Sample.lock.find(s.id)
+ latch1.count_down
+ latch2.wait
+ end
+ end
+
+ begin
+ Sample.transaction do
+ latch1.wait
+ Sample.connection.execute("SET statement_timeout = 1")
+ Sample.lock.find(s.id)
+ end
+ ensure
+ Sample.connection.execute("SET statement_timeout = DEFAULT")
+ latch2.count_down
+ thread.join
+ end
+ end
+ end
+
+ test "raises QueryCanceled when canceling statement due to user request" do
+ assert_raises(ActiveRecord::QueryCanceled) do
+ s = Sample.create!(value: 1)
+ latch = Concurrent::CountDownLatch.new
+
+ thread = Thread.new do
+ Sample.transaction do
+ Sample.lock.find(s.id)
+ latch.count_down
+ sleep(0.5)
+ conn = Sample.connection
+ pid = conn.query_value("SELECT pid FROM pg_stat_activity WHERE query LIKE '% FOR UPDATE'")
+ conn.execute("SELECT pg_cancel_backend(#{pid})")
+ end
+ end
+
+ begin
+ Sample.transaction do
+ latch.wait
+ Sample.lock.find(s.id)
+ end
+ ensure
+ thread.join
+ end
+ end
+ end
+
+ private
+
+ def with_warning_suppression
+ log_level = ActiveRecord::Base.connection.client_min_messages
+ ActiveRecord::Base.connection.client_min_messages = "error"
+ yield
+ ensure
+ ActiveRecord::Base.connection.client_min_messages = log_level
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/type_lookup_test.rb b/activerecord/test/cases/adapters/postgresql/type_lookup_test.rb
new file mode 100644
index 0000000000..8212ed4263
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/type_lookup_test.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class PostgresqlTypeLookupTest < ActiveRecord::PostgreSQLTestCase
+ setup do
+ @connection = ActiveRecord::Base.connection
+ end
+
+ test "array delimiters are looked up correctly" do
+ box_array = @connection.send(:type_map).lookup(1020)
+ int_array = @connection.send(:type_map).lookup(1007)
+
+ assert_equal ";", box_array.delimiter
+ assert_equal ",", int_array.delimiter
+ end
+
+ test "array types correctly respect registration of subtypes" do
+ int_array = @connection.send(:type_map).lookup(1007, -1, "integer[]")
+ bigint_array = @connection.send(:type_map).lookup(1016, -1, "bigint[]")
+ big_array = [123456789123456789]
+
+ assert_raises(ActiveModel::RangeError) { int_array.serialize(big_array) }
+ assert_equal "{123456789123456789}", @connection.type_cast(bigint_array.serialize(big_array))
+ end
+
+ test "range types correctly respect registration of subtypes" do
+ int_range = @connection.send(:type_map).lookup(3904, -1, "int4range")
+ bigint_range = @connection.send(:type_map).lookup(3926, -1, "int8range")
+ big_range = 0..123456789123456789
+
+ assert_raises(ActiveModel::RangeError) { int_range.serialize(big_range) }
+ assert_equal "[0,123456789123456789]", @connection.type_cast(bigint_range.serialize(big_range))
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/utils_test.rb b/activerecord/test/cases/adapters/postgresql/utils_test.rb
new file mode 100644
index 0000000000..c91884f384
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/utils_test.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "active_record/connection_adapters/postgresql/utils"
+
+class PostgreSQLUtilsTest < ActiveRecord::PostgreSQLTestCase
+ Name = ActiveRecord::ConnectionAdapters::PostgreSQL::Name
+ include ActiveRecord::ConnectionAdapters::PostgreSQL::Utils
+
+ def test_extract_schema_qualified_name
+ {
+ %(table_name) => [nil, "table_name"],
+ %("table.name") => [nil, "table.name"],
+ %(schema.table_name) => %w{schema table_name},
+ %("schema".table_name) => %w{schema table_name},
+ %(schema."table_name") => %w{schema table_name},
+ %("schema"."table_name") => %w{schema table_name},
+ %("even spaces".table) => ["even spaces", "table"],
+ %(schema."table.name") => ["schema", "table.name"]
+ }.each do |given, expect|
+ assert_equal Name.new(*expect), extract_schema_qualified_name(given)
+ end
+ end
+end
+
+class PostgreSQLNameTest < ActiveRecord::PostgreSQLTestCase
+ Name = ActiveRecord::ConnectionAdapters::PostgreSQL::Name
+
+ test "represents itself as schema.name" do
+ obj = Name.new("public", "articles")
+ assert_equal "public.articles", obj.to_s
+ end
+
+ test "without schema, represents itself as name only" do
+ obj = Name.new(nil, "articles")
+ assert_equal "articles", obj.to_s
+ end
+
+ test "quoted returns a string representation usable in a query" do
+ assert_equal %("articles"), Name.new(nil, "articles").quoted
+ assert_equal %("public"."articles"), Name.new("public", "articles").quoted
+ end
+
+ test "prevents double quoting" do
+ name = Name.new('"quoted_schema"', '"quoted_table"')
+ assert_equal "quoted_schema.quoted_table", name.to_s
+ assert_equal %("quoted_schema"."quoted_table"), name.quoted
+ end
+
+ test "equality based on state" do
+ assert_equal Name.new("access", "users"), Name.new("access", "users")
+ assert_equal Name.new(nil, "users"), Name.new(nil, "users")
+ assert_not_equal Name.new(nil, "users"), Name.new("access", "users")
+ assert_not_equal Name.new("access", "users"), Name.new("public", "users")
+ assert_not_equal Name.new("public", "users"), Name.new("public", "articles")
+ end
+
+ test "can be used as hash key" do
+ hash = { Name.new("schema", "article_seq") => "success" }
+ assert_equal "success", hash[Name.new("schema", "article_seq")]
+ assert_nil hash[Name.new("schema", "articles")]
+ assert_nil hash[Name.new("public", "article_seq")]
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/uuid_test.rb b/activerecord/test/cases/adapters/postgresql/uuid_test.rb
new file mode 100644
index 0000000000..9912763c1b
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/uuid_test.rb
@@ -0,0 +1,383 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+module PostgresqlUUIDHelper
+ def connection
+ @connection ||= ActiveRecord::Base.connection
+ end
+
+ def drop_table(name)
+ connection.drop_table name, if_exists: true
+ end
+
+ def uuid_function
+ connection.supports_pgcrypto_uuid? ? "gen_random_uuid()" : "uuid_generate_v4()"
+ end
+
+ def uuid_default
+ connection.supports_pgcrypto_uuid? ? {} : { default: uuid_function }
+ end
+end
+
+class PostgresqlUUIDTest < ActiveRecord::PostgreSQLTestCase
+ include PostgresqlUUIDHelper
+ include SchemaDumpingHelper
+
+ class UUIDType < ActiveRecord::Base
+ self.table_name = "uuid_data_type"
+ end
+
+ setup do
+ enable_extension!("uuid-ossp", connection)
+ enable_extension!("pgcrypto", connection) if connection.supports_pgcrypto_uuid?
+
+ connection.create_table "uuid_data_type" do |t|
+ t.uuid "guid"
+ end
+ end
+
+ teardown do
+ drop_table "uuid_data_type"
+ end
+
+ if ActiveRecord::Base.connection.respond_to?(:supports_pgcrypto_uuid?) &&
+ ActiveRecord::Base.connection.supports_pgcrypto_uuid?
+ def test_uuid_column_default
+ connection.add_column :uuid_data_type, :thingy, :uuid, null: false, default: "gen_random_uuid()"
+ UUIDType.reset_column_information
+ column = UUIDType.columns_hash["thingy"]
+ assert_equal "gen_random_uuid()", column.default_function
+ end
+ end
+
+ def test_change_column_default
+ connection.add_column :uuid_data_type, :thingy, :uuid, null: false, default: "uuid_generate_v1()"
+ UUIDType.reset_column_information
+ column = UUIDType.columns_hash["thingy"]
+ assert_equal "uuid_generate_v1()", column.default_function
+
+ connection.change_column :uuid_data_type, :thingy, :uuid, null: false, default: "uuid_generate_v4()"
+ UUIDType.reset_column_information
+ column = UUIDType.columns_hash["thingy"]
+ assert_equal "uuid_generate_v4()", column.default_function
+ ensure
+ UUIDType.reset_column_information
+ end
+
+ def test_add_column_with_null_true_and_default_nil
+ connection.add_column :uuid_data_type, :thingy, :uuid, null: true, default: nil
+
+ UUIDType.reset_column_information
+ column = UUIDType.columns_hash["thingy"]
+
+ assert column.null
+ assert_nil column.default
+ end
+
+ def test_add_column_with_default_array
+ connection.add_column :uuid_data_type, :thingy, :uuid, array: true, default: []
+
+ UUIDType.reset_column_information
+ column = UUIDType.columns_hash["thingy"]
+
+ assert_predicate column, :array?
+ assert_equal "{}", column.default
+
+ schema = dump_table_schema "uuid_data_type"
+ assert_match %r{t\.uuid "thingy", default: \[\], array: true$}, schema
+ end
+
+ def test_data_type_of_uuid_types
+ column = UUIDType.columns_hash["guid"]
+ assert_equal :uuid, column.type
+ assert_equal "uuid", column.sql_type
+ assert_not_predicate column, :array?
+
+ type = UUIDType.type_for_attribute("guid")
+ assert_not_predicate type, :binary?
+ end
+
+ def test_treat_blank_uuid_as_nil
+ UUIDType.create! guid: ""
+ assert_nil(UUIDType.last.guid)
+ end
+
+ def test_treat_invalid_uuid_as_nil
+ uuid = UUIDType.create! guid: "foobar"
+ assert_nil(uuid.guid)
+ end
+
+ def test_invalid_uuid_dont_modify_before_type_cast
+ uuid = UUIDType.new guid: "foobar"
+ assert_equal "foobar", uuid.guid_before_type_cast
+ end
+
+ def test_acceptable_uuid_regex
+ # Valid uuids
+ ["A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11",
+ "{a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11}",
+ "a0eebc999c0b4ef8bb6d6bb9bd380a11",
+ "a0ee-bc99-9c0b-4ef8-bb6d-6bb9-bd38-0a11",
+ "{a0eebc99-9c0b4ef8-bb6d6bb9-bd380a11}",
+ # The following is not a valid RFC 4122 UUID, but PG doesn't seem to care,
+ # so we shouldn't block it either. (Pay attention to "fb6d" – the "f" here
+ # is invalid – it must be one of 8, 9, A, B, a, b according to the spec.)
+ "{a0eebc99-9c0b-4ef8-fb6d-6bb9bd380a11}",
+ ].each do |valid_uuid|
+ uuid = UUIDType.new guid: valid_uuid
+ assert_not_nil uuid.guid
+ end
+
+ # Invalid uuids
+ [["A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11"],
+ Hash.new,
+ 0,
+ 0.0,
+ true,
+ "Z0000C99-9C0B-4EF8-BB6D-6BB9BD380A11",
+ "a0eebc999r0b4ef8ab6d6bb9bd380a11",
+ "a0ee-bc99------4ef8-bb6d-6bb9-bd38-0a11",
+ "{a0eebc99-bb6d6bb9-bd380a11}",
+ "{a0eebc99-9c0b4ef8-bb6d6bb9-bd380a11",
+ "a0eebc99-9c0b4ef8-bb6d6bb9-bd380a11}"].each do |invalid_uuid|
+ uuid = UUIDType.new guid: invalid_uuid
+ assert_nil uuid.guid
+ end
+ end
+
+ def test_uuid_formats
+ ["A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11",
+ "{a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11}",
+ "a0eebc999c0b4ef8bb6d6bb9bd380a11",
+ "a0ee-bc99-9c0b-4ef8-bb6d-6bb9-bd38-0a11",
+ "{a0eebc99-9c0b4ef8-bb6d6bb9-bd380a11}"].each do |valid_uuid|
+ UUIDType.create(guid: valid_uuid)
+ uuid = UUIDType.last
+ assert_equal "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", uuid.guid
+ end
+ end
+
+ def test_schema_dump_with_shorthand
+ output = dump_table_schema "uuid_data_type"
+ assert_match %r{t\.uuid "guid"}, output
+ end
+
+ def test_uniqueness_validation_ignores_uuid
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "uuid_data_type"
+ validates :guid, uniqueness: { case_sensitive: false }
+
+ def self.name
+ "UUIDType"
+ end
+ end
+
+ record = klass.create!(guid: "a0ee-bc99-9c0b-4ef8-bb6d-6bb9-bd38-0a11")
+ duplicate = klass.new(guid: record.guid)
+
+ assert record.guid.present? # Ensure we actually are testing a UUID
+ assert_not_predicate duplicate, :valid?
+ end
+end
+
+class PostgresqlUUIDGenerationTest < ActiveRecord::PostgreSQLTestCase
+ include PostgresqlUUIDHelper
+ include SchemaDumpingHelper
+
+ class UUID < ActiveRecord::Base
+ self.table_name = "pg_uuids"
+ end
+
+ setup do
+ connection.create_table("pg_uuids", id: :uuid, default: "uuid_generate_v1()") do |t|
+ t.string "name"
+ t.uuid "other_uuid", default: "uuid_generate_v4()"
+ end
+
+ # Create custom PostgreSQL function to generate UUIDs
+ # to test dumping tables which columns have defaults with custom functions
+ connection.execute <<~SQL
+ CREATE OR REPLACE FUNCTION my_uuid_generator() RETURNS uuid
+ AS $$ SELECT * FROM #{uuid_function} $$
+ LANGUAGE SQL VOLATILE;
+ SQL
+
+ # Create such a table with custom function as default value generator
+ connection.create_table("pg_uuids_2", id: :uuid, default: "my_uuid_generator()") do |t|
+ t.string "name"
+ t.uuid "other_uuid_2", default: "my_uuid_generator()"
+ end
+
+ connection.create_table("pg_uuids_3", id: :uuid, **uuid_default) do |t|
+ t.string "name"
+ end
+ end
+
+ teardown do
+ drop_table "pg_uuids"
+ drop_table "pg_uuids_2"
+ drop_table "pg_uuids_3"
+ connection.execute "DROP FUNCTION IF EXISTS my_uuid_generator();"
+ end
+
+ def test_id_is_uuid
+ assert_equal :uuid, UUID.columns_hash["id"].type
+ assert UUID.primary_key
+ end
+
+ def test_id_has_a_default
+ u = UUID.create
+ assert_not_nil u.id
+ end
+
+ def test_auto_create_uuid
+ u = UUID.create
+ u.reload
+ assert_not_nil u.other_uuid
+ end
+
+ def test_pk_and_sequence_for_uuid_primary_key
+ pk, seq = connection.pk_and_sequence_for("pg_uuids")
+ assert_equal "id", pk
+ assert_nil seq
+ end
+
+ def test_schema_dumper_for_uuid_primary_key
+ schema = dump_table_schema "pg_uuids"
+ assert_match(/\bcreate_table "pg_uuids", id: :uuid, default: -> { "uuid_generate_v1\(\)" }/, schema)
+ assert_match(/t\.uuid "other_uuid", default: -> { "uuid_generate_v4\(\)" }/, schema)
+ end
+
+ def test_schema_dumper_for_uuid_primary_key_with_custom_default
+ schema = dump_table_schema "pg_uuids_2"
+ assert_match(/\bcreate_table "pg_uuids_2", id: :uuid, default: -> { "my_uuid_generator\(\)" }/, schema)
+ assert_match(/t\.uuid "other_uuid_2", default: -> { "my_uuid_generator\(\)" }/, schema)
+ end
+
+ def test_schema_dumper_for_uuid_primary_key_default
+ schema = dump_table_schema "pg_uuids_3"
+ if connection.supports_pgcrypto_uuid?
+ assert_match(/\bcreate_table "pg_uuids_3", id: :uuid, default: -> { "gen_random_uuid\(\)" }/, schema)
+ else
+ assert_match(/\bcreate_table "pg_uuids_3", id: :uuid, default: -> { "uuid_generate_v4\(\)" }/, schema)
+ end
+ end
+
+ def test_schema_dumper_for_uuid_primary_key_default_in_legacy_migration
+ @verbose_was = ActiveRecord::Migration.verbose
+ ActiveRecord::Migration.verbose = false
+
+ migration = Class.new(ActiveRecord::Migration[5.0]) do
+ def version; 101 end
+ def migrate(x)
+ create_table("pg_uuids_4", id: :uuid)
+ end
+ end.new
+ ActiveRecord::Migrator.new(:up, [migration]).migrate
+
+ schema = dump_table_schema "pg_uuids_4"
+ assert_match(/\bcreate_table "pg_uuids_4", id: :uuid, default: -> { "uuid_generate_v4\(\)" }/, schema)
+ ensure
+ drop_table "pg_uuids_4"
+ ActiveRecord::Migration.verbose = @verbose_was
+ end
+end
+
+class PostgresqlUUIDTestNilDefault < ActiveRecord::PostgreSQLTestCase
+ include PostgresqlUUIDHelper
+ include SchemaDumpingHelper
+
+ setup do
+ connection.create_table("pg_uuids", id: false) do |t|
+ t.primary_key :id, :uuid, default: nil
+ t.string "name"
+ end
+ end
+
+ teardown do
+ drop_table "pg_uuids"
+ end
+
+ def test_id_allows_default_override_via_nil
+ col_desc = connection.execute("SELECT pg_get_expr(d.adbin, d.adrelid) as default
+ FROM pg_attribute a
+ LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum
+ WHERE a.attname='id' AND a.attrelid = 'pg_uuids'::regclass").first
+ assert_nil col_desc["default"]
+ end
+
+ def test_schema_dumper_for_uuid_primary_key_with_default_override_via_nil
+ schema = dump_table_schema "pg_uuids"
+ assert_match(/\bcreate_table "pg_uuids", id: :uuid, default: nil/, schema)
+ end
+
+ def test_schema_dumper_for_uuid_primary_key_with_default_nil_in_legacy_migration
+ @verbose_was = ActiveRecord::Migration.verbose
+ ActiveRecord::Migration.verbose = false
+
+ migration = Class.new(ActiveRecord::Migration[5.0]) do
+ def version; 101 end
+ def migrate(x)
+ create_table("pg_uuids_4", id: :uuid, default: nil)
+ end
+ end.new
+ ActiveRecord::Migrator.new(:up, [migration]).migrate
+
+ schema = dump_table_schema "pg_uuids_4"
+ assert_match(/\bcreate_table "pg_uuids_4", id: :uuid, default: nil/, schema)
+ ensure
+ drop_table "pg_uuids_4"
+ ActiveRecord::Migration.verbose = @verbose_was
+ end
+end
+
+class PostgresqlUUIDTestInverseOf < ActiveRecord::PostgreSQLTestCase
+ include PostgresqlUUIDHelper
+
+ class UuidPost < ActiveRecord::Base
+ self.table_name = "pg_uuid_posts"
+ has_many :uuid_comments, inverse_of: :uuid_post
+ end
+
+ class UuidComment < ActiveRecord::Base
+ self.table_name = "pg_uuid_comments"
+ belongs_to :uuid_post
+ end
+
+ setup do
+ connection.transaction do
+ connection.create_table("pg_uuid_posts", id: :uuid, **uuid_default) do |t|
+ t.string "title"
+ end
+ connection.create_table("pg_uuid_comments", id: :uuid, **uuid_default) do |t|
+ t.references :uuid_post, type: :uuid
+ t.string "content"
+ end
+ end
+ end
+
+ teardown do
+ drop_table "pg_uuid_comments"
+ drop_table "pg_uuid_posts"
+ end
+
+ def test_collection_association_with_uuid
+ post = UuidPost.create!
+ comment = post.uuid_comments.create!
+ assert post.uuid_comments.find(comment.id)
+ end
+
+ def test_find_with_uuid
+ UuidPost.create!
+ assert_raise ActiveRecord::RecordNotFound do
+ UuidPost.find(123456)
+ end
+ end
+
+ def test_find_by_with_uuid
+ UuidPost.create!
+ assert_nil UuidPost.find_by(id: 789)
+ end
+end
diff --git a/activerecord/test/cases/adapters/postgresql/xml_test.rb b/activerecord/test/cases/adapters/postgresql/xml_test.rb
new file mode 100644
index 0000000000..71ead6f7f3
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/xml_test.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+class PostgresqlXMLTest < ActiveRecord::PostgreSQLTestCase
+ include SchemaDumpingHelper
+ class XmlDataType < ActiveRecord::Base
+ self.table_name = "xml_data_type"
+ end
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ begin
+ @connection.transaction do
+ @connection.create_table("xml_data_type") do |t|
+ t.xml "payload"
+ end
+ end
+ rescue ActiveRecord::StatementInvalid
+ skip "do not test on PG without xml"
+ end
+ @column = XmlDataType.columns_hash["payload"]
+ end
+
+ teardown do
+ @connection.drop_table "xml_data_type", if_exists: true
+ end
+
+ def test_column
+ assert_equal :xml, @column.type
+ end
+
+ def test_null_xml
+ @connection.execute "insert into xml_data_type (payload) VALUES(null)"
+ assert_nil XmlDataType.first.payload
+ end
+
+ def test_round_trip
+ data = XmlDataType.new(payload: "<foo>bar</foo>")
+ assert_equal "<foo>bar</foo>", data.payload
+ data.save!
+ assert_equal "<foo>bar</foo>", data.reload.payload
+ end
+
+ def test_update_all
+ data = XmlDataType.create!
+ XmlDataType.update_all(payload: "<bar>baz</bar>")
+ assert_equal "<bar>baz</bar>", data.reload.payload
+ end
+
+ def test_schema_dump_with_shorthand
+ output = dump_table_schema("xml_data_type")
+ assert_match %r{t\.xml "payload"}, output
+ end
+end
diff --git a/activerecord/test/cases/adapters/sqlite3/bind_parameter_test.rb b/activerecord/test/cases/adapters/sqlite3/bind_parameter_test.rb
new file mode 100644
index 0000000000..93a7dafebd
--- /dev/null
+++ b/activerecord/test/cases/adapters/sqlite3/bind_parameter_test.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class SQLite3Adapter
+ class BindParameterTest < ActiveRecord::SQLite3TestCase
+ def test_too_many_binds
+ topics = Topic.where(id: (1..999).to_a << 2**63)
+ assert_equal Topic.count, topics.count
+
+ topics = Topic.where.not(id: (1..999).to_a << 2**63)
+ assert_equal 0, topics.count
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/sqlite3/collation_test.rb b/activerecord/test/cases/adapters/sqlite3/collation_test.rb
new file mode 100644
index 0000000000..76c8f7d8dd
--- /dev/null
+++ b/activerecord/test/cases/adapters/sqlite3/collation_test.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+class SQLite3CollationTest < ActiveRecord::SQLite3TestCase
+ include SchemaDumpingHelper
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table :collation_table_sqlite3, force: true do |t|
+ t.string :string_nocase, collation: "NOCASE"
+ t.text :text_rtrim, collation: "RTRIM"
+ end
+ end
+
+ def teardown
+ @connection.drop_table :collation_table_sqlite3, if_exists: true
+ end
+
+ test "string column with collation" do
+ column = @connection.columns(:collation_table_sqlite3).find { |c| c.name == "string_nocase" }
+ assert_equal :string, column.type
+ assert_equal "NOCASE", column.collation
+ end
+
+ test "text column with collation" do
+ column = @connection.columns(:collation_table_sqlite3).find { |c| c.name == "text_rtrim" }
+ assert_equal :text, column.type
+ assert_equal "RTRIM", column.collation
+ end
+
+ test "add column with collation" do
+ @connection.add_column :collation_table_sqlite3, :title, :string, collation: "RTRIM"
+
+ column = @connection.columns(:collation_table_sqlite3).find { |c| c.name == "title" }
+ assert_equal :string, column.type
+ assert_equal "RTRIM", column.collation
+ end
+
+ test "change column with collation" do
+ @connection.add_column :collation_table_sqlite3, :description, :string
+ @connection.change_column :collation_table_sqlite3, :description, :text, collation: "RTRIM"
+
+ column = @connection.columns(:collation_table_sqlite3).find { |c| c.name == "description" }
+ assert_equal :text, column.type
+ assert_equal "RTRIM", column.collation
+ end
+
+ test "schema dump includes collation" do
+ output = dump_table_schema("collation_table_sqlite3")
+ assert_match %r{t\.string\s+"string_nocase",\s+collation: "NOCASE"$}, output
+ assert_match %r{t\.text\s+"text_rtrim",\s+collation: "RTRIM"$}, output
+ end
+end
diff --git a/activerecord/test/cases/adapters/sqlite3/copy_table_test.rb b/activerecord/test/cases/adapters/sqlite3/copy_table_test.rb
new file mode 100644
index 0000000000..ffb1d6afce
--- /dev/null
+++ b/activerecord/test/cases/adapters/sqlite3/copy_table_test.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class CopyTableTest < ActiveRecord::SQLite3TestCase
+ fixtures :customers
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ class << @connection
+ public :copy_table, :table_structure, :indexes
+ end
+ end
+
+ def test_copy_table(from = "customers", to = "customers2", options = {})
+ assert_nothing_raised { copy_table(from, to, options) }
+ assert_equal row_count(from), row_count(to)
+
+ if block_given?
+ yield from, to, options
+ else
+ assert_equal column_names(from), column_names(to)
+ end
+
+ @connection.drop_table(to) rescue nil
+ end
+
+ def test_copy_table_renaming_column
+ test_copy_table("customers", "customers2",
+ rename: { "name" => "person_name" }) do |from, to, options|
+ expected = column_values(from, "name")
+ assert_equal expected, column_values(to, "person_name")
+ assert expected.any?, "No values in table: #{expected.inspect}"
+ end
+ end
+
+ def test_copy_table_allows_to_pass_options_to_create_table
+ @connection.create_table("blocker_table")
+ test_copy_table("customers", "blocker_table", force: true)
+ end
+
+ def test_copy_table_with_index
+ test_copy_table("comments", "comments_with_index") do
+ @connection.add_index("comments_with_index", ["post_id", "type"])
+ test_copy_table("comments_with_index", "comments_with_index2") do
+ assert_nil table_indexes_without_name("comments_with_index")
+ assert_nil table_indexes_without_name("comments_with_index2")
+ end
+ end
+ end
+
+ def test_copy_table_without_primary_key
+ test_copy_table("developers_projects", "programmers_projects") do
+ assert_nil @connection.primary_key("programmers_projects")
+ end
+ end
+
+ def test_copy_table_with_id_col_that_is_not_primary_key
+ test_copy_table("goofy_string_id", "goofy_string_id2") do
+ original_id = @connection.columns("goofy_string_id").detect { |col| col.name == "id" }
+ copied_id = @connection.columns("goofy_string_id2").detect { |col| col.name == "id" }
+ assert_equal original_id.type, copied_id.type
+ assert_equal original_id.sql_type, copied_id.sql_type
+ assert_nil original_id.limit
+ assert_nil copied_id.limit
+ end
+ end
+
+ def test_copy_table_with_unconventional_primary_key
+ test_copy_table("owners", "owners_unconventional") do
+ original_pk = @connection.primary_key("owners")
+ copied_pk = @connection.primary_key("owners_unconventional")
+ assert_equal original_pk, copied_pk
+ end
+ end
+
+ def test_copy_table_with_binary_column
+ test_copy_table "binaries", "binaries2"
+ end
+
+private
+ def copy_table(from, to, options = {})
+ @connection.copy_table(from, to, { temporary: true }.merge(options))
+ end
+
+ def column_names(table)
+ @connection.table_structure(table).map { |column| column["name"] }
+ end
+
+ def column_values(table, column)
+ @connection.select_all("SELECT #{column} FROM #{table} ORDER BY id").map { |row| row[column] }
+ end
+
+ def table_indexes_without_name(table)
+ @connection.indexes(table).delete(:name)
+ end
+
+ def row_count(table)
+ @connection.select_one("SELECT COUNT(*) AS count FROM #{table}")["count"]
+ end
+end
diff --git a/activerecord/test/cases/adapters/sqlite3/explain_test.rb b/activerecord/test/cases/adapters/sqlite3/explain_test.rb
new file mode 100644
index 0000000000..b6d2ccdb53
--- /dev/null
+++ b/activerecord/test/cases/adapters/sqlite3/explain_test.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/author"
+require "models/post"
+
+class SQLite3ExplainTest < ActiveRecord::SQLite3TestCase
+ fixtures :authors
+
+ def test_explain_for_one_query
+ explain = Author.where(id: 1).explain
+ assert_match %r(EXPLAIN for: SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\? \[\["id", 1\]\]|1)), explain
+ assert_match(/(SEARCH )?TABLE authors USING (INTEGER )?PRIMARY KEY/, explain)
+ end
+
+ def test_explain_with_eager_loading
+ explain = Author.where(id: 1).includes(:posts).explain
+ assert_match %r(EXPLAIN for: SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\? \[\["id", 1\]\]|1)), explain
+ assert_match(/(SEARCH )?TABLE authors USING (INTEGER )?PRIMARY KEY/, explain)
+ assert_match %r(EXPLAIN for: SELECT "posts"\.\* FROM "posts" WHERE "posts"\."author_id" = (?:\? \[\["author_id", 1\]\]|1)), explain
+ assert_match(/(SCAN )?TABLE posts/, explain)
+ end
+end
diff --git a/activerecord/test/cases/adapters/sqlite3/json_test.rb b/activerecord/test/cases/adapters/sqlite3/json_test.rb
new file mode 100644
index 0000000000..6f247fcd22
--- /dev/null
+++ b/activerecord/test/cases/adapters/sqlite3/json_test.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "cases/json_shared_test_cases"
+
+class SQLite3JSONTest < ActiveRecord::SQLite3TestCase
+ include JSONSharedTestCases
+
+ def setup
+ super
+ @connection.create_table("json_data_type") do |t|
+ t.json "payload", default: {}
+ t.json "settings"
+ end
+ end
+
+ def test_default
+ @connection.add_column "json_data_type", "permissions", column_type, default: { "users": "read", "posts": ["read", "write"] }
+ klass.reset_column_information
+
+ assert_equal({ "users" => "read", "posts" => ["read", "write"] }, klass.column_defaults["permissions"])
+ assert_equal({ "users" => "read", "posts" => ["read", "write"] }, klass.new.permissions)
+ end
+
+ private
+ def column_type
+ :json
+ end
+end
diff --git a/activerecord/test/cases/adapters/sqlite3/quoting_test.rb b/activerecord/test/cases/adapters/sqlite3/quoting_test.rb
new file mode 100644
index 0000000000..40b58e86bf
--- /dev/null
+++ b/activerecord/test/cases/adapters/sqlite3/quoting_test.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "bigdecimal"
+require "securerandom"
+
+class SQLite3QuotingTest < ActiveRecord::SQLite3TestCase
+ def setup
+ @conn = ActiveRecord::Base.connection
+ @initial_represent_boolean_as_integer = ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer
+ end
+
+ def teardown
+ ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = @initial_represent_boolean_as_integer
+ end
+
+ def test_type_cast_binary_encoding_without_logger
+ @conn.extend(Module.new { def logger; end })
+ binary = SecureRandom.hex
+ expected = binary.dup.encode!(Encoding::UTF_8)
+ assert_equal expected, @conn.type_cast(binary)
+ end
+
+ def test_type_cast_true
+ ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = false
+ assert_equal "t", @conn.type_cast(true)
+
+ ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = true
+ assert_equal 1, @conn.type_cast(true)
+ end
+
+ def test_type_cast_false
+ ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = false
+ assert_equal "f", @conn.type_cast(false)
+
+ ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = true
+ assert_equal 0, @conn.type_cast(false)
+ end
+
+ def test_type_cast_bigdecimal
+ bd = BigDecimal "10.0"
+ assert_equal bd.to_f, @conn.type_cast(bd)
+ end
+
+ def test_quoting_binary_strings
+ value = "hello".encode("ascii-8bit")
+ type = ActiveRecord::Type::String.new
+
+ assert_equal "'hello'", @conn.quote(type.serialize(value))
+ end
+
+ def test_quoted_time_returns_date_qualified_time
+ value = ::Time.utc(2000, 1, 1, 12, 30, 0, 999999)
+ type = ActiveRecord::Type::Time.new
+
+ assert_equal "'2000-01-01 12:30:00.999999'", @conn.quote(type.serialize(value))
+ end
+
+ def test_quoted_time_normalizes_date_qualified_time
+ value = ::Time.utc(2018, 3, 11, 12, 30, 0, 999999)
+ type = ActiveRecord::Type::Time.new
+
+ assert_equal "'2000-01-01 12:30:00.999999'", @conn.quote(type.serialize(value))
+ end
+
+ def test_quoted_time_dst_utc
+ with_env_tz "America/New_York" do
+ with_timezone_config default: :utc do
+ t = Time.new(2000, 7, 1, 0, 0, 0, "+04:30")
+
+ expected = t.change(year: 2000, month: 1, day: 1)
+ expected = expected.getutc.to_s(:db).sub(/\A\d\d\d\d-\d\d-\d\d /, "2000-01-01 ")
+
+ assert_equal expected, @conn.quoted_time(t)
+ end
+ end
+ end
+
+ def test_quoted_time_dst_local
+ with_env_tz "America/New_York" do
+ with_timezone_config default: :local do
+ t = Time.new(2000, 7, 1, 0, 0, 0, "+04:30")
+
+ expected = t.change(year: 2000, month: 1, day: 1)
+ expected = expected.getlocal.to_s(:db).sub(/\A\d\d\d\d-\d\d-\d\d /, "2000-01-01 ")
+
+ assert_equal expected, @conn.quoted_time(t)
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb b/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb
new file mode 100644
index 0000000000..56ceb45040
--- /dev/null
+++ b/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb
@@ -0,0 +1,652 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/owner"
+require "tempfile"
+require "support/ddl_helper"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class SQLite3AdapterTest < ActiveRecord::SQLite3TestCase
+ include DdlHelper
+
+ self.use_transactional_tests = false
+
+ class DualEncoding < ActiveRecord::Base
+ end
+
+ def setup
+ @conn = Base.sqlite3_connection database: ":memory:",
+ adapter: "sqlite3",
+ timeout: 100
+ end
+
+ def test_bad_connection
+ assert_raise ActiveRecord::NoDatabaseError do
+ connection = ActiveRecord::Base.sqlite3_connection(adapter: "sqlite3", database: "/tmp/should/_not/_exist/-cinco-dog.db")
+ connection.drop_table "ex", if_exists: true
+ end
+ end
+
+ unless in_memory_db?
+ def test_connect_with_url
+ original_connection = ActiveRecord::Base.remove_connection
+ tf = Tempfile.open "whatever"
+ url = "sqlite3:#{tf.path}"
+ ActiveRecord::Base.establish_connection(url)
+ assert ActiveRecord::Base.connection
+ ensure
+ tf.close
+ tf.unlink
+ ActiveRecord::Base.establish_connection(original_connection)
+ end
+
+ def test_connect_memory_with_url
+ original_connection = ActiveRecord::Base.remove_connection
+ url = "sqlite3::memory:"
+ ActiveRecord::Base.establish_connection(url)
+ assert ActiveRecord::Base.connection
+ ensure
+ ActiveRecord::Base.establish_connection(original_connection)
+ end
+ end
+
+ def test_column_types
+ owner = Owner.create!(name: "hello".encode("ascii-8bit"))
+ owner.reload
+ select = Owner.columns.map { |c| "typeof(#{c.name})" }.join ", "
+ result = Owner.connection.exec_query <<~SQL
+ SELECT #{select}
+ FROM #{Owner.table_name}
+ WHERE #{Owner.primary_key} = #{owner.id}
+ SQL
+
+ assert_not(result.rows.first.include?("blob"), "should not store blobs")
+ ensure
+ owner.delete
+ end
+
+ def test_exec_insert
+ with_example_table do
+ vals = [Relation::QueryAttribute.new("number", 10, Type::Value.new)]
+ @conn.exec_insert("insert into ex (number) VALUES (?)", "SQL", vals)
+
+ result = @conn.exec_query(
+ "select number from ex where number = ?", "SQL", vals)
+
+ assert_equal 1, result.rows.length
+ assert_equal 10, result.rows.first.first
+ end
+ end
+
+ def test_primary_key_returns_nil_for_no_pk
+ with_example_table "id int, data string" do
+ assert_nil @conn.primary_key("ex")
+ end
+ end
+
+ def test_connection_no_db
+ assert_raises(ArgumentError) do
+ Base.sqlite3_connection { }
+ end
+ end
+
+ def test_bad_timeout
+ assert_raises(TypeError) do
+ Base.sqlite3_connection database: ":memory:",
+ adapter: "sqlite3",
+ timeout: "usa"
+ end
+ end
+
+ # connection is OK with a nil timeout
+ def test_nil_timeout
+ conn = Base.sqlite3_connection database: ":memory:",
+ adapter: "sqlite3",
+ timeout: nil
+ assert conn, "made a connection"
+ end
+
+ def test_connect
+ assert @conn, "should have connection"
+ end
+
+ # sqlite3 defaults to UTF-8 encoding
+ def test_encoding
+ assert_equal "UTF-8", @conn.encoding
+ end
+
+ def test_exec_no_binds
+ with_example_table "id int, data string" do
+ result = @conn.exec_query("SELECT id, data FROM ex")
+ assert_equal 0, result.rows.length
+ assert_equal 2, result.columns.length
+ assert_equal %w{ id data }, result.columns
+
+ @conn.exec_query('INSERT INTO ex (id, data) VALUES (1, "foo")')
+ result = @conn.exec_query("SELECT id, data FROM ex")
+ assert_equal 1, result.rows.length
+ assert_equal 2, result.columns.length
+
+ assert_equal [[1, "foo"]], result.rows
+ end
+ end
+
+ def test_exec_query_with_binds
+ with_example_table "id int, data string" do
+ @conn.exec_query('INSERT INTO ex (id, data) VALUES (1, "foo")')
+ result = @conn.exec_query(
+ "SELECT id, data FROM ex WHERE id = ?", nil, [Relation::QueryAttribute.new(nil, 1, Type::Value.new)])
+
+ assert_equal 1, result.rows.length
+ assert_equal 2, result.columns.length
+
+ assert_equal [[1, "foo"]], result.rows
+ end
+ end
+
+ def test_exec_query_typecasts_bind_vals
+ with_example_table "id int, data string" do
+ @conn.exec_query('INSERT INTO ex (id, data) VALUES (1, "foo")')
+
+ result = @conn.exec_query(
+ "SELECT id, data FROM ex WHERE id = ?", nil, [Relation::QueryAttribute.new("id", "1-fuu", Type::Integer.new)])
+
+ assert_equal 1, result.rows.length
+ assert_equal 2, result.columns.length
+
+ assert_equal [[1, "foo"]], result.rows
+ end
+ end
+
+ def test_quote_binary_column_escapes_it
+ DualEncoding.connection.execute(<<~SQL)
+ CREATE TABLE IF NOT EXISTS dual_encodings (
+ id integer PRIMARY KEY AUTOINCREMENT,
+ name varchar(255),
+ data binary
+ )
+ SQL
+ str = (+"\x80").force_encoding("ASCII-8BIT")
+ binary = DualEncoding.new name: "いただきます!", data: str
+ binary.save!
+ assert_equal str, binary.data
+ ensure
+ DualEncoding.connection.drop_table "dual_encodings", if_exists: true
+ end
+
+ def test_type_cast_should_not_mutate_encoding
+ name = (+"hello").force_encoding(Encoding::ASCII_8BIT)
+ Owner.create(name: name)
+ assert_equal Encoding::ASCII_8BIT, name.encoding
+ ensure
+ Owner.delete_all
+ end
+
+ def test_execute
+ with_example_table do
+ @conn.execute "INSERT INTO ex (number) VALUES (10)"
+ records = @conn.execute "SELECT * FROM ex"
+ assert_equal 1, records.length
+
+ record = records.first
+ assert_equal 10, record["number"]
+ assert_equal 1, record["id"]
+ end
+ end
+
+ def test_quote_string
+ assert_equal "''", @conn.quote_string("'")
+ end
+
+ def test_insert_logged
+ with_example_table do
+ sql = "INSERT INTO ex (number) VALUES (10)"
+ name = "foo"
+ assert_logged [[sql, name, []]] do
+ @conn.insert(sql, name)
+ end
+ end
+ end
+
+ def test_insert_id_value_returned
+ with_example_table do
+ sql = "INSERT INTO ex (number) VALUES (10)"
+ idval = "vuvuzela"
+ id = @conn.insert(sql, nil, nil, idval)
+ assert_equal idval, id
+ end
+ end
+
+ def test_select_rows
+ with_example_table do
+ 2.times do |i|
+ @conn.create "INSERT INTO ex (number) VALUES (#{i})"
+ end
+ rows = @conn.select_rows "select number, id from ex"
+ assert_equal [[0, 1], [1, 2]], rows
+ end
+ end
+
+ def test_select_rows_logged
+ with_example_table do
+ sql = "select * from ex"
+ name = "foo"
+ assert_logged [[sql, name, []]] do
+ @conn.select_rows sql, name
+ end
+ end
+ end
+
+ def test_transaction
+ with_example_table do
+ count_sql = "select count(*) from ex"
+
+ @conn.begin_db_transaction
+ @conn.create "INSERT INTO ex (number) VALUES (10)"
+
+ assert_equal 1, @conn.select_rows(count_sql).first.first
+ @conn.rollback_db_transaction
+ assert_equal 0, @conn.select_rows(count_sql).first.first
+ end
+ end
+
+ def test_tables
+ with_example_table do
+ assert_equal %w{ ex }, @conn.tables
+ with_example_table "id integer PRIMARY KEY AUTOINCREMENT, number integer", "people" do
+ assert_equal %w{ ex people }.sort, @conn.tables.sort
+ end
+ end
+ end
+
+ def test_tables_logs_name
+ sql = <<~SQL
+ SELECT name FROM sqlite_master WHERE name <> 'sqlite_sequence' AND type IN ('table')
+ SQL
+ assert_logged [[sql.squish, "SCHEMA", []]] do
+ @conn.tables
+ end
+ end
+
+ def test_table_exists_logs_name
+ with_example_table do
+ sql = <<~SQL
+ SELECT name FROM sqlite_master WHERE name <> 'sqlite_sequence' AND name = 'ex' AND type IN ('table')
+ SQL
+ assert_logged [[sql.squish, "SCHEMA", []]] do
+ assert @conn.table_exists?("ex")
+ end
+ end
+ end
+
+ def test_columns
+ with_example_table do
+ columns = @conn.columns("ex").sort_by(&:name)
+ assert_equal 2, columns.length
+ assert_equal %w{ id number }.sort, columns.map(&:name)
+ assert_equal [nil, nil], columns.map(&:default)
+ assert_equal [true, true], columns.map(&:null)
+ end
+ end
+
+ def test_columns_with_default
+ with_example_table "id integer PRIMARY KEY AUTOINCREMENT, number integer default 10" do
+ column = @conn.columns("ex").find { |x|
+ x.name == "number"
+ }
+ assert_equal "10", column.default
+ end
+ end
+
+ def test_columns_with_not_null
+ with_example_table "id integer PRIMARY KEY AUTOINCREMENT, number integer not null" do
+ column = @conn.columns("ex").find { |x| x.name == "number" }
+ assert_not column.null, "column should not be null"
+ end
+ end
+
+ def test_indexes_logs
+ with_example_table do
+ assert_logged [["PRAGMA index_list(\"ex\")", "SCHEMA", []]] do
+ @conn.indexes("ex")
+ end
+ end
+ end
+
+ def test_no_indexes
+ assert_equal [], @conn.indexes("items")
+ end
+
+ def test_index
+ with_example_table do
+ @conn.add_index "ex", "id", unique: true, name: "fun"
+ index = @conn.indexes("ex").find { |idx| idx.name == "fun" }
+
+ assert_equal "ex", index.table
+ assert index.unique, "index is unique"
+ assert_equal ["id"], index.columns
+ end
+ end
+
+ def test_non_unique_index
+ with_example_table do
+ @conn.add_index "ex", "id", name: "fun"
+ index = @conn.indexes("ex").find { |idx| idx.name == "fun" }
+ assert_not index.unique, "index is not unique"
+ end
+ end
+
+ def test_compound_index
+ with_example_table do
+ @conn.add_index "ex", %w{ id number }, name: "fun"
+ index = @conn.indexes("ex").find { |idx| idx.name == "fun" }
+ assert_equal %w{ id number }.sort, index.columns.sort
+ end
+ end
+
+ if ActiveRecord::Base.connection.supports_expression_index?
+ def test_expression_index
+ with_example_table do
+ @conn.add_index "ex", "max(id, number)", name: "expression"
+ index = @conn.indexes("ex").find { |idx| idx.name == "expression" }
+ assert_equal "max(id, number)", index.columns
+ end
+ end
+
+ def test_expression_index_with_where
+ with_example_table do
+ @conn.add_index "ex", "id % 10, max(id, number)", name: "expression", where: "id > 1000"
+ index = @conn.indexes("ex").find { |idx| idx.name == "expression" }
+ assert_equal "id % 10, max(id, number)", index.columns
+ assert_equal "id > 1000", index.where
+ end
+ end
+
+ def test_complicated_expression
+ with_example_table do
+ @conn.execute "CREATE INDEX expression ON ex (id % 10, (CASE WHEN number > 0 THEN max(id, number) END))WHERE(id > 1000)"
+ index = @conn.indexes("ex").find { |idx| idx.name == "expression" }
+ assert_equal "id % 10, (CASE WHEN number > 0 THEN max(id, number) END)", index.columns
+ assert_equal "(id > 1000)", index.where
+ end
+ end
+
+ def test_not_everything_an_expression
+ with_example_table do
+ @conn.add_index "ex", "id, max(id, number)", name: "expression"
+ index = @conn.indexes("ex").find { |idx| idx.name == "expression" }
+ assert_equal "id, max(id, number)", index.columns
+ end
+ end
+ end
+
+ def test_primary_key
+ with_example_table do
+ assert_equal "id", @conn.primary_key("ex")
+ with_example_table "internet integer PRIMARY KEY AUTOINCREMENT, number integer not null", "foos" do
+ assert_equal "internet", @conn.primary_key("foos")
+ end
+ end
+ end
+
+ def test_no_primary_key
+ with_example_table "number integer not null" do
+ assert_nil @conn.primary_key("ex")
+ end
+ end
+
+ class Barcode < ActiveRecord::Base
+ self.primary_key = "code"
+ end
+
+ def test_copy_table_with_existing_records_have_custom_primary_key
+ connection = Barcode.connection
+ connection.create_table(:barcodes, primary_key: "code", id: :string, limit: 42, force: true) do |t|
+ t.text :other_attr
+ end
+ code = "214fe0c2-dd47-46df-b53b-66090b3c1d40"
+ Barcode.create!(code: code, other_attr: "xxx")
+
+ connection.remove_column("barcodes", "other_attr")
+
+ assert_equal code, Barcode.first.id
+ ensure
+ Barcode.reset_column_information
+ end
+
+ def test_copy_table_with_composite_primary_keys
+ connection = Barcode.connection
+ connection.create_table(:barcodes, primary_key: ["region", "code"], force: true) do |t|
+ t.string :region
+ t.string :code
+ t.text :other_attr
+ end
+ region = "US"
+ code = "214fe0c2-dd47-46df-b53b-66090b3c1d40"
+ Barcode.create!(region: region, code: code, other_attr: "xxx")
+
+ connection.remove_column("barcodes", "other_attr")
+
+ assert_equal ["region", "code"], connection.primary_keys("barcodes")
+
+ barcode = Barcode.first
+ assert_equal region, barcode.region
+ assert_equal code, barcode.code
+ ensure
+ Barcode.reset_column_information
+ end
+
+ def test_custom_primary_key_in_create_table
+ connection = Barcode.connection
+ connection.create_table :barcodes, id: false, force: true do |t|
+ t.primary_key :id, :string
+ end
+
+ assert_equal "id", connection.primary_key("barcodes")
+
+ custom_pk = Barcode.columns_hash["id"]
+
+ assert_equal :string, custom_pk.type
+ assert_not custom_pk.null
+ ensure
+ Barcode.reset_column_information
+ end
+
+ def test_custom_primary_key_in_change_table
+ connection = Barcode.connection
+ connection.create_table :barcodes, id: false, force: true do |t|
+ t.integer :dummy
+ end
+ connection.change_table :barcodes do |t|
+ t.primary_key :id, :string
+ end
+
+ assert_equal "id", connection.primary_key("barcodes")
+
+ custom_pk = Barcode.columns_hash["id"]
+
+ assert_equal :string, custom_pk.type
+ assert_not custom_pk.null
+ ensure
+ Barcode.reset_column_information
+ end
+
+ def test_add_column_with_custom_primary_key
+ connection = Barcode.connection
+ connection.create_table :barcodes, id: false, force: true do |t|
+ t.integer :dummy
+ end
+ connection.add_column :barcodes, :id, :string, primary_key: true
+
+ assert_equal "id", connection.primary_key("barcodes")
+
+ custom_pk = Barcode.columns_hash["id"]
+
+ assert_equal :string, custom_pk.type
+ assert_not custom_pk.null
+ ensure
+ Barcode.reset_column_information
+ end
+
+ def test_remove_column_preserves_partial_indexes
+ connection = Barcode.connection
+ connection.create_table :barcodes, force: true do |t|
+ t.string :code
+ t.string :region
+ t.boolean :bool_attr
+
+ t.index :code, unique: true, where: :bool_attr, name: "partial"
+ end
+ connection.remove_column :barcodes, :region
+
+ index = connection.indexes("barcodes").find { |idx| idx.name == "partial" }
+ assert_equal "bool_attr", index.where
+ ensure
+ Barcode.reset_column_information
+ end
+
+ def test_supports_extensions
+ assert_not @conn.supports_extensions?, "does not support extensions"
+ end
+
+ def test_respond_to_enable_extension
+ assert_respond_to @conn, :enable_extension
+ end
+
+ def test_respond_to_disable_extension
+ assert_respond_to @conn, :disable_extension
+ end
+
+ def test_statement_closed
+ db = ::SQLite3::Database.new(ActiveRecord::Base.
+ configurations["arunit"]["database"])
+ statement = ::SQLite3::Statement.new(db,
+ "CREATE TABLE statement_test (number integer not null)")
+ statement.stub(:step, -> { raise ::SQLite3::BusyException.new("busy") }) do
+ assert_called(statement, :columns, returns: []) do
+ assert_called(statement, :close) do
+ ::SQLite3::Statement.stub(:new, statement) do
+ assert_raises ActiveRecord::StatementInvalid do
+ @conn.exec_query "select * from statement_test"
+ end
+ end
+ end
+ end
+ end
+ end
+
+ def test_deprecate_valid_alter_table_type
+ assert_deprecated { @conn.valid_alter_table_type?(:string) }
+ end
+
+ def test_db_is_not_readonly_when_readonly_option_is_false
+ conn = Base.sqlite3_connection database: ":memory:",
+ adapter: "sqlite3",
+ readonly: false
+
+ assert_not_predicate conn.raw_connection, :readonly?
+ end
+
+ def test_db_is_not_readonly_when_readonly_option_is_unspecified
+ conn = Base.sqlite3_connection database: ":memory:",
+ adapter: "sqlite3"
+
+ assert_not_predicate conn.raw_connection, :readonly?
+ end
+
+ def test_db_is_readonly_when_readonly_option_is_true
+ conn = Base.sqlite3_connection database: ":memory:",
+ adapter: "sqlite3",
+ readonly: true
+
+ assert_predicate conn.raw_connection, :readonly?
+ end
+
+ def test_writes_are_not_permitted_to_readonly_databases
+ conn = Base.sqlite3_connection database: ":memory:",
+ adapter: "sqlite3",
+ readonly: true
+
+ assert_raises(ActiveRecord::StatementInvalid, /SQLite3::ReadOnlyException/) do
+ conn.execute("CREATE TABLE test(id integer)")
+ end
+ end
+
+ def test_errors_when_an_insert_query_is_called_while_preventing_writes
+ with_example_table "id int, data string" do
+ assert_raises(ActiveRecord::ReadOnlyError) do
+ @conn.while_preventing_writes do
+ @conn.execute("INSERT INTO ex (data) VALUES ('138853948594')")
+ end
+ end
+ end
+ end
+
+ def test_errors_when_an_update_query_is_called_while_preventing_writes
+ with_example_table "id int, data string" do
+ @conn.execute("INSERT INTO ex (data) VALUES ('138853948594')")
+
+ assert_raises(ActiveRecord::ReadOnlyError) do
+ @conn.while_preventing_writes do
+ @conn.execute("UPDATE ex SET data = '9989' WHERE data = '138853948594'")
+ end
+ end
+ end
+ end
+
+ def test_errors_when_a_delete_query_is_called_while_preventing_writes
+ with_example_table "id int, data string" do
+ @conn.execute("INSERT INTO ex (data) VALUES ('138853948594')")
+
+ assert_raises(ActiveRecord::ReadOnlyError) do
+ @conn.while_preventing_writes do
+ @conn.execute("DELETE FROM ex where data = '138853948594'")
+ end
+ end
+ end
+ end
+
+ def test_errors_when_a_replace_query_is_called_while_preventing_writes
+ with_example_table "id int, data string" do
+ @conn.execute("INSERT INTO ex (data) VALUES ('138853948594')")
+
+ assert_raises(ActiveRecord::ReadOnlyError) do
+ @conn.while_preventing_writes do
+ @conn.execute("REPLACE INTO ex (data) VALUES ('249823948')")
+ end
+ end
+ end
+ end
+
+ def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes
+ with_example_table "id int, data string" do
+ @conn.execute("INSERT INTO ex (data) VALUES ('138853948594')")
+
+ @conn.while_preventing_writes do
+ assert_equal 1, @conn.execute("SELECT data from ex WHERE data = '138853948594'").count
+ end
+ end
+ end
+
+ private
+
+ def assert_logged(logs)
+ subscriber = SQLSubscriber.new
+ subscription = ActiveSupport::Notifications.subscribe("sql.active_record", subscriber)
+ yield
+ assert_equal logs, subscriber.logged
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscription)
+ end
+
+ def with_example_table(definition = nil, table_name = "ex", &block)
+ definition ||= <<~SQL
+ id integer PRIMARY KEY AUTOINCREMENT,
+ number integer
+ SQL
+ super(@conn, table_name, definition, &block)
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/sqlite3/sqlite3_create_folder_test.rb b/activerecord/test/cases/adapters/sqlite3/sqlite3_create_folder_test.rb
new file mode 100644
index 0000000000..cfc9853aba
--- /dev/null
+++ b/activerecord/test/cases/adapters/sqlite3/sqlite3_create_folder_test.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/owner"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class SQLite3CreateFolder < ActiveRecord::SQLite3TestCase
+ def test_sqlite_creates_directory
+ Dir.mktmpdir do |dir|
+ dir = Pathname.new(dir)
+ @conn = Base.sqlite3_connection database: dir.join("db/foo.sqlite3"),
+ adapter: "sqlite3",
+ timeout: 100
+
+ assert Dir.exist? dir.join("db")
+ assert File.exist? dir.join("db/foo.sqlite3")
+ ensure
+ @conn.disconnect! if @conn
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/adapters/sqlite3/statement_pool_test.rb b/activerecord/test/cases/adapters/sqlite3/statement_pool_test.rb
new file mode 100644
index 0000000000..61002435a4
--- /dev/null
+++ b/activerecord/test/cases/adapters/sqlite3/statement_pool_test.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class SQLite3StatementPoolTest < ActiveRecord::SQLite3TestCase
+ if Process.respond_to?(:fork)
+ def test_cache_is_per_pid
+ cache = ActiveRecord::ConnectionAdapters::SQLite3Adapter::StatementPool.new(10)
+ cache["foo"] = "bar"
+ assert_equal "bar", cache["foo"]
+
+ pid = fork {
+ lookup = cache["foo"]
+ exit!(!lookup)
+ }
+
+ Process.waitpid pid
+ assert $?.success?, "process should exit successfully"
+ end
+ end
+end
diff --git a/activerecord/test/cases/aggregations_test.rb b/activerecord/test/cases/aggregations_test.rb
new file mode 100644
index 0000000000..d270175af4
--- /dev/null
+++ b/activerecord/test/cases/aggregations_test.rb
@@ -0,0 +1,170 @@
+# 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
diff --git a/activerecord/test/cases/ar_schema_test.rb b/activerecord/test/cases/ar_schema_test.rb
new file mode 100644
index 0000000000..f05dcac7dd
--- /dev/null
+++ b/activerecord/test/cases/ar_schema_test.rb
@@ -0,0 +1,145 @@
+# 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
+ ActiveRecord::SchemaMigration.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
+ ActiveRecord::SchemaMigration.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", ActiveRecord::SchemaMigration.primary_key
+
+ ActiveRecord::SchemaMigration.create_table
+ assert_difference "ActiveRecord::SchemaMigration.count", 1 do
+ ActiveRecord::SchemaMigration.create version: 12
+ end
+ ensure
+ ActiveRecord::SchemaMigration.drop_table
+ ActiveRecord::Base.primary_key_prefix_type = old_primary_key_prefix_type
+ 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.migration_context.current_version
+ end
+
+ def test_schema_define_w_table_name_prefix
+ table_name = ActiveRecord::SchemaMigration.table_name
+ old_table_name_prefix = ActiveRecord::Base.table_name_prefix
+ ActiveRecord::Base.table_name_prefix = "nep_"
+ ActiveRecord::SchemaMigration.table_name = "nep_#{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
+ ActiveRecord::SchemaMigration.table_name = 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", ActiveRecord::SchemaMigration.normalize_migration_number("0000118")
+ assert_equal "002", ActiveRecord::SchemaMigration.normalize_migration_number("2")
+ assert_equal "017", ActiveRecord::SchemaMigration.normalize_migration_number("0017")
+ assert_equal "20131219224947", ActiveRecord::SchemaMigration.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
+
+ 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_not @connection.columns(:has_timestamps).find { |c| c.name == "created_at" }.null
+ assert_not @connection.columns(:has_timestamps).find { |c| c.name == "updated_at" }.null
+ end
+
+ def test_timestamps_without_null_set_null_to_false_on_change_table
+ ActiveRecord::Schema.define do
+ create_table :has_timestamps
+
+ change_table :has_timestamps do |t|
+ t.timestamps default: Time.now
+ end
+ end
+
+ assert_not @connection.columns(:has_timestamps).find { |c| c.name == "created_at" }.null
+ assert_not @connection.columns(:has_timestamps).find { |c| c.name == "updated_at" }.null
+ end
+
+ def test_timestamps_without_null_set_null_to_false_on_add_timestamps
+ ActiveRecord::Schema.define do
+ create_table :has_timestamps
+ add_timestamps :has_timestamps, default: Time.now
+ end
+
+ assert_not @connection.columns(:has_timestamps).find { |c| c.name == "created_at" }.null
+ assert_not @connection.columns(:has_timestamps).find { |c| c.name == "updated_at" }.null
+ end
+end
diff --git a/activerecord/test/cases/arel/attributes/attribute_test.rb b/activerecord/test/cases/arel/attributes/attribute_test.rb
new file mode 100644
index 0000000000..671e273543
--- /dev/null
+++ b/activerecord/test/cases/arel/attributes/attribute_test.rb
@@ -0,0 +1,1015 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+require "ostruct"
+
+module Arel
+ module Attributes
+ class AttributeTest < Arel::Spec
+ describe "#not_eq" do
+ it "should create a NotEqual node" do
+ relation = Table.new(:users)
+ relation[:id].not_eq(10).must_be_kind_of Nodes::NotEqual
+ end
+
+ it "should generate != in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].not_eq(10)
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE "users"."id" != 10
+ }
+ end
+
+ it "should handle nil" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].not_eq(nil)
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE "users"."id" IS NOT NULL
+ }
+ end
+ end
+
+ describe "#not_eq_any" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:id].not_eq_any([1, 2]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ORs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].not_eq_any([1, 2])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."id" != 1 OR "users"."id" != 2)
+ }
+ end
+ end
+
+ describe "#not_eq_all" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:id].not_eq_all([1, 2]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ANDs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].not_eq_all([1, 2])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."id" != 1 AND "users"."id" != 2)
+ }
+ end
+ end
+
+ describe "#gt" do
+ it "should create a GreaterThan node" do
+ relation = Table.new(:users)
+ relation[:id].gt(10).must_be_kind_of Nodes::GreaterThan
+ end
+
+ it "should generate > in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].gt(10)
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE "users"."id" > 10
+ }
+ end
+
+ it "should handle comparing with a subquery" do
+ users = Table.new(:users)
+
+ avg = users.project(users[:karma].average)
+ mgr = users.project(Arel.star).where(users[:karma].gt(avg))
+
+ mgr.to_sql.must_be_like %{
+ SELECT * FROM "users" WHERE "users"."karma" > (SELECT AVG("users"."karma") FROM "users")
+ }
+ end
+
+ it "should accept various data types." do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:name].gt("fake_name")
+ mgr.to_sql.must_match %{"users"."name" > 'fake_name'}
+
+ current_time = ::Time.now
+ mgr.where relation[:created_at].gt(current_time)
+ mgr.to_sql.must_match %{"users"."created_at" > '#{current_time}'}
+ end
+ end
+
+ describe "#gt_any" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:id].gt_any([1, 2]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ORs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].gt_any([1, 2])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."id" > 1 OR "users"."id" > 2)
+ }
+ end
+ end
+
+ describe "#gt_all" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:id].gt_all([1, 2]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ANDs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].gt_all([1, 2])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."id" > 1 AND "users"."id" > 2)
+ }
+ end
+ end
+
+ describe "#gteq" do
+ it "should create a GreaterThanOrEqual node" do
+ relation = Table.new(:users)
+ relation[:id].gteq(10).must_be_kind_of Nodes::GreaterThanOrEqual
+ end
+
+ it "should generate >= in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].gteq(10)
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE "users"."id" >= 10
+ }
+ end
+
+ it "should accept various data types." do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:name].gteq("fake_name")
+ mgr.to_sql.must_match %{"users"."name" >= 'fake_name'}
+
+ current_time = ::Time.now
+ mgr.where relation[:created_at].gteq(current_time)
+ mgr.to_sql.must_match %{"users"."created_at" >= '#{current_time}'}
+ end
+ end
+
+ describe "#gteq_any" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:id].gteq_any([1, 2]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ORs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].gteq_any([1, 2])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."id" >= 1 OR "users"."id" >= 2)
+ }
+ end
+ end
+
+ describe "#gteq_all" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:id].gteq_all([1, 2]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ANDs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].gteq_all([1, 2])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."id" >= 1 AND "users"."id" >= 2)
+ }
+ end
+ end
+
+ describe "#lt" do
+ it "should create a LessThan node" do
+ relation = Table.new(:users)
+ relation[:id].lt(10).must_be_kind_of Nodes::LessThan
+ end
+
+ it "should generate < in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].lt(10)
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE "users"."id" < 10
+ }
+ end
+
+ it "should accept various data types." do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:name].lt("fake_name")
+ mgr.to_sql.must_match %{"users"."name" < 'fake_name'}
+
+ current_time = ::Time.now
+ mgr.where relation[:created_at].lt(current_time)
+ mgr.to_sql.must_match %{"users"."created_at" < '#{current_time}'}
+ end
+ end
+
+ describe "#lt_any" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:id].lt_any([1, 2]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ORs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].lt_any([1, 2])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."id" < 1 OR "users"."id" < 2)
+ }
+ end
+ end
+
+ describe "#lt_all" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:id].lt_all([1, 2]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ANDs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].lt_all([1, 2])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."id" < 1 AND "users"."id" < 2)
+ }
+ end
+ end
+
+ describe "#lteq" do
+ it "should create a LessThanOrEqual node" do
+ relation = Table.new(:users)
+ relation[:id].lteq(10).must_be_kind_of Nodes::LessThanOrEqual
+ end
+
+ it "should generate <= in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].lteq(10)
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE "users"."id" <= 10
+ }
+ end
+
+ it "should accept various data types." do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:name].lteq("fake_name")
+ mgr.to_sql.must_match %{"users"."name" <= 'fake_name'}
+
+ current_time = ::Time.now
+ mgr.where relation[:created_at].lteq(current_time)
+ mgr.to_sql.must_match %{"users"."created_at" <= '#{current_time}'}
+ end
+ end
+
+ describe "#lteq_any" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:id].lteq_any([1, 2]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ORs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].lteq_any([1, 2])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."id" <= 1 OR "users"."id" <= 2)
+ }
+ end
+ end
+
+ describe "#lteq_all" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:id].lteq_all([1, 2]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ANDs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].lteq_all([1, 2])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."id" <= 1 AND "users"."id" <= 2)
+ }
+ end
+ end
+
+ describe "#average" do
+ it "should create a AVG node" do
+ relation = Table.new(:users)
+ relation[:id].average.must_be_kind_of Nodes::Avg
+ end
+
+ it "should generate the proper SQL" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id].average
+ mgr.to_sql.must_be_like %{
+ SELECT AVG("users"."id")
+ FROM "users"
+ }
+ end
+ end
+
+ describe "#maximum" do
+ it "should create a MAX node" do
+ relation = Table.new(:users)
+ relation[:id].maximum.must_be_kind_of Nodes::Max
+ end
+
+ it "should generate proper SQL" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id].maximum
+ mgr.to_sql.must_be_like %{
+ SELECT MAX("users"."id")
+ FROM "users"
+ }
+ end
+ end
+
+ describe "#minimum" do
+ it "should create a Min node" do
+ relation = Table.new(:users)
+ relation[:id].minimum.must_be_kind_of Nodes::Min
+ end
+
+ it "should generate proper SQL" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id].minimum
+ mgr.to_sql.must_be_like %{
+ SELECT MIN("users"."id")
+ FROM "users"
+ }
+ end
+ end
+
+ describe "#sum" do
+ it "should create a SUM node" do
+ relation = Table.new(:users)
+ relation[:id].sum.must_be_kind_of Nodes::Sum
+ end
+
+ it "should generate the proper SQL" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id].sum
+ mgr.to_sql.must_be_like %{
+ SELECT SUM("users"."id")
+ FROM "users"
+ }
+ end
+ end
+
+ describe "#count" do
+ it "should return a count node" do
+ relation = Table.new(:users)
+ relation[:id].count.must_be_kind_of Nodes::Count
+ end
+
+ it "should take a distinct param" do
+ relation = Table.new(:users)
+ count = relation[:id].count(nil)
+ count.must_be_kind_of Nodes::Count
+ count.distinct.must_be_nil
+ end
+ end
+
+ describe "#eq" do
+ it "should return an equality node" do
+ attribute = Attribute.new nil, nil
+ equality = attribute.eq 1
+ equality.left.must_equal attribute
+ equality.right.val.must_equal 1
+ equality.must_be_kind_of Nodes::Equality
+ end
+
+ it "should generate = in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].eq(10)
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE "users"."id" = 10
+ }
+ end
+
+ it "should handle nil" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].eq(nil)
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE "users"."id" IS NULL
+ }
+ end
+ end
+
+ describe "#eq_any" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:id].eq_any([1, 2]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ORs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].eq_any([1, 2])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."id" = 1 OR "users"."id" = 2)
+ }
+ end
+
+ it "should not eat input" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ values = [1, 2]
+ mgr.where relation[:id].eq_any(values)
+ values.must_equal [1, 2]
+ end
+ end
+
+ describe "#eq_all" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:id].eq_all([1, 2]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ANDs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].eq_all([1, 2])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."id" = 1 AND "users"."id" = 2)
+ }
+ end
+
+ it "should not eat input" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ values = [1, 2]
+ mgr.where relation[:id].eq_all(values)
+ values.must_equal [1, 2]
+ end
+ end
+
+ describe "#matches" do
+ it "should create a Matches node" do
+ relation = Table.new(:users)
+ relation[:name].matches("%bacon%").must_be_kind_of Nodes::Matches
+ end
+
+ it "should generate LIKE in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:name].matches("%bacon%")
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE "users"."name" LIKE '%bacon%'
+ }
+ end
+ end
+
+ describe "#matches_any" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:name].matches_any(["%chunky%", "%bacon%"]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ORs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:name].matches_any(["%chunky%", "%bacon%"])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."name" LIKE '%chunky%' OR "users"."name" LIKE '%bacon%')
+ }
+ end
+ end
+
+ describe "#matches_all" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:name].matches_all(["%chunky%", "%bacon%"]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ANDs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:name].matches_all(["%chunky%", "%bacon%"])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."name" LIKE '%chunky%' AND "users"."name" LIKE '%bacon%')
+ }
+ end
+ end
+
+ describe "#does_not_match" do
+ it "should create a DoesNotMatch node" do
+ relation = Table.new(:users)
+ relation[:name].does_not_match("%bacon%").must_be_kind_of Nodes::DoesNotMatch
+ end
+
+ it "should generate NOT LIKE in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:name].does_not_match("%bacon%")
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE "users"."name" NOT LIKE '%bacon%'
+ }
+ end
+ end
+
+ describe "#does_not_match_any" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:name].does_not_match_any(["%chunky%", "%bacon%"]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ORs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:name].does_not_match_any(["%chunky%", "%bacon%"])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."name" NOT LIKE '%chunky%' OR "users"."name" NOT LIKE '%bacon%')
+ }
+ end
+ end
+
+ describe "#does_not_match_all" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:name].does_not_match_all(["%chunky%", "%bacon%"]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ANDs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:name].does_not_match_all(["%chunky%", "%bacon%"])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."name" NOT LIKE '%chunky%' AND "users"."name" NOT LIKE '%bacon%')
+ }
+ end
+ end
+
+ describe "with a range" do
+ it "can be constructed with a standard range" do
+ attribute = Attribute.new nil, nil
+ node = attribute.between(1..3)
+
+ node.must_equal Nodes::Between.new(
+ attribute,
+ Nodes::And.new([
+ Nodes::Casted.new(1, attribute),
+ Nodes::Casted.new(3, attribute)
+ ])
+ )
+ end
+
+ it "can be constructed with a range starting from -Infinity" do
+ attribute = Attribute.new nil, nil
+ node = attribute.between(-::Float::INFINITY..3)
+
+ node.must_equal Nodes::LessThanOrEqual.new(
+ attribute,
+ Nodes::Casted.new(3, attribute)
+ )
+ end
+
+ it "can be constructed with a quoted range starting from -Infinity" do
+ attribute = Attribute.new nil, nil
+ node = attribute.between(quoted_range(-::Float::INFINITY, 3, false))
+
+ node.must_equal Nodes::LessThanOrEqual.new(
+ attribute,
+ Nodes::Quoted.new(3)
+ )
+ end
+
+ it "can be constructed with an exclusive range starting from -Infinity" do
+ attribute = Attribute.new nil, nil
+ node = attribute.between(-::Float::INFINITY...3)
+
+ node.must_equal Nodes::LessThan.new(
+ attribute,
+ Nodes::Casted.new(3, attribute)
+ )
+ end
+
+ it "can be constructed with a quoted exclusive range starting from -Infinity" do
+ attribute = Attribute.new nil, nil
+ node = attribute.between(quoted_range(-::Float::INFINITY, 3, true))
+
+ node.must_equal Nodes::LessThan.new(
+ attribute,
+ Nodes::Quoted.new(3)
+ )
+ end
+
+ it "can be constructed with an infinite range" do
+ attribute = Attribute.new nil, nil
+ node = attribute.between(-::Float::INFINITY..::Float::INFINITY)
+
+ node.must_equal Nodes::NotIn.new(attribute, [])
+ end
+
+ it "can be constructed with a quoted infinite range" do
+ attribute = Attribute.new nil, nil
+ node = attribute.between(quoted_range(-::Float::INFINITY, ::Float::INFINITY, false))
+
+ node.must_equal Nodes::NotIn.new(attribute, [])
+ end
+
+
+ it "can be constructed with a range ending at Infinity" do
+ attribute = Attribute.new nil, nil
+ node = attribute.between(0..::Float::INFINITY)
+
+ node.must_equal Nodes::GreaterThanOrEqual.new(
+ attribute,
+ Nodes::Casted.new(0, attribute)
+ )
+ end
+
+ it "can be constructed with a quoted range ending at Infinity" do
+ attribute = Attribute.new nil, nil
+ node = attribute.between(quoted_range(0, ::Float::INFINITY, false))
+
+ node.must_equal Nodes::GreaterThanOrEqual.new(
+ attribute,
+ Nodes::Quoted.new(0)
+ )
+ end
+
+ it "can be constructed with an exclusive range" do
+ attribute = Attribute.new nil, nil
+ node = attribute.between(0...3)
+
+ node.must_equal Nodes::And.new([
+ Nodes::GreaterThanOrEqual.new(
+ attribute,
+ Nodes::Casted.new(0, attribute)
+ ),
+ Nodes::LessThan.new(
+ attribute,
+ Nodes::Casted.new(3, attribute)
+ )
+ ])
+ end
+
+ def quoted_range(begin_val, end_val, exclude)
+ OpenStruct.new(
+ begin: Nodes::Quoted.new(begin_val),
+ end: Nodes::Quoted.new(end_val),
+ exclude_end?: exclude,
+ )
+ end
+ end
+
+ describe "#in" do
+ it "can be constructed with a subquery" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:name].does_not_match_all(["%chunky%", "%bacon%"])
+ attribute = Attribute.new nil, nil
+
+ node = attribute.in(mgr)
+
+ node.must_equal Nodes::In.new(attribute, mgr.ast)
+ end
+
+ it "can be constructed with a list" do
+ attribute = Attribute.new nil, nil
+ node = attribute.in([1, 2, 3])
+
+ node.must_equal Nodes::In.new(
+ attribute,
+ [
+ Nodes::Casted.new(1, attribute),
+ Nodes::Casted.new(2, attribute),
+ Nodes::Casted.new(3, attribute),
+ ]
+ )
+ end
+
+ it "can be constructed with a random object" do
+ attribute = Attribute.new nil, nil
+ random_object = Object.new
+ node = attribute.in(random_object)
+
+ node.must_equal Nodes::In.new(
+ attribute,
+ Nodes::Casted.new(random_object, attribute)
+ )
+ end
+
+ it "should generate IN in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].in([1, 2, 3])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE "users"."id" IN (1, 2, 3)
+ }
+ end
+ end
+
+ describe "#in_any" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:id].in_any([1, 2]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ORs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].in_any([[1, 2], [3, 4]])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."id" IN (1, 2) OR "users"."id" IN (3, 4))
+ }
+ end
+ end
+
+ describe "#in_all" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:id].in_all([1, 2]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ANDs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].in_all([[1, 2], [3, 4]])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."id" IN (1, 2) AND "users"."id" IN (3, 4))
+ }
+ end
+ end
+
+ describe "with a range" do
+ it "can be constructed with a standard range" do
+ attribute = Attribute.new nil, nil
+ node = attribute.not_between(1..3)
+
+ node.must_equal Nodes::Grouping.new(Nodes::Or.new(
+ Nodes::LessThan.new(
+ attribute,
+ Nodes::Casted.new(1, attribute)
+ ),
+ Nodes::GreaterThan.new(
+ attribute,
+ Nodes::Casted.new(3, attribute)
+ )
+ ))
+ end
+
+ it "can be constructed with a range starting from -Infinity" do
+ attribute = Attribute.new nil, nil
+ node = attribute.not_between(-::Float::INFINITY..3)
+
+ node.must_equal Nodes::GreaterThan.new(
+ attribute,
+ Nodes::Casted.new(3, attribute)
+ )
+ end
+
+ it "can be constructed with an exclusive range starting from -Infinity" do
+ attribute = Attribute.new nil, nil
+ node = attribute.not_between(-::Float::INFINITY...3)
+
+ node.must_equal Nodes::GreaterThanOrEqual.new(
+ attribute,
+ Nodes::Casted.new(3, attribute)
+ )
+ end
+
+ it "can be constructed with an infinite range" do
+ attribute = Attribute.new nil, nil
+ node = attribute.not_between(-::Float::INFINITY..::Float::INFINITY)
+
+ node.must_equal Nodes::In.new(attribute, [])
+ end
+
+ it "can be constructed with a range ending at Infinity" do
+ attribute = Attribute.new nil, nil
+ node = attribute.not_between(0..::Float::INFINITY)
+
+ node.must_equal Nodes::LessThan.new(
+ attribute,
+ Nodes::Casted.new(0, attribute)
+ )
+ end
+
+ it "can be constructed with an exclusive range" do
+ attribute = Attribute.new nil, nil
+ node = attribute.not_between(0...3)
+
+ node.must_equal Nodes::Grouping.new(Nodes::Or.new(
+ Nodes::LessThan.new(
+ attribute,
+ Nodes::Casted.new(0, attribute)
+ ),
+ Nodes::GreaterThanOrEqual.new(
+ attribute,
+ Nodes::Casted.new(3, attribute)
+ )
+ ))
+ end
+ end
+
+ describe "#not_in" do
+ it "can be constructed with a subquery" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:name].does_not_match_all(["%chunky%", "%bacon%"])
+ attribute = Attribute.new nil, nil
+
+ node = attribute.not_in(mgr)
+
+ node.must_equal Nodes::NotIn.new(attribute, mgr.ast)
+ end
+
+ it "can be constructed with a Union" do
+ relation = Table.new(:users)
+ mgr1 = relation.project(relation[:id])
+ mgr2 = relation.project(relation[:id])
+
+ union = mgr1.union(mgr2)
+ node = relation[:id].in(union)
+ node.to_sql.must_be_like %{
+ "users"."id" IN (( SELECT "users"."id" FROM "users" UNION SELECT "users"."id" FROM "users" ))
+ }
+ end
+
+ it "can be constructed with a list" do
+ attribute = Attribute.new nil, nil
+ node = attribute.not_in([1, 2, 3])
+
+ node.must_equal Nodes::NotIn.new(
+ attribute,
+ [
+ Nodes::Casted.new(1, attribute),
+ Nodes::Casted.new(2, attribute),
+ Nodes::Casted.new(3, attribute),
+ ]
+ )
+ end
+
+ it "can be constructed with a random object" do
+ attribute = Attribute.new nil, nil
+ random_object = Object.new
+ node = attribute.not_in(random_object)
+
+ node.must_equal Nodes::NotIn.new(
+ attribute,
+ Nodes::Casted.new(random_object, attribute)
+ )
+ end
+
+ it "should generate NOT IN in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].not_in([1, 2, 3])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE "users"."id" NOT IN (1, 2, 3)
+ }
+ end
+ end
+
+ describe "#not_in_any" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:id].not_in_any([1, 2]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ORs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].not_in_any([[1, 2], [3, 4]])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."id" NOT IN (1, 2) OR "users"."id" NOT IN (3, 4))
+ }
+ end
+ end
+
+ describe "#not_in_all" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:id].not_in_all([1, 2]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ANDs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].not_in_all([[1, 2], [3, 4]])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."id" NOT IN (1, 2) AND "users"."id" NOT IN (3, 4))
+ }
+ end
+ end
+
+ describe "#eq_all" do
+ it "should create a Grouping node" do
+ relation = Table.new(:users)
+ relation[:id].eq_all([1, 2]).must_be_kind_of Nodes::Grouping
+ end
+
+ it "should generate ANDs in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.where relation[:id].eq_all([1, 2])
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" WHERE ("users"."id" = 1 AND "users"."id" = 2)
+ }
+ end
+ end
+
+ describe "#asc" do
+ it "should create an Ascending node" do
+ relation = Table.new(:users)
+ relation[:id].asc.must_be_kind_of Nodes::Ascending
+ end
+
+ it "should generate ASC in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.order relation[:id].asc
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" ORDER BY "users"."id" ASC
+ }
+ end
+ end
+
+ describe "#desc" do
+ it "should create a Descending node" do
+ relation = Table.new(:users)
+ relation[:id].desc.must_be_kind_of Nodes::Descending
+ end
+
+ it "should generate DESC in sql" do
+ relation = Table.new(:users)
+ mgr = relation.project relation[:id]
+ mgr.order relation[:id].desc
+ mgr.to_sql.must_be_like %{
+ SELECT "users"."id" FROM "users" ORDER BY "users"."id" DESC
+ }
+ end
+ end
+
+ describe "equality" do
+ describe "#to_sql" do
+ it "should produce sql" do
+ table = Table.new :users
+ condition = table["id"].eq 1
+ condition.to_sql.must_equal '"users"."id" = 1'
+ end
+ end
+ end
+
+ describe "type casting" do
+ it "does not type cast by default" do
+ table = Table.new(:foo)
+ condition = table["id"].eq("1")
+
+ assert_not table.able_to_type_cast?
+ condition.to_sql.must_equal %("foo"."id" = '1')
+ end
+
+ it "type casts when given an explicit caster" do
+ fake_caster = Object.new
+ def fake_caster.type_cast_for_database(attr_name, value)
+ if attr_name == "id"
+ value.to_i
+ else
+ value
+ end
+ end
+ table = Table.new(:foo, type_caster: fake_caster)
+ condition = table["id"].eq("1").and(table["other_id"].eq("2"))
+
+ assert table.able_to_type_cast?
+ condition.to_sql.must_equal %("foo"."id" = 1 AND "foo"."other_id" = '2')
+ end
+
+ it "does not type cast SqlLiteral nodes" do
+ fake_caster = Object.new
+ def fake_caster.type_cast_for_database(attr_name, value)
+ value.to_i
+ end
+ table = Table.new(:foo, type_caster: fake_caster)
+ condition = table["id"].eq(Arel.sql("(select 1)"))
+
+ assert table.able_to_type_cast?
+ condition.to_sql.must_equal %("foo"."id" = (select 1))
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/attributes/math_test.rb b/activerecord/test/cases/arel/attributes/math_test.rb
new file mode 100644
index 0000000000..41eea217c0
--- /dev/null
+++ b/activerecord/test/cases/arel/attributes/math_test.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Attributes
+ class MathTest < Arel::Spec
+ %i[* /].each do |math_operator|
+ it "average should be compatible with #{math_operator}" do
+ table = Arel::Table.new :users
+ (table[:id].average.public_send(math_operator, 2)).to_sql.must_be_like %{
+ AVG("users"."id") #{math_operator} 2
+ }
+ end
+
+ it "count should be compatible with #{math_operator}" do
+ table = Arel::Table.new :users
+ (table[:id].count.public_send(math_operator, 2)).to_sql.must_be_like %{
+ COUNT("users"."id") #{math_operator} 2
+ }
+ end
+
+ it "maximum should be compatible with #{math_operator}" do
+ table = Arel::Table.new :users
+ (table[:id].maximum.public_send(math_operator, 2)).to_sql.must_be_like %{
+ MAX("users"."id") #{math_operator} 2
+ }
+ end
+
+ it "minimum should be compatible with #{math_operator}" do
+ table = Arel::Table.new :users
+ (table[:id].minimum.public_send(math_operator, 2)).to_sql.must_be_like %{
+ MIN("users"."id") #{math_operator} 2
+ }
+ end
+
+ it "attribute node should be compatible with #{math_operator}" do
+ table = Arel::Table.new :users
+ (table[:id].public_send(math_operator, 2)).to_sql.must_be_like %{
+ "users"."id" #{math_operator} 2
+ }
+ end
+ end
+
+ %i[+ - & | ^ << >>].each do |math_operator|
+ it "average should be compatible with #{math_operator}" do
+ table = Arel::Table.new :users
+ (table[:id].average.public_send(math_operator, 2)).to_sql.must_be_like %{
+ (AVG("users"."id") #{math_operator} 2)
+ }
+ end
+
+ it "count should be compatible with #{math_operator}" do
+ table = Arel::Table.new :users
+ (table[:id].count.public_send(math_operator, 2)).to_sql.must_be_like %{
+ (COUNT("users"."id") #{math_operator} 2)
+ }
+ end
+
+ it "maximum should be compatible with #{math_operator}" do
+ table = Arel::Table.new :users
+ (table[:id].maximum.public_send(math_operator, 2)).to_sql.must_be_like %{
+ (MAX("users"."id") #{math_operator} 2)
+ }
+ end
+
+ it "minimum should be compatible with #{math_operator}" do
+ table = Arel::Table.new :users
+ (table[:id].minimum.public_send(math_operator, 2)).to_sql.must_be_like %{
+ (MIN("users"."id") #{math_operator} 2)
+ }
+ end
+
+ it "attribute node should be compatible with #{math_operator}" do
+ table = Arel::Table.new :users
+ (table[:id].public_send(math_operator, 2)).to_sql.must_be_like %{
+ ("users"."id" #{math_operator} 2)
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/attributes_test.rb b/activerecord/test/cases/arel/attributes_test.rb
new file mode 100644
index 0000000000..b00af4bd29
--- /dev/null
+++ b/activerecord/test/cases/arel/attributes_test.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require_relative "helper"
+
+module Arel
+ describe "Attributes" do
+ it "responds to lower" do
+ relation = Table.new(:users)
+ attribute = relation[:foo]
+ node = attribute.lower
+ assert_equal "LOWER", node.name
+ assert_equal [attribute], node.expressions
+ end
+
+ describe "equality" do
+ it "is equal with equal ivars" do
+ array = [Attribute.new("foo", "bar"), Attribute.new("foo", "bar")]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ array = [Attribute.new("foo", "bar"), Attribute.new("foo", "baz")]
+ assert_equal 2, array.uniq.size
+ end
+ end
+
+ describe "for" do
+ it "deals with unknown column types" do
+ column = Struct.new(:type).new :crazy
+ Attributes.for(column).must_equal Attributes::Undefined
+ end
+
+ it "returns the correct constant for strings" do
+ [:string, :text, :binary].each do |type|
+ column = Struct.new(:type).new type
+ Attributes.for(column).must_equal Attributes::String
+ end
+ end
+
+ it "returns the correct constant for ints" do
+ column = Struct.new(:type).new :integer
+ Attributes.for(column).must_equal Attributes::Integer
+ end
+
+ it "returns the correct constant for floats" do
+ column = Struct.new(:type).new :float
+ Attributes.for(column).must_equal Attributes::Float
+ end
+
+ it "returns the correct constant for decimals" do
+ column = Struct.new(:type).new :decimal
+ Attributes.for(column).must_equal Attributes::Decimal
+ end
+
+ it "returns the correct constant for boolean" do
+ column = Struct.new(:type).new :boolean
+ Attributes.for(column).must_equal Attributes::Boolean
+ end
+
+ it "returns the correct constant for time" do
+ [:date, :datetime, :timestamp, :time].each do |type|
+ column = Struct.new(:type).new type
+ Attributes.for(column).must_equal Attributes::Time
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/collectors/bind_test.rb b/activerecord/test/cases/arel/collectors/bind_test.rb
new file mode 100644
index 0000000000..ffa9b15f66
--- /dev/null
+++ b/activerecord/test/cases/arel/collectors/bind_test.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+require "arel/collectors/bind"
+
+module Arel
+ module Collectors
+ class TestBind < Arel::Test
+ def setup
+ @conn = FakeRecord::Base.new
+ @visitor = Visitors::ToSql.new @conn.connection
+ super
+ end
+
+ def collect(node)
+ @visitor.accept(node, Collectors::Bind.new)
+ end
+
+ def compile(node)
+ collect(node).value
+ end
+
+ def ast_with_binds(bvs)
+ table = Table.new(:users)
+ manager = Arel::SelectManager.new table
+ manager.where(table[:age].eq(Nodes::BindParam.new(bvs.shift)))
+ manager.where(table[:name].eq(Nodes::BindParam.new(bvs.shift)))
+ manager.ast
+ end
+
+ def test_compile_gathers_all_bind_params
+ binds = compile(ast_with_binds(["hello", "world"]))
+ assert_equal ["hello", "world"], binds
+
+ binds = compile(ast_with_binds(["hello2", "world3"]))
+ assert_equal ["hello2", "world3"], binds
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/collectors/composite_test.rb b/activerecord/test/cases/arel/collectors/composite_test.rb
new file mode 100644
index 0000000000..545637496f
--- /dev/null
+++ b/activerecord/test/cases/arel/collectors/composite_test.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+require "arel/collectors/bind"
+require "arel/collectors/composite"
+
+module Arel
+ module Collectors
+ class TestComposite < Arel::Test
+ def setup
+ @conn = FakeRecord::Base.new
+ @visitor = Visitors::ToSql.new @conn.connection
+ super
+ end
+
+ def collect(node)
+ sql_collector = Collectors::SQLString.new
+ bind_collector = Collectors::Bind.new
+ collector = Collectors::Composite.new(sql_collector, bind_collector)
+ @visitor.accept(node, collector)
+ end
+
+ def compile(node)
+ collect(node).value
+ end
+
+ def ast_with_binds(bvs)
+ table = Table.new(:users)
+ manager = Arel::SelectManager.new table
+ manager.where(table[:age].eq(Nodes::BindParam.new(bvs.shift)))
+ manager.where(table[:name].eq(Nodes::BindParam.new(bvs.shift)))
+ manager.ast
+ end
+
+ def test_composite_collector_performs_multiple_collections_at_once
+ sql, binds = compile(ast_with_binds(["hello", "world"]))
+ assert_equal 'SELECT FROM "users" WHERE "users"."age" = ? AND "users"."name" = ?', sql
+ assert_equal ["hello", "world"], binds
+
+ sql, binds = compile(ast_with_binds(["hello2", "world3"]))
+ assert_equal 'SELECT FROM "users" WHERE "users"."age" = ? AND "users"."name" = ?', sql
+ assert_equal ["hello2", "world3"], binds
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/collectors/sql_string_test.rb b/activerecord/test/cases/arel/collectors/sql_string_test.rb
new file mode 100644
index 0000000000..443c7eb54b
--- /dev/null
+++ b/activerecord/test/cases/arel/collectors/sql_string_test.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Collectors
+ class TestSqlString < Arel::Test
+ def setup
+ @conn = FakeRecord::Base.new
+ @visitor = Visitors::ToSql.new @conn.connection
+ super
+ end
+
+ def collect(node)
+ @visitor.accept(node, Collectors::SQLString.new)
+ end
+
+ def compile(node)
+ collect(node).value
+ end
+
+ def ast_with_binds
+ table = Table.new(:users)
+ manager = Arel::SelectManager.new table
+ manager.where(table[:age].eq(Nodes::BindParam.new("hello")))
+ manager.where(table[:name].eq(Nodes::BindParam.new("world")))
+ manager.ast
+ end
+
+ def test_compile
+ sql = compile(ast_with_binds)
+ assert_equal 'SELECT FROM "users" WHERE "users"."age" = ? AND "users"."name" = ?', sql
+ end
+
+ def test_returned_sql_uses_utf8_encoding
+ sql = compile(ast_with_binds)
+ assert_equal sql.encoding, Encoding::UTF_8
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/collectors/substitute_bind_collector_test.rb b/activerecord/test/cases/arel/collectors/substitute_bind_collector_test.rb
new file mode 100644
index 0000000000..255c8e79e9
--- /dev/null
+++ b/activerecord/test/cases/arel/collectors/substitute_bind_collector_test.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+require "arel/collectors/substitute_binds"
+require "arel/collectors/sql_string"
+
+module Arel
+ module Collectors
+ class TestSubstituteBindCollector < Arel::Test
+ def setup
+ @conn = FakeRecord::Base.new
+ @visitor = Visitors::ToSql.new @conn.connection
+ super
+ end
+
+ def ast_with_binds
+ table = Table.new(:users)
+ manager = Arel::SelectManager.new table
+ manager.where(table[:age].eq(Nodes::BindParam.new("hello")))
+ manager.where(table[:name].eq(Nodes::BindParam.new("world")))
+ manager.ast
+ end
+
+ def compile(node, quoter)
+ collector = Collectors::SubstituteBinds.new(quoter, Collectors::SQLString.new)
+ @visitor.accept(node, collector).value
+ end
+
+ def test_compile
+ quoter = Object.new
+ def quoter.quote(val)
+ val.to_s
+ end
+ sql = compile(ast_with_binds, quoter)
+ assert_equal 'SELECT FROM "users" WHERE "users"."age" = hello AND "users"."name" = world', sql
+ end
+
+ def test_quoting_is_delegated_to_quoter
+ quoter = Object.new
+ def quoter.quote(val)
+ val.inspect
+ end
+ sql = compile(ast_with_binds, quoter)
+ assert_equal 'SELECT FROM "users" WHERE "users"."age" = "hello" AND "users"."name" = "world"', sql
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/crud_test.rb b/activerecord/test/cases/arel/crud_test.rb
new file mode 100644
index 0000000000..f3cdd8927f
--- /dev/null
+++ b/activerecord/test/cases/arel/crud_test.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require_relative "helper"
+
+module Arel
+ class FakeCrudder < SelectManager
+ class FakeEngine
+ attr_reader :calls, :connection_pool, :spec, :config
+
+ def initialize
+ @calls = []
+ @connection_pool = self
+ @spec = self
+ @config = { adapter: "sqlite3" }
+ end
+
+ def connection; self end
+
+ def method_missing(name, *args)
+ @calls << [name, args]
+ end
+ end
+
+ include Crud
+
+ attr_reader :engine
+ attr_accessor :ctx
+
+ def initialize(engine = FakeEngine.new)
+ super
+ end
+ end
+
+ describe "crud" do
+ describe "insert" do
+ it "should call insert on the connection" do
+ table = Table.new :users
+ fc = FakeCrudder.new
+ fc.from table
+ im = fc.compile_insert [[table[:id], "foo"]]
+ assert_instance_of Arel::InsertManager, im
+ end
+ end
+
+ describe "update" do
+ it "should call update on the connection" do
+ table = Table.new :users
+ fc = FakeCrudder.new
+ fc.from table
+ stmt = fc.compile_update [[table[:id], "foo"]], Arel::Attributes::Attribute.new(table, "id")
+ assert_instance_of Arel::UpdateManager, stmt
+ end
+ end
+
+ describe "delete" do
+ it "should call delete on the connection" do
+ table = Table.new :users
+ fc = FakeCrudder.new
+ fc.from table
+ stmt = fc.compile_delete
+ assert_instance_of Arel::DeleteManager, stmt
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/delete_manager_test.rb b/activerecord/test/cases/arel/delete_manager_test.rb
new file mode 100644
index 0000000000..0bad02f4d2
--- /dev/null
+++ b/activerecord/test/cases/arel/delete_manager_test.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require_relative "helper"
+
+module Arel
+ class DeleteManagerTest < Arel::Spec
+ describe "new" do
+ it "takes an engine" do
+ Arel::DeleteManager.new
+ end
+ end
+
+ it "handles limit properly" do
+ table = Table.new(:users)
+ dm = Arel::DeleteManager.new
+ dm.take 10
+ dm.from table
+ dm.key = table[:id]
+ assert_match(/LIMIT 10/, dm.to_sql)
+ end
+
+ describe "from" do
+ it "uses from" do
+ table = Table.new(:users)
+ dm = Arel::DeleteManager.new
+ dm.from table
+ dm.to_sql.must_be_like %{ DELETE FROM "users" }
+ end
+
+ it "chains" do
+ table = Table.new(:users)
+ dm = Arel::DeleteManager.new
+ dm.from(table).must_equal dm
+ end
+ end
+
+ describe "where" do
+ it "uses where values" do
+ table = Table.new(:users)
+ dm = Arel::DeleteManager.new
+ dm.from table
+ dm.where table[:id].eq(10)
+ dm.to_sql.must_be_like %{ DELETE FROM "users" WHERE "users"."id" = 10}
+ end
+
+ it "chains" do
+ table = Table.new(:users)
+ dm = Arel::DeleteManager.new
+ dm.where(table[:id].eq(10)).must_equal dm
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/factory_methods_test.rb b/activerecord/test/cases/arel/factory_methods_test.rb
new file mode 100644
index 0000000000..26d2cdd08d
--- /dev/null
+++ b/activerecord/test/cases/arel/factory_methods_test.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require_relative "helper"
+
+module Arel
+ module FactoryMethods
+ class TestFactoryMethods < Arel::Test
+ class Factory
+ include Arel::FactoryMethods
+ end
+
+ def setup
+ @factory = Factory.new
+ end
+
+ def test_create_join
+ join = @factory.create_join :one, :two
+ assert_kind_of Nodes::Join, join
+ assert_equal :two, join.right
+ end
+
+ def test_create_on
+ on = @factory.create_on :one
+ assert_instance_of Nodes::On, on
+ assert_equal :one, on.expr
+ end
+
+ def test_create_true
+ true_node = @factory.create_true
+ assert_instance_of Nodes::True, true_node
+ end
+
+ def test_create_false
+ false_node = @factory.create_false
+ assert_instance_of Nodes::False, false_node
+ end
+
+ def test_lower
+ lower = @factory.lower :one
+ assert_instance_of Nodes::NamedFunction, lower
+ assert_equal "LOWER", lower.name
+ assert_equal [:one], lower.expressions.map(&:expr)
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/helper.rb b/activerecord/test/cases/arel/helper.rb
new file mode 100644
index 0000000000..f8ce658440
--- /dev/null
+++ b/activerecord/test/cases/arel/helper.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require "active_support"
+require "minitest/autorun"
+require "arel"
+
+require_relative "support/fake_record"
+
+class Object
+ def must_be_like(other)
+ gsub(/\s+/, " ").strip.must_equal other.gsub(/\s+/, " ").strip
+ end
+end
+
+module Arel
+ class Test < ActiveSupport::TestCase
+ def setup
+ super
+ @arel_engine = Arel::Table.engine
+ Arel::Table.engine = FakeRecord::Base.new
+ end
+
+ def teardown
+ Arel::Table.engine = @arel_engine if defined? @arel_engine
+ super
+ end
+ end
+
+ class Spec < Minitest::Spec
+ before do
+ @arel_engine = Arel::Table.engine
+ Arel::Table.engine = FakeRecord::Base.new
+ end
+
+ after do
+ Arel::Table.engine = @arel_engine if defined? @arel_engine
+ end
+ include ActiveSupport::Testing::Assertions
+
+ # test/unit backwards compatibility methods
+ alias :assert_no_match :refute_match
+ alias :assert_not_equal :refute_equal
+ alias :assert_not_same :refute_same
+ end
+end
diff --git a/activerecord/test/cases/arel/insert_manager_test.rb b/activerecord/test/cases/arel/insert_manager_test.rb
new file mode 100644
index 0000000000..2376ad8d37
--- /dev/null
+++ b/activerecord/test/cases/arel/insert_manager_test.rb
@@ -0,0 +1,242 @@
+# frozen_string_literal: true
+
+require_relative "helper"
+
+module Arel
+ class InsertManagerTest < Arel::Spec
+ describe "new" do
+ it "takes an engine" do
+ Arel::InsertManager.new
+ end
+ end
+
+ describe "insert" do
+ it "can create a Values node" do
+ manager = Arel::InsertManager.new
+ values = manager.create_values %w{ a b }, %w{ c d }
+
+ assert_kind_of Arel::Nodes::Values, values
+ assert_equal %w{ a b }, values.left
+ assert_equal %w{ c d }, values.right
+ end
+
+ it "allows sql literals" do
+ manager = Arel::InsertManager.new
+ manager.into Table.new(:users)
+ manager.values = manager.create_values [Arel.sql("*")], %w{ a }
+ manager.to_sql.must_be_like %{
+ INSERT INTO \"users\" VALUES (*)
+ }
+ end
+
+ it "works with multiple values" do
+ table = Table.new(:users)
+ manager = Arel::InsertManager.new
+ manager.into table
+
+ manager.columns << table[:id]
+ manager.columns << table[:name]
+
+ manager.values = manager.create_values_list([
+ %w{1 david},
+ %w{2 kir},
+ ["3", Arel.sql("DEFAULT")],
+ ])
+
+ manager.to_sql.must_be_like %{
+ INSERT INTO \"users\" (\"id\", \"name\") VALUES ('1', 'david'), ('2', 'kir'), ('3', DEFAULT)
+ }
+ end
+
+ it "literals in multiple values are not escaped" do
+ table = Table.new(:users)
+ manager = Arel::InsertManager.new
+ manager.into table
+
+ manager.columns << table[:name]
+
+ manager.values = manager.create_values_list([
+ [Arel.sql("*")],
+ [Arel.sql("DEFAULT")],
+ ])
+
+ manager.to_sql.must_be_like %{
+ INSERT INTO \"users\" (\"name\") VALUES (*), (DEFAULT)
+ }
+ end
+
+ it "works with multiple single values" do
+ table = Table.new(:users)
+ manager = Arel::InsertManager.new
+ manager.into table
+
+ manager.columns << table[:name]
+
+ manager.values = manager.create_values_list([
+ %w{david},
+ %w{kir},
+ [Arel.sql("DEFAULT")],
+ ])
+
+ manager.to_sql.must_be_like %{
+ INSERT INTO \"users\" (\"name\") VALUES ('david'), ('kir'), (DEFAULT)
+ }
+ end
+
+ it "inserts false" do
+ table = Table.new(:users)
+ manager = Arel::InsertManager.new
+
+ manager.insert [[table[:bool], false]]
+ manager.to_sql.must_be_like %{
+ INSERT INTO "users" ("bool") VALUES ('f')
+ }
+ end
+
+ it "inserts null" do
+ table = Table.new(:users)
+ manager = Arel::InsertManager.new
+ manager.insert [[table[:id], nil]]
+ manager.to_sql.must_be_like %{
+ INSERT INTO "users" ("id") VALUES (NULL)
+ }
+ end
+
+ it "inserts time" do
+ table = Table.new(:users)
+ manager = Arel::InsertManager.new
+
+ time = Time.now
+ attribute = table[:created_at]
+
+ manager.insert [[attribute, time]]
+ manager.to_sql.must_be_like %{
+ INSERT INTO "users" ("created_at") VALUES (#{Table.engine.connection.quote time})
+ }
+ end
+
+ it "takes a list of lists" do
+ table = Table.new(:users)
+ manager = Arel::InsertManager.new
+ manager.into table
+ manager.insert [[table[:id], 1], [table[:name], "aaron"]]
+ manager.to_sql.must_be_like %{
+ INSERT INTO "users" ("id", "name") VALUES (1, 'aaron')
+ }
+ end
+
+ it "defaults the table" do
+ table = Table.new(:users)
+ manager = Arel::InsertManager.new
+ manager.insert [[table[:id], 1], [table[:name], "aaron"]]
+ manager.to_sql.must_be_like %{
+ INSERT INTO "users" ("id", "name") VALUES (1, 'aaron')
+ }
+ end
+
+ it "noop for empty list" do
+ table = Table.new(:users)
+ manager = Arel::InsertManager.new
+ manager.insert [[table[:id], 1]]
+ manager.insert []
+ manager.to_sql.must_be_like %{
+ INSERT INTO "users" ("id") VALUES (1)
+ }
+ end
+
+ it "is chainable" do
+ table = Table.new(:users)
+ manager = Arel::InsertManager.new
+ insert_result = manager.insert [[table[:id], 1]]
+ assert_equal manager, insert_result
+ end
+ end
+
+ describe "into" do
+ it "takes a Table and chains" do
+ manager = Arel::InsertManager.new
+ manager.into(Table.new(:users)).must_equal manager
+ end
+
+ it "converts to sql" do
+ table = Table.new :users
+ manager = Arel::InsertManager.new
+ manager.into table
+ manager.to_sql.must_be_like %{
+ INSERT INTO "users"
+ }
+ end
+ end
+
+ describe "columns" do
+ it "converts to sql" do
+ table = Table.new :users
+ manager = Arel::InsertManager.new
+ manager.into table
+ manager.columns << table[:id]
+ manager.to_sql.must_be_like %{
+ INSERT INTO "users" ("id")
+ }
+ end
+ end
+
+ describe "values" do
+ it "converts to sql" do
+ table = Table.new :users
+ manager = Arel::InsertManager.new
+ manager.into table
+
+ manager.values = Nodes::Values.new [1]
+ manager.to_sql.must_be_like %{
+ INSERT INTO "users" VALUES (1)
+ }
+ end
+
+ it "accepts sql literals" do
+ table = Table.new :users
+ manager = Arel::InsertManager.new
+ manager.into table
+
+ manager.values = Arel.sql("DEFAULT VALUES")
+ manager.to_sql.must_be_like %{
+ INSERT INTO "users" DEFAULT VALUES
+ }
+ end
+ end
+
+ describe "combo" do
+ it "combines columns and values list in order" do
+ table = Table.new :users
+ manager = Arel::InsertManager.new
+ manager.into table
+
+ manager.values = Nodes::Values.new [1, "aaron"]
+ manager.columns << table[:id]
+ manager.columns << table[:name]
+ manager.to_sql.must_be_like %{
+ INSERT INTO "users" ("id", "name") VALUES (1, 'aaron')
+ }
+ end
+ end
+
+ describe "select" do
+ it "accepts a select query in place of a VALUES clause" do
+ table = Table.new :users
+
+ manager = Arel::InsertManager.new
+ manager.into table
+
+ select = Arel::SelectManager.new
+ select.project Arel.sql("1")
+ select.project Arel.sql('"aaron"')
+
+ manager.select select
+ manager.columns << table[:id]
+ manager.columns << table[:name]
+ manager.to_sql.must_be_like %{
+ INSERT INTO "users" ("id", "name") (SELECT 1, "aaron")
+ }
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/and_test.rb b/activerecord/test/cases/arel/nodes/and_test.rb
new file mode 100644
index 0000000000..eff54abd91
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/and_test.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ describe "And" do
+ describe "equality" do
+ it "is equal with equal ivars" do
+ array = [And.new(["foo", "bar"]), And.new(["foo", "bar"])]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ array = [And.new(["foo", "bar"]), And.new(["foo", "baz"])]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/as_test.rb b/activerecord/test/cases/arel/nodes/as_test.rb
new file mode 100644
index 0000000000..1169ea11c9
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/as_test.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ describe "As" do
+ describe "#as" do
+ it "makes an AS node" do
+ attr = Table.new(:users)[:id]
+ as = attr.as(Arel.sql("foo"))
+ assert_equal attr, as.left
+ assert_equal "foo", as.right
+ end
+
+ it "converts right to SqlLiteral if a string" do
+ attr = Table.new(:users)[:id]
+ as = attr.as("foo")
+ assert_kind_of Arel::Nodes::SqlLiteral, as.right
+ end
+ end
+
+ describe "equality" do
+ it "is equal with equal ivars" do
+ array = [As.new("foo", "bar"), As.new("foo", "bar")]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ array = [As.new("foo", "bar"), As.new("foo", "baz")]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/ascending_test.rb b/activerecord/test/cases/arel/nodes/ascending_test.rb
new file mode 100644
index 0000000000..4811e6ff5b
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/ascending_test.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ class TestAscending < Arel::Test
+ def test_construct
+ ascending = Ascending.new "zomg"
+ assert_equal "zomg", ascending.expr
+ end
+
+ def test_reverse
+ ascending = Ascending.new "zomg"
+ descending = ascending.reverse
+ assert_kind_of Descending, descending
+ assert_equal ascending.expr, descending.expr
+ end
+
+ def test_direction
+ ascending = Ascending.new "zomg"
+ assert_equal :asc, ascending.direction
+ end
+
+ def test_ascending?
+ ascending = Ascending.new "zomg"
+ assert ascending.ascending?
+ end
+
+ def test_descending?
+ ascending = Ascending.new "zomg"
+ assert_not ascending.descending?
+ end
+
+ def test_equality_with_same_ivars
+ array = [Ascending.new("zomg"), Ascending.new("zomg")]
+ assert_equal 1, array.uniq.size
+ end
+
+ def test_inequality_with_different_ivars
+ array = [Ascending.new("zomg"), Ascending.new("zomg!")]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/bin_test.rb b/activerecord/test/cases/arel/nodes/bin_test.rb
new file mode 100644
index 0000000000..ee2ec3cf2f
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/bin_test.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ class TestBin < Arel::Test
+ def test_new
+ assert Arel::Nodes::Bin.new("zomg")
+ end
+
+ def test_default_to_sql
+ viz = Arel::Visitors::ToSql.new Table.engine.connection_pool
+ node = Arel::Nodes::Bin.new(Arel.sql("zomg"))
+ assert_equal "zomg", viz.accept(node, Collectors::SQLString.new).value
+ end
+
+ def test_mysql_to_sql
+ viz = Arel::Visitors::MySQL.new Table.engine.connection_pool
+ node = Arel::Nodes::Bin.new(Arel.sql("zomg"))
+ assert_equal "BINARY zomg", viz.accept(node, Collectors::SQLString.new).value
+ end
+
+ def test_equality_with_same_ivars
+ array = [Bin.new("zomg"), Bin.new("zomg")]
+ assert_equal 1, array.uniq.size
+ end
+
+ def test_inequality_with_different_ivars
+ array = [Bin.new("zomg"), Bin.new("zomg!")]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/binary_test.rb b/activerecord/test/cases/arel/nodes/binary_test.rb
new file mode 100644
index 0000000000..d160e7cd9d
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/binary_test.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ class NodesTest < Arel::Spec
+ describe "Binary" do
+ describe "#hash" do
+ it "generates a hash based on its value" do
+ eq = Equality.new("foo", "bar")
+ eq2 = Equality.new("foo", "bar")
+ eq3 = Equality.new("bar", "baz")
+
+ assert_equal eq.hash, eq2.hash
+ assert_not_equal eq.hash, eq3.hash
+ end
+
+ it "generates a hash specific to its class" do
+ eq = Equality.new("foo", "bar")
+ neq = NotEqual.new("foo", "bar")
+
+ assert_not_equal eq.hash, neq.hash
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/bind_param_test.rb b/activerecord/test/cases/arel/nodes/bind_param_test.rb
new file mode 100644
index 0000000000..37a362ece4
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/bind_param_test.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ describe "BindParam" do
+ it "is equal to other bind params with the same value" do
+ BindParam.new(1).must_equal(BindParam.new(1))
+ BindParam.new("foo").must_equal(BindParam.new("foo"))
+ end
+
+ it "is not equal to other nodes" do
+ BindParam.new(nil).wont_equal(Node.new)
+ end
+
+ it "is not equal to bind params with different values" do
+ BindParam.new(1).wont_equal(BindParam.new(2))
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/case_test.rb b/activerecord/test/cases/arel/nodes/case_test.rb
new file mode 100644
index 0000000000..89861488df
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/case_test.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ class NodesTest < Arel::Spec
+ describe "Case" do
+ describe "#initialize" do
+ it "sets case expression from first argument" do
+ node = Case.new "foo"
+
+ assert_equal "foo", node.case
+ end
+
+ it "sets default case from second argument" do
+ node = Case.new nil, "bar"
+
+ assert_equal "bar", node.default
+ end
+ end
+
+ describe "#clone" do
+ it "clones case, conditions and default" do
+ foo = Nodes.build_quoted "foo"
+
+ node = Case.new
+ node.case = foo
+ node.conditions = [When.new(foo, foo)]
+ node.default = foo
+
+ dolly = node.clone
+
+ assert_equal dolly.case, node.case
+ assert_not_same dolly.case, node.case
+
+ assert_equal dolly.conditions, node.conditions
+ assert_not_same dolly.conditions, node.conditions
+
+ assert_equal dolly.default, node.default
+ assert_not_same dolly.default, node.default
+ end
+ end
+
+ describe "equality" do
+ it "is equal with equal ivars" do
+ foo = Nodes.build_quoted "foo"
+ one = Nodes.build_quoted 1
+ zero = Nodes.build_quoted 0
+
+ case1 = Case.new foo
+ case1.conditions = [When.new(foo, one)]
+ case1.default = Else.new zero
+
+ case2 = Case.new foo
+ case2.conditions = [When.new(foo, one)]
+ case2.default = Else.new zero
+
+ array = [case1, case2]
+
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ foo = Nodes.build_quoted "foo"
+ bar = Nodes.build_quoted "bar"
+ one = Nodes.build_quoted 1
+ zero = Nodes.build_quoted 0
+
+ case1 = Case.new foo
+ case1.conditions = [When.new(foo, one)]
+ case1.default = Else.new zero
+
+ case2 = Case.new foo
+ case2.conditions = [When.new(bar, one)]
+ case2.default = Else.new zero
+
+ array = [case1, case2]
+
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/casted_test.rb b/activerecord/test/cases/arel/nodes/casted_test.rb
new file mode 100644
index 0000000000..e27f58a4e2
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/casted_test.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ describe Casted do
+ describe "#hash" do
+ it "is equal when eql? returns true" do
+ one = Casted.new 1, 2
+ also_one = Casted.new 1, 2
+
+ assert_equal one.hash, also_one.hash
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/count_test.rb b/activerecord/test/cases/arel/nodes/count_test.rb
new file mode 100644
index 0000000000..daabea6c4c
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/count_test.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+class Arel::Nodes::CountTest < Arel::Spec
+ describe "as" do
+ it "should alias the count" do
+ table = Arel::Table.new :users
+ table[:id].count.as("foo").to_sql.must_be_like %{
+ COUNT("users"."id") AS foo
+ }
+ end
+ end
+
+ describe "eq" do
+ it "should compare the count" do
+ table = Arel::Table.new :users
+ table[:id].count.eq(2).to_sql.must_be_like %{
+ COUNT("users"."id") = 2
+ }
+ end
+ end
+
+ describe "equality" do
+ it "is equal with equal ivars" do
+ array = [Arel::Nodes::Count.new("foo"), Arel::Nodes::Count.new("foo")]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ array = [Arel::Nodes::Count.new("foo"), Arel::Nodes::Count.new("foo!")]
+ assert_equal 2, array.uniq.size
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/delete_statement_test.rb b/activerecord/test/cases/arel/nodes/delete_statement_test.rb
new file mode 100644
index 0000000000..3f078063a4
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/delete_statement_test.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+describe Arel::Nodes::DeleteStatement do
+ describe "#clone" do
+ it "clones wheres" do
+ statement = Arel::Nodes::DeleteStatement.new
+ statement.wheres = %w[a b c]
+
+ dolly = statement.clone
+ dolly.wheres.must_equal statement.wheres
+ dolly.wheres.wont_be_same_as statement.wheres
+ end
+ end
+
+ describe "equality" do
+ it "is equal with equal ivars" do
+ statement1 = Arel::Nodes::DeleteStatement.new
+ statement1.wheres = %w[a b c]
+ statement2 = Arel::Nodes::DeleteStatement.new
+ statement2.wheres = %w[a b c]
+ array = [statement1, statement2]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ statement1 = Arel::Nodes::DeleteStatement.new
+ statement1.wheres = %w[a b c]
+ statement2 = Arel::Nodes::DeleteStatement.new
+ statement2.wheres = %w[1 2 3]
+ array = [statement1, statement2]
+ assert_equal 2, array.uniq.size
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/descending_test.rb b/activerecord/test/cases/arel/nodes/descending_test.rb
new file mode 100644
index 0000000000..5f1747e1da
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/descending_test.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ class TestDescending < Arel::Test
+ def test_construct
+ descending = Descending.new "zomg"
+ assert_equal "zomg", descending.expr
+ end
+
+ def test_reverse
+ descending = Descending.new "zomg"
+ ascending = descending.reverse
+ assert_kind_of Ascending, ascending
+ assert_equal descending.expr, ascending.expr
+ end
+
+ def test_direction
+ descending = Descending.new "zomg"
+ assert_equal :desc, descending.direction
+ end
+
+ def test_ascending?
+ descending = Descending.new "zomg"
+ assert_not descending.ascending?
+ end
+
+ def test_descending?
+ descending = Descending.new "zomg"
+ assert descending.descending?
+ end
+
+ def test_equality_with_same_ivars
+ array = [Descending.new("zomg"), Descending.new("zomg")]
+ assert_equal 1, array.uniq.size
+ end
+
+ def test_inequality_with_different_ivars
+ array = [Descending.new("zomg"), Descending.new("zomg!")]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/distinct_test.rb b/activerecord/test/cases/arel/nodes/distinct_test.rb
new file mode 100644
index 0000000000..de5f0ee588
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/distinct_test.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ describe "Distinct" do
+ describe "equality" do
+ it "is equal to other distinct nodes" do
+ array = [Distinct.new, Distinct.new]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with other nodes" do
+ array = [Distinct.new, Node.new]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/equality_test.rb b/activerecord/test/cases/arel/nodes/equality_test.rb
new file mode 100644
index 0000000000..e173720e86
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/equality_test.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ describe "equality" do
+ # FIXME: backwards compat
+ describe "backwards compat" do
+ describe "operator" do
+ it "returns :==" do
+ attr = Table.new(:users)[:id]
+ left = attr.eq(10)
+ left.operator.must_equal :==
+ end
+ end
+
+ describe "operand1" do
+ it "should equal left" do
+ attr = Table.new(:users)[:id]
+ left = attr.eq(10)
+ left.left.must_equal left.operand1
+ end
+ end
+
+ describe "operand2" do
+ it "should equal right" do
+ attr = Table.new(:users)[:id]
+ left = attr.eq(10)
+ left.right.must_equal left.operand2
+ end
+ end
+
+ describe "to_sql" do
+ it "takes an engine" do
+ engine = FakeRecord::Base.new
+ engine.connection.extend Module.new {
+ attr_accessor :quote_count
+ def quote(*args) @quote_count += 1; super; end
+ def quote_column_name(*args) @quote_count += 1; super; end
+ def quote_table_name(*args) @quote_count += 1; super; end
+ }
+ engine.connection.quote_count = 0
+
+ attr = Table.new(:users)[:id]
+ test = attr.eq(10)
+ test.to_sql engine
+ engine.connection.quote_count.must_equal 3
+ end
+ end
+ end
+
+ describe "or" do
+ it "makes an OR node" do
+ attr = Table.new(:users)[:id]
+ left = attr.eq(10)
+ right = attr.eq(11)
+ node = left.or right
+ node.expr.left.must_equal left
+ node.expr.right.must_equal right
+ end
+ end
+
+ describe "and" do
+ it "makes and AND node" do
+ attr = Table.new(:users)[:id]
+ left = attr.eq(10)
+ right = attr.eq(11)
+ node = left.and right
+ node.left.must_equal left
+ node.right.must_equal right
+ end
+ end
+
+ it "is equal with equal ivars" do
+ array = [Equality.new("foo", "bar"), Equality.new("foo", "bar")]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ array = [Equality.new("foo", "bar"), Equality.new("foo", "baz")]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/extract_test.rb b/activerecord/test/cases/arel/nodes/extract_test.rb
new file mode 100644
index 0000000000..8fc1e04d67
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/extract_test.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+class Arel::Nodes::ExtractTest < Arel::Spec
+ it "should extract field" do
+ table = Arel::Table.new :users
+ table[:timestamp].extract("date").to_sql.must_be_like %{
+ EXTRACT(DATE FROM "users"."timestamp")
+ }
+ end
+
+ describe "as" do
+ it "should alias the extract" do
+ table = Arel::Table.new :users
+ table[:timestamp].extract("date").as("foo").to_sql.must_be_like %{
+ EXTRACT(DATE FROM "users"."timestamp") AS foo
+ }
+ end
+
+ it "should not mutate the extract" do
+ table = Arel::Table.new :users
+ extract = table[:timestamp].extract("date")
+ before = extract.dup
+ extract.as("foo")
+ assert_equal extract, before
+ end
+ end
+
+ describe "equality" do
+ it "is equal with equal ivars" do
+ table = Arel::Table.new :users
+ array = [table[:attr].extract("foo"), table[:attr].extract("foo")]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ table = Arel::Table.new :users
+ array = [table[:attr].extract("foo"), table[:attr].extract("bar")]
+ assert_equal 2, array.uniq.size
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/false_test.rb b/activerecord/test/cases/arel/nodes/false_test.rb
new file mode 100644
index 0000000000..4ecf8e332e
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/false_test.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ describe "False" do
+ describe "equality" do
+ it "is equal to other false nodes" do
+ array = [False.new, False.new]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with other nodes" do
+ array = [False.new, Node.new]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/grouping_test.rb b/activerecord/test/cases/arel/nodes/grouping_test.rb
new file mode 100644
index 0000000000..03d5c142d5
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/grouping_test.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ class GroupingTest < Arel::Spec
+ it "should create Equality nodes" do
+ grouping = Grouping.new(Nodes.build_quoted("foo"))
+ grouping.eq("foo").to_sql.must_be_like "('foo') = 'foo'"
+ end
+
+ describe "equality" do
+ it "is equal with equal ivars" do
+ array = [Grouping.new("foo"), Grouping.new("foo")]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ array = [Grouping.new("foo"), Grouping.new("bar")]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/infix_operation_test.rb b/activerecord/test/cases/arel/nodes/infix_operation_test.rb
new file mode 100644
index 0000000000..dcf2200c12
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/infix_operation_test.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ class TestInfixOperation < Arel::Test
+ def test_construct
+ operation = InfixOperation.new :+, 1, 2
+ assert_equal :+, operation.operator
+ assert_equal 1, operation.left
+ assert_equal 2, operation.right
+ end
+
+ def test_operation_alias
+ operation = InfixOperation.new :+, 1, 2
+ aliaz = operation.as("zomg")
+ assert_kind_of As, aliaz
+ assert_equal operation, aliaz.left
+ assert_equal "zomg", aliaz.right
+ end
+
+ def test_operation_ordering
+ operation = InfixOperation.new :+, 1, 2
+ ordering = operation.desc
+ assert_kind_of Descending, ordering
+ assert_equal operation, ordering.expr
+ assert ordering.descending?
+ end
+
+ def test_equality_with_same_ivars
+ array = [InfixOperation.new(:+, 1, 2), InfixOperation.new(:+, 1, 2)]
+ assert_equal 1, array.uniq.size
+ end
+
+ def test_inequality_with_different_ivars
+ array = [InfixOperation.new(:+, 1, 2), InfixOperation.new(:+, 1, 3)]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/insert_statement_test.rb b/activerecord/test/cases/arel/nodes/insert_statement_test.rb
new file mode 100644
index 0000000000..252a0d0d0b
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/insert_statement_test.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+describe Arel::Nodes::InsertStatement do
+ describe "#clone" do
+ it "clones columns and values" do
+ statement = Arel::Nodes::InsertStatement.new
+ statement.columns = %w[a b c]
+ statement.values = %w[x y z]
+
+ dolly = statement.clone
+ dolly.columns.must_equal statement.columns
+ dolly.values.must_equal statement.values
+
+ dolly.columns.wont_be_same_as statement.columns
+ dolly.values.wont_be_same_as statement.values
+ end
+ end
+
+ describe "equality" do
+ it "is equal with equal ivars" do
+ statement1 = Arel::Nodes::InsertStatement.new
+ statement1.columns = %w[a b c]
+ statement1.values = %w[x y z]
+ statement2 = Arel::Nodes::InsertStatement.new
+ statement2.columns = %w[a b c]
+ statement2.values = %w[x y z]
+ array = [statement1, statement2]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ statement1 = Arel::Nodes::InsertStatement.new
+ statement1.columns = %w[a b c]
+ statement1.values = %w[x y z]
+ statement2 = Arel::Nodes::InsertStatement.new
+ statement2.columns = %w[a b c]
+ statement2.values = %w[1 2 3]
+ array = [statement1, statement2]
+ assert_equal 2, array.uniq.size
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/named_function_test.rb b/activerecord/test/cases/arel/nodes/named_function_test.rb
new file mode 100644
index 0000000000..dbd7ae43be
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/named_function_test.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ class TestNamedFunction < Arel::Test
+ def test_construct
+ function = NamedFunction.new "omg", "zomg"
+ assert_equal "omg", function.name
+ assert_equal "zomg", function.expressions
+ end
+
+ def test_function_alias
+ function = NamedFunction.new "omg", "zomg"
+ function = function.as("wth")
+ assert_equal "omg", function.name
+ assert_equal "zomg", function.expressions
+ assert_kind_of SqlLiteral, function.alias
+ assert_equal "wth", function.alias
+ end
+
+ def test_construct_with_alias
+ function = NamedFunction.new "omg", "zomg", "wth"
+ assert_equal "omg", function.name
+ assert_equal "zomg", function.expressions
+ assert_kind_of SqlLiteral, function.alias
+ assert_equal "wth", function.alias
+ end
+
+ def test_equality_with_same_ivars
+ array = [
+ NamedFunction.new("omg", "zomg", "wth"),
+ NamedFunction.new("omg", "zomg", "wth")
+ ]
+ assert_equal 1, array.uniq.size
+ end
+
+ def test_inequality_with_different_ivars
+ array = [
+ NamedFunction.new("omg", "zomg", "wth"),
+ NamedFunction.new("zomg", "zomg", "wth")
+ ]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/node_test.rb b/activerecord/test/cases/arel/nodes/node_test.rb
new file mode 100644
index 0000000000..f4f07ef2c5
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/node_test.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ class TestNode < Arel::Test
+ def test_includes_factory_methods
+ assert Node.new.respond_to?(:create_join)
+ end
+
+ def test_all_nodes_are_nodes
+ Nodes.constants.map { |k|
+ Nodes.const_get(k)
+ }.grep(Class).each do |klass|
+ next if Nodes::SqlLiteral == klass
+ next if Nodes::BindParam == klass
+ next if klass.name =~ /^Arel::Nodes::(?:Test|.*Test$)/
+ assert klass.ancestors.include?(Nodes::Node), klass.name
+ end
+ end
+
+ def test_each
+ list = []
+ node = Nodes::Node.new
+ node.each { |n| list << n }
+ assert_equal [node], list
+ end
+
+ def test_generator
+ list = []
+ node = Nodes::Node.new
+ node.each.each { |n| list << n }
+ assert_equal [node], list
+ end
+
+ def test_enumerable
+ node = Nodes::Node.new
+ assert_kind_of Enumerable, node
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/not_test.rb b/activerecord/test/cases/arel/nodes/not_test.rb
new file mode 100644
index 0000000000..481e678700
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/not_test.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ describe "not" do
+ describe "#not" do
+ it "makes a NOT node" do
+ attr = Table.new(:users)[:id]
+ expr = attr.eq(10)
+ node = expr.not
+ node.must_be_kind_of Not
+ node.expr.must_equal expr
+ end
+ end
+
+ describe "equality" do
+ it "is equal with equal ivars" do
+ array = [Not.new("foo"), Not.new("foo")]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ array = [Not.new("foo"), Not.new("baz")]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/or_test.rb b/activerecord/test/cases/arel/nodes/or_test.rb
new file mode 100644
index 0000000000..93f826740d
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/or_test.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ describe "or" do
+ describe "#or" do
+ it "makes an OR node" do
+ attr = Table.new(:users)[:id]
+ left = attr.eq(10)
+ right = attr.eq(11)
+ node = left.or right
+ node.expr.left.must_equal left
+ node.expr.right.must_equal right
+
+ oror = node.or(right)
+ oror.expr.left.must_equal node
+ oror.expr.right.must_equal right
+ end
+ end
+
+ describe "equality" do
+ it "is equal with equal ivars" do
+ array = [Or.new("foo", "bar"), Or.new("foo", "bar")]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ array = [Or.new("foo", "bar"), Or.new("foo", "baz")]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/over_test.rb b/activerecord/test/cases/arel/nodes/over_test.rb
new file mode 100644
index 0000000000..981ec2e34b
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/over_test.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+class Arel::Nodes::OverTest < Arel::Spec
+ describe "as" do
+ it "should alias the expression" do
+ table = Arel::Table.new :users
+ table[:id].count.over.as("foo").to_sql.must_be_like %{
+ COUNT("users"."id") OVER () AS foo
+ }
+ end
+ end
+
+ describe "with literal" do
+ it "should reference the window definition by name" do
+ table = Arel::Table.new :users
+ table[:id].count.over("foo").to_sql.must_be_like %{
+ COUNT("users"."id") OVER "foo"
+ }
+ end
+ end
+
+ describe "with SQL literal" do
+ it "should reference the window definition by name" do
+ table = Arel::Table.new :users
+ table[:id].count.over(Arel.sql("foo")).to_sql.must_be_like %{
+ COUNT("users"."id") OVER foo
+ }
+ end
+ end
+
+ describe "with no expression" do
+ it "should use empty definition" do
+ table = Arel::Table.new :users
+ table[:id].count.over.to_sql.must_be_like %{
+ COUNT("users"."id") OVER ()
+ }
+ end
+ end
+
+ describe "with expression" do
+ it "should use definition in sub-expression" do
+ table = Arel::Table.new :users
+ window = Arel::Nodes::Window.new.order(table["foo"])
+ table[:id].count.over(window).to_sql.must_be_like %{
+ COUNT("users"."id") OVER (ORDER BY \"users\".\"foo\")
+ }
+ end
+ end
+
+ describe "equality" do
+ it "is equal with equal ivars" do
+ array = [
+ Arel::Nodes::Over.new("foo", "bar"),
+ Arel::Nodes::Over.new("foo", "bar")
+ ]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ array = [
+ Arel::Nodes::Over.new("foo", "bar"),
+ Arel::Nodes::Over.new("foo", "baz")
+ ]
+ assert_equal 2, array.uniq.size
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/select_core_test.rb b/activerecord/test/cases/arel/nodes/select_core_test.rb
new file mode 100644
index 0000000000..0b698205ff
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/select_core_test.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ class TestSelectCore < Arel::Test
+ def test_clone
+ core = Arel::Nodes::SelectCore.new
+ core.froms = %w[a b c]
+ core.projections = %w[d e f]
+ core.wheres = %w[g h i]
+
+ dolly = core.clone
+
+ assert_equal core.froms, dolly.froms
+ assert_equal core.projections, dolly.projections
+ assert_equal core.wheres, dolly.wheres
+
+ assert_not_same core.froms, dolly.froms
+ assert_not_same core.projections, dolly.projections
+ assert_not_same core.wheres, dolly.wheres
+ end
+
+ def test_set_quantifier
+ core = Arel::Nodes::SelectCore.new
+ core.set_quantifier = Arel::Nodes::Distinct.new
+ viz = Arel::Visitors::ToSql.new Table.engine.connection_pool
+ assert_match "DISTINCT", viz.accept(core, Collectors::SQLString.new).value
+ end
+
+ def test_equality_with_same_ivars
+ core1 = SelectCore.new
+ core1.froms = %w[a b c]
+ core1.projections = %w[d e f]
+ core1.wheres = %w[g h i]
+ core1.groups = %w[j k l]
+ core1.windows = %w[m n o]
+ core1.havings = %w[p q r]
+ core2 = SelectCore.new
+ core2.froms = %w[a b c]
+ core2.projections = %w[d e f]
+ core2.wheres = %w[g h i]
+ core2.groups = %w[j k l]
+ core2.windows = %w[m n o]
+ core2.havings = %w[p q r]
+ array = [core1, core2]
+ assert_equal 1, array.uniq.size
+ end
+
+ def test_inequality_with_different_ivars
+ core1 = SelectCore.new
+ core1.froms = %w[a b c]
+ core1.projections = %w[d e f]
+ core1.wheres = %w[g h i]
+ core1.groups = %w[j k l]
+ core1.windows = %w[m n o]
+ core1.havings = %w[p q r]
+ core2 = SelectCore.new
+ core2.froms = %w[a b c]
+ core2.projections = %w[d e f]
+ core2.wheres = %w[g h i]
+ core2.groups = %w[j k l]
+ core2.windows = %w[m n o]
+ core2.havings = %w[l o l]
+ array = [core1, core2]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/select_statement_test.rb b/activerecord/test/cases/arel/nodes/select_statement_test.rb
new file mode 100644
index 0000000000..a91605de3e
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/select_statement_test.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+describe Arel::Nodes::SelectStatement do
+ describe "#clone" do
+ it "clones cores" do
+ statement = Arel::Nodes::SelectStatement.new %w[a b c]
+
+ dolly = statement.clone
+ dolly.cores.must_equal statement.cores
+ dolly.cores.wont_be_same_as statement.cores
+ end
+ end
+
+ describe "equality" do
+ it "is equal with equal ivars" do
+ statement1 = Arel::Nodes::SelectStatement.new %w[a b c]
+ statement1.offset = 1
+ statement1.limit = 2
+ statement1.lock = false
+ statement1.orders = %w[x y z]
+ statement1.with = "zomg"
+ statement2 = Arel::Nodes::SelectStatement.new %w[a b c]
+ statement2.offset = 1
+ statement2.limit = 2
+ statement2.lock = false
+ statement2.orders = %w[x y z]
+ statement2.with = "zomg"
+ array = [statement1, statement2]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ statement1 = Arel::Nodes::SelectStatement.new %w[a b c]
+ statement1.offset = 1
+ statement1.limit = 2
+ statement1.lock = false
+ statement1.orders = %w[x y z]
+ statement1.with = "zomg"
+ statement2 = Arel::Nodes::SelectStatement.new %w[a b c]
+ statement2.offset = 1
+ statement2.limit = 2
+ statement2.lock = false
+ statement2.orders = %w[x y z]
+ statement2.with = "wth"
+ array = [statement1, statement2]
+ assert_equal 2, array.uniq.size
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/sql_literal_test.rb b/activerecord/test/cases/arel/nodes/sql_literal_test.rb
new file mode 100644
index 0000000000..3b95fed1f4
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/sql_literal_test.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+require "yaml"
+
+module Arel
+ module Nodes
+ class SqlLiteralTest < Arel::Spec
+ before do
+ @visitor = Visitors::ToSql.new Table.engine.connection
+ end
+
+ def compile(node)
+ @visitor.accept(node, Collectors::SQLString.new).value
+ end
+
+ describe "sql" do
+ it "makes a sql literal node" do
+ sql = Arel.sql "foo"
+ sql.must_be_kind_of Arel::Nodes::SqlLiteral
+ end
+ end
+
+ describe "count" do
+ it "makes a count node" do
+ node = SqlLiteral.new("*").count
+ compile(node).must_be_like %{ COUNT(*) }
+ end
+
+ it "makes a distinct node" do
+ node = SqlLiteral.new("*").count true
+ compile(node).must_be_like %{ COUNT(DISTINCT *) }
+ end
+ end
+
+ describe "equality" do
+ it "makes an equality node" do
+ node = SqlLiteral.new("foo").eq(1)
+ compile(node).must_be_like %{ foo = 1 }
+ end
+
+ it "is equal with equal contents" do
+ array = [SqlLiteral.new("foo"), SqlLiteral.new("foo")]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different contents" do
+ array = [SqlLiteral.new("foo"), SqlLiteral.new("bar")]
+ assert_equal 2, array.uniq.size
+ end
+ end
+
+ describe 'grouped "or" equality' do
+ it "makes a grouping node with an or node" do
+ node = SqlLiteral.new("foo").eq_any([1, 2])
+ compile(node).must_be_like %{ (foo = 1 OR foo = 2) }
+ end
+ end
+
+ describe 'grouped "and" equality' do
+ it "makes a grouping node with an and node" do
+ node = SqlLiteral.new("foo").eq_all([1, 2])
+ compile(node).must_be_like %{ (foo = 1 AND foo = 2) }
+ end
+ end
+
+ describe "serialization" do
+ it "serializes into YAML" do
+ yaml_literal = SqlLiteral.new("foo").to_yaml
+ assert_equal("foo", YAML.load(yaml_literal))
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/sum_test.rb b/activerecord/test/cases/arel/nodes/sum_test.rb
new file mode 100644
index 0000000000..5015964951
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/sum_test.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+class Arel::Nodes::SumTest < Arel::Spec
+ describe "as" do
+ it "should alias the sum" do
+ table = Arel::Table.new :users
+ table[:id].sum.as("foo").to_sql.must_be_like %{
+ SUM("users"."id") AS foo
+ }
+ end
+ end
+
+ describe "equality" do
+ it "is equal with equal ivars" do
+ array = [Arel::Nodes::Sum.new("foo"), Arel::Nodes::Sum.new("foo")]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ array = [Arel::Nodes::Sum.new("foo"), Arel::Nodes::Sum.new("foo!")]
+ assert_equal 2, array.uniq.size
+ end
+ end
+
+ describe "order" do
+ it "should order the sum" do
+ table = Arel::Table.new :users
+ table[:id].sum.desc.to_sql.must_be_like %{
+ SUM("users"."id") DESC
+ }
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/table_alias_test.rb b/activerecord/test/cases/arel/nodes/table_alias_test.rb
new file mode 100644
index 0000000000..c661b6771e
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/table_alias_test.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ describe "table alias" do
+ describe "equality" do
+ it "is equal with equal ivars" do
+ relation1 = Table.new(:users)
+ node1 = TableAlias.new relation1, :foo
+ relation2 = Table.new(:users)
+ node2 = TableAlias.new relation2, :foo
+ array = [node1, node2]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ relation1 = Table.new(:users)
+ node1 = TableAlias.new relation1, :foo
+ relation2 = Table.new(:users)
+ node2 = TableAlias.new relation2, :bar
+ array = [node1, node2]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/true_test.rb b/activerecord/test/cases/arel/nodes/true_test.rb
new file mode 100644
index 0000000000..1e85fe7d48
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/true_test.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ describe "True" do
+ describe "equality" do
+ it "is equal to other true nodes" do
+ array = [True.new, True.new]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with other nodes" do
+ array = [True.new, Node.new]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/unary_operation_test.rb b/activerecord/test/cases/arel/nodes/unary_operation_test.rb
new file mode 100644
index 0000000000..f0dd0c625c
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/unary_operation_test.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ class TestUnaryOperation < Arel::Test
+ def test_construct
+ operation = UnaryOperation.new :-, 1
+ assert_equal :-, operation.operator
+ assert_equal 1, operation.expr
+ end
+
+ def test_operation_alias
+ operation = UnaryOperation.new :-, 1
+ aliaz = operation.as("zomg")
+ assert_kind_of As, aliaz
+ assert_equal operation, aliaz.left
+ assert_equal "zomg", aliaz.right
+ end
+
+ def test_operation_ordering
+ operation = UnaryOperation.new :-, 1
+ ordering = operation.desc
+ assert_kind_of Descending, ordering
+ assert_equal operation, ordering.expr
+ assert ordering.descending?
+ end
+
+ def test_equality_with_same_ivars
+ array = [UnaryOperation.new(:-, 1), UnaryOperation.new(:-, 1)]
+ assert_equal 1, array.uniq.size
+ end
+
+ def test_inequality_with_different_ivars
+ array = [UnaryOperation.new(:-, 1), UnaryOperation.new(:-, 2)]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/update_statement_test.rb b/activerecord/test/cases/arel/nodes/update_statement_test.rb
new file mode 100644
index 0000000000..a83ce32f68
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/update_statement_test.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+describe Arel::Nodes::UpdateStatement do
+ describe "#clone" do
+ it "clones wheres and values" do
+ statement = Arel::Nodes::UpdateStatement.new
+ statement.wheres = %w[a b c]
+ statement.values = %w[x y z]
+
+ dolly = statement.clone
+ dolly.wheres.must_equal statement.wheres
+ dolly.wheres.wont_be_same_as statement.wheres
+
+ dolly.values.must_equal statement.values
+ dolly.values.wont_be_same_as statement.values
+ end
+ end
+
+ describe "equality" do
+ it "is equal with equal ivars" do
+ statement1 = Arel::Nodes::UpdateStatement.new
+ statement1.relation = "zomg"
+ statement1.wheres = 2
+ statement1.values = false
+ statement1.orders = %w[x y z]
+ statement1.limit = 42
+ statement1.key = "zomg"
+ statement2 = Arel::Nodes::UpdateStatement.new
+ statement2.relation = "zomg"
+ statement2.wheres = 2
+ statement2.values = false
+ statement2.orders = %w[x y z]
+ statement2.limit = 42
+ statement2.key = "zomg"
+ array = [statement1, statement2]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ statement1 = Arel::Nodes::UpdateStatement.new
+ statement1.relation = "zomg"
+ statement1.wheres = 2
+ statement1.values = false
+ statement1.orders = %w[x y z]
+ statement1.limit = 42
+ statement1.key = "zomg"
+ statement2 = Arel::Nodes::UpdateStatement.new
+ statement2.relation = "zomg"
+ statement2.wheres = 2
+ statement2.values = false
+ statement2.orders = %w[x y z]
+ statement2.limit = 42
+ statement2.key = "wth"
+ array = [statement1, statement2]
+ assert_equal 2, array.uniq.size
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes/window_test.rb b/activerecord/test/cases/arel/nodes/window_test.rb
new file mode 100644
index 0000000000..729b0556a4
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes/window_test.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Nodes
+ describe "Window" do
+ describe "equality" do
+ it "is equal with equal ivars" do
+ window1 = Window.new
+ window1.orders = [1, 2]
+ window1.partitions = [1]
+ window1.frame 3
+ window2 = Window.new
+ window2.orders = [1, 2]
+ window2.partitions = [1]
+ window2.frame 3
+ array = [window1, window2]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ window1 = Window.new
+ window1.orders = [1, 2]
+ window1.partitions = [1]
+ window1.frame 3
+ window2 = Window.new
+ window2.orders = [1, 2]
+ window1.partitions = [1]
+ window2.frame 4
+ array = [window1, window2]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+
+ describe "NamedWindow" do
+ describe "equality" do
+ it "is equal with equal ivars" do
+ window1 = NamedWindow.new "foo"
+ window1.orders = [1, 2]
+ window1.partitions = [1]
+ window1.frame 3
+ window2 = NamedWindow.new "foo"
+ window2.orders = [1, 2]
+ window2.partitions = [1]
+ window2.frame 3
+ array = [window1, window2]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ window1 = NamedWindow.new "foo"
+ window1.orders = [1, 2]
+ window1.partitions = [1]
+ window1.frame 3
+ window2 = NamedWindow.new "bar"
+ window2.orders = [1, 2]
+ window2.partitions = [1]
+ window2.frame 3
+ array = [window1, window2]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+
+ describe "CurrentRow" do
+ describe "equality" do
+ it "is equal to other current row nodes" do
+ array = [CurrentRow.new, CurrentRow.new]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with other nodes" do
+ array = [CurrentRow.new, Node.new]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/nodes_test.rb b/activerecord/test/cases/arel/nodes_test.rb
new file mode 100644
index 0000000000..9021de0d20
--- /dev/null
+++ b/activerecord/test/cases/arel/nodes_test.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require_relative "helper"
+
+module Arel
+ module Nodes
+ class TestNodes < Arel::Test
+ def test_every_arel_nodes_have_hash_eql_eqeq_from_same_class
+ # #descendants code from activesupport
+ node_descendants = []
+ ObjectSpace.each_object(Arel::Nodes::Node.singleton_class) do |k|
+ next if k.respond_to?(:singleton_class?) && k.singleton_class?
+ node_descendants.unshift k unless k == self
+ end
+ node_descendants.delete(Arel::Nodes::Node)
+ node_descendants.delete(Arel::Nodes::NodeExpression)
+
+ bad_node_descendants = node_descendants.reject do |subnode|
+ eqeq_owner = subnode.instance_method(:==).owner
+ eql_owner = subnode.instance_method(:eql?).owner
+ hash_owner = subnode.instance_method(:hash).owner
+
+ eqeq_owner < Arel::Nodes::Node &&
+ eqeq_owner == eql_owner &&
+ eqeq_owner == hash_owner
+ end
+
+ problem_msg = "Some subclasses of Arel::Nodes::Node do not have a" \
+ " #== or #eql? or #hash defined from the same class as the others"
+ assert_empty bad_node_descendants, problem_msg
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/select_manager_test.rb b/activerecord/test/cases/arel/select_manager_test.rb
new file mode 100644
index 0000000000..5220950905
--- /dev/null
+++ b/activerecord/test/cases/arel/select_manager_test.rb
@@ -0,0 +1,1225 @@
+# frozen_string_literal: true
+
+require_relative "helper"
+
+module Arel
+ class SelectManagerTest < Arel::Spec
+ def test_join_sources
+ manager = Arel::SelectManager.new
+ manager.join_sources << Arel::Nodes::StringJoin.new(Nodes.build_quoted("foo"))
+ assert_equal "SELECT FROM 'foo'", manager.to_sql
+ end
+
+ describe "backwards compatibility" do
+ describe "project" do
+ it "accepts symbols as sql literals" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.project :id
+ manager.from table
+ manager.to_sql.must_be_like %{
+ SELECT id FROM "users"
+ }
+ end
+ end
+
+ describe "order" do
+ it "accepts symbols" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.project Nodes::SqlLiteral.new "*"
+ manager.from table
+ manager.order :foo
+ manager.to_sql.must_be_like %{ SELECT * FROM "users" ORDER BY foo }
+ end
+ end
+
+ describe "group" do
+ it "takes a symbol" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.group :foo
+ manager.to_sql.must_be_like %{ SELECT FROM "users" GROUP BY foo }
+ end
+ end
+
+ describe "as" do
+ it "makes an AS node by grouping the AST" do
+ manager = Arel::SelectManager.new
+ as = manager.as(Arel.sql("foo"))
+ assert_kind_of Arel::Nodes::Grouping, as.left
+ assert_equal manager.ast, as.left.expr
+ assert_equal "foo", as.right
+ end
+
+ it "converts right to SqlLiteral if a string" do
+ manager = Arel::SelectManager.new
+ as = manager.as("foo")
+ assert_kind_of Arel::Nodes::SqlLiteral, as.right
+ end
+
+ it "can make a subselect" do
+ manager = Arel::SelectManager.new
+ manager.project Arel.star
+ manager.from Arel.sql("zomg")
+ as = manager.as(Arel.sql("foo"))
+
+ manager = Arel::SelectManager.new
+ manager.project Arel.sql("name")
+ manager.from as
+ manager.to_sql.must_be_like "SELECT name FROM (SELECT * FROM zomg) foo"
+ end
+ end
+
+ describe "from" do
+ it "ignores strings when table of same name exists" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+
+ manager.from table
+ manager.from "users"
+ manager.project table["id"]
+ manager.to_sql.must_be_like 'SELECT "users"."id" FROM users'
+ end
+
+ it "should support any ast" do
+ table = Table.new :users
+ manager1 = Arel::SelectManager.new
+
+ manager2 = Arel::SelectManager.new
+ manager2.project(Arel.sql("*"))
+ manager2.from table
+
+ manager1.project Arel.sql("lol")
+ as = manager2.as Arel.sql("omg")
+ manager1.from(as)
+
+ manager1.to_sql.must_be_like %{
+ SELECT lol FROM (SELECT * FROM "users") omg
+ }
+ end
+ end
+
+ describe "having" do
+ it "converts strings to SQLLiterals" do
+ table = Table.new :users
+ mgr = table.from
+ mgr.having Arel.sql("foo")
+ mgr.to_sql.must_be_like %{ SELECT FROM "users" HAVING foo }
+ end
+
+ it "can have multiple items specified separately" do
+ table = Table.new :users
+ mgr = table.from
+ mgr.having Arel.sql("foo")
+ mgr.having Arel.sql("bar")
+ mgr.to_sql.must_be_like %{ SELECT FROM "users" HAVING foo AND bar }
+ end
+
+ it "can receive any node" do
+ table = Table.new :users
+ mgr = table.from
+ mgr.having Arel::Nodes::And.new([Arel.sql("foo"), Arel.sql("bar")])
+ mgr.to_sql.must_be_like %{ SELECT FROM "users" HAVING foo AND bar }
+ end
+ end
+
+ describe "on" do
+ it "converts to sqlliterals" do
+ table = Table.new :users
+ right = table.alias
+ mgr = table.from
+ mgr.join(right).on("omg")
+ mgr.to_sql.must_be_like %{ SELECT FROM "users" INNER JOIN "users" "users_2" ON omg }
+ end
+
+ it "converts to sqlliterals with multiple items" do
+ table = Table.new :users
+ right = table.alias
+ mgr = table.from
+ mgr.join(right).on("omg", "123")
+ mgr.to_sql.must_be_like %{ SELECT FROM "users" INNER JOIN "users" "users_2" ON omg AND 123 }
+ end
+ end
+ end
+
+ describe "clone" do
+ it "creates new cores" do
+ table = Table.new :users, as: "foo"
+ mgr = table.from
+ m2 = mgr.clone
+ m2.project "foo"
+ mgr.to_sql.wont_equal m2.to_sql
+ end
+
+ it "makes updates to the correct copy" do
+ table = Table.new :users, as: "foo"
+ mgr = table.from
+ m2 = mgr.clone
+ m3 = m2.clone
+ m2.project "foo"
+ mgr.to_sql.wont_equal m2.to_sql
+ m3.to_sql.must_equal mgr.to_sql
+ end
+ end
+
+ describe "initialize" do
+ it "uses alias in sql" do
+ table = Table.new :users, as: "foo"
+ mgr = table.from
+ mgr.skip 10
+ mgr.to_sql.must_be_like %{ SELECT FROM "users" "foo" OFFSET 10 }
+ end
+ end
+
+ describe "skip" do
+ it "should add an offset" do
+ table = Table.new :users
+ mgr = table.from
+ mgr.skip 10
+ mgr.to_sql.must_be_like %{ SELECT FROM "users" OFFSET 10 }
+ end
+
+ it "should chain" do
+ table = Table.new :users
+ mgr = table.from
+ mgr.skip(10).to_sql.must_be_like %{ SELECT FROM "users" OFFSET 10 }
+ end
+ end
+
+ describe "offset" do
+ it "should add an offset" do
+ table = Table.new :users
+ mgr = table.from
+ mgr.offset = 10
+ mgr.to_sql.must_be_like %{ SELECT FROM "users" OFFSET 10 }
+ end
+
+ it "should remove an offset" do
+ table = Table.new :users
+ mgr = table.from
+ mgr.offset = 10
+ mgr.to_sql.must_be_like %{ SELECT FROM "users" OFFSET 10 }
+
+ mgr.offset = nil
+ mgr.to_sql.must_be_like %{ SELECT FROM "users" }
+ end
+
+ it "should return the offset" do
+ table = Table.new :users
+ mgr = table.from
+ mgr.offset = 10
+ assert_equal 10, mgr.offset
+ end
+ end
+
+ describe "exists" do
+ it "should create an exists clause" do
+ table = Table.new(:users)
+ manager = Arel::SelectManager.new table
+ manager.project Nodes::SqlLiteral.new "*"
+ m2 = Arel::SelectManager.new
+ m2.project manager.exists
+ m2.to_sql.must_be_like %{ SELECT EXISTS (#{manager.to_sql}) }
+ end
+
+ it "can be aliased" do
+ table = Table.new(:users)
+ manager = Arel::SelectManager.new table
+ manager.project Nodes::SqlLiteral.new "*"
+ m2 = Arel::SelectManager.new
+ m2.project manager.exists.as("foo")
+ m2.to_sql.must_be_like %{ SELECT EXISTS (#{manager.to_sql}) AS foo }
+ end
+ end
+
+ describe "union" do
+ before do
+ table = Table.new :users
+ @m1 = Arel::SelectManager.new table
+ @m1.project Arel.star
+ @m1.where(table[:age].lt(18))
+
+ @m2 = Arel::SelectManager.new table
+ @m2.project Arel.star
+ @m2.where(table[:age].gt(99))
+ end
+
+ it "should union two managers" do
+ # FIXME should this union "managers" or "statements" ?
+ # FIXME this probably shouldn't return a node
+ node = @m1.union @m2
+
+ # maybe FIXME: decide when wrapper parens are needed
+ node.to_sql.must_be_like %{
+ ( SELECT * FROM "users" WHERE "users"."age" < 18 UNION SELECT * FROM "users" WHERE "users"."age" > 99 )
+ }
+ end
+
+ it "should union all" do
+ node = @m1.union :all, @m2
+
+ node.to_sql.must_be_like %{
+ ( SELECT * FROM "users" WHERE "users"."age" < 18 UNION ALL SELECT * FROM "users" WHERE "users"."age" > 99 )
+ }
+ end
+ end
+
+ describe "intersect" do
+ before do
+ table = Table.new :users
+ @m1 = Arel::SelectManager.new table
+ @m1.project Arel.star
+ @m1.where(table[:age].gt(18))
+
+ @m2 = Arel::SelectManager.new table
+ @m2.project Arel.star
+ @m2.where(table[:age].lt(99))
+ end
+
+ it "should intersect two managers" do
+ # FIXME should this intersect "managers" or "statements" ?
+ # FIXME this probably shouldn't return a node
+ node = @m1.intersect @m2
+
+ # maybe FIXME: decide when wrapper parens are needed
+ node.to_sql.must_be_like %{
+ ( SELECT * FROM "users" WHERE "users"."age" > 18 INTERSECT SELECT * FROM "users" WHERE "users"."age" < 99 )
+ }
+ end
+ end
+
+ describe "except" do
+ before do
+ table = Table.new :users
+ @m1 = Arel::SelectManager.new table
+ @m1.project Arel.star
+ @m1.where(table[:age].between(18..60))
+
+ @m2 = Arel::SelectManager.new table
+ @m2.project Arel.star
+ @m2.where(table[:age].between(40..99))
+ end
+
+ it "should except two managers" do
+ # FIXME should this except "managers" or "statements" ?
+ # FIXME this probably shouldn't return a node
+ node = @m1.except @m2
+
+ # maybe FIXME: decide when wrapper parens are needed
+ node.to_sql.must_be_like %{
+ ( SELECT * FROM "users" WHERE "users"."age" BETWEEN 18 AND 60 EXCEPT SELECT * FROM "users" WHERE "users"."age" BETWEEN 40 AND 99 )
+ }
+ end
+ end
+
+ describe "with" do
+ it "should support basic WITH" do
+ users = Table.new(:users)
+ users_top = Table.new(:users_top)
+ comments = Table.new(:comments)
+
+ top = users.project(users[:id]).where(users[:karma].gt(100))
+ users_as = Arel::Nodes::As.new(users_top, top)
+ select_manager = comments.project(Arel.star).with(users_as)
+ .where(comments[:author_id].in(users_top.project(users_top[:id])))
+
+ select_manager.to_sql.must_be_like %{
+ WITH "users_top" AS (SELECT "users"."id" FROM "users" WHERE "users"."karma" > 100) SELECT * FROM "comments" WHERE "comments"."author_id" IN (SELECT "users_top"."id" FROM "users_top")
+ }
+ end
+
+ it "should support WITH RECURSIVE" do
+ comments = Table.new(:comments)
+ comments_id = comments[:id]
+ comments_parent_id = comments[:parent_id]
+
+ replies = Table.new(:replies)
+ replies_id = replies[:id]
+
+ recursive_term = Arel::SelectManager.new
+ recursive_term.from(comments).project(comments_id, comments_parent_id).where(comments_id.eq 42)
+
+ non_recursive_term = Arel::SelectManager.new
+ non_recursive_term.from(comments).project(comments_id, comments_parent_id).join(replies).on(comments_parent_id.eq replies_id)
+
+ union = recursive_term.union(non_recursive_term)
+
+ as_statement = Arel::Nodes::As.new replies, union
+
+ manager = Arel::SelectManager.new
+ manager.with(:recursive, as_statement).from(replies).project(Arel.star)
+
+ sql = manager.to_sql
+ sql.must_be_like %{
+ WITH RECURSIVE "replies" AS (
+ SELECT "comments"."id", "comments"."parent_id" FROM "comments" WHERE "comments"."id" = 42
+ UNION
+ SELECT "comments"."id", "comments"."parent_id" FROM "comments" INNER JOIN "replies" ON "comments"."parent_id" = "replies"."id"
+ )
+ SELECT * FROM "replies"
+ }
+ end
+ end
+
+ describe "ast" do
+ it "should return the ast" do
+ table = Table.new :users
+ mgr = table.from
+ assert mgr.ast
+ end
+
+ it "should allow orders to work when the ast is grepped" do
+ table = Table.new :users
+ mgr = table.from
+ mgr.project Arel.sql "*"
+ mgr.from table
+ mgr.orders << Arel::Nodes::Ascending.new(Arel.sql("foo"))
+ mgr.ast.grep(Arel::Nodes::OuterJoin)
+ mgr.to_sql.must_be_like %{ SELECT * FROM "users" ORDER BY foo ASC }
+ end
+ end
+
+ describe "taken" do
+ it "should return limit" do
+ manager = Arel::SelectManager.new
+ manager.take 10
+ manager.taken.must_equal 10
+ end
+ end
+
+ describe "lock" do
+ # This should fail on other databases
+ it "adds a lock node" do
+ table = Table.new :users
+ mgr = table.from
+ mgr.lock.to_sql.must_be_like %{ SELECT FROM "users" FOR UPDATE }
+ end
+ end
+
+ describe "orders" do
+ it "returns order clauses" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ order = table[:id]
+ manager.order table[:id]
+ manager.orders.must_equal [order]
+ end
+ end
+
+ describe "order" do
+ it "generates order clauses" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.project Nodes::SqlLiteral.new "*"
+ manager.from table
+ manager.order table[:id]
+ manager.to_sql.must_be_like %{
+ SELECT * FROM "users" ORDER BY "users"."id"
+ }
+ end
+
+ # FIXME: I would like to deprecate this
+ it "takes *args" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.project Nodes::SqlLiteral.new "*"
+ manager.from table
+ manager.order table[:id], table[:name]
+ manager.to_sql.must_be_like %{
+ SELECT * FROM "users" ORDER BY "users"."id", "users"."name"
+ }
+ end
+
+ it "chains" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.order(table[:id]).must_equal manager
+ end
+
+ it "has order attributes" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.project Nodes::SqlLiteral.new "*"
+ manager.from table
+ manager.order table[:id].desc
+ manager.to_sql.must_be_like %{
+ SELECT * FROM "users" ORDER BY "users"."id" DESC
+ }
+ end
+ end
+
+ describe "on" do
+ it "takes two params" do
+ left = Table.new :users
+ right = left.alias
+ predicate = left[:id].eq(right[:id])
+ manager = Arel::SelectManager.new
+
+ manager.from left
+ manager.join(right).on(predicate, predicate)
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users"
+ INNER JOIN "users" "users_2"
+ ON "users"."id" = "users_2"."id" AND
+ "users"."id" = "users_2"."id"
+ }
+ end
+
+ it "takes three params" do
+ left = Table.new :users
+ right = left.alias
+ predicate = left[:id].eq(right[:id])
+ manager = Arel::SelectManager.new
+
+ manager.from left
+ manager.join(right).on(
+ predicate,
+ predicate,
+ left[:name].eq(right[:name])
+ )
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users"
+ INNER JOIN "users" "users_2"
+ ON "users"."id" = "users_2"."id" AND
+ "users"."id" = "users_2"."id" AND
+ "users"."name" = "users_2"."name"
+ }
+ end
+ end
+
+ it "should hand back froms" do
+ relation = Arel::SelectManager.new
+ assert_equal [], relation.froms
+ end
+
+ it "should create and nodes" do
+ relation = Arel::SelectManager.new
+ children = ["foo", "bar", "baz"]
+ clause = relation.create_and children
+ assert_kind_of Arel::Nodes::And, clause
+ assert_equal children, clause.children
+ end
+
+ it "should create insert managers" do
+ relation = Arel::SelectManager.new
+ insert = relation.create_insert
+ assert_kind_of Arel::InsertManager, insert
+ end
+
+ it "should create join nodes" do
+ relation = Arel::SelectManager.new
+ join = relation.create_join "foo", "bar"
+ assert_kind_of Arel::Nodes::InnerJoin, join
+ assert_equal "foo", join.left
+ assert_equal "bar", join.right
+ end
+
+ it "should create join nodes with a full outer join klass" do
+ relation = Arel::SelectManager.new
+ join = relation.create_join "foo", "bar", Arel::Nodes::FullOuterJoin
+ assert_kind_of Arel::Nodes::FullOuterJoin, join
+ assert_equal "foo", join.left
+ assert_equal "bar", join.right
+ end
+
+ it "should create join nodes with a outer join klass" do
+ relation = Arel::SelectManager.new
+ join = relation.create_join "foo", "bar", Arel::Nodes::OuterJoin
+ assert_kind_of Arel::Nodes::OuterJoin, join
+ assert_equal "foo", join.left
+ assert_equal "bar", join.right
+ end
+
+ it "should create join nodes with a right outer join klass" do
+ relation = Arel::SelectManager.new
+ join = relation.create_join "foo", "bar", Arel::Nodes::RightOuterJoin
+ assert_kind_of Arel::Nodes::RightOuterJoin, join
+ assert_equal "foo", join.left
+ assert_equal "bar", join.right
+ end
+
+ describe "join" do
+ it "responds to join" do
+ left = Table.new :users
+ right = left.alias
+ predicate = left[:id].eq(right[:id])
+ manager = Arel::SelectManager.new
+
+ manager.from left
+ manager.join(right).on(predicate)
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users"
+ INNER JOIN "users" "users_2"
+ ON "users"."id" = "users_2"."id"
+ }
+ end
+
+ it "takes a class" do
+ left = Table.new :users
+ right = left.alias
+ predicate = left[:id].eq(right[:id])
+ manager = Arel::SelectManager.new
+
+ manager.from left
+ manager.join(right, Nodes::OuterJoin).on(predicate)
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users"
+ LEFT OUTER JOIN "users" "users_2"
+ ON "users"."id" = "users_2"."id"
+ }
+ end
+
+ it "takes the full outer join class" do
+ left = Table.new :users
+ right = left.alias
+ predicate = left[:id].eq(right[:id])
+ manager = Arel::SelectManager.new
+
+ manager.from left
+ manager.join(right, Nodes::FullOuterJoin).on(predicate)
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users"
+ FULL OUTER JOIN "users" "users_2"
+ ON "users"."id" = "users_2"."id"
+ }
+ end
+
+ it "takes the right outer join class" do
+ left = Table.new :users
+ right = left.alias
+ predicate = left[:id].eq(right[:id])
+ manager = Arel::SelectManager.new
+
+ manager.from left
+ manager.join(right, Nodes::RightOuterJoin).on(predicate)
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users"
+ RIGHT OUTER JOIN "users" "users_2"
+ ON "users"."id" = "users_2"."id"
+ }
+ end
+
+ it "noops on nil" do
+ manager = Arel::SelectManager.new
+ manager.join(nil).must_equal manager
+ end
+
+ it "raises EmptyJoinError on empty" do
+ left = Table.new :users
+ manager = Arel::SelectManager.new
+
+ manager.from left
+ assert_raises(EmptyJoinError) do
+ manager.join("")
+ end
+ end
+ end
+
+ describe "outer join" do
+ it "responds to join" do
+ left = Table.new :users
+ right = left.alias
+ predicate = left[:id].eq(right[:id])
+ manager = Arel::SelectManager.new
+
+ manager.from left
+ manager.outer_join(right).on(predicate)
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users"
+ LEFT OUTER JOIN "users" "users_2"
+ ON "users"."id" = "users_2"."id"
+ }
+ end
+
+ it "noops on nil" do
+ manager = Arel::SelectManager.new
+ manager.outer_join(nil).must_equal manager
+ end
+ end
+
+ describe "joins" do
+ it "returns inner join sql" do
+ table = Table.new :users
+ aliaz = table.alias
+ manager = Arel::SelectManager.new
+ manager.from Nodes::InnerJoin.new(aliaz, table[:id].eq(aliaz[:id]))
+ assert_match 'INNER JOIN "users" "users_2" "users"."id" = "users_2"."id"',
+ manager.to_sql
+ end
+
+ it "returns outer join sql" do
+ table = Table.new :users
+ aliaz = table.alias
+ manager = Arel::SelectManager.new
+ manager.from Nodes::OuterJoin.new(aliaz, table[:id].eq(aliaz[:id]))
+ assert_match 'LEFT OUTER JOIN "users" "users_2" "users"."id" = "users_2"."id"',
+ manager.to_sql
+ end
+
+ it "can have a non-table alias as relation name" do
+ users = Table.new :users
+ comments = Table.new :comments
+
+ counts = comments.from.
+ group(comments[:user_id]).
+ project(
+ comments[:user_id].as("user_id"),
+ comments[:user_id].count.as("count")
+ ).as("counts")
+
+ joins = users.join(counts).on(counts[:user_id].eq(10))
+ joins.to_sql.must_be_like %{
+ SELECT FROM "users" INNER JOIN (SELECT "comments"."user_id" AS user_id, COUNT("comments"."user_id") AS count FROM "comments" GROUP BY "comments"."user_id") counts ON counts."user_id" = 10
+ }
+ end
+
+ it "joins itself" do
+ left = Table.new :users
+ right = left.alias
+ predicate = left[:id].eq(right[:id])
+
+ mgr = left.join(right)
+ mgr.project Nodes::SqlLiteral.new("*")
+ mgr.on(predicate).must_equal mgr
+
+ mgr.to_sql.must_be_like %{
+ SELECT * FROM "users"
+ INNER JOIN "users" "users_2"
+ ON "users"."id" = "users_2"."id"
+ }
+ end
+
+ it "returns string join sql" do
+ manager = Arel::SelectManager.new
+ manager.from Nodes::StringJoin.new(Nodes.build_quoted("hello"))
+ assert_match "'hello'", manager.to_sql
+ end
+ end
+
+ describe "group" do
+ it "takes an attribute" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.group table[:id]
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" GROUP BY "users"."id"
+ }
+ end
+
+ it "chains" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.group(table[:id]).must_equal manager
+ end
+
+ it "takes multiple args" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.group table[:id], table[:name]
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" GROUP BY "users"."id", "users"."name"
+ }
+ end
+
+ # FIXME: backwards compat
+ it "makes strings literals" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.group "foo"
+ manager.to_sql.must_be_like %{ SELECT FROM "users" GROUP BY foo }
+ end
+ end
+
+ describe "window definition" do
+ it "can be empty" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.window("a_window")
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" WINDOW "a_window" AS ()
+ }
+ end
+
+ it "takes an order" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.window("a_window").order(table["foo"].asc)
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" WINDOW "a_window" AS (ORDER BY "users"."foo" ASC)
+ }
+ end
+
+ it "takes an order with multiple columns" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.window("a_window").order(table["foo"].asc, table["bar"].desc)
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" WINDOW "a_window" AS (ORDER BY "users"."foo" ASC, "users"."bar" DESC)
+ }
+ end
+
+ it "takes a partition" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.window("a_window").partition(table["bar"])
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" WINDOW "a_window" AS (PARTITION BY "users"."bar")
+ }
+ end
+
+ it "takes a partition and an order" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.window("a_window").partition(table["foo"]).order(table["foo"].asc)
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" WINDOW "a_window" AS (PARTITION BY "users"."foo"
+ ORDER BY "users"."foo" ASC)
+ }
+ end
+
+ it "takes a partition with multiple columns" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.window("a_window").partition(table["bar"], table["baz"])
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" WINDOW "a_window" AS (PARTITION BY "users"."bar", "users"."baz")
+ }
+ end
+
+ it "takes a rows frame, unbounded preceding" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.window("a_window").rows(Arel::Nodes::Preceding.new)
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" WINDOW "a_window" AS (ROWS UNBOUNDED PRECEDING)
+ }
+ end
+
+ it "takes a rows frame, bounded preceding" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.window("a_window").rows(Arel::Nodes::Preceding.new(5))
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" WINDOW "a_window" AS (ROWS 5 PRECEDING)
+ }
+ end
+
+ it "takes a rows frame, unbounded following" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.window("a_window").rows(Arel::Nodes::Following.new)
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" WINDOW "a_window" AS (ROWS UNBOUNDED FOLLOWING)
+ }
+ end
+
+ it "takes a rows frame, bounded following" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.window("a_window").rows(Arel::Nodes::Following.new(5))
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" WINDOW "a_window" AS (ROWS 5 FOLLOWING)
+ }
+ end
+
+ it "takes a rows frame, current row" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.window("a_window").rows(Arel::Nodes::CurrentRow.new)
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" WINDOW "a_window" AS (ROWS CURRENT ROW)
+ }
+ end
+
+ it "takes a rows frame, between two delimiters" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ window = manager.window("a_window")
+ window.frame(
+ Arel::Nodes::Between.new(
+ window.rows,
+ Nodes::And.new([
+ Arel::Nodes::Preceding.new,
+ Arel::Nodes::CurrentRow.new
+ ])))
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" WINDOW "a_window" AS (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
+ }
+ end
+
+ it "takes a range frame, unbounded preceding" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.window("a_window").range(Arel::Nodes::Preceding.new)
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" WINDOW "a_window" AS (RANGE UNBOUNDED PRECEDING)
+ }
+ end
+
+ it "takes a range frame, bounded preceding" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.window("a_window").range(Arel::Nodes::Preceding.new(5))
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" WINDOW "a_window" AS (RANGE 5 PRECEDING)
+ }
+ end
+
+ it "takes a range frame, unbounded following" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.window("a_window").range(Arel::Nodes::Following.new)
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" WINDOW "a_window" AS (RANGE UNBOUNDED FOLLOWING)
+ }
+ end
+
+ it "takes a range frame, bounded following" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.window("a_window").range(Arel::Nodes::Following.new(5))
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" WINDOW "a_window" AS (RANGE 5 FOLLOWING)
+ }
+ end
+
+ it "takes a range frame, current row" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.window("a_window").range(Arel::Nodes::CurrentRow.new)
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" WINDOW "a_window" AS (RANGE CURRENT ROW)
+ }
+ end
+
+ it "takes a range frame, between two delimiters" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ window = manager.window("a_window")
+ window.frame(
+ Arel::Nodes::Between.new(
+ window.range,
+ Nodes::And.new([
+ Arel::Nodes::Preceding.new,
+ Arel::Nodes::CurrentRow.new
+ ])))
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" WINDOW "a_window" AS (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
+ }
+ end
+ end
+
+ describe "delete" do
+ it "copies from" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ stmt = manager.compile_delete
+
+ stmt.to_sql.must_be_like %{ DELETE FROM "users" }
+ end
+
+ it "copies where" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.where table[:id].eq 10
+ stmt = manager.compile_delete
+
+ stmt.to_sql.must_be_like %{
+ DELETE FROM "users" WHERE "users"."id" = 10
+ }
+ end
+ end
+
+ describe "where_sql" do
+ it "gives me back the where sql" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.where table[:id].eq 10
+ manager.where_sql.must_be_like %{ WHERE "users"."id" = 10 }
+ end
+
+ it "joins wheres with AND" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.where table[:id].eq 10
+ manager.where table[:id].eq 11
+ manager.where_sql.must_be_like %{ WHERE "users"."id" = 10 AND "users"."id" = 11}
+ end
+
+ it "handles database specific statements" do
+ old_visitor = Table.engine.connection.visitor
+ Table.engine.connection.visitor = Visitors::PostgreSQL.new Table.engine.connection
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.where table[:id].eq 10
+ manager.where table[:name].matches "foo%"
+ manager.where_sql.must_be_like %{ WHERE "users"."id" = 10 AND "users"."name" ILIKE 'foo%' }
+ Table.engine.connection.visitor = old_visitor
+ end
+
+ it "returns nil when there are no wheres" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.where_sql.must_be_nil
+ end
+ end
+
+ describe "update" do
+ it "creates an update statement" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ stmt = manager.compile_update({ table[:id] => 1 }, Arel::Attributes::Attribute.new(table, "id"))
+
+ stmt.to_sql.must_be_like %{
+ UPDATE "users" SET "id" = 1
+ }
+ end
+
+ it "takes a string" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ stmt = manager.compile_update(Nodes::SqlLiteral.new("foo = bar"), Arel::Attributes::Attribute.new(table, "id"))
+
+ stmt.to_sql.must_be_like %{ UPDATE "users" SET foo = bar }
+ end
+
+ it "copies limits" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.take 1
+ stmt = manager.compile_update(Nodes::SqlLiteral.new("foo = bar"), Arel::Attributes::Attribute.new(table, "id"))
+ stmt.key = table["id"]
+
+ stmt.to_sql.must_be_like %{
+ UPDATE "users" SET foo = bar
+ WHERE "users"."id" IN (SELECT "users"."id" FROM "users" LIMIT 1)
+ }
+ end
+
+ it "copies order" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from table
+ manager.order :foo
+ stmt = manager.compile_update(Nodes::SqlLiteral.new("foo = bar"), Arel::Attributes::Attribute.new(table, "id"))
+ stmt.key = table["id"]
+
+ stmt.to_sql.must_be_like %{
+ UPDATE "users" SET foo = bar
+ WHERE "users"."id" IN (SELECT "users"."id" FROM "users" ORDER BY foo)
+ }
+ end
+
+ it "copies where clauses" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.where table[:id].eq 10
+ manager.from table
+ stmt = manager.compile_update({ table[:id] => 1 }, Arel::Attributes::Attribute.new(table, "id"))
+
+ stmt.to_sql.must_be_like %{
+ UPDATE "users" SET "id" = 1 WHERE "users"."id" = 10
+ }
+ end
+
+ it "copies where clauses when nesting is triggered" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.where table[:foo].eq 10
+ manager.take 42
+ manager.from table
+ stmt = manager.compile_update({ table[:id] => 1 }, Arel::Attributes::Attribute.new(table, "id"))
+
+ stmt.to_sql.must_be_like %{
+ UPDATE "users" SET "id" = 1 WHERE "users"."id" IN (SELECT "users"."id" FROM "users" WHERE "users"."foo" = 10 LIMIT 42)
+ }
+ end
+ end
+
+ describe "project" do
+ it "takes sql literals" do
+ manager = Arel::SelectManager.new
+ manager.project Nodes::SqlLiteral.new "*"
+ manager.to_sql.must_be_like %{ SELECT * }
+ end
+
+ it "takes multiple args" do
+ manager = Arel::SelectManager.new
+ manager.project Nodes::SqlLiteral.new("foo"),
+ Nodes::SqlLiteral.new("bar")
+ manager.to_sql.must_be_like %{ SELECT foo, bar }
+ end
+
+ it "takes strings" do
+ manager = Arel::SelectManager.new
+ manager.project "*"
+ manager.to_sql.must_be_like %{ SELECT * }
+ end
+ end
+
+ describe "projections" do
+ it "reads projections" do
+ manager = Arel::SelectManager.new
+ manager.project Arel.sql("foo"), Arel.sql("bar")
+ manager.projections.must_equal [Arel.sql("foo"), Arel.sql("bar")]
+ end
+ end
+
+ describe "projections=" do
+ it "overwrites projections" do
+ manager = Arel::SelectManager.new
+ manager.project Arel.sql("foo")
+ manager.projections = [Arel.sql("bar")]
+ manager.to_sql.must_be_like %{ SELECT bar }
+ end
+ end
+
+ describe "take" do
+ it "knows take" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from(table).project(table["id"])
+ manager.where(table["id"].eq(1))
+ manager.take 1
+
+ manager.to_sql.must_be_like %{
+ SELECT "users"."id"
+ FROM "users"
+ WHERE "users"."id" = 1
+ LIMIT 1
+ }
+ end
+
+ it "chains" do
+ manager = Arel::SelectManager.new
+ manager.take(1).must_equal manager
+ end
+
+ it "removes LIMIT when nil is passed" do
+ manager = Arel::SelectManager.new
+ manager.limit = 10
+ assert_match("LIMIT", manager.to_sql)
+
+ manager.limit = nil
+ assert_no_match("LIMIT", manager.to_sql)
+ end
+ end
+
+ describe "where" do
+ it "knows where" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from(table).project(table["id"])
+ manager.where(table["id"].eq(1))
+ manager.to_sql.must_be_like %{
+ SELECT "users"."id"
+ FROM "users"
+ WHERE "users"."id" = 1
+ }
+ end
+
+ it "chains" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from(table)
+ manager.project(table["id"]).where(table["id"].eq 1).must_equal manager
+ end
+ end
+
+ describe "from" do
+ it "makes sql" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+
+ manager.from table
+ manager.project table["id"]
+ manager.to_sql.must_be_like 'SELECT "users"."id" FROM "users"'
+ end
+
+ it "chains" do
+ table = Table.new :users
+ manager = Arel::SelectManager.new
+ manager.from(table).project(table["id"]).must_equal manager
+ manager.to_sql.must_be_like 'SELECT "users"."id" FROM "users"'
+ end
+ end
+
+ describe "source" do
+ it "returns the join source of the select core" do
+ manager = Arel::SelectManager.new
+ manager.source.must_equal manager.ast.cores.last.source
+ end
+ end
+
+ describe "distinct" do
+ it "sets the quantifier" do
+ manager = Arel::SelectManager.new
+
+ manager.distinct
+ manager.ast.cores.last.set_quantifier.class.must_equal Arel::Nodes::Distinct
+
+ manager.distinct(false)
+ manager.ast.cores.last.set_quantifier.must_be_nil
+ end
+
+ it "chains" do
+ manager = Arel::SelectManager.new
+ manager.distinct.must_equal manager
+ manager.distinct(false).must_equal manager
+ end
+ end
+
+ describe "distinct_on" do
+ it "sets the quantifier" do
+ manager = Arel::SelectManager.new
+ table = Table.new :users
+
+ manager.distinct_on(table["id"])
+ manager.ast.cores.last.set_quantifier.must_equal Arel::Nodes::DistinctOn.new(table["id"])
+
+ manager.distinct_on(false)
+ manager.ast.cores.last.set_quantifier.must_be_nil
+ end
+
+ it "chains" do
+ manager = Arel::SelectManager.new
+ table = Table.new :users
+
+ manager.distinct_on(table["id"]).must_equal manager
+ manager.distinct_on(false).must_equal manager
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/support/fake_record.rb b/activerecord/test/cases/arel/support/fake_record.rb
new file mode 100644
index 0000000000..559ff5d4e6
--- /dev/null
+++ b/activerecord/test/cases/arel/support/fake_record.rb
@@ -0,0 +1,129 @@
+# frozen_string_literal: true
+
+require "date"
+module FakeRecord
+ class Column < Struct.new(:name, :type)
+ end
+
+ class Connection
+ attr_reader :tables
+ attr_accessor :visitor
+
+ def initialize(visitor = nil)
+ @tables = %w{ users photos developers products}
+ @columns = {
+ "users" => [
+ Column.new("id", :integer),
+ Column.new("name", :string),
+ Column.new("bool", :boolean),
+ Column.new("created_at", :date)
+ ],
+ "products" => [
+ Column.new("id", :integer),
+ Column.new("price", :decimal)
+ ]
+ }
+ @columns_hash = {
+ "users" => Hash[@columns["users"].map { |x| [x.name, x] }],
+ "products" => Hash[@columns["products"].map { |x| [x.name, x] }]
+ }
+ @primary_keys = {
+ "users" => "id",
+ "products" => "id"
+ }
+ @visitor = visitor
+ end
+
+ def columns_hash(table_name)
+ @columns_hash[table_name]
+ end
+
+ def primary_key(name)
+ @primary_keys[name.to_s]
+ end
+
+ def data_source_exists?(name)
+ @tables.include? name.to_s
+ end
+
+ def columns(name, message = nil)
+ @columns[name.to_s]
+ end
+
+ def quote_table_name(name)
+ "\"#{name}\""
+ end
+
+ def quote_column_name(name)
+ "\"#{name}\""
+ end
+
+ def schema_cache
+ self
+ end
+
+ def quote(thing)
+ case thing
+ when DateTime
+ "'#{thing.strftime("%Y-%m-%d %H:%M:%S")}'"
+ when Date
+ "'#{thing.strftime("%Y-%m-%d")}'"
+ when true
+ "'t'"
+ when false
+ "'f'"
+ when nil
+ "NULL"
+ when Numeric
+ thing
+ else
+ "'#{thing.to_s.gsub("'", "\\\\'")}'"
+ end
+ end
+ end
+
+ class ConnectionPool
+ class Spec < Struct.new(:config)
+ end
+
+ attr_reader :spec, :connection
+
+ def initialize
+ @spec = Spec.new(adapter: "america")
+ @connection = Connection.new
+ @connection.visitor = Arel::Visitors::ToSql.new(connection)
+ end
+
+ def with_connection
+ yield connection
+ end
+
+ def table_exists?(name)
+ connection.tables.include? name.to_s
+ end
+
+ def columns_hash
+ connection.columns_hash
+ end
+
+ def schema_cache
+ connection
+ end
+
+ def quote(thing)
+ connection.quote thing
+ end
+ end
+
+ class Base
+ attr_accessor :connection_pool
+
+ def initialize
+ @connection_pool = ConnectionPool.new
+ end
+
+ def connection
+ connection_pool.connection
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/table_test.rb b/activerecord/test/cases/arel/table_test.rb
new file mode 100644
index 0000000000..91b7a5a480
--- /dev/null
+++ b/activerecord/test/cases/arel/table_test.rb
@@ -0,0 +1,216 @@
+# frozen_string_literal: true
+
+require_relative "helper"
+
+module Arel
+ class TableTest < Arel::Spec
+ before do
+ @relation = Table.new(:users)
+ end
+
+ it "should create join nodes" do
+ join = @relation.create_string_join "foo"
+ assert_kind_of Arel::Nodes::StringJoin, join
+ assert_equal "foo", join.left
+ end
+
+ it "should create join nodes" do
+ join = @relation.create_join "foo", "bar"
+ assert_kind_of Arel::Nodes::InnerJoin, join
+ assert_equal "foo", join.left
+ assert_equal "bar", join.right
+ end
+
+ it "should create join nodes with a klass" do
+ join = @relation.create_join "foo", "bar", Arel::Nodes::FullOuterJoin
+ assert_kind_of Arel::Nodes::FullOuterJoin, join
+ assert_equal "foo", join.left
+ assert_equal "bar", join.right
+ end
+
+ it "should create join nodes with a klass" do
+ join = @relation.create_join "foo", "bar", Arel::Nodes::OuterJoin
+ assert_kind_of Arel::Nodes::OuterJoin, join
+ assert_equal "foo", join.left
+ assert_equal "bar", join.right
+ end
+
+ it "should create join nodes with a klass" do
+ join = @relation.create_join "foo", "bar", Arel::Nodes::RightOuterJoin
+ assert_kind_of Arel::Nodes::RightOuterJoin, join
+ assert_equal "foo", join.left
+ assert_equal "bar", join.right
+ end
+
+ it "should return an insert manager" do
+ im = @relation.compile_insert "VALUES(NULL)"
+ assert_kind_of Arel::InsertManager, im
+ im.into Table.new(:users)
+ assert_equal "INSERT INTO \"users\" VALUES(NULL)", im.to_sql
+ end
+
+ describe "skip" do
+ it "should add an offset" do
+ sm = @relation.skip 2
+ sm.to_sql.must_be_like "SELECT FROM \"users\" OFFSET 2"
+ end
+ end
+
+ describe "having" do
+ it "adds a having clause" do
+ mgr = @relation.having @relation[:id].eq(10)
+ mgr.to_sql.must_be_like %{
+ SELECT FROM "users" HAVING "users"."id" = 10
+ }
+ end
+ end
+
+ describe "backwards compat" do
+ describe "join" do
+ it "noops on nil" do
+ mgr = @relation.join nil
+
+ mgr.to_sql.must_be_like %{ SELECT FROM "users" }
+ end
+
+ it "raises EmptyJoinError on empty" do
+ assert_raises(EmptyJoinError) do
+ @relation.join ""
+ end
+ end
+
+ it "takes a second argument for join type" do
+ right = @relation.alias
+ predicate = @relation[:id].eq(right[:id])
+ mgr = @relation.join(right, Nodes::OuterJoin).on(predicate)
+
+ mgr.to_sql.must_be_like %{
+ SELECT FROM "users"
+ LEFT OUTER JOIN "users" "users_2"
+ ON "users"."id" = "users_2"."id"
+ }
+ end
+ end
+
+ describe "join" do
+ it "creates an outer join" do
+ right = @relation.alias
+ predicate = @relation[:id].eq(right[:id])
+ mgr = @relation.outer_join(right).on(predicate)
+
+ mgr.to_sql.must_be_like %{
+ SELECT FROM "users"
+ LEFT OUTER JOIN "users" "users_2"
+ ON "users"."id" = "users_2"."id"
+ }
+ end
+ end
+ end
+
+ describe "group" do
+ it "should create a group" do
+ manager = @relation.group @relation[:id]
+ manager.to_sql.must_be_like %{
+ SELECT FROM "users" GROUP BY "users"."id"
+ }
+ end
+ end
+
+ describe "alias" do
+ it "should create a node that proxies to a table" do
+ node = @relation.alias
+ node.name.must_equal "users_2"
+ node[:id].relation.must_equal node
+ end
+ end
+
+ describe "new" do
+ it "should accept a hash" do
+ rel = Table.new :users, as: "foo"
+ rel.table_alias.must_equal "foo"
+ end
+
+ it "ignores as if it equals name" do
+ rel = Table.new :users, as: "users"
+ rel.table_alias.must_be_nil
+ end
+ end
+
+ describe "order" do
+ it "should take an order" do
+ manager = @relation.order "foo"
+ manager.to_sql.must_be_like %{ SELECT FROM "users" ORDER BY foo }
+ end
+ end
+
+ describe "take" do
+ it "should add a limit" do
+ manager = @relation.take 1
+ manager.project Nodes::SqlLiteral.new "*"
+ manager.to_sql.must_be_like %{ SELECT * FROM "users" LIMIT 1 }
+ end
+ end
+
+ describe "project" do
+ it "can project" do
+ manager = @relation.project Nodes::SqlLiteral.new "*"
+ manager.to_sql.must_be_like %{ SELECT * FROM "users" }
+ end
+
+ it "takes multiple parameters" do
+ manager = @relation.project Nodes::SqlLiteral.new("*"), Nodes::SqlLiteral.new("*")
+ manager.to_sql.must_be_like %{ SELECT *, * FROM "users" }
+ end
+ end
+
+ describe "where" do
+ it "returns a tree manager" do
+ manager = @relation.where @relation[:id].eq 1
+ manager.project @relation[:id]
+ manager.must_be_kind_of TreeManager
+ manager.to_sql.must_be_like %{
+ SELECT "users"."id"
+ FROM "users"
+ WHERE "users"."id" = 1
+ }
+ end
+ end
+
+ it "should have a name" do
+ @relation.name.must_equal "users"
+ end
+
+ it "should have a table name" do
+ @relation.table_name.must_equal "users"
+ end
+
+ describe "[]" do
+ describe "when given a Symbol" do
+ it "manufactures an attribute if the symbol names an attribute within the relation" do
+ column = @relation[:id]
+ column.name.must_equal :id
+ end
+ end
+ end
+
+ describe "equality" do
+ it "is equal with equal ivars" do
+ relation1 = Table.new(:users)
+ relation1.table_alias = "zomg"
+ relation2 = Table.new(:users)
+ relation2.table_alias = "zomg"
+ array = [relation1, relation2]
+ assert_equal 1, array.uniq.size
+ end
+
+ it "is not equal with different ivars" do
+ relation1 = Table.new(:users)
+ relation1.table_alias = "zomg"
+ relation2 = Table.new(:users)
+ relation2.table_alias = "zomg2"
+ array = [relation1, relation2]
+ assert_equal 2, array.uniq.size
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/update_manager_test.rb b/activerecord/test/cases/arel/update_manager_test.rb
new file mode 100644
index 0000000000..cc1b9ac5b3
--- /dev/null
+++ b/activerecord/test/cases/arel/update_manager_test.rb
@@ -0,0 +1,126 @@
+# frozen_string_literal: true
+
+require_relative "helper"
+
+module Arel
+ class UpdateManagerTest < Arel::Spec
+ describe "new" do
+ it "takes an engine" do
+ Arel::UpdateManager.new
+ end
+ end
+
+ it "should not quote sql literals" do
+ table = Table.new(:users)
+ um = Arel::UpdateManager.new
+ um.table table
+ um.set [[table[:name], Arel::Nodes::BindParam.new(1)]]
+ um.to_sql.must_be_like %{ UPDATE "users" SET "name" = ? }
+ end
+
+ it "handles limit properly" do
+ table = Table.new(:users)
+ um = Arel::UpdateManager.new
+ um.key = "id"
+ um.take 10
+ um.table table
+ um.set [[table[:name], nil]]
+ assert_match(/LIMIT 10/, um.to_sql)
+ end
+
+ describe "set" do
+ it "updates with null" do
+ table = Table.new(:users)
+ um = Arel::UpdateManager.new
+ um.table table
+ um.set [[table[:name], nil]]
+ um.to_sql.must_be_like %{ UPDATE "users" SET "name" = NULL }
+ end
+
+ it "takes a string" do
+ table = Table.new(:users)
+ um = Arel::UpdateManager.new
+ um.table table
+ um.set Nodes::SqlLiteral.new "foo = bar"
+ um.to_sql.must_be_like %{ UPDATE "users" SET foo = bar }
+ end
+
+ it "takes a list of lists" do
+ table = Table.new(:users)
+ um = Arel::UpdateManager.new
+ um.table table
+ um.set [[table[:id], 1], [table[:name], "hello"]]
+ um.to_sql.must_be_like %{
+ UPDATE "users" SET "id" = 1, "name" = 'hello'
+ }
+ end
+
+ it "chains" do
+ table = Table.new(:users)
+ um = Arel::UpdateManager.new
+ um.set([[table[:id], 1], [table[:name], "hello"]]).must_equal um
+ end
+ end
+
+ describe "table" do
+ it "generates an update statement" do
+ um = Arel::UpdateManager.new
+ um.table Table.new(:users)
+ um.to_sql.must_be_like %{ UPDATE "users" }
+ end
+
+ it "chains" do
+ um = Arel::UpdateManager.new
+ um.table(Table.new(:users)).must_equal um
+ end
+
+ it "generates an update statement with joins" do
+ um = Arel::UpdateManager.new
+
+ table = Table.new(:users)
+ join_source = Arel::Nodes::JoinSource.new(
+ table,
+ [table.create_join(Table.new(:posts))]
+ )
+
+ um.table join_source
+ um.to_sql.must_be_like %{ UPDATE "users" INNER JOIN "posts" }
+ end
+ end
+
+ describe "where" do
+ it "generates a where clause" do
+ table = Table.new :users
+ um = Arel::UpdateManager.new
+ um.table table
+ um.where table[:id].eq(1)
+ um.to_sql.must_be_like %{
+ UPDATE "users" WHERE "users"."id" = 1
+ }
+ end
+
+ it "chains" do
+ table = Table.new :users
+ um = Arel::UpdateManager.new
+ um.table table
+ um.where(table[:id].eq(1)).must_equal um
+ end
+ end
+
+ describe "key" do
+ before do
+ @table = Table.new :users
+ @um = Arel::UpdateManager.new
+ @um.key = @table[:foo]
+ end
+
+ it "can be set" do
+ @um.ast.key.must_equal @table[:foo]
+ end
+
+ it "can be accessed" do
+ @um.key.must_equal @table[:foo]
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/visitors/depth_first_test.rb b/activerecord/test/cases/arel/visitors/depth_first_test.rb
new file mode 100644
index 0000000000..f94ad521d7
--- /dev/null
+++ b/activerecord/test/cases/arel/visitors/depth_first_test.rb
@@ -0,0 +1,270 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Visitors
+ class TestDepthFirst < Arel::Test
+ Collector = Struct.new(:calls) do
+ def call(object)
+ calls << object
+ end
+ end
+
+ def setup
+ @collector = Collector.new []
+ @visitor = Visitors::DepthFirst.new @collector
+ end
+
+ def test_raises_with_object
+ assert_raises(TypeError) do
+ @visitor.accept(Object.new)
+ end
+ end
+
+
+ # unary ops
+ [
+ Arel::Nodes::Not,
+ Arel::Nodes::Group,
+ Arel::Nodes::On,
+ Arel::Nodes::Grouping,
+ Arel::Nodes::Offset,
+ Arel::Nodes::Ordering,
+ Arel::Nodes::StringJoin,
+ Arel::Nodes::UnqualifiedColumn,
+ Arel::Nodes::Limit,
+ Arel::Nodes::Else,
+ ].each do |klass|
+ define_method("test_#{klass.name.gsub('::', '_')}") do
+ op = klass.new(:a)
+ @visitor.accept op
+ assert_equal [:a, op], @collector.calls
+ end
+ end
+
+ # functions
+ [
+ Arel::Nodes::Exists,
+ Arel::Nodes::Avg,
+ Arel::Nodes::Min,
+ Arel::Nodes::Max,
+ Arel::Nodes::Sum,
+ ].each do |klass|
+ define_method("test_#{klass.name.gsub('::', '_')}") do
+ func = klass.new(:a, "b")
+ @visitor.accept func
+ assert_equal [:a, "b", false, func], @collector.calls
+ end
+ end
+
+ def test_named_function
+ func = Arel::Nodes::NamedFunction.new(:a, :b, "c")
+ @visitor.accept func
+ assert_equal [:a, :b, false, "c", func], @collector.calls
+ end
+
+ def test_lock
+ lock = Nodes::Lock.new true
+ @visitor.accept lock
+ assert_equal [lock], @collector.calls
+ end
+
+ def test_count
+ count = Nodes::Count.new :a, :b, "c"
+ @visitor.accept count
+ assert_equal [:a, "c", :b, count], @collector.calls
+ end
+
+ def test_inner_join
+ join = Nodes::InnerJoin.new :a, :b
+ @visitor.accept join
+ assert_equal [:a, :b, join], @collector.calls
+ end
+
+ def test_full_outer_join
+ join = Nodes::FullOuterJoin.new :a, :b
+ @visitor.accept join
+ assert_equal [:a, :b, join], @collector.calls
+ end
+
+ def test_outer_join
+ join = Nodes::OuterJoin.new :a, :b
+ @visitor.accept join
+ assert_equal [:a, :b, join], @collector.calls
+ end
+
+ def test_right_outer_join
+ join = Nodes::RightOuterJoin.new :a, :b
+ @visitor.accept join
+ assert_equal [:a, :b, join], @collector.calls
+ end
+
+ [
+ Arel::Nodes::Assignment,
+ Arel::Nodes::Between,
+ Arel::Nodes::Concat,
+ Arel::Nodes::DoesNotMatch,
+ Arel::Nodes::Equality,
+ Arel::Nodes::GreaterThan,
+ Arel::Nodes::GreaterThanOrEqual,
+ Arel::Nodes::In,
+ Arel::Nodes::LessThan,
+ Arel::Nodes::LessThanOrEqual,
+ Arel::Nodes::Matches,
+ Arel::Nodes::NotEqual,
+ Arel::Nodes::NotIn,
+ Arel::Nodes::Or,
+ Arel::Nodes::TableAlias,
+ Arel::Nodes::Values,
+ Arel::Nodes::As,
+ Arel::Nodes::DeleteStatement,
+ Arel::Nodes::JoinSource,
+ Arel::Nodes::When,
+ ].each do |klass|
+ define_method("test_#{klass.name.gsub('::', '_')}") do
+ binary = klass.new(:a, :b)
+ @visitor.accept binary
+ assert_equal [:a, :b, binary], @collector.calls
+ end
+ end
+
+ def test_Arel_Nodes_InfixOperation
+ binary = Arel::Nodes::InfixOperation.new(:o, :a, :b)
+ @visitor.accept binary
+ assert_equal [:a, :b, binary], @collector.calls
+ end
+
+ # N-ary
+ [
+ Arel::Nodes::And,
+ ].each do |klass|
+ define_method("test_#{klass.name.gsub('::', '_')}") do
+ binary = klass.new([:a, :b, :c])
+ @visitor.accept binary
+ assert_equal [:a, :b, :c, binary], @collector.calls
+ end
+ end
+
+ [
+ Arel::Attributes::Integer,
+ Arel::Attributes::Float,
+ Arel::Attributes::String,
+ Arel::Attributes::Time,
+ Arel::Attributes::Boolean,
+ Arel::Attributes::Attribute
+ ].each do |klass|
+ define_method("test_#{klass.name.gsub('::', '_')}") do
+ binary = klass.new(:a, :b)
+ @visitor.accept binary
+ assert_equal [:a, :b, binary], @collector.calls
+ end
+ end
+
+ def test_table
+ relation = Arel::Table.new(:users)
+ @visitor.accept relation
+ assert_equal ["users", relation], @collector.calls
+ end
+
+ def test_array
+ node = Nodes::Or.new(:a, :b)
+ list = [node]
+ @visitor.accept list
+ assert_equal [:a, :b, node, list], @collector.calls
+ end
+
+ def test_set
+ node = Nodes::Or.new(:a, :b)
+ set = Set.new([node])
+ @visitor.accept set
+ assert_equal [:a, :b, node, set], @collector.calls
+ end
+
+ def test_hash
+ node = Nodes::Or.new(:a, :b)
+ hash = { node => node }
+ @visitor.accept hash
+ assert_equal [:a, :b, node, :a, :b, node, hash], @collector.calls
+ end
+
+ def test_update_statement
+ stmt = Nodes::UpdateStatement.new
+ stmt.relation = :a
+ stmt.values << :b
+ stmt.wheres << :c
+ stmt.orders << :d
+ stmt.limit = :e
+
+ @visitor.accept stmt
+ assert_equal [:a, :b, stmt.values, :c, stmt.wheres, :d, stmt.orders,
+ :e, stmt], @collector.calls
+ end
+
+ def test_select_core
+ core = Nodes::SelectCore.new
+ core.projections << :a
+ core.froms = :b
+ core.wheres << :c
+ core.groups << :d
+ core.windows << :e
+ core.havings << :f
+
+ @visitor.accept core
+ assert_equal [
+ :a, core.projections,
+ :b, [],
+ core.source,
+ :c, core.wheres,
+ :d, core.groups,
+ :e, core.windows,
+ :f, core.havings,
+ core], @collector.calls
+ end
+
+ def test_select_statement
+ ss = Nodes::SelectStatement.new
+ ss.cores.replace [:a]
+ ss.orders << :b
+ ss.limit = :c
+ ss.lock = :d
+ ss.offset = :e
+
+ @visitor.accept ss
+ assert_equal [
+ :a, ss.cores,
+ :b, ss.orders,
+ :c,
+ :d,
+ :e,
+ ss], @collector.calls
+ end
+
+ def test_insert_statement
+ stmt = Nodes::InsertStatement.new
+ stmt.relation = :a
+ stmt.columns << :b
+ stmt.values = :c
+
+ @visitor.accept stmt
+ assert_equal [:a, :b, stmt.columns, :c, stmt], @collector.calls
+ end
+
+ def test_case
+ node = Arel::Nodes::Case.new
+ node.case = :a
+ node.conditions << :b
+ node.default = :c
+
+ @visitor.accept node
+ assert_equal [:a, :b, node.conditions, :c, node], @collector.calls
+ end
+
+ def test_node
+ node = Nodes::Node.new
+ @visitor.accept node
+ assert_equal [node], @collector.calls
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/visitors/dispatch_contamination_test.rb b/activerecord/test/cases/arel/visitors/dispatch_contamination_test.rb
new file mode 100644
index 0000000000..a07a1a050a
--- /dev/null
+++ b/activerecord/test/cases/arel/visitors/dispatch_contamination_test.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+require "concurrent"
+
+module Arel
+ module Visitors
+ class DummyVisitor < Visitor
+ def initialize
+ super
+ @barrier = Concurrent::CyclicBarrier.new(2)
+ end
+
+ def visit_Arel_Visitors_DummySuperNode(node)
+ 42
+ end
+
+ # This is terrible, but it's the only way to reliably reproduce
+ # the possible race where two threads attempt to correct the
+ # dispatch hash at the same time.
+ def send(*args)
+ super
+ rescue
+ # Both threads try (and fail) to dispatch to the subclass's name
+ @barrier.wait
+ raise
+ ensure
+ # Then one thread successfully completes (updating the dispatch
+ # table in the process) before the other finishes raising its
+ # exception.
+ Thread.current[:delay].wait if Thread.current[:delay]
+ end
+ end
+
+ class DummySuperNode
+ end
+
+ class DummySubNode < DummySuperNode
+ end
+
+ class DispatchContaminationTest < Arel::Spec
+ before do
+ @connection = Table.engine.connection
+ @table = Table.new(:users)
+ end
+
+ it "dispatches properly after failing upwards" do
+ node = Nodes::Union.new(Nodes::True.new, Nodes::False.new)
+ assert_equal "( TRUE UNION FALSE )", node.to_sql
+
+ node.first # from Nodes::Node's Enumerable mixin
+
+ assert_equal "( TRUE UNION FALSE )", node.to_sql
+ end
+
+ it "is threadsafe when implementing superclass fallback" do
+ visitor = DummyVisitor.new
+ main_thread_finished = Concurrent::Event.new
+
+ racing_thread = Thread.new do
+ Thread.current[:delay] = main_thread_finished
+ visitor.accept DummySubNode.new
+ end
+
+ assert_equal 42, visitor.accept(DummySubNode.new)
+ main_thread_finished.set
+
+ assert_equal 42, racing_thread.value
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/visitors/dot_test.rb b/activerecord/test/cases/arel/visitors/dot_test.rb
new file mode 100644
index 0000000000..6b3c132f83
--- /dev/null
+++ b/activerecord/test/cases/arel/visitors/dot_test.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Visitors
+ class TestDot < Arel::Test
+ def setup
+ @visitor = Visitors::Dot.new
+ end
+
+ # functions
+ [
+ Nodes::Sum,
+ Nodes::Exists,
+ Nodes::Max,
+ Nodes::Min,
+ Nodes::Avg,
+ ].each do |klass|
+ define_method("test_#{klass.name.gsub('::', '_')}") do
+ op = klass.new(:a, "z")
+ @visitor.accept op, Collectors::PlainString.new
+ end
+ end
+
+ def test_named_function
+ func = Nodes::NamedFunction.new "omg", "omg"
+ @visitor.accept func, Collectors::PlainString.new
+ end
+
+ # unary ops
+ [
+ Arel::Nodes::Not,
+ Arel::Nodes::Group,
+ Arel::Nodes::On,
+ Arel::Nodes::Grouping,
+ Arel::Nodes::Offset,
+ Arel::Nodes::Ordering,
+ Arel::Nodes::UnqualifiedColumn,
+ Arel::Nodes::Limit,
+ ].each do |klass|
+ define_method("test_#{klass.name.gsub('::', '_')}") do
+ op = klass.new(:a)
+ @visitor.accept op, Collectors::PlainString.new
+ end
+ end
+
+ # binary ops
+ [
+ Arel::Nodes::Assignment,
+ Arel::Nodes::Between,
+ Arel::Nodes::DoesNotMatch,
+ Arel::Nodes::Equality,
+ Arel::Nodes::GreaterThan,
+ Arel::Nodes::GreaterThanOrEqual,
+ Arel::Nodes::In,
+ Arel::Nodes::LessThan,
+ Arel::Nodes::LessThanOrEqual,
+ Arel::Nodes::Matches,
+ Arel::Nodes::NotEqual,
+ Arel::Nodes::NotIn,
+ Arel::Nodes::Or,
+ Arel::Nodes::TableAlias,
+ Arel::Nodes::Values,
+ Arel::Nodes::As,
+ Arel::Nodes::DeleteStatement,
+ Arel::Nodes::JoinSource,
+ Arel::Nodes::Casted,
+ ].each do |klass|
+ define_method("test_#{klass.name.gsub('::', '_')}") do
+ binary = klass.new(:a, :b)
+ @visitor.accept binary, Collectors::PlainString.new
+ end
+ end
+
+ def test_Arel_Nodes_BindParam
+ node = Arel::Nodes::BindParam.new(1)
+ collector = Collectors::PlainString.new
+ assert_match '[label="<f0>Arel::Nodes::BindParam"]', @visitor.accept(node, collector).value
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/visitors/ibm_db_test.rb b/activerecord/test/cases/arel/visitors/ibm_db_test.rb
new file mode 100644
index 0000000000..2ddbec3266
--- /dev/null
+++ b/activerecord/test/cases/arel/visitors/ibm_db_test.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Visitors
+ class IbmDbTest < Arel::Spec
+ before do
+ @visitor = IBM_DB.new Table.engine.connection
+ end
+
+ def compile(node)
+ @visitor.accept(node, Collectors::SQLString.new).value
+ end
+
+ it "uses FETCH FIRST n ROWS to limit results" do
+ stmt = Nodes::SelectStatement.new
+ stmt.limit = Nodes::Limit.new(1)
+ sql = compile(stmt)
+ sql.must_be_like "SELECT FETCH FIRST 1 ROWS ONLY"
+ end
+
+ it "uses FETCH FIRST n ROWS in updates with a limit" do
+ table = Table.new(:users)
+ stmt = Nodes::UpdateStatement.new
+ stmt.relation = table
+ stmt.limit = Nodes::Limit.new(Nodes.build_quoted(1))
+ stmt.key = table[:id]
+ sql = compile(stmt)
+ sql.must_be_like "UPDATE \"users\" WHERE \"users\".\"id\" IN (SELECT \"users\".\"id\" FROM \"users\" FETCH FIRST 1 ROWS ONLY)"
+ end
+
+ describe "Nodes::IsNotDistinctFrom" do
+ it "should construct a valid generic SQL statement" do
+ test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson"
+ compile(test).must_be_like %{
+ DECODE("users"."name", 'Aaron Patterson', 0, 1) = 0
+ }
+ end
+
+ it "should handle column names on both sides" do
+ test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name]
+ compile(test).must_be_like %{
+ DECODE("users"."first_name", "users"."last_name", 0, 1) = 0
+ }
+ end
+
+ it "should handle nil" do
+ @table = Table.new(:users)
+ val = Nodes.build_quoted(nil, @table[:active])
+ sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val)
+ sql.must_be_like %{ "users"."name" IS NULL }
+ end
+ end
+
+ describe "Nodes::IsDistinctFrom" do
+ it "should handle column names on both sides" do
+ test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name]
+ compile(test).must_be_like %{
+ DECODE("users"."first_name", "users"."last_name", 0, 1) = 1
+ }
+ end
+
+ it "should handle nil" do
+ @table = Table.new(:users)
+ val = Nodes.build_quoted(nil, @table[:active])
+ sql = compile Nodes::IsDistinctFrom.new(@table[:name], val)
+ sql.must_be_like %{ "users"."name" IS NOT NULL }
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/visitors/informix_test.rb b/activerecord/test/cases/arel/visitors/informix_test.rb
new file mode 100644
index 0000000000..b6c2dd6ae7
--- /dev/null
+++ b/activerecord/test/cases/arel/visitors/informix_test.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Visitors
+ class InformixTest < Arel::Spec
+ before do
+ @visitor = Informix.new Table.engine.connection
+ end
+
+ def compile(node)
+ @visitor.accept(node, Collectors::SQLString.new).value
+ end
+
+ it "uses FIRST n to limit results" do
+ stmt = Nodes::SelectStatement.new
+ stmt.limit = Nodes::Limit.new(1)
+ sql = compile(stmt)
+ sql.must_be_like "SELECT FIRST 1"
+ end
+
+ it "uses FIRST n in updates with a limit" do
+ table = Table.new(:users)
+ stmt = Nodes::UpdateStatement.new
+ stmt.relation = table
+ stmt.limit = Nodes::Limit.new(Nodes.build_quoted(1))
+ stmt.key = table[:id]
+ sql = compile(stmt)
+ sql.must_be_like "UPDATE \"users\" WHERE \"users\".\"id\" IN (SELECT FIRST 1 \"users\".\"id\" FROM \"users\")"
+ end
+
+ it "uses SKIP n to jump results" do
+ stmt = Nodes::SelectStatement.new
+ stmt.offset = Nodes::Offset.new(10)
+ sql = compile(stmt)
+ sql.must_be_like "SELECT SKIP 10"
+ end
+
+ it "uses SKIP before FIRST" do
+ stmt = Nodes::SelectStatement.new
+ stmt.limit = Nodes::Limit.new(1)
+ stmt.offset = Nodes::Offset.new(1)
+ sql = compile(stmt)
+ sql.must_be_like "SELECT SKIP 1 FIRST 1"
+ end
+
+ it "uses INNER JOIN to perform joins" do
+ core = Nodes::SelectCore.new
+ table = Table.new(:posts)
+ core.source = Nodes::JoinSource.new(table, [table.create_join(Table.new(:comments))])
+
+ stmt = Nodes::SelectStatement.new([core])
+ sql = compile(stmt)
+ sql.must_be_like 'SELECT FROM "posts" INNER JOIN "comments"'
+ end
+
+ describe "Nodes::IsNotDistinctFrom" do
+ it "should construct a valid generic SQL statement" do
+ test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson"
+ compile(test).must_be_like %{
+ CASE WHEN "users"."name" = 'Aaron Patterson' OR ("users"."name" IS NULL AND 'Aaron Patterson' IS NULL) THEN 0 ELSE 1 END = 0
+ }
+ end
+
+ it "should handle column names on both sides" do
+ test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name]
+ compile(test).must_be_like %{
+ CASE WHEN "users"."first_name" = "users"."last_name" OR ("users"."first_name" IS NULL AND "users"."last_name" IS NULL) THEN 0 ELSE 1 END = 0
+ }
+ end
+
+ it "should handle nil" do
+ @table = Table.new(:users)
+ val = Nodes.build_quoted(nil, @table[:active])
+ sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val)
+ sql.must_be_like %{ "users"."name" IS NULL }
+ end
+ end
+
+ describe "Nodes::IsDistinctFrom" do
+ it "should handle column names on both sides" do
+ test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name]
+ compile(test).must_be_like %{
+ CASE WHEN "users"."first_name" = "users"."last_name" OR ("users"."first_name" IS NULL AND "users"."last_name" IS NULL) THEN 0 ELSE 1 END = 1
+ }
+ end
+
+ it "should handle nil" do
+ @table = Table.new(:users)
+ val = Nodes.build_quoted(nil, @table[:active])
+ sql = compile Nodes::IsDistinctFrom.new(@table[:name], val)
+ sql.must_be_like %{ "users"."name" IS NOT NULL }
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/visitors/mssql_test.rb b/activerecord/test/cases/arel/visitors/mssql_test.rb
new file mode 100644
index 0000000000..74f34b4dad
--- /dev/null
+++ b/activerecord/test/cases/arel/visitors/mssql_test.rb
@@ -0,0 +1,138 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Visitors
+ class MssqlTest < Arel::Spec
+ before do
+ @visitor = MSSQL.new Table.engine.connection
+ @table = Arel::Table.new "users"
+ end
+
+ def compile(node)
+ @visitor.accept(node, Collectors::SQLString.new).value
+ end
+
+ it "should not modify query if no offset or limit" do
+ stmt = Nodes::SelectStatement.new
+ sql = compile(stmt)
+ sql.must_be_like "SELECT"
+ end
+
+ it "should go over table PK if no .order() or .group()" do
+ stmt = Nodes::SelectStatement.new
+ stmt.cores.first.from = @table
+ stmt.limit = Nodes::Limit.new(10)
+ sql = compile(stmt)
+ sql.must_be_like "SELECT _t.* FROM (SELECT ROW_NUMBER() OVER (ORDER BY \"users\".\"id\") as _row_num FROM \"users\") as _t WHERE _row_num BETWEEN 1 AND 10"
+ end
+
+ it "caches the PK lookup for order" do
+ connection = Minitest::Mock.new
+ connection.expect(:primary_key, ["id"], ["users"])
+
+ # We don't care how many times these methods are called
+ def connection.quote_table_name(*); ""; end
+ def connection.quote_column_name(*); ""; end
+
+ @visitor = MSSQL.new(connection)
+ stmt = Nodes::SelectStatement.new
+ stmt.cores.first.from = @table
+ stmt.limit = Nodes::Limit.new(10)
+
+ compile(stmt)
+ compile(stmt)
+
+ connection.verify
+ end
+
+ it "should use TOP for limited deletes" do
+ stmt = Nodes::DeleteStatement.new
+ stmt.relation = @table
+ stmt.limit = Nodes::Limit.new(10)
+ sql = compile(stmt)
+
+ sql.must_be_like "DELETE TOP (10) FROM \"users\""
+ end
+
+ it "should go over query ORDER BY if .order()" do
+ stmt = Nodes::SelectStatement.new
+ stmt.limit = Nodes::Limit.new(10)
+ stmt.orders << Nodes::SqlLiteral.new("order_by")
+ sql = compile(stmt)
+ sql.must_be_like "SELECT _t.* FROM (SELECT ROW_NUMBER() OVER (ORDER BY order_by) as _row_num) as _t WHERE _row_num BETWEEN 1 AND 10"
+ end
+
+ it "should go over query GROUP BY if no .order() and there is .group()" do
+ stmt = Nodes::SelectStatement.new
+ stmt.cores.first.groups << Nodes::SqlLiteral.new("group_by")
+ stmt.limit = Nodes::Limit.new(10)
+ sql = compile(stmt)
+ sql.must_be_like "SELECT _t.* FROM (SELECT ROW_NUMBER() OVER (ORDER BY group_by) as _row_num GROUP BY group_by) as _t WHERE _row_num BETWEEN 1 AND 10"
+ end
+
+ it "should use BETWEEN if both .limit() and .offset" do
+ stmt = Nodes::SelectStatement.new
+ stmt.limit = Nodes::Limit.new(10)
+ stmt.offset = Nodes::Offset.new(20)
+ sql = compile(stmt)
+ sql.must_be_like "SELECT _t.* FROM (SELECT ROW_NUMBER() OVER (ORDER BY ) as _row_num) as _t WHERE _row_num BETWEEN 21 AND 30"
+ end
+
+ it "should use >= if only .offset" do
+ stmt = Nodes::SelectStatement.new
+ stmt.offset = Nodes::Offset.new(20)
+ sql = compile(stmt)
+ sql.must_be_like "SELECT _t.* FROM (SELECT ROW_NUMBER() OVER (ORDER BY ) as _row_num) as _t WHERE _row_num >= 21"
+ end
+
+ it "should generate subquery for .count" do
+ stmt = Nodes::SelectStatement.new
+ stmt.limit = Nodes::Limit.new(10)
+ stmt.cores.first.projections << Nodes::Count.new("*")
+ sql = compile(stmt)
+ sql.must_be_like "SELECT COUNT(1) as count_id FROM (SELECT _t.* FROM (SELECT ROW_NUMBER() OVER (ORDER BY ) as _row_num) as _t WHERE _row_num BETWEEN 1 AND 10) AS subquery"
+ end
+
+ describe "Nodes::IsNotDistinctFrom" do
+ it "should construct a valid generic SQL statement" do
+ test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson"
+ compile(test).must_be_like %{
+ EXISTS (VALUES ("users"."name") INTERSECT VALUES ('Aaron Patterson'))
+ }
+ end
+
+ it "should handle column names on both sides" do
+ test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name]
+ compile(test).must_be_like %{
+ EXISTS (VALUES ("users"."first_name") INTERSECT VALUES ("users"."last_name"))
+ }
+ end
+
+ it "should handle nil" do
+ @table = Table.new(:users)
+ val = Nodes.build_quoted(nil, @table[:active])
+ sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val)
+ sql.must_be_like %{ "users"."name" IS NULL }
+ end
+ end
+
+ describe "Nodes::IsDistinctFrom" do
+ it "should handle column names on both sides" do
+ test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name]
+ compile(test).must_be_like %{
+ NOT EXISTS (VALUES ("users"."first_name") INTERSECT VALUES ("users"."last_name"))
+ }
+ end
+
+ it "should handle nil" do
+ @table = Table.new(:users)
+ val = Nodes.build_quoted(nil, @table[:active])
+ sql = compile Nodes::IsDistinctFrom.new(@table[:name], val)
+ sql.must_be_like %{ "users"."name" IS NOT NULL }
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/visitors/mysql_test.rb b/activerecord/test/cases/arel/visitors/mysql_test.rb
new file mode 100644
index 0000000000..5f37587957
--- /dev/null
+++ b/activerecord/test/cases/arel/visitors/mysql_test.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Visitors
+ class MysqlTest < Arel::Spec
+ before do
+ @visitor = MySQL.new Table.engine.connection
+ end
+
+ def compile(node)
+ @visitor.accept(node, Collectors::SQLString.new).value
+ end
+
+ ###
+ # :'(
+ # http://dev.mysql.com/doc/refman/5.0/en/select.html#id3482214
+ it "defaults limit to 18446744073709551615" do
+ stmt = Nodes::SelectStatement.new
+ stmt.offset = Nodes::Offset.new(1)
+ sql = compile(stmt)
+ sql.must_be_like "SELECT FROM DUAL LIMIT 18446744073709551615 OFFSET 1"
+ end
+
+ it "should escape LIMIT" do
+ sc = Arel::Nodes::UpdateStatement.new
+ sc.relation = Table.new(:users)
+ sc.limit = Nodes::Limit.new(Nodes.build_quoted("omg"))
+ assert_equal("UPDATE \"users\" LIMIT 'omg'", compile(sc))
+ end
+
+ it "uses DUAL for empty from" do
+ stmt = Nodes::SelectStatement.new
+ sql = compile(stmt)
+ sql.must_be_like "SELECT FROM DUAL"
+ end
+
+ describe "locking" do
+ it "defaults to FOR UPDATE when locking" do
+ node = Nodes::Lock.new(Arel.sql("FOR UPDATE"))
+ compile(node).must_be_like "FOR UPDATE"
+ end
+
+ it "allows a custom string to be used as a lock" do
+ node = Nodes::Lock.new(Arel.sql("LOCK IN SHARE MODE"))
+ compile(node).must_be_like "LOCK IN SHARE MODE"
+ end
+ end
+
+ describe "concat" do
+ it "concats columns" do
+ @table = Table.new(:users)
+ query = @table[:name].concat(@table[:name])
+ compile(query).must_be_like %{
+ CONCAT("users"."name", "users"."name")
+ }
+ end
+
+ it "concats a string" do
+ @table = Table.new(:users)
+ query = @table[:name].concat(Nodes.build_quoted("abc"))
+ compile(query).must_be_like %{
+ CONCAT("users"."name", 'abc')
+ }
+ end
+ end
+
+ describe "Nodes::IsNotDistinctFrom" do
+ it "should construct a valid generic SQL statement" do
+ test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson"
+ compile(test).must_be_like %{
+ "users"."name" <=> 'Aaron Patterson'
+ }
+ end
+
+ it "should handle column names on both sides" do
+ test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name]
+ compile(test).must_be_like %{
+ "users"."first_name" <=> "users"."last_name"
+ }
+ end
+
+ it "should handle nil" do
+ @table = Table.new(:users)
+ val = Nodes.build_quoted(nil, @table[:active])
+ sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val)
+ sql.must_be_like %{ "users"."name" <=> NULL }
+ end
+ end
+
+ describe "Nodes::IsDistinctFrom" do
+ it "should handle column names on both sides" do
+ test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name]
+ compile(test).must_be_like %{
+ NOT "users"."first_name" <=> "users"."last_name"
+ }
+ end
+
+ it "should handle nil" do
+ @table = Table.new(:users)
+ val = Nodes.build_quoted(nil, @table[:active])
+ sql = compile Nodes::IsDistinctFrom.new(@table[:name], val)
+ sql.must_be_like %{ NOT "users"."name" <=> NULL }
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/visitors/oracle12_test.rb b/activerecord/test/cases/arel/visitors/oracle12_test.rb
new file mode 100644
index 0000000000..4ce5cab4db
--- /dev/null
+++ b/activerecord/test/cases/arel/visitors/oracle12_test.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Visitors
+ class Oracle12Test < Arel::Spec
+ before do
+ @visitor = Oracle12.new Table.engine.connection
+ @table = Table.new(:users)
+ end
+
+ def compile(node)
+ @visitor.accept(node, Collectors::SQLString.new).value
+ end
+
+ it "modified except to be minus" do
+ left = Nodes::SqlLiteral.new("SELECT * FROM users WHERE age > 10")
+ right = Nodes::SqlLiteral.new("SELECT * FROM users WHERE age > 20")
+ sql = compile Nodes::Except.new(left, right)
+ sql.must_be_like %{
+ ( SELECT * FROM users WHERE age > 10 MINUS SELECT * FROM users WHERE age > 20 )
+ }
+ end
+
+ it "generates select options offset then limit" do
+ stmt = Nodes::SelectStatement.new
+ stmt.offset = Nodes::Offset.new(1)
+ stmt.limit = Nodes::Limit.new(10)
+ sql = compile(stmt)
+ sql.must_be_like "SELECT OFFSET 1 ROWS FETCH FIRST 10 ROWS ONLY"
+ end
+
+ describe "locking" do
+ it "generates ArgumentError if limit and lock are used" do
+ stmt = Nodes::SelectStatement.new
+ stmt.limit = Nodes::Limit.new(10)
+ stmt.lock = Nodes::Lock.new(Arel.sql("FOR UPDATE"))
+ assert_raises ArgumentError do
+ compile(stmt)
+ end
+ end
+
+ it "defaults to FOR UPDATE when locking" do
+ node = Nodes::Lock.new(Arel.sql("FOR UPDATE"))
+ compile(node).must_be_like "FOR UPDATE"
+ end
+ end
+
+ describe "Nodes::BindParam" do
+ it "increments each bind param" do
+ query = @table[:name].eq(Arel::Nodes::BindParam.new(1))
+ .and(@table[:id].eq(Arel::Nodes::BindParam.new(1)))
+ compile(query).must_be_like %{
+ "users"."name" = :a1 AND "users"."id" = :a2
+ }
+ end
+ end
+
+ describe "Nodes::IsNotDistinctFrom" do
+ it "should construct a valid generic SQL statement" do
+ test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson"
+ compile(test).must_be_like %{
+ DECODE("users"."name", 'Aaron Patterson', 0, 1) = 0
+ }
+ end
+
+ it "should handle column names on both sides" do
+ test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name]
+ compile(test).must_be_like %{
+ DECODE("users"."first_name", "users"."last_name", 0, 1) = 0
+ }
+ end
+
+ it "should handle nil" do
+ @table = Table.new(:users)
+ val = Nodes.build_quoted(nil, @table[:active])
+ sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val)
+ sql.must_be_like %{ "users"."name" IS NULL }
+ end
+ end
+
+ describe "Nodes::IsDistinctFrom" do
+ it "should handle column names on both sides" do
+ test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name]
+ compile(test).must_be_like %{
+ DECODE("users"."first_name", "users"."last_name", 0, 1) = 1
+ }
+ end
+
+ it "should handle nil" do
+ @table = Table.new(:users)
+ val = Nodes.build_quoted(nil, @table[:active])
+ sql = compile Nodes::IsDistinctFrom.new(@table[:name], val)
+ sql.must_be_like %{ "users"."name" IS NOT NULL }
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/visitors/oracle_test.rb b/activerecord/test/cases/arel/visitors/oracle_test.rb
new file mode 100644
index 0000000000..893edc7f74
--- /dev/null
+++ b/activerecord/test/cases/arel/visitors/oracle_test.rb
@@ -0,0 +1,236 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Visitors
+ class OracleTest < Arel::Spec
+ before do
+ @visitor = Oracle.new Table.engine.connection
+ @table = Table.new(:users)
+ end
+
+ def compile(node)
+ @visitor.accept(node, Collectors::SQLString.new).value
+ end
+
+ it "modifies order when there is distinct and first value" do
+ # *sigh*
+ select = "DISTINCT foo.id, FIRST_VALUE(projects.name) OVER (foo) AS alias_0__"
+ stmt = Nodes::SelectStatement.new
+ stmt.cores.first.projections << Nodes::SqlLiteral.new(select)
+ stmt.orders << Nodes::SqlLiteral.new("foo")
+ sql = compile(stmt)
+ sql.must_be_like %{
+ SELECT #{select} ORDER BY alias_0__
+ }
+ end
+
+ it "is idempotent with crazy query" do
+ # *sigh*
+ select = "DISTINCT foo.id, FIRST_VALUE(projects.name) OVER (foo) AS alias_0__"
+ stmt = Nodes::SelectStatement.new
+ stmt.cores.first.projections << Nodes::SqlLiteral.new(select)
+ stmt.orders << Nodes::SqlLiteral.new("foo")
+
+ sql = compile(stmt)
+ sql2 = compile(stmt)
+ sql.must_equal sql2
+ end
+
+ it "splits orders with commas" do
+ # *sigh*
+ select = "DISTINCT foo.id, FIRST_VALUE(projects.name) OVER (foo) AS alias_0__"
+ stmt = Nodes::SelectStatement.new
+ stmt.cores.first.projections << Nodes::SqlLiteral.new(select)
+ stmt.orders << Nodes::SqlLiteral.new("foo, bar")
+ sql = compile(stmt)
+ sql.must_be_like %{
+ SELECT #{select} ORDER BY alias_0__, alias_1__
+ }
+ end
+
+ it "splits orders with commas and function calls" do
+ # *sigh*
+ select = "DISTINCT foo.id, FIRST_VALUE(projects.name) OVER (foo) AS alias_0__"
+ stmt = Nodes::SelectStatement.new
+ stmt.cores.first.projections << Nodes::SqlLiteral.new(select)
+ stmt.orders << Nodes::SqlLiteral.new("NVL(LOWER(bar, foo), foo) DESC, UPPER(baz)")
+ sql = compile(stmt)
+ sql.must_be_like %{
+ SELECT #{select} ORDER BY alias_0__ DESC, alias_1__
+ }
+ end
+
+ describe "Nodes::SelectStatement" do
+ describe "limit" do
+ it "adds a rownum clause" do
+ stmt = Nodes::SelectStatement.new
+ stmt.limit = Nodes::Limit.new(10)
+ sql = compile stmt
+ sql.must_be_like %{ SELECT WHERE ROWNUM <= 10 }
+ end
+
+ it "is idempotent" do
+ stmt = Nodes::SelectStatement.new
+ stmt.orders << Nodes::SqlLiteral.new("foo")
+ stmt.limit = Nodes::Limit.new(10)
+ sql = compile stmt
+ sql2 = compile stmt
+ sql.must_equal sql2
+ end
+
+ it "creates a subquery when there is order_by" do
+ stmt = Nodes::SelectStatement.new
+ stmt.orders << Nodes::SqlLiteral.new("foo")
+ stmt.limit = Nodes::Limit.new(10)
+ sql = compile stmt
+ sql.must_be_like %{
+ SELECT * FROM (SELECT ORDER BY foo ) WHERE ROWNUM <= 10
+ }
+ end
+
+ it "creates a subquery when there is group by" do
+ stmt = Nodes::SelectStatement.new
+ stmt.cores.first.groups << Nodes::SqlLiteral.new("foo")
+ stmt.limit = Nodes::Limit.new(10)
+ sql = compile stmt
+ sql.must_be_like %{
+ SELECT * FROM (SELECT GROUP BY foo ) WHERE ROWNUM <= 10
+ }
+ end
+
+ it "creates a subquery when there is DISTINCT" do
+ stmt = Nodes::SelectStatement.new
+ stmt.cores.first.set_quantifier = Arel::Nodes::Distinct.new
+ stmt.cores.first.projections << Nodes::SqlLiteral.new("id")
+ stmt.limit = Arel::Nodes::Limit.new(10)
+ sql = compile stmt
+ sql.must_be_like %{
+ SELECT * FROM (SELECT DISTINCT id ) WHERE ROWNUM <= 10
+ }
+ end
+
+ it "creates a different subquery when there is an offset" do
+ stmt = Nodes::SelectStatement.new
+ stmt.limit = Nodes::Limit.new(10)
+ stmt.offset = Nodes::Offset.new(10)
+ sql = compile stmt
+ sql.must_be_like %{
+ SELECT * FROM (
+ SELECT raw_sql_.*, rownum raw_rnum_
+ FROM (SELECT ) raw_sql_
+ WHERE rownum <= 20
+ )
+ WHERE raw_rnum_ > 10
+ }
+ end
+
+ it "creates a subquery when there is limit and offset with BindParams" do
+ stmt = Nodes::SelectStatement.new
+ stmt.limit = Nodes::Limit.new(Nodes::BindParam.new(1))
+ stmt.offset = Nodes::Offset.new(Nodes::BindParam.new(1))
+ sql = compile stmt
+ sql.must_be_like %{
+ SELECT * FROM (
+ SELECT raw_sql_.*, rownum raw_rnum_
+ FROM (SELECT ) raw_sql_
+ WHERE rownum <= (:a1 + :a2)
+ )
+ WHERE raw_rnum_ > :a3
+ }
+ end
+
+ it "is idempotent with different subquery" do
+ stmt = Nodes::SelectStatement.new
+ stmt.limit = Nodes::Limit.new(10)
+ stmt.offset = Nodes::Offset.new(10)
+ sql = compile stmt
+ sql2 = compile stmt
+ sql.must_equal sql2
+ end
+ end
+
+ describe "only offset" do
+ it "creates a select from subquery with rownum condition" do
+ stmt = Nodes::SelectStatement.new
+ stmt.offset = Nodes::Offset.new(10)
+ sql = compile stmt
+ sql.must_be_like %{
+ SELECT * FROM (
+ SELECT raw_sql_.*, rownum raw_rnum_
+ FROM (SELECT) raw_sql_
+ )
+ WHERE raw_rnum_ > 10
+ }
+ end
+ end
+ end
+
+ it "modified except to be minus" do
+ left = Nodes::SqlLiteral.new("SELECT * FROM users WHERE age > 10")
+ right = Nodes::SqlLiteral.new("SELECT * FROM users WHERE age > 20")
+ sql = compile Nodes::Except.new(left, right)
+ sql.must_be_like %{
+ ( SELECT * FROM users WHERE age > 10 MINUS SELECT * FROM users WHERE age > 20 )
+ }
+ end
+
+ describe "locking" do
+ it "defaults to FOR UPDATE when locking" do
+ node = Nodes::Lock.new(Arel.sql("FOR UPDATE"))
+ compile(node).must_be_like "FOR UPDATE"
+ end
+ end
+
+ describe "Nodes::BindParam" do
+ it "increments each bind param" do
+ query = @table[:name].eq(Arel::Nodes::BindParam.new(1))
+ .and(@table[:id].eq(Arel::Nodes::BindParam.new(1)))
+ compile(query).must_be_like %{
+ "users"."name" = :a1 AND "users"."id" = :a2
+ }
+ end
+ end
+
+ describe "Nodes::IsNotDistinctFrom" do
+ it "should construct a valid generic SQL statement" do
+ test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson"
+ compile(test).must_be_like %{
+ DECODE("users"."name", 'Aaron Patterson', 0, 1) = 0
+ }
+ end
+
+ it "should handle column names on both sides" do
+ test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name]
+ compile(test).must_be_like %{
+ DECODE("users"."first_name", "users"."last_name", 0, 1) = 0
+ }
+ end
+
+ it "should handle nil" do
+ @table = Table.new(:users)
+ val = Nodes.build_quoted(nil, @table[:active])
+ sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val)
+ sql.must_be_like %{ "users"."name" IS NULL }
+ end
+ end
+
+ describe "Nodes::IsDistinctFrom" do
+ it "should handle column names on both sides" do
+ test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name]
+ compile(test).must_be_like %{
+ DECODE("users"."first_name", "users"."last_name", 0, 1) = 1
+ }
+ end
+
+ it "should handle nil" do
+ @table = Table.new(:users)
+ val = Nodes.build_quoted(nil, @table[:active])
+ sql = compile Nodes::IsDistinctFrom.new(@table[:name], val)
+ sql.must_be_like %{ "users"."name" IS NOT NULL }
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/visitors/postgres_test.rb b/activerecord/test/cases/arel/visitors/postgres_test.rb
new file mode 100644
index 0000000000..0f9efb20b4
--- /dev/null
+++ b/activerecord/test/cases/arel/visitors/postgres_test.rb
@@ -0,0 +1,320 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Visitors
+ class PostgresTest < Arel::Spec
+ before do
+ @visitor = PostgreSQL.new Table.engine.connection
+ @table = Table.new(:users)
+ @attr = @table[:id]
+ end
+
+ def compile(node)
+ @visitor.accept(node, Collectors::SQLString.new).value
+ end
+
+ describe "locking" do
+ it "defaults to FOR UPDATE" do
+ compile(Nodes::Lock.new(Arel.sql("FOR UPDATE"))).must_be_like %{
+ FOR UPDATE
+ }
+ end
+
+ it "allows a custom string to be used as a lock" do
+ node = Nodes::Lock.new(Arel.sql("FOR SHARE"))
+ compile(node).must_be_like %{
+ FOR SHARE
+ }
+ end
+ end
+
+ it "should escape LIMIT" do
+ sc = Arel::Nodes::SelectStatement.new
+ sc.limit = Nodes::Limit.new(Nodes.build_quoted("omg"))
+ sc.cores.first.projections << Arel.sql("DISTINCT ON")
+ sc.orders << Arel.sql("xyz")
+ sql = compile(sc)
+ assert_match(/LIMIT 'omg'/, sql)
+ assert_equal 1, sql.scan(/LIMIT/).length, "should have one limit"
+ end
+
+ it "should support DISTINCT ON" do
+ core = Arel::Nodes::SelectCore.new
+ core.set_quantifier = Arel::Nodes::DistinctOn.new(Arel.sql("aaron"))
+ assert_match "DISTINCT ON ( aaron )", compile(core)
+ end
+
+ it "should support DISTINCT" do
+ core = Arel::Nodes::SelectCore.new
+ core.set_quantifier = Arel::Nodes::Distinct.new
+ assert_equal "SELECT DISTINCT", compile(core)
+ end
+
+ it "encloses LATERAL queries in parens" do
+ subquery = @table.project(:id).where(@table[:name].matches("foo%"))
+ compile(subquery.lateral).must_be_like %{
+ LATERAL (SELECT id FROM "users" WHERE "users"."name" ILIKE 'foo%')
+ }
+ end
+
+ it "produces LATERAL queries with alias" do
+ subquery = @table.project(:id).where(@table[:name].matches("foo%"))
+ compile(subquery.lateral("bar")).must_be_like %{
+ LATERAL (SELECT id FROM "users" WHERE "users"."name" ILIKE 'foo%') bar
+ }
+ end
+
+ describe "Nodes::Matches" do
+ it "should know how to visit" do
+ node = @table[:name].matches("foo%")
+ node.must_be_kind_of Nodes::Matches
+ node.case_sensitive.must_equal(false)
+ compile(node).must_be_like %{
+ "users"."name" ILIKE 'foo%'
+ }
+ end
+
+ it "should know how to visit case sensitive" do
+ node = @table[:name].matches("foo%", nil, true)
+ node.case_sensitive.must_equal(true)
+ compile(node).must_be_like %{
+ "users"."name" LIKE 'foo%'
+ }
+ end
+
+ it "can handle ESCAPE" do
+ node = @table[:name].matches("foo!%", "!")
+ compile(node).must_be_like %{
+ "users"."name" ILIKE 'foo!%' ESCAPE '!'
+ }
+ end
+
+ it "can handle subqueries" do
+ subquery = @table.project(:id).where(@table[:name].matches("foo%"))
+ node = @attr.in subquery
+ compile(node).must_be_like %{
+ "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" ILIKE 'foo%')
+ }
+ end
+ end
+
+ describe "Nodes::DoesNotMatch" do
+ it "should know how to visit" do
+ node = @table[:name].does_not_match("foo%")
+ node.must_be_kind_of Nodes::DoesNotMatch
+ node.case_sensitive.must_equal(false)
+ compile(node).must_be_like %{
+ "users"."name" NOT ILIKE 'foo%'
+ }
+ end
+
+ it "should know how to visit case sensitive" do
+ node = @table[:name].does_not_match("foo%", nil, true)
+ node.case_sensitive.must_equal(true)
+ compile(node).must_be_like %{
+ "users"."name" NOT LIKE 'foo%'
+ }
+ end
+
+ it "can handle ESCAPE" do
+ node = @table[:name].does_not_match("foo!%", "!")
+ compile(node).must_be_like %{
+ "users"."name" NOT ILIKE 'foo!%' ESCAPE '!'
+ }
+ end
+
+ it "can handle subqueries" do
+ subquery = @table.project(:id).where(@table[:name].does_not_match("foo%"))
+ node = @attr.in subquery
+ compile(node).must_be_like %{
+ "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" NOT ILIKE 'foo%')
+ }
+ end
+ end
+
+ describe "Nodes::Regexp" do
+ it "should know how to visit" do
+ node = @table[:name].matches_regexp("foo.*")
+ node.must_be_kind_of Nodes::Regexp
+ node.case_sensitive.must_equal(true)
+ compile(node).must_be_like %{
+ "users"."name" ~ 'foo.*'
+ }
+ end
+
+ it "can handle case insensitive" do
+ node = @table[:name].matches_regexp("foo.*", false)
+ node.must_be_kind_of Nodes::Regexp
+ node.case_sensitive.must_equal(false)
+ compile(node).must_be_like %{
+ "users"."name" ~* 'foo.*'
+ }
+ end
+
+ it "can handle subqueries" do
+ subquery = @table.project(:id).where(@table[:name].matches_regexp("foo.*"))
+ node = @attr.in subquery
+ compile(node).must_be_like %{
+ "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" ~ 'foo.*')
+ }
+ end
+ end
+
+ describe "Nodes::NotRegexp" do
+ it "should know how to visit" do
+ node = @table[:name].does_not_match_regexp("foo.*")
+ node.must_be_kind_of Nodes::NotRegexp
+ node.case_sensitive.must_equal(true)
+ compile(node).must_be_like %{
+ "users"."name" !~ 'foo.*'
+ }
+ end
+
+ it "can handle case insensitive" do
+ node = @table[:name].does_not_match_regexp("foo.*", false)
+ node.case_sensitive.must_equal(false)
+ compile(node).must_be_like %{
+ "users"."name" !~* 'foo.*'
+ }
+ end
+
+ it "can handle subqueries" do
+ subquery = @table.project(:id).where(@table[:name].does_not_match_regexp("foo.*"))
+ node = @attr.in subquery
+ compile(node).must_be_like %{
+ "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" !~ 'foo.*')
+ }
+ end
+ end
+
+ describe "Nodes::BindParam" do
+ it "increments each bind param" do
+ query = @table[:name].eq(Arel::Nodes::BindParam.new(1))
+ .and(@table[:id].eq(Arel::Nodes::BindParam.new(1)))
+ compile(query).must_be_like %{
+ "users"."name" = $1 AND "users"."id" = $2
+ }
+ end
+ end
+
+ describe "Nodes::Cube" do
+ it "should know how to visit with array arguments" do
+ node = Arel::Nodes::Cube.new([@table[:name], @table[:bool]])
+ compile(node).must_be_like %{
+ CUBE( "users"."name", "users"."bool" )
+ }
+ end
+
+ it "should know how to visit with CubeDimension Argument" do
+ dimensions = Arel::Nodes::GroupingElement.new([@table[:name], @table[:bool]])
+ node = Arel::Nodes::Cube.new(dimensions)
+ compile(node).must_be_like %{
+ CUBE( "users"."name", "users"."bool" )
+ }
+ end
+
+ it "should know how to generate parenthesis when supplied with many Dimensions" do
+ dim1 = Arel::Nodes::GroupingElement.new(@table[:name])
+ dim2 = Arel::Nodes::GroupingElement.new([@table[:bool], @table[:created_at]])
+ node = Arel::Nodes::Cube.new([dim1, dim2])
+ compile(node).must_be_like %{
+ CUBE( ( "users"."name" ), ( "users"."bool", "users"."created_at" ) )
+ }
+ end
+ end
+
+ describe "Nodes::GroupingSet" do
+ it "should know how to visit with array arguments" do
+ node = Arel::Nodes::GroupingSet.new([@table[:name], @table[:bool]])
+ compile(node).must_be_like %{
+ GROUPING SETS( "users"."name", "users"."bool" )
+ }
+ end
+
+ it "should know how to visit with CubeDimension Argument" do
+ group = Arel::Nodes::GroupingElement.new([@table[:name], @table[:bool]])
+ node = Arel::Nodes::GroupingSet.new(group)
+ compile(node).must_be_like %{
+ GROUPING SETS( "users"."name", "users"."bool" )
+ }
+ end
+
+ it "should know how to generate parenthesis when supplied with many Dimensions" do
+ group1 = Arel::Nodes::GroupingElement.new(@table[:name])
+ group2 = Arel::Nodes::GroupingElement.new([@table[:bool], @table[:created_at]])
+ node = Arel::Nodes::GroupingSet.new([group1, group2])
+ compile(node).must_be_like %{
+ GROUPING SETS( ( "users"."name" ), ( "users"."bool", "users"."created_at" ) )
+ }
+ end
+ end
+
+ describe "Nodes::RollUp" do
+ it "should know how to visit with array arguments" do
+ node = Arel::Nodes::RollUp.new([@table[:name], @table[:bool]])
+ compile(node).must_be_like %{
+ ROLLUP( "users"."name", "users"."bool" )
+ }
+ end
+
+ it "should know how to visit with CubeDimension Argument" do
+ group = Arel::Nodes::GroupingElement.new([@table[:name], @table[:bool]])
+ node = Arel::Nodes::RollUp.new(group)
+ compile(node).must_be_like %{
+ ROLLUP( "users"."name", "users"."bool" )
+ }
+ end
+
+ it "should know how to generate parenthesis when supplied with many Dimensions" do
+ group1 = Arel::Nodes::GroupingElement.new(@table[:name])
+ group2 = Arel::Nodes::GroupingElement.new([@table[:bool], @table[:created_at]])
+ node = Arel::Nodes::RollUp.new([group1, group2])
+ compile(node).must_be_like %{
+ ROLLUP( ( "users"."name" ), ( "users"."bool", "users"."created_at" ) )
+ }
+ end
+ end
+
+ describe "Nodes::IsNotDistinctFrom" do
+ it "should construct a valid generic SQL statement" do
+ test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson"
+ compile(test).must_be_like %{
+ "users"."name" IS NOT DISTINCT FROM 'Aaron Patterson'
+ }
+ end
+
+ it "should handle column names on both sides" do
+ test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name]
+ compile(test).must_be_like %{
+ "users"."first_name" IS NOT DISTINCT FROM "users"."last_name"
+ }
+ end
+
+ it "should handle nil" do
+ @table = Table.new(:users)
+ val = Nodes.build_quoted(nil, @table[:active])
+ sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val)
+ sql.must_be_like %{ "users"."name" IS NOT DISTINCT FROM NULL }
+ end
+ end
+
+ describe "Nodes::IsDistinctFrom" do
+ it "should handle column names on both sides" do
+ test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name]
+ compile(test).must_be_like %{
+ "users"."first_name" IS DISTINCT FROM "users"."last_name"
+ }
+ end
+
+ it "should handle nil" do
+ @table = Table.new(:users)
+ val = Nodes.build_quoted(nil, @table[:active])
+ sql = compile Nodes::IsDistinctFrom.new(@table[:name], val)
+ sql.must_be_like %{ "users"."name" IS DISTINCT FROM NULL }
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/visitors/sqlite_test.rb b/activerecord/test/cases/arel/visitors/sqlite_test.rb
new file mode 100644
index 0000000000..ee4e07a675
--- /dev/null
+++ b/activerecord/test/cases/arel/visitors/sqlite_test.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+
+module Arel
+ module Visitors
+ class SqliteTest < Arel::Spec
+ before do
+ @visitor = SQLite.new Table.engine.connection
+ end
+
+ def compile(node)
+ @visitor.accept(node, Collectors::SQLString.new).value
+ end
+
+ it "defaults limit to -1" do
+ stmt = Nodes::SelectStatement.new
+ stmt.offset = Nodes::Offset.new(1)
+ sql = @visitor.accept(stmt, Collectors::SQLString.new).value
+ sql.must_be_like "SELECT LIMIT -1 OFFSET 1"
+ end
+
+ it "does not support locking" do
+ node = Nodes::Lock.new(Arel.sql("FOR UPDATE"))
+ assert_equal "", @visitor.accept(node, Collectors::SQLString.new).value
+ end
+
+ it "does not support boolean" do
+ node = Nodes::True.new()
+ assert_equal "1", @visitor.accept(node, Collectors::SQLString.new).value
+ node = Nodes::False.new()
+ assert_equal "0", @visitor.accept(node, Collectors::SQLString.new).value
+ end
+
+ describe "Nodes::IsNotDistinctFrom" do
+ it "should construct a valid generic SQL statement" do
+ test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson"
+ compile(test).must_be_like %{
+ "users"."name" IS 'Aaron Patterson'
+ }
+ end
+
+ it "should handle column names on both sides" do
+ test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name]
+ compile(test).must_be_like %{
+ "users"."first_name" IS "users"."last_name"
+ }
+ end
+
+ it "should handle nil" do
+ @table = Table.new(:users)
+ val = Nodes.build_quoted(nil, @table[:active])
+ sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val)
+ sql.must_be_like %{ "users"."name" IS NULL }
+ end
+ end
+
+ describe "Nodes::IsDistinctFrom" do
+ it "should handle column names on both sides" do
+ test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name]
+ compile(test).must_be_like %{
+ "users"."first_name" IS NOT "users"."last_name"
+ }
+ end
+
+ it "should handle nil" do
+ @table = Table.new(:users)
+ val = Nodes.build_quoted(nil, @table[:active])
+ sql = compile Nodes::IsDistinctFrom.new(@table[:name], val)
+ sql.must_be_like %{ "users"."name" IS NOT NULL }
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/arel/visitors/to_sql_test.rb b/activerecord/test/cases/arel/visitors/to_sql_test.rb
new file mode 100644
index 0000000000..4bfa799a96
--- /dev/null
+++ b/activerecord/test/cases/arel/visitors/to_sql_test.rb
@@ -0,0 +1,713 @@
+# frozen_string_literal: true
+
+require_relative "../helper"
+require "bigdecimal"
+
+module Arel
+ module Visitors
+ describe "the to_sql visitor" do
+ before do
+ @conn = FakeRecord::Base.new
+ @visitor = ToSql.new @conn.connection
+ @table = Table.new(:users)
+ @attr = @table[:id]
+ end
+
+ def compile(node)
+ @visitor.accept(node, Collectors::SQLString.new).value
+ end
+
+ it "works with BindParams" do
+ node = Nodes::BindParam.new(1)
+ sql = compile node
+ sql.must_be_like "?"
+ end
+
+ it "does not quote BindParams used as part of a Values" do
+ bp = Nodes::BindParam.new(1)
+ values = Nodes::Values.new([bp])
+ sql = compile values
+ sql.must_be_like "VALUES (?)"
+ end
+
+ it "can define a dispatch method" do
+ visited = false
+ viz = Class.new(Arel::Visitors::Visitor) {
+ define_method(:hello) do |node, c|
+ visited = true
+ end
+
+ def dispatch
+ { Arel::Table => "hello" }
+ end
+ }.new
+
+ viz.accept(@table, Collectors::SQLString.new)
+ assert visited, "hello method was called"
+ end
+
+ it "should not quote sql literals" do
+ node = @table[Arel.star]
+ sql = compile node
+ sql.must_be_like '"users".*'
+ end
+
+ it "should visit named functions" do
+ function = Nodes::NamedFunction.new("omg", [Arel.star])
+ assert_equal "omg(*)", compile(function)
+ end
+
+ it "should chain predications on named functions" do
+ function = Nodes::NamedFunction.new("omg", [Arel.star])
+ sql = compile(function.eq(2))
+ sql.must_be_like %{ omg(*) = 2 }
+ end
+
+ it "should handle nil with named functions" do
+ function = Nodes::NamedFunction.new("omg", [Arel.star])
+ sql = compile(function.eq(nil))
+ sql.must_be_like %{ omg(*) IS NULL }
+ end
+
+ it "should visit built-in functions" do
+ function = Nodes::Count.new([Arel.star])
+ assert_equal "COUNT(*)", compile(function)
+
+ function = Nodes::Sum.new([Arel.star])
+ assert_equal "SUM(*)", compile(function)
+
+ function = Nodes::Max.new([Arel.star])
+ assert_equal "MAX(*)", compile(function)
+
+ function = Nodes::Min.new([Arel.star])
+ assert_equal "MIN(*)", compile(function)
+
+ function = Nodes::Avg.new([Arel.star])
+ assert_equal "AVG(*)", compile(function)
+ end
+
+ it "should visit built-in functions operating on distinct values" do
+ function = Nodes::Count.new([Arel.star])
+ function.distinct = true
+ assert_equal "COUNT(DISTINCT *)", compile(function)
+
+ function = Nodes::Sum.new([Arel.star])
+ function.distinct = true
+ assert_equal "SUM(DISTINCT *)", compile(function)
+
+ function = Nodes::Max.new([Arel.star])
+ function.distinct = true
+ assert_equal "MAX(DISTINCT *)", compile(function)
+
+ function = Nodes::Min.new([Arel.star])
+ function.distinct = true
+ assert_equal "MIN(DISTINCT *)", compile(function)
+
+ function = Nodes::Avg.new([Arel.star])
+ function.distinct = true
+ assert_equal "AVG(DISTINCT *)", compile(function)
+ end
+
+ it "works with lists" do
+ function = Nodes::NamedFunction.new("omg", [Arel.star, Arel.star])
+ assert_equal "omg(*, *)", compile(function)
+ end
+
+ describe "Nodes::Equality" do
+ it "should escape strings" do
+ test = Table.new(:users)[:name].eq "Aaron Patterson"
+ compile(test).must_be_like %{
+ "users"."name" = 'Aaron Patterson'
+ }
+ end
+
+ it "should handle false" do
+ table = Table.new(:users)
+ val = Nodes.build_quoted(false, table[:active])
+ sql = compile Nodes::Equality.new(val, val)
+ sql.must_be_like %{ 'f' = 'f' }
+ end
+
+ it "should handle nil" do
+ sql = compile Nodes::Equality.new(@table[:name], nil)
+ sql.must_be_like %{ "users"."name" IS NULL }
+ end
+ end
+
+ describe "Nodes::Grouping" do
+ it "wraps nested groupings in brackets only once" do
+ sql = compile Nodes::Grouping.new(Nodes::Grouping.new(Nodes.build_quoted("foo")))
+ sql.must_equal "('foo')"
+ end
+ end
+
+ describe "Nodes::NotEqual" do
+ it "should handle false" do
+ val = Nodes.build_quoted(false, @table[:active])
+ sql = compile Nodes::NotEqual.new(@table[:active], val)
+ sql.must_be_like %{ "users"."active" != 'f' }
+ end
+
+ it "should handle nil" do
+ val = Nodes.build_quoted(nil, @table[:active])
+ sql = compile Nodes::NotEqual.new(@table[:name], val)
+ sql.must_be_like %{ "users"."name" IS NOT NULL }
+ end
+ end
+
+ describe "Nodes::IsNotDistinctFrom" do
+ it "should construct a valid generic SQL statement" do
+ test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson"
+ compile(test).must_be_like %{
+ CASE WHEN "users"."name" = 'Aaron Patterson' OR ("users"."name" IS NULL AND 'Aaron Patterson' IS NULL) THEN 0 ELSE 1 END = 0
+ }
+ end
+
+ it "should handle column names on both sides" do
+ test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name]
+ compile(test).must_be_like %{
+ CASE WHEN "users"."first_name" = "users"."last_name" OR ("users"."first_name" IS NULL AND "users"."last_name" IS NULL) THEN 0 ELSE 1 END = 0
+ }
+ end
+
+ it "should handle nil" do
+ val = Nodes.build_quoted(nil, @table[:active])
+ sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val)
+ sql.must_be_like %{ "users"."name" IS NULL }
+ end
+ end
+
+ describe "Nodes::IsDistinctFrom" do
+ it "should handle column names on both sides" do
+ test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name]
+ compile(test).must_be_like %{
+ CASE WHEN "users"."first_name" = "users"."last_name" OR ("users"."first_name" IS NULL AND "users"."last_name" IS NULL) THEN 0 ELSE 1 END = 1
+ }
+ end
+
+ it "should handle nil" do
+ val = Nodes.build_quoted(nil, @table[:active])
+ sql = compile Nodes::IsDistinctFrom.new(@table[:name], val)
+ sql.must_be_like %{ "users"."name" IS NOT NULL }
+ end
+ end
+
+ it "should visit string subclass" do
+ [
+ Class.new(String).new(":'("),
+ Class.new(Class.new(String)).new(":'("),
+ ].each do |obj|
+ val = Nodes.build_quoted(obj, @table[:active])
+ sql = compile Nodes::NotEqual.new(@table[:name], val)
+ sql.must_be_like %{ "users"."name" != ':\\'(' }
+ end
+ end
+
+ it "should visit_Class" do
+ compile(Nodes.build_quoted(DateTime)).must_equal "'DateTime'"
+ end
+
+ it "should escape LIMIT" do
+ sc = Arel::Nodes::SelectStatement.new
+ sc.limit = Arel::Nodes::Limit.new(Nodes.build_quoted("omg"))
+ assert_match(/LIMIT 'omg'/, compile(sc))
+ end
+
+ it "should contain a single space before ORDER BY" do
+ table = Table.new(:users)
+ test = table.order(table[:name])
+ sql = compile test
+ assert_match(/"users" ORDER BY/, sql)
+ end
+
+ it "should quote LIMIT without column type coercion" do
+ table = Table.new(:users)
+ sc = table.where(table[:name].eq(0)).take(1).ast
+ assert_match(/WHERE "users"."name" = 0 LIMIT 1/, compile(sc))
+ end
+
+ it "should visit_DateTime" do
+ dt = DateTime.now
+ table = Table.new(:users)
+ test = table[:created_at].eq dt
+ sql = compile test
+
+ sql.must_be_like %{"users"."created_at" = '#{dt.strftime("%Y-%m-%d %H:%M:%S")}'}
+ end
+
+ it "should visit_Float" do
+ test = Table.new(:products)[:price].eq 2.14
+ sql = compile test
+ sql.must_be_like %{"products"."price" = 2.14}
+ end
+
+ it "should visit_Not" do
+ sql = compile Nodes::Not.new(Arel.sql("foo"))
+ sql.must_be_like "NOT (foo)"
+ end
+
+ it "should apply Not to the whole expression" do
+ node = Nodes::And.new [@attr.eq(10), @attr.eq(11)]
+ sql = compile Nodes::Not.new(node)
+ sql.must_be_like %{NOT ("users"."id" = 10 AND "users"."id" = 11)}
+ end
+
+ it "should visit_As" do
+ as = Nodes::As.new(Arel.sql("foo"), Arel.sql("bar"))
+ sql = compile as
+ sql.must_be_like "foo AS bar"
+ end
+
+ it "should visit_Integer" do
+ compile 8787878092
+ end
+
+ it "should visit_Hash" do
+ compile(Nodes.build_quoted(a: 1))
+ end
+
+ it "should visit_Set" do
+ compile Nodes.build_quoted(Set.new([1, 2]))
+ end
+
+ it "should visit_BigDecimal" do
+ compile Nodes.build_quoted(BigDecimal("2.14"))
+ end
+
+ it "should visit_Date" do
+ dt = Date.today
+ table = Table.new(:users)
+ test = table[:created_at].eq dt
+ sql = compile test
+
+ sql.must_be_like %{"users"."created_at" = '#{dt.strftime("%Y-%m-%d")}'}
+ end
+
+ it "should visit_NilClass" do
+ compile(Nodes.build_quoted(nil)).must_be_like "NULL"
+ end
+
+ it "unsupported input should raise UnsupportedVisitError" do
+ error = assert_raises(UnsupportedVisitError) { compile(nil) }
+ assert_match(/\AUnsupported/, error.message)
+ end
+
+ it "should visit_Arel_SelectManager, which is a subquery" do
+ mgr = Table.new(:foo).project(:bar)
+ compile(mgr).must_be_like '(SELECT bar FROM "foo")'
+ end
+
+ it "should visit_Arel_Nodes_And" do
+ node = Nodes::And.new [@attr.eq(10), @attr.eq(11)]
+ compile(node).must_be_like %{
+ "users"."id" = 10 AND "users"."id" = 11
+ }
+ end
+
+ it "should visit_Arel_Nodes_Or" do
+ node = Nodes::Or.new @attr.eq(10), @attr.eq(11)
+ compile(node).must_be_like %{
+ "users"."id" = 10 OR "users"."id" = 11
+ }
+ end
+
+ it "should visit_Arel_Nodes_Assignment" do
+ column = @table["id"]
+ node = Nodes::Assignment.new(
+ Nodes::UnqualifiedColumn.new(column),
+ Nodes::UnqualifiedColumn.new(column)
+ )
+ compile(node).must_be_like %{
+ "id" = "id"
+ }
+ end
+
+ it "should visit visit_Arel_Attributes_Time" do
+ attr = Attributes::Time.new(@attr.relation, @attr.name)
+ compile attr
+ end
+
+ it "should visit_TrueClass" do
+ test = Table.new(:users)[:bool].eq(true)
+ compile(test).must_be_like %{ "users"."bool" = 't' }
+ end
+
+ describe "Nodes::Matches" do
+ it "should know how to visit" do
+ node = @table[:name].matches("foo%")
+ compile(node).must_be_like %{
+ "users"."name" LIKE 'foo%'
+ }
+ end
+
+ it "can handle ESCAPE" do
+ node = @table[:name].matches("foo!%", "!")
+ compile(node).must_be_like %{
+ "users"."name" LIKE 'foo!%' ESCAPE '!'
+ }
+ end
+
+ it "can handle subqueries" do
+ subquery = @table.project(:id).where(@table[:name].matches("foo%"))
+ node = @attr.in subquery
+ compile(node).must_be_like %{
+ "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" LIKE 'foo%')
+ }
+ end
+ end
+
+ describe "Nodes::DoesNotMatch" do
+ it "should know how to visit" do
+ node = @table[:name].does_not_match("foo%")
+ compile(node).must_be_like %{
+ "users"."name" NOT LIKE 'foo%'
+ }
+ end
+
+ it "can handle ESCAPE" do
+ node = @table[:name].does_not_match("foo!%", "!")
+ compile(node).must_be_like %{
+ "users"."name" NOT LIKE 'foo!%' ESCAPE '!'
+ }
+ end
+
+ it "can handle subqueries" do
+ subquery = @table.project(:id).where(@table[:name].does_not_match("foo%"))
+ node = @attr.in subquery
+ compile(node).must_be_like %{
+ "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" NOT LIKE 'foo%')
+ }
+ end
+ end
+
+ describe "Nodes::Ordering" do
+ it "should know how to visit" do
+ node = @attr.desc
+ compile(node).must_be_like %{
+ "users"."id" DESC
+ }
+ end
+ end
+
+ describe "Nodes::In" do
+ it "should know how to visit" do
+ node = @attr.in [1, 2, 3]
+ compile(node).must_be_like %{
+ "users"."id" IN (1, 2, 3)
+ }
+ end
+
+ it "should return 1=0 when empty right which is always false" do
+ node = @attr.in []
+ compile(node).must_equal "1=0"
+ end
+
+ it "can handle two dot ranges" do
+ node = @attr.between 1..3
+ compile(node).must_be_like %{
+ "users"."id" BETWEEN 1 AND 3
+ }
+ end
+
+ it "can handle three dot ranges" do
+ node = @attr.between 1...3
+ compile(node).must_be_like %{
+ "users"."id" >= 1 AND "users"."id" < 3
+ }
+ end
+
+ it "can handle ranges bounded by infinity" do
+ node = @attr.between 1..Float::INFINITY
+ compile(node).must_be_like %{
+ "users"."id" >= 1
+ }
+ node = @attr.between(-Float::INFINITY..3)
+ compile(node).must_be_like %{
+ "users"."id" <= 3
+ }
+ node = @attr.between(-Float::INFINITY...3)
+ compile(node).must_be_like %{
+ "users"."id" < 3
+ }
+ node = @attr.between(-Float::INFINITY..Float::INFINITY)
+ compile(node).must_be_like %{1=1}
+ end
+
+ it "can handle subqueries" do
+ table = Table.new(:users)
+ subquery = table.project(:id).where(table[:name].eq("Aaron"))
+ node = @attr.in subquery
+ compile(node).must_be_like %{
+ "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" = 'Aaron')
+ }
+ end
+ end
+
+ describe "Nodes::InfixOperation" do
+ it "should handle Multiplication" do
+ node = Arel::Attributes::Decimal.new(Table.new(:products), :price) * Arel::Attributes::Decimal.new(Table.new(:currency_rates), :rate)
+ compile(node).must_equal %("products"."price" * "currency_rates"."rate")
+ end
+
+ it "should handle Division" do
+ node = Arel::Attributes::Decimal.new(Table.new(:products), :price) / 5
+ compile(node).must_equal %("products"."price" / 5)
+ end
+
+ it "should handle Addition" do
+ node = Arel::Attributes::Decimal.new(Table.new(:products), :price) + 6
+ compile(node).must_equal %(("products"."price" + 6))
+ end
+
+ it "should handle Subtraction" do
+ node = Arel::Attributes::Decimal.new(Table.new(:products), :price) - 7
+ compile(node).must_equal %(("products"."price" - 7))
+ end
+
+ it "should handle Concatenation" do
+ table = Table.new(:users)
+ node = table[:name].concat(table[:name])
+ compile(node).must_equal %("users"."name" || "users"."name")
+ end
+
+ it "should handle BitwiseAnd" do
+ node = Arel::Attributes::Integer.new(Table.new(:products), :bitmap) & 16
+ compile(node).must_equal %(("products"."bitmap" & 16))
+ end
+
+ it "should handle BitwiseOr" do
+ node = Arel::Attributes::Integer.new(Table.new(:products), :bitmap) | 16
+ compile(node).must_equal %(("products"."bitmap" | 16))
+ end
+
+ it "should handle BitwiseXor" do
+ node = Arel::Attributes::Integer.new(Table.new(:products), :bitmap) ^ 16
+ compile(node).must_equal %(("products"."bitmap" ^ 16))
+ end
+
+ it "should handle BitwiseShiftLeft" do
+ node = Arel::Attributes::Integer.new(Table.new(:products), :bitmap) << 4
+ compile(node).must_equal %(("products"."bitmap" << 4))
+ end
+
+ it "should handle BitwiseShiftRight" do
+ node = Arel::Attributes::Integer.new(Table.new(:products), :bitmap) >> 4
+ compile(node).must_equal %(("products"."bitmap" >> 4))
+ end
+
+ it "should handle arbitrary operators" do
+ node = Arel::Nodes::InfixOperation.new(
+ "&&",
+ Arel::Attributes::String.new(Table.new(:products), :name),
+ Arel::Attributes::String.new(Table.new(:products), :name)
+ )
+ compile(node).must_equal %("products"."name" && "products"."name")
+ end
+ end
+
+ describe "Nodes::UnaryOperation" do
+ it "should handle BitwiseNot" do
+ node = ~ Arel::Attributes::Integer.new(Table.new(:products), :bitmap)
+ compile(node).must_equal %( ~ "products"."bitmap")
+ end
+
+ it "should handle arbitrary operators" do
+ node = Arel::Nodes::UnaryOperation.new("!", Arel::Attributes::String.new(Table.new(:products), :active))
+ compile(node).must_equal %( ! "products"."active")
+ end
+ end
+
+ describe "Nodes::Union" do
+ it "squashes parenthesis on multiple unions" do
+ subnode = Nodes::Union.new Arel.sql("left"), Arel.sql("right")
+ node = Nodes::Union.new subnode, Arel.sql("topright")
+ assert_equal("( left UNION right UNION topright )", compile(node))
+ subnode = Nodes::Union.new Arel.sql("left"), Arel.sql("right")
+ node = Nodes::Union.new Arel.sql("topleft"), subnode
+ assert_equal("( topleft UNION left UNION right )", compile(node))
+ end
+ end
+
+ describe "Nodes::UnionAll" do
+ it "squashes parenthesis on multiple union alls" do
+ subnode = Nodes::UnionAll.new Arel.sql("left"), Arel.sql("right")
+ node = Nodes::UnionAll.new subnode, Arel.sql("topright")
+ assert_equal("( left UNION ALL right UNION ALL topright )", compile(node))
+ subnode = Nodes::UnionAll.new Arel.sql("left"), Arel.sql("right")
+ node = Nodes::UnionAll.new Arel.sql("topleft"), subnode
+ assert_equal("( topleft UNION ALL left UNION ALL right )", compile(node))
+ end
+ end
+
+ describe "Nodes::NotIn" do
+ it "should know how to visit" do
+ node = @attr.not_in [1, 2, 3]
+ compile(node).must_be_like %{
+ "users"."id" NOT IN (1, 2, 3)
+ }
+ end
+
+ it "should return 1=1 when empty right which is always true" do
+ node = @attr.not_in []
+ compile(node).must_equal "1=1"
+ end
+
+ it "can handle two dot ranges" do
+ node = @attr.not_between 1..3
+ compile(node).must_equal(
+ %{("users"."id" < 1 OR "users"."id" > 3)}
+ )
+ end
+
+ it "can handle three dot ranges" do
+ node = @attr.not_between 1...3
+ compile(node).must_equal(
+ %{("users"."id" < 1 OR "users"."id" >= 3)}
+ )
+ end
+
+ it "can handle ranges bounded by infinity" do
+ node = @attr.not_between 1..Float::INFINITY
+ compile(node).must_be_like %{
+ "users"."id" < 1
+ }
+ node = @attr.not_between(-Float::INFINITY..3)
+ compile(node).must_be_like %{
+ "users"."id" > 3
+ }
+ node = @attr.not_between(-Float::INFINITY...3)
+ compile(node).must_be_like %{
+ "users"."id" >= 3
+ }
+ node = @attr.not_between(-Float::INFINITY..Float::INFINITY)
+ compile(node).must_be_like %{1=0}
+ end
+
+ it "can handle subqueries" do
+ table = Table.new(:users)
+ subquery = table.project(:id).where(table[:name].eq("Aaron"))
+ node = @attr.not_in subquery
+ compile(node).must_be_like %{
+ "users"."id" NOT IN (SELECT id FROM "users" WHERE "users"."name" = 'Aaron')
+ }
+ end
+ end
+
+ describe "Constants" do
+ it "should handle true" do
+ test = Table.new(:users).create_true
+ compile(test).must_be_like %{
+ TRUE
+ }
+ end
+
+ it "should handle false" do
+ test = Table.new(:users).create_false
+ compile(test).must_be_like %{
+ FALSE
+ }
+ end
+ end
+
+ describe "TableAlias" do
+ it "should use the underlying table for checking columns" do
+ test = Table.new(:users).alias("zomgusers")[:id].eq "3"
+ compile(test).must_be_like %{
+ "zomgusers"."id" = '3'
+ }
+ end
+ end
+
+ describe "distinct on" do
+ it "raises not implemented error" do
+ core = Arel::Nodes::SelectCore.new
+ core.set_quantifier = Arel::Nodes::DistinctOn.new(Arel.sql("aaron"))
+
+ assert_raises(NotImplementedError) do
+ compile(core)
+ end
+ end
+ end
+
+ describe "Nodes::Regexp" do
+ it "raises not implemented error" do
+ node = Arel::Nodes::Regexp.new(@table[:name], Nodes.build_quoted("foo%"))
+
+ assert_raises(NotImplementedError) do
+ compile(node)
+ end
+ end
+ end
+
+ describe "Nodes::NotRegexp" do
+ it "raises not implemented error" do
+ node = Arel::Nodes::NotRegexp.new(@table[:name], Nodes.build_quoted("foo%"))
+
+ assert_raises(NotImplementedError) do
+ compile(node)
+ end
+ end
+ end
+
+ describe "Nodes::Case" do
+ it "supports simple case expressions" do
+ node = Arel::Nodes::Case.new(@table[:name])
+ .when("foo").then(1)
+ .else(0)
+
+ compile(node).must_be_like %{
+ CASE "users"."name" WHEN 'foo' THEN 1 ELSE 0 END
+ }
+ end
+
+ it "supports extended case expressions" do
+ node = Arel::Nodes::Case.new
+ .when(@table[:name].in(%w(foo bar))).then(1)
+ .else(0)
+
+ compile(node).must_be_like %{
+ CASE WHEN "users"."name" IN ('foo', 'bar') THEN 1 ELSE 0 END
+ }
+ end
+
+ it "works without default branch" do
+ node = Arel::Nodes::Case.new(@table[:name])
+ .when("foo").then(1)
+
+ compile(node).must_be_like %{
+ CASE "users"."name" WHEN 'foo' THEN 1 END
+ }
+ end
+
+ it "allows chaining multiple conditions" do
+ node = Arel::Nodes::Case.new(@table[:name])
+ .when("foo").then(1)
+ .when("bar").then(2)
+ .else(0)
+
+ compile(node).must_be_like %{
+ CASE "users"."name" WHEN 'foo' THEN 1 WHEN 'bar' THEN 2 ELSE 0 END
+ }
+ end
+
+ it "supports #when with two arguments and no #then" do
+ node = Arel::Nodes::Case.new @table[:name]
+
+ { foo: 1, bar: 0 }.reduce(node) { |_node, pair| _node.when(*pair) }
+
+ compile(node).must_be_like %{
+ CASE "users"."name" WHEN 'foo' THEN 1 WHEN 'bar' THEN 0 END
+ }
+ end
+
+ it "can be chained as a predicate" do
+ node = @table[:name].when("foo").then("bar").else("baz")
+
+ compile(node).must_be_like %{
+ CASE "users"."name" WHEN 'foo' THEN 'bar' ELSE 'baz' END
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/associations/belongs_to_associations_test.rb b/activerecord/test/cases/associations/belongs_to_associations_test.rb
new file mode 100644
index 0000000000..acafbe0b4d
--- /dev/null
+++ b/activerecord/test/cases/associations/belongs_to_associations_test.rb
@@ -0,0 +1,1376 @@
+# 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"
+
+class BelongsToAssociationsTest < ActiveRecord::TestCase
+ fixtures :accounts, :companies, :developers, :projects, :topics,
+ :developers_projects, :computers, :authors, :author_addresses,
+ :posts, :tags, :taggings, :comments, :sponsors, :members
+
+ 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_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
+ ActiveRecord::SQLCounter.clear_log
+ Client.find(3).firm
+ ensure
+ assert ActiveRecord::SQLCounter.log_all.all? { |sql| /order by/i !~ sql }, "ORDER BY was used in the query"
+ 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
+ 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) { |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).send(: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).send(:klass)
+ sponsor.association(:sponsorable).reload
+ assert_nil sponsor.sponsorable
+
+ sponsor.sponsorable = Member.new name: "Bert"
+ assert_equal Member, sponsor.association(:sponsorable).send(:klass)
+ end
+
+ def test_with_polymorphic_and_condition
+ sponsor = Sponsor.create
+ member = Member.create name: "Bert"
+ sponsor.sponsorable = member
+
+ assert_equal member, sponsor.sponsorable
+ assert_nil sponsor.sponsorable_with_conditions
+ end
+
+ def test_with_select
+ assert_equal 1, Company.find(2).firm_with_select.attributes.size
+ assert_equal 1, Company.all.merge!(includes: :firm_with_select).find(2).firm_with_select.attributes.size
+ 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 = Author.first
+ comment.resource = Member.first
+ comment.save
+
+ assert_equal Comment.all.to_a,
+ Comment.includes(:author).to_a
+
+ assert_equal Comment.all.to_a,
+ Comment.includes(:resource).to_a
+ 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], but is :nullify"
+ end
+
+ class DestroyableBook < ActiveRecord::Base
+ self.table_name = "books"
+ belongs_to :author, class_name: "UndestroyableAuthor", dependent: :destroy
+ end
+
+ class UndestroyableAuthor < ActiveRecord::Base
+ self.table_name = "authors"
+ has_one :book, class_name: "DestroyableBook", foreign_key: "author_id"
+ before_destroy :dont
+
+ def dont
+ throw(:abort)
+ end
+ end
+
+ def test_dependency_should_halt_parent_destruction
+ author = UndestroyableAuthor.create!(name: "Test")
+ book = DestroyableBook.create!(author: author)
+
+ assert_no_difference ["UndestroyableAuthor.count", "DestroyableBook.count"] do
+ assert_not book.destroy
+ end
+ end
+
+ def test_attributes_are_being_set_when_initialized_from_belongs_to_association_with_where_clause
+ new_firm = accounts(:signals37).build_firm(name: "Apple")
+ assert_equal new_firm.name, "Apple"
+ 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_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
+
+ 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(Comment) do
+ def self.name; "Temp"; end
+ validates :post, presence: true
+ end
+
+ comment = model.new
+ comment.post_id = 9223372036854775808 # out of range in the bigint
+
+ assert_nil comment.post
+ assert_not_predicate comment, :valid?
+ assert_equal [{ error: :blank }], comment.errors.details[:post]
+ end
+
+ def test_polymorphic_with_custom_primary_key
+ toy = Toy.create!
+ sponsor = Sponsor.create!(sponsorable: toy)
+
+ assert_equal toy, sponsor.reload.sponsorable
+ 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
+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
diff --git a/activerecord/test/cases/associations/bidirectional_destroy_dependencies_test.rb b/activerecord/test/cases/associations/bidirectional_destroy_dependencies_test.rb
new file mode 100644
index 0000000000..88221b012e
--- /dev/null
+++ b/activerecord/test/cases/associations/bidirectional_destroy_dependencies_test.rb
@@ -0,0 +1,43 @@
+# 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
diff --git a/activerecord/test/cases/associations/callbacks_test.rb b/activerecord/test/cases/associations/callbacks_test.rb
new file mode 100644
index 0000000000..25d55dc4c9
--- /dev/null
+++ b/activerecord/test/cases/associations/callbacks_test.rb
@@ -0,0 +1,191 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+require "models/author"
+require "models/project"
+require "models/developer"
+require "models/computer"
+require "models/company"
+
+class AssociationCallbacksTest < ActiveRecord::TestCase
+ fixtures :posts, :authors, :author_addresses, :projects, :developers
+
+ def setup
+ @david = authors(:david)
+ @thinking = posts(:thinking)
+ @authorless = posts(:authorless)
+ assert_empty @david.post_log
+ end
+
+ def test_adding_macro_callbacks
+ @david.posts_with_callbacks << @thinking
+ assert_equal ["before_adding#{@thinking.id}", "after_adding#{@thinking.id}"], @david.post_log
+ @david.posts_with_callbacks << @thinking
+ assert_equal ["before_adding#{@thinking.id}", "after_adding#{@thinking.id}", "before_adding#{@thinking.id}",
+ "after_adding#{@thinking.id}"], @david.post_log
+ end
+
+ def test_adding_with_proc_callbacks
+ @david.posts_with_proc_callbacks << @thinking
+ assert_equal ["before_adding#{@thinking.id}", "after_adding#{@thinking.id}"], @david.post_log
+ @david.posts_with_proc_callbacks << @thinking
+ assert_equal ["before_adding#{@thinking.id}", "after_adding#{@thinking.id}", "before_adding#{@thinking.id}",
+ "after_adding#{@thinking.id}"], @david.post_log
+ end
+
+ def test_removing_with_macro_callbacks
+ first_post, second_post = @david.posts_with_callbacks[0, 2]
+ @david.posts_with_callbacks.delete(first_post)
+ assert_equal ["before_removing#{first_post.id}", "after_removing#{first_post.id}"], @david.post_log
+ @david.posts_with_callbacks.delete(second_post)
+ assert_equal ["before_removing#{first_post.id}", "after_removing#{first_post.id}", "before_removing#{second_post.id}",
+ "after_removing#{second_post.id}"], @david.post_log
+ end
+
+ def test_removing_with_proc_callbacks
+ first_post, second_post = @david.posts_with_callbacks[0, 2]
+ @david.posts_with_proc_callbacks.delete(first_post)
+ assert_equal ["before_removing#{first_post.id}", "after_removing#{first_post.id}"], @david.post_log
+ @david.posts_with_proc_callbacks.delete(second_post)
+ assert_equal ["before_removing#{first_post.id}", "after_removing#{first_post.id}", "before_removing#{second_post.id}",
+ "after_removing#{second_post.id}"], @david.post_log
+ end
+
+ def test_multiple_callbacks
+ @david.posts_with_multiple_callbacks << @thinking
+ assert_equal ["before_adding#{@thinking.id}", "before_adding_proc#{@thinking.id}", "after_adding#{@thinking.id}",
+ "after_adding_proc#{@thinking.id}"], @david.post_log
+ @david.posts_with_multiple_callbacks << @thinking
+ assert_equal ["before_adding#{@thinking.id}", "before_adding_proc#{@thinking.id}", "after_adding#{@thinking.id}",
+ "after_adding_proc#{@thinking.id}", "before_adding#{@thinking.id}", "before_adding_proc#{@thinking.id}",
+ "after_adding#{@thinking.id}", "after_adding_proc#{@thinking.id}"], @david.post_log
+ end
+
+ def test_has_many_callbacks_with_create
+ morten = Author.create name: "Morten"
+ post = morten.posts_with_proc_callbacks.create! title: "Hello", body: "How are you doing?"
+ assert_equal ["before_adding<new>", "after_adding#{post.id}"], morten.post_log
+ end
+
+ def test_has_many_callbacks_with_create!
+ morten = Author.create! name: "Morten"
+ post = morten.posts_with_proc_callbacks.create title: "Hello", body: "How are you doing?"
+ assert_equal ["before_adding<new>", "after_adding#{post.id}"], morten.post_log
+ end
+
+ def test_has_many_callbacks_for_save_on_parent
+ jack = Author.new name: "Jack"
+ jack.posts_with_callbacks.build title: "Call me back!", body: "Before you wake up and after you sleep"
+
+ callback_log = ["before_adding<new>", "after_adding#{jack.posts_with_callbacks.first.id}"]
+ assert_equal callback_log, jack.post_log
+ assert jack.save
+ assert_equal 1, jack.posts_with_callbacks.count
+ assert_equal callback_log, jack.post_log
+ end
+
+ def test_has_many_callbacks_for_destroy_on_parent
+ firm = Firm.create! name: "Firm"
+ client = firm.clients.create! name: "Client"
+ firm.destroy
+
+ assert_equal ["before_remove#{client.id}", "after_remove#{client.id}"], firm.log
+ end
+
+ def test_has_and_belongs_to_many_add_callback
+ david = developers(:david)
+ ar = projects(:active_record)
+ assert_empty ar.developers_log
+ ar.developers_with_callbacks << david
+ assert_equal ["before_adding#{david.id}", "after_adding#{david.id}"], ar.developers_log
+ ar.developers_with_callbacks << david
+ assert_equal ["before_adding#{david.id}", "after_adding#{david.id}", "before_adding#{david.id}",
+ "after_adding#{david.id}"], ar.developers_log
+ end
+
+ def test_has_and_belongs_to_many_before_add_called_before_save
+ dev = nil
+ new_dev = nil
+ klass = Class.new(Project) do
+ def self.name; Project.name; end
+ has_and_belongs_to_many :developers_with_callbacks,
+ class_name: "Developer",
+ before_add: lambda { |o, r|
+ dev = r
+ new_dev = r.new_record?
+ }
+ end
+ rec = klass.create!
+ alice = Developer.new(name: "alice")
+ rec.developers_with_callbacks << alice
+ assert_equal alice, dev
+ assert_not_nil new_dev
+ assert new_dev, "record should not have been saved"
+ assert_not_predicate alice, :new_record?
+ end
+
+ def test_has_and_belongs_to_many_after_add_called_after_save
+ ar = projects(:active_record)
+ assert_empty ar.developers_log
+ alice = Developer.new(name: "alice")
+ ar.developers_with_callbacks << alice
+ assert_equal "after_adding#{alice.id}", ar.developers_log.last
+
+ bob = ar.developers_with_callbacks.create(name: "bob")
+ assert_equal "after_adding#{bob.id}", ar.developers_log.last
+
+ ar.developers_with_callbacks.build(name: "charlie")
+ assert_equal "after_adding<new>", ar.developers_log.last
+ end
+
+ def test_has_and_belongs_to_many_remove_callback
+ david = developers(:david)
+ jamis = developers(:jamis)
+ activerecord = projects(:active_record)
+ assert_empty activerecord.developers_log
+ activerecord.developers_with_callbacks.delete(david)
+ assert_equal ["before_removing#{david.id}", "after_removing#{david.id}"], activerecord.developers_log
+
+ activerecord.developers_with_callbacks.delete(jamis)
+ assert_equal ["before_removing#{david.id}", "after_removing#{david.id}", "before_removing#{jamis.id}",
+ "after_removing#{jamis.id}"], activerecord.developers_log
+ end
+
+ def test_has_and_belongs_to_many_does_not_fire_callbacks_on_clear
+ activerecord = projects(:active_record)
+ assert_empty activerecord.developers_log
+ if activerecord.developers_with_callbacks.size == 0
+ activerecord.developers << developers(:david)
+ activerecord.developers << developers(:jamis)
+ activerecord.reload
+ assert activerecord.developers_with_callbacks.size == 2
+ end
+ activerecord.developers_with_callbacks.flat_map { |d| ["before_removing#{d.id}", "after_removing#{d.id}"] }.sort
+ assert activerecord.developers_with_callbacks.clear
+ assert_empty activerecord.developers_log
+ end
+
+ def test_has_many_and_belongs_to_many_callbacks_for_save_on_parent
+ project = Project.new name: "Callbacks"
+ project.developers_with_callbacks.build name: "Jack", salary: 95000
+
+ callback_log = ["before_adding<new>", "after_adding<new>"]
+ assert_equal callback_log, project.developers_log
+ assert project.save
+ assert_equal 1, project.developers_with_callbacks.size
+ assert_equal callback_log, project.developers_log
+ end
+
+ def test_dont_add_if_before_callback_raises_exception
+ assert_not_includes @david.unchangeable_posts, @authorless
+ begin
+ @david.unchangeable_posts << @authorless
+ rescue Exception
+ end
+ assert_empty @david.post_log
+ assert_not_includes @david.unchangeable_posts, @authorless
+ @david.reload
+ assert_not_includes @david.unchangeable_posts, @authorless
+ end
+end
diff --git a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb
new file mode 100644
index 0000000000..a9e22c7643
--- /dev/null
+++ b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb
@@ -0,0 +1,193 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+require "models/comment"
+require "models/author"
+require "models/categorization"
+require "models/category"
+require "models/company"
+require "models/topic"
+require "models/reply"
+require "models/person"
+require "models/vertex"
+require "models/edge"
+
+class CascadedEagerLoadingTest < ActiveRecord::TestCase
+ fixtures :authors, :author_addresses, :mixins, :companies, :posts, :topics, :accounts, :comments,
+ :categorizations, :people, :categories, :edges, :vertices
+
+ def test_eager_association_loading_with_cascaded_two_levels
+ authors = Author.all.merge!(includes: { posts: :comments }, order: "authors.id").to_a
+ assert_equal 3, authors.size
+ assert_equal 5, authors[0].posts.size
+ assert_equal 3, authors[1].posts.size
+ assert_equal 10, authors[0].posts.collect { |post| post.comments.size }.inject(0) { |sum, i| sum + i }
+ end
+
+ def test_eager_association_loading_with_cascaded_two_levels_and_one_level
+ authors = Author.all.merge!(includes: [{ posts: :comments }, :categorizations], order: "authors.id").to_a
+ assert_equal 3, authors.size
+ assert_equal 5, authors[0].posts.size
+ assert_equal 3, authors[1].posts.size
+ assert_equal 10, authors[0].posts.collect { |post| post.comments.size }.inject(0) { |sum, i| sum + i }
+ assert_equal 1, authors[0].categorizations.size
+ assert_equal 2, authors[1].categorizations.size
+ end
+
+ def test_eager_association_loading_with_hmt_does_not_table_name_collide_when_joining_associations
+ authors = Author.joins(:posts).eager_load(:comments).where(posts: { tags_count: 1 }).to_a
+ assert_equal 3, assert_no_queries { authors.size }
+ assert_equal 10, assert_no_queries { authors[0].comments.size }
+ end
+
+ def test_eager_association_loading_grafts_stashed_associations_to_correct_parent
+ assert_equal people(:michael), Person.eager_load(primary_contact: :primary_contact).where("primary_contacts_people_2.first_name = ?", "Susan").order("people.id").first
+ end
+
+ def test_cascaded_eager_association_loading_with_join_for_count
+ categories = Category.joins(:categorizations).includes([{ posts: :comments }, :authors])
+
+ assert_equal 4, categories.count
+ assert_equal 4, categories.to_a.count
+ assert_equal 3, categories.distinct.count
+ assert_equal 3, categories.to_a.uniq.size # Must uniq since instantiating with inner joins will get dupes
+ end
+
+ def test_cascaded_eager_association_loading_with_duplicated_includes
+ categories = Category.includes(:categorizations).includes(categorizations: :author).where("categorizations.id is not null").references(:categorizations)
+ assert_nothing_raised do
+ assert_equal 3, categories.count
+ assert_equal 3, categories.to_a.size
+ end
+ end
+
+ def test_cascaded_eager_association_loading_with_twice_includes_edge_cases
+ categories = Category.includes(categorizations: :author).includes(categorizations: :post).where("posts.id is not null").references(:posts)
+ assert_nothing_raised do
+ assert_equal 3, categories.count
+ assert_equal 3, categories.to_a.size
+ end
+ end
+
+ def test_eager_association_loading_with_join_for_count
+ authors = Author.joins(:special_posts).includes([:posts, :categorizations])
+
+ assert_nothing_raised { authors.count }
+ assert_queries(3) { authors.to_a }
+ end
+
+ def test_eager_association_loading_with_cascaded_two_levels_with_two_has_many_associations
+ authors = Author.all.merge!(includes: { posts: [:comments, :categorizations] }, order: "authors.id").to_a
+ assert_equal 3, authors.size
+ assert_equal 5, authors[0].posts.size
+ assert_equal 3, authors[1].posts.size
+ assert_equal 10, authors[0].posts.collect { |post| post.comments.size }.inject(0) { |sum, i| sum + i }
+ end
+
+ def test_eager_association_loading_with_cascaded_two_levels_and_self_table_reference
+ authors = Author.all.merge!(includes: { posts: [:comments, :author] }, order: "authors.id").to_a
+ assert_equal 3, authors.size
+ assert_equal 5, authors[0].posts.size
+ assert_equal authors(:david).name, authors[0].name
+ assert_equal [authors(:david).name], authors[0].posts.collect { |post| post.author.name }.uniq
+ end
+
+ def test_eager_association_loading_with_cascaded_two_levels_with_condition
+ authors = Author.all.merge!(includes: { posts: :comments }, where: "authors.id=1", order: "authors.id").to_a
+ assert_equal 1, authors.size
+ assert_equal 5, authors[0].posts.size
+ end
+
+ def test_eager_association_loading_with_cascaded_three_levels_by_ping_pong
+ firms = Firm.all.merge!(includes: { account: { firm: :account } }, order: "companies.id").to_a
+ assert_equal 2, firms.size
+ assert_equal firms.first.account, firms.first.account.firm.account
+ assert_equal companies(:first_firm).account, assert_no_queries { firms.first.account.firm.account }
+ assert_equal companies(:first_firm).account.firm.account, assert_no_queries { firms.first.account.firm.account }
+ end
+
+ def test_eager_association_loading_with_has_many_sti
+ topics = Topic.all.merge!(includes: :replies, order: "topics.id").to_a
+ first, second, = topics(:first).replies.size, topics(:second).replies.size
+ assert_no_queries do
+ assert_equal first, topics[0].replies.size
+ assert_equal second, topics[1].replies.size
+ end
+ end
+
+ def test_eager_association_loading_with_has_many_sti_and_subclasses
+ reply = Reply.new(title: "gaga", content: "boo-boo", parent_id: 1)
+ assert reply.save
+
+ topics = Topic.all.merge!(includes: :replies, order: ["topics.id", "replies_topics.id"]).to_a
+ assert_no_queries do
+ assert_equal 2, topics[0].replies.size
+ assert_equal 0, topics[1].replies.size
+ end
+ end
+
+ def test_eager_association_loading_with_belongs_to_sti
+ replies = Reply.all.merge!(includes: :topic, order: "topics.id").to_a
+ assert_includes replies, topics(:second)
+ assert_not_includes replies, topics(:first)
+ assert_equal topics(:first), assert_no_queries { replies.first.topic }
+ end
+
+ def test_eager_association_loading_with_multiple_stis_and_order
+ author = Author.all.merge!(includes: { posts: [ :special_comments, :very_special_comment ] }, order: ["authors.name", "comments.body", "very_special_comments_posts.body"], where: "posts.id = 4").first
+ assert_equal authors(:david), author
+ assert_no_queries do
+ author.posts.first.special_comments
+ author.posts.first.very_special_comment
+ end
+ end
+
+ def test_eager_association_loading_of_stis_with_multiple_references
+ authors = Author.all.merge!(includes: { posts: { special_comments: { post: [ :special_comments, :very_special_comment ] } } }, order: "comments.body, very_special_comments_posts.body", where: "posts.id = 4").to_a
+ assert_equal [authors(:david)], authors
+ assert_no_queries do
+ authors.first.posts.first.special_comments.first.post.special_comments
+ authors.first.posts.first.special_comments.first.post.very_special_comment
+ end
+ end
+
+ def test_eager_association_loading_where_first_level_returns_nil
+ authors = Author.all.merge!(includes: { post_about_thinking: :comments }, order: "authors.id DESC").to_a
+ assert_equal [authors(:bob), authors(:mary), authors(:david)], authors
+ assert_no_queries do
+ authors[2].post_about_thinking.comments.first
+ end
+ end
+
+ def test_preload_through_missing_records
+ post = Post.where.not(author_id: Author.select(:id)).preload(author: { comments: :post }).first!
+ assert_no_queries { assert_nil post.author }
+ end
+
+ def test_eager_association_loading_with_missing_first_record
+ posts = Post.where(id: 3).preload(author: { comments: :post }).to_a
+ assert_equal posts.size, 1
+ end
+
+ def test_eager_association_loading_with_recursive_cascading_four_levels_has_many_through
+ source = Vertex.all.merge!(includes: { sinks: { sinks: { sinks: :sinks } } }, order: "vertices.id").first
+ assert_equal vertices(:vertex_4), assert_no_queries { source.sinks.first.sinks.first.sinks.first }
+ end
+
+ def test_eager_association_loading_with_recursive_cascading_four_levels_has_and_belongs_to_many
+ sink = Vertex.all.merge!(includes: { sources: { sources: { sources: :sources } } }, order: "vertices.id DESC").first
+ assert_equal vertices(:vertex_1), assert_no_queries { sink.sources.first.sources.first.sources.first.sources.first }
+ end
+
+ def test_eager_association_loading_with_cascaded_interdependent_one_level_and_two_levels
+ authors_relation = Author.all.merge!(includes: [:comments, { posts: :categorizations }], order: "authors.id")
+ authors = authors_relation.to_a
+ assert_equal 3, authors.size
+ assert_equal 10, authors[0].comments.size
+ assert_equal 1, authors[1].comments.size
+ assert_equal 5, authors[0].posts.size
+ assert_equal 3, authors[1].posts.size
+ assert_equal 3, authors[0].posts.collect { |post| post.categorizations.size }.inject(0) { |sum, i| sum + i }
+ end
+end
diff --git a/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb b/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb
new file mode 100644
index 0000000000..5fca972aee
--- /dev/null
+++ b/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+require "models/tagging"
+
+module Namespaced
+ class Post < ActiveRecord::Base
+ self.table_name = "posts"
+ has_one :tagging, as: :taggable, class_name: "Tagging"
+
+ def self.polymorphic_name
+ sti_name
+ end
+ end
+end
+
+module PolymorphicFullStiClassNamesSharedTest
+ def setup
+ @old_store_full_sti_class = ActiveRecord::Base.store_full_sti_class
+ ActiveRecord::Base.store_full_sti_class = store_full_sti_class
+
+ post = Namespaced::Post.create(title: "Great stuff", body: "This is not", author_id: 1)
+ @tagging = Tagging.create(taggable: post)
+ end
+
+ def teardown
+ ActiveRecord::Base.store_full_sti_class = @old_store_full_sti_class
+ end
+
+ def test_class_names
+ ActiveRecord::Base.store_full_sti_class = !store_full_sti_class
+ post = Namespaced::Post.find_by_title("Great stuff")
+ assert_nil post.tagging
+
+ ActiveRecord::Base.store_full_sti_class = store_full_sti_class
+ post = Namespaced::Post.find_by_title("Great stuff")
+ assert_equal @tagging, post.tagging
+ end
+
+ def test_class_names_with_includes
+ ActiveRecord::Base.store_full_sti_class = !store_full_sti_class
+ post = Namespaced::Post.includes(:tagging).find_by_title("Great stuff")
+ assert_nil post.tagging
+
+ ActiveRecord::Base.store_full_sti_class = store_full_sti_class
+ post = Namespaced::Post.includes(:tagging).find_by_title("Great stuff")
+ assert_equal @tagging, post.tagging
+ end
+
+ def test_class_names_with_eager_load
+ ActiveRecord::Base.store_full_sti_class = !store_full_sti_class
+ post = Namespaced::Post.eager_load(:tagging).find_by_title("Great stuff")
+ assert_nil post.tagging
+
+ ActiveRecord::Base.store_full_sti_class = store_full_sti_class
+ post = Namespaced::Post.eager_load(:tagging).find_by_title("Great stuff")
+ assert_equal @tagging, post.tagging
+ end
+
+ def test_class_names_with_find_by
+ post = Namespaced::Post.find_by_title("Great stuff")
+
+ ActiveRecord::Base.store_full_sti_class = !store_full_sti_class
+ assert_nil Tagging.find_by(taggable: post)
+
+ ActiveRecord::Base.store_full_sti_class = store_full_sti_class
+ assert_equal @tagging, Tagging.find_by(taggable: post)
+ end
+end
+
+class PolymorphicFullStiClassNamesTest < ActiveRecord::TestCase
+ include PolymorphicFullStiClassNamesSharedTest
+
+ private
+ def store_full_sti_class
+ true
+ end
+end
+
+class PolymorphicNonFullStiClassNamesTest < ActiveRecord::TestCase
+ include PolymorphicFullStiClassNamesSharedTest
+
+ private
+ def store_full_sti_class
+ false
+ end
+end
diff --git a/activerecord/test/cases/associations/eager_load_nested_include_test.rb b/activerecord/test/cases/associations/eager_load_nested_include_test.rb
new file mode 100644
index 0000000000..525ad3197a
--- /dev/null
+++ b/activerecord/test/cases/associations/eager_load_nested_include_test.rb
@@ -0,0 +1,126 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+require "models/tag"
+require "models/author"
+require "models/comment"
+require "models/category"
+require "models/categorization"
+require "models/tagging"
+
+module Remembered
+ extend ActiveSupport::Concern
+
+ included do
+ after_create :remember
+ private
+ def remember; self.class.remembered << self; end
+ end
+
+ module ClassMethods
+ def remembered; @@remembered ||= []; end
+ def sample; @@remembered.sample; end
+ end
+end
+
+class ShapeExpression < ActiveRecord::Base
+ belongs_to :shape, polymorphic: true
+ belongs_to :paint, polymorphic: true
+end
+
+class Circle < ActiveRecord::Base
+ has_many :shape_expressions, as: :shape
+ include Remembered
+end
+class Square < ActiveRecord::Base
+ has_many :shape_expressions, as: :shape
+ include Remembered
+end
+class Triangle < ActiveRecord::Base
+ has_many :shape_expressions, as: :shape
+ include Remembered
+end
+class PaintColor < ActiveRecord::Base
+ has_many :shape_expressions, as: :paint
+ belongs_to :non_poly, foreign_key: "non_poly_one_id", class_name: "NonPolyOne"
+ include Remembered
+end
+class PaintTexture < ActiveRecord::Base
+ has_many :shape_expressions, as: :paint
+ belongs_to :non_poly, foreign_key: "non_poly_two_id", class_name: "NonPolyTwo"
+ include Remembered
+end
+class NonPolyOne < ActiveRecord::Base
+ has_many :paint_colors
+ include Remembered
+end
+class NonPolyTwo < ActiveRecord::Base
+ has_many :paint_textures
+ include Remembered
+end
+
+class EagerLoadPolyAssocsTest < ActiveRecord::TestCase
+ NUM_SIMPLE_OBJS = 50
+ NUM_SHAPE_EXPRESSIONS = 100
+
+ def setup
+ generate_test_object_graphs
+ end
+
+ teardown do
+ [Circle, Square, Triangle, PaintColor, PaintTexture,
+ ShapeExpression, NonPolyOne, NonPolyTwo].each(&:delete_all)
+ end
+
+ def generate_test_object_graphs
+ 1.upto(NUM_SIMPLE_OBJS) do
+ [Circle, Square, Triangle, NonPolyOne, NonPolyTwo].map(&:create!)
+ end
+ 1.upto(NUM_SIMPLE_OBJS) do
+ PaintColor.create!(non_poly_one_id: NonPolyOne.sample.id)
+ PaintTexture.create!(non_poly_two_id: NonPolyTwo.sample.id)
+ end
+ 1.upto(NUM_SHAPE_EXPRESSIONS) do
+ shape_type = [Circle, Square, Triangle].sample
+ paint_type = [PaintColor, PaintTexture].sample
+ ShapeExpression.create!(shape_type: shape_type.to_s, shape_id: shape_type.sample.id,
+ paint_type: paint_type.to_s, paint_id: paint_type.sample.id)
+ end
+ end
+
+ def test_include_query
+ res = ShapeExpression.all.merge!(includes: [ :shape, { paint: :non_poly } ]).to_a
+ assert_equal NUM_SHAPE_EXPRESSIONS, res.size
+ assert_no_queries do
+ res.each do |se|
+ assert_not_nil se.paint.non_poly, "this is the association that was loading incorrectly before the change"
+ assert_not_nil se.shape, "just making sure other associations still work"
+ end
+ end
+ end
+end
+
+class EagerLoadNestedIncludeWithMissingDataTest < ActiveRecord::TestCase
+ def setup
+ @davey_mcdave = Author.create(name: "Davey McDave")
+ @first_post = @davey_mcdave.posts.create(title: "Davey Speaks", body: "Expressive wordage")
+ @first_comment = @first_post.comments.create(body: "Inflamatory doublespeak")
+ @first_categorization = @davey_mcdave.categorizations.create(category: Category.first, post: @first_post)
+ end
+
+ teardown do
+ @davey_mcdave.destroy
+ @first_post.destroy
+ @first_comment.destroy
+ @first_categorization.destroy
+ end
+
+ def test_missing_data_in_a_nested_include_should_not_cause_errors_when_constructing_objects
+ assert_nothing_raised do
+ # @davey_mcdave doesn't have any author_favorites
+ includes = { posts: :comments, categorizations: :category, author_favorites: :favorite_author }
+ Author.all.merge!(includes: includes, where: { authors: { name: @davey_mcdave.name } }, order: "categories.name").to_a
+ end
+ end
+end
diff --git a/activerecord/test/cases/associations/eager_singularization_test.rb b/activerecord/test/cases/associations/eager_singularization_test.rb
new file mode 100644
index 0000000000..420a5a805b
--- /dev/null
+++ b/activerecord/test/cases/associations/eager_singularization_test.rb
@@ -0,0 +1,148 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class EagerSingularizationTest < ActiveRecord::TestCase
+ class Virus < ActiveRecord::Base
+ belongs_to :octopus
+ end
+
+ class Octopus < ActiveRecord::Base
+ has_one :virus
+ end
+
+ class Pass < ActiveRecord::Base
+ belongs_to :bus
+ end
+
+ class Bus < ActiveRecord::Base
+ has_many :passes
+ end
+
+ class Mess < ActiveRecord::Base
+ has_and_belongs_to_many :crises
+ end
+
+ class Crisis < ActiveRecord::Base
+ has_and_belongs_to_many :messes
+ has_many :analyses, dependent: :destroy
+ has_many :successes, through: :analyses
+ has_many :dresses, dependent: :destroy
+ has_many :compresses, through: :dresses
+ end
+
+ class Analysis < ActiveRecord::Base
+ belongs_to :crisis
+ belongs_to :success
+ end
+
+ class Success < ActiveRecord::Base
+ has_many :analyses, dependent: :destroy
+ has_many :crises, through: :analyses
+ end
+
+ class Dress < ActiveRecord::Base
+ belongs_to :crisis
+ has_many :compresses
+ end
+
+ class Compress < ActiveRecord::Base
+ belongs_to :dress
+ end
+
+ def setup
+ connection.create_table :viri do |t|
+ t.column :octopus_id, :integer
+ t.column :species, :string
+ end
+ connection.create_table :octopi do |t|
+ t.column :species, :string
+ end
+ connection.create_table :passes do |t|
+ t.column :bus_id, :integer
+ t.column :rides, :integer
+ end
+ connection.create_table :buses do |t|
+ t.column :name, :string
+ end
+ connection.create_table :crises_messes, id: false do |t|
+ t.column :crisis_id, :integer
+ t.column :mess_id, :integer
+ end
+ connection.create_table :messes do |t|
+ t.column :name, :string
+ end
+ connection.create_table :crises do |t|
+ t.column :name, :string
+ end
+ connection.create_table :successes do |t|
+ t.column :name, :string
+ end
+ connection.create_table :analyses do |t|
+ t.column :crisis_id, :integer
+ t.column :success_id, :integer
+ end
+ connection.create_table :dresses do |t|
+ t.column :crisis_id, :integer
+ end
+ connection.create_table :compresses do |t|
+ t.column :dress_id, :integer
+ end
+ end
+
+ teardown do
+ connection.drop_table :viri
+ connection.drop_table :octopi
+ connection.drop_table :passes
+ connection.drop_table :buses
+ connection.drop_table :crises_messes
+ connection.drop_table :messes
+ connection.drop_table :crises
+ connection.drop_table :successes
+ connection.drop_table :analyses
+ connection.drop_table :dresses
+ connection.drop_table :compresses
+ end
+
+ def test_eager_no_extra_singularization_belongs_to
+ assert_nothing_raised do
+ Virus.all.merge!(includes: :octopus).to_a
+ end
+ end
+
+ def test_eager_no_extra_singularization_has_one
+ assert_nothing_raised do
+ Octopus.all.merge!(includes: :virus).to_a
+ end
+ end
+
+ def test_eager_no_extra_singularization_has_many
+ assert_nothing_raised do
+ Bus.all.merge!(includes: :passes).to_a
+ end
+ end
+
+ def test_eager_no_extra_singularization_has_and_belongs_to_many
+ assert_nothing_raised do
+ Crisis.all.merge!(includes: :messes).to_a
+ Mess.all.merge!(includes: :crises).to_a
+ end
+ end
+
+ def test_eager_no_extra_singularization_has_many_through_belongs_to
+ assert_nothing_raised do
+ Crisis.all.merge!(includes: :successes).to_a
+ end
+ end
+
+ def test_eager_no_extra_singularization_has_many_through_has_many
+ assert_nothing_raised do
+ Crisis.all.merge!(includes: :compresses).to_a
+ end
+ end
+
+ private
+ def connection
+ ActiveRecord::Base.connection
+ end
+end
diff --git a/activerecord/test/cases/associations/eager_test.rb b/activerecord/test/cases/associations/eager_test.rb
new file mode 100644
index 0000000000..b37e59038e
--- /dev/null
+++ b/activerecord/test/cases/associations/eager_test.rb
@@ -0,0 +1,1625 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+require "models/tagging"
+require "models/tag"
+require "models/comment"
+require "models/author"
+require "models/essay"
+require "models/category"
+require "models/company"
+require "models/person"
+require "models/reader"
+require "models/owner"
+require "models/pet"
+require "models/reference"
+require "models/job"
+require "models/subscriber"
+require "models/subscription"
+require "models/book"
+require "models/citation"
+require "models/developer"
+require "models/computer"
+require "models/project"
+require "models/member"
+require "models/membership"
+require "models/club"
+require "models/categorization"
+require "models/sponsor"
+require "models/mentor"
+require "models/contract"
+
+class EagerLoadingTooManyIdsTest < ActiveRecord::TestCase
+ fixtures :citations
+
+ def test_preloading_too_many_ids
+ assert_equal Citation.count, Citation.preload(:reference_of).to_a.size
+ end
+
+ def test_eager_loading_too_may_ids
+ assert_equal Citation.count, Citation.eager_load(:citations).offset(0).size
+ end
+end
+
+class EagerAssociationTest < ActiveRecord::TestCase
+ fixtures :posts, :comments, :authors, :essays, :author_addresses, :categories, :categories_posts,
+ :companies, :accounts, :tags, :taggings, :people, :readers, :categorizations,
+ :owners, :pets, :author_favorites, :jobs, :references, :subscribers, :subscriptions, :books,
+ :developers, :projects, :developers_projects, :members, :memberships, :clubs, :sponsors
+
+ def test_eager_with_has_one_through_join_model_with_conditions_on_the_through
+ member = Member.all.merge!(includes: :favourite_club).find(members(:some_other_guy).id)
+ assert_nil member.favourite_club
+ end
+
+ def test_should_work_inverse_of_with_eager_load
+ author = authors(:david)
+ assert_same author, author.posts.first.author
+ assert_same author, author.posts.eager_load(:comments).first.author
+ end
+
+ def test_loading_with_one_association
+ posts = Post.all.merge!(includes: :comments).to_a
+ post = posts.find { |p| p.id == 1 }
+ assert_equal 2, post.comments.size
+ assert_includes post.comments, comments(:greetings)
+
+ post = Post.all.merge!(includes: :comments, where: "posts.title = 'Welcome to the weblog'").first
+ assert_equal 2, post.comments.size
+ assert_includes post.comments, comments(:greetings)
+
+ posts = Post.all.merge!(includes: :last_comment).to_a
+ post = posts.find { |p| p.id == 1 }
+ assert_equal Post.find(1).last_comment, post.last_comment
+ end
+
+ def test_loading_with_one_association_with_non_preload
+ posts = Post.all.merge!(includes: :last_comment, order: "comments.id DESC").to_a
+ post = posts.find { |p| p.id == 1 }
+ assert_equal Post.find(1).last_comment, post.last_comment
+ end
+
+ def test_loading_conditions_with_or
+ posts = authors(:david).posts.references(:comments).merge(
+ includes: :comments,
+ where: "comments.body like 'Normal%' OR comments.#{QUOTED_TYPE} = 'SpecialComment'"
+ ).to_a
+ assert_nil posts.detect { |p| p.author_id != authors(:david).id },
+ "expected to find only david's posts"
+ end
+
+ def test_loading_with_scope_including_joins
+ member = Member.first
+ assert_equal members(:groucho), member
+ assert_equal clubs(:boring_club), member.general_club
+
+ member = Member.preload(:general_club).first
+ assert_equal members(:groucho), member
+ assert_equal clubs(:boring_club), member.general_club
+
+ member = Member.eager_load(:general_club).first
+ assert_equal members(:groucho), member
+ assert_equal clubs(:boring_club), member.general_club
+ end
+
+ def test_loading_association_with_same_table_joins
+ super_memberships = [memberships(:super_membership_of_boring_club)]
+
+ member = Member.joins(:favourite_memberships).first
+ assert_equal members(:groucho), member
+ assert_equal super_memberships, member.super_memberships
+
+ member = Member.joins(:favourite_memberships).preload(:super_memberships).first
+ assert_equal members(:groucho), member
+ assert_equal super_memberships, member.super_memberships
+
+ member = Member.joins(:favourite_memberships).eager_load(:super_memberships).first
+ assert_equal members(:groucho), member
+ assert_equal super_memberships, member.super_memberships
+ end
+
+ def test_loading_association_with_intersection_joins
+ member = Member.joins(:current_membership).first
+ assert_equal members(:groucho), member
+ assert_equal clubs(:boring_club), member.club
+ assert_equal memberships(:membership_of_boring_club), member.current_membership
+
+ member = Member.joins(:current_membership).preload(:club, :current_membership).first
+ assert_equal members(:groucho), member
+ assert_equal clubs(:boring_club), member.club
+ assert_equal memberships(:membership_of_boring_club), member.current_membership
+
+ member = Member.joins(:current_membership).eager_load(:club, :current_membership).first
+ assert_equal members(:groucho), member
+ assert_equal clubs(:boring_club), member.club
+ assert_equal memberships(:membership_of_boring_club), member.current_membership
+ end
+
+ def test_loading_associations_dont_leak_instance_state
+ assertions = ->(firm) {
+ assert_equal companies(:first_firm), firm
+
+ assert_predicate firm.association(:readonly_account), :loaded?
+ assert_predicate firm.association(:accounts), :loaded?
+
+ assert_equal accounts(:signals37), firm.readonly_account
+ assert_equal [accounts(:signals37)], firm.accounts
+
+ assert_predicate firm.readonly_account, :readonly?
+ assert firm.accounts.none?(&:readonly?)
+ }
+
+ assertions.call(Firm.preload(:readonly_account, :accounts).first)
+ assertions.call(Firm.eager_load(:readonly_account, :accounts).first)
+ end
+
+ def test_with_ordering
+ list = Post.all.merge!(includes: :comments, order: "posts.id DESC").to_a
+ [:other_by_mary, :other_by_bob, :misc_by_mary, :misc_by_bob, :eager_other,
+ :sti_habtm, :sti_post_and_comments, :sti_comments, :authorless, :thinking, :welcome
+ ].each_with_index do |post, index|
+ assert_equal posts(post), list[index]
+ end
+ end
+
+ def test_has_many_through_with_order
+ authors = Author.includes(:favorite_authors).to_a
+ assert authors.count > 0
+ assert_no_queries { authors.map(&:favorite_authors) }
+ end
+
+ def test_eager_loaded_has_one_association_with_references_does_not_run_additional_queries
+ Post.update_all(author_id: nil)
+ authors = Author.includes(:post).references(:post).to_a
+ assert authors.count > 0
+ assert_no_queries { authors.map(&:post) }
+ end
+
+ def test_calculate_with_string_in_from_and_eager_loading
+ assert_equal 10, Post.from("authors, posts").eager_load(:comments).where("posts.author_id = authors.id").count
+ end
+
+ def test_with_two_tables_in_from_without_getting_double_quoted
+ posts = Post.select("posts.*").from("authors, posts").eager_load(:comments).where("posts.author_id = authors.id").order("posts.id").to_a
+ assert_equal 2, posts.first.comments.size
+ end
+
+ def test_loading_with_multiple_associations
+ posts = Post.all.merge!(includes: [ :comments, :author, :categories ], order: "posts.id").to_a
+ assert_equal 2, posts.first.comments.size
+ assert_equal 2, posts.first.categories.size
+ assert_includes posts.first.comments, comments(:greetings)
+ end
+
+ def test_duplicate_middle_objects
+ comments = Comment.all.merge!(where: "post_id = 1", includes: [post: :author]).to_a
+ assert_no_queries do
+ comments.each { |comment| comment.post.author.name }
+ end
+ end
+
+ def test_preloading_has_many_in_multiple_queries_with_more_ids_than_database_can_handle
+ assert_called(Comment.connection, :in_clause_length, returns: 5) do
+ posts = Post.all.merge!(includes: :comments).to_a
+ assert_equal 11, posts.size
+ end
+ end
+
+ def test_preloading_has_many_in_one_queries_when_database_has_no_limit_on_ids_it_can_handle
+ assert_called(Comment.connection, :in_clause_length, returns: nil) do
+ posts = Post.all.merge!(includes: :comments).to_a
+ assert_equal 11, posts.size
+ end
+ end
+
+ def test_preloading_habtm_in_multiple_queries_with_more_ids_than_database_can_handle
+ assert_called(Comment.connection, :in_clause_length, times: 2, returns: 5) do
+ posts = Post.all.merge!(includes: :categories).to_a
+ assert_equal 11, posts.size
+ end
+ end
+
+ def test_preloading_habtm_in_one_queries_when_database_has_no_limit_on_ids_it_can_handle
+ assert_called(Comment.connection, :in_clause_length, times: 2, returns: nil) do
+ posts = Post.all.merge!(includes: :categories).to_a
+ assert_equal 11, posts.size
+ end
+ end
+
+ def test_load_associated_records_in_one_query_when_adapter_has_no_limit
+ assert_called(Comment.connection, :in_clause_length, returns: nil) do
+ post = posts(:welcome)
+ assert_queries(2) do
+ Post.includes(:comments).where(id: post.id).to_a
+ end
+ end
+ end
+
+ def test_load_associated_records_in_several_queries_when_many_ids_passed
+ assert_called(Comment.connection, :in_clause_length, returns: 1) do
+ post1, post2 = posts(:welcome), posts(:thinking)
+ assert_queries(3) do
+ Post.includes(:comments).where(id: [post1.id, post2.id]).to_a
+ end
+ end
+ end
+
+ def test_load_associated_records_in_one_query_when_a_few_ids_passed
+ assert_called(Comment.connection, :in_clause_length, returns: 3) do
+ post = posts(:welcome)
+ assert_queries(2) do
+ Post.includes(:comments).where(id: post.id).to_a
+ end
+ end
+ end
+
+ def test_including_duplicate_objects_from_belongs_to
+ popular_post = Post.create!(title: "foo", body: "I like cars!")
+ comment = popular_post.comments.create!(body: "lol")
+ popular_post.readers.create!(person: people(:michael))
+ popular_post.readers.create!(person: people(:david))
+
+ readers = Reader.all.merge!(where: ["post_id = ?", popular_post.id],
+ includes: { post: :comments }).to_a
+ readers.each do |reader|
+ assert_equal [comment], reader.post.comments
+ end
+ end
+
+ def test_including_duplicate_objects_from_has_many
+ car_post = Post.create!(title: "foo", body: "I like cars!")
+ car_post.categories << categories(:general)
+ car_post.categories << categories(:technology)
+
+ comment = car_post.comments.create!(body: "hmm")
+ categories = Category.all.merge!(where: { "posts.id" => car_post.id },
+ includes: { posts: :comments }).to_a
+ categories.each do |category|
+ assert_equal [comment], category.posts[0].comments
+ end
+ end
+
+ def test_associations_loaded_for_all_records
+ post = Post.create!(title: "foo", body: "I like cars!")
+ SpecialComment.create!(body: "Come on!", post: post)
+ first_category = Category.create! name: "First!", posts: [post]
+ second_category = Category.create! name: "Second!", posts: [post]
+
+ categories = Category.where(id: [first_category.id, second_category.id]).includes(posts: :special_comments)
+ assert_equal categories.map { |category| category.posts.first.special_comments.loaded? }, [true, true]
+ end
+
+ def test_finding_with_includes_on_has_many_association_with_same_include_includes_only_once
+ author_id = authors(:david).id
+ author = assert_queries(3) { Author.all.merge!(includes: { posts_with_comments: :comments }).find(author_id) } # find the author, then find the posts, then find the comments
+ author.posts_with_comments.each do |post_with_comments|
+ assert_equal post_with_comments.comments.length, post_with_comments.comments.count
+ assert_nil post_with_comments.comments.to_a.uniq!
+ end
+ end
+
+ def test_finding_with_includes_on_has_one_association_with_same_include_includes_only_once
+ author = authors(:david)
+ post = author.post_about_thinking_with_last_comment
+ last_comment = post.last_comment
+ author = assert_queries(3) { Author.all.merge!(includes: { post_about_thinking_with_last_comment: :last_comment }).find(author.id) } # find the author, then find the posts, then find the comments
+ assert_no_queries do
+ assert_equal post, author.post_about_thinking_with_last_comment
+ assert_equal last_comment, author.post_about_thinking_with_last_comment.last_comment
+ end
+ end
+
+ def test_finding_with_includes_on_belongs_to_association_with_same_include_includes_only_once
+ post = posts(:welcome)
+ author = post.author
+ author_address = author.author_address
+ post = assert_queries(3) { Post.all.merge!(includes: { author_with_address: :author_address }).find(post.id) } # find the post, then find the author, then find the address
+ assert_no_queries do
+ assert_equal author, post.author_with_address
+ assert_equal author_address, post.author_with_address.author_address
+ end
+ end
+
+ def test_finding_with_includes_on_null_belongs_to_association_with_same_include_includes_only_once
+ post = posts(:welcome)
+ post.update!(author: nil)
+ post = assert_queries(1) { Post.all.merge!(includes: { author_with_address: :author_address }).find(post.id) }
+ # find the post, then find the author which is null so no query for the author or address
+ assert_no_queries do
+ assert_nil post.author_with_address
+ end
+ end
+
+ def test_finding_with_includes_on_null_belongs_to_polymorphic_association
+ sponsor = sponsors(:moustache_club_sponsor_for_groucho)
+ sponsor.update!(sponsorable: nil)
+ sponsor = assert_queries(1) { Sponsor.all.merge!(includes: :sponsorable).find(sponsor.id) }
+ assert_no_queries do
+ assert_nil sponsor.sponsorable
+ end
+ end
+
+ def test_finding_with_includes_on_empty_polymorphic_type_column
+ sponsor = sponsors(:moustache_club_sponsor_for_groucho)
+ sponsor.update!(sponsorable_type: "", sponsorable_id: nil) # sponsorable_type column might be declared NOT NULL
+ sponsor = assert_queries(1) do
+ assert_nothing_raised { Sponsor.all.merge!(includes: :sponsorable).find(sponsor.id) }
+ end
+ assert_no_queries do
+ assert_nil sponsor.sponsorable
+ end
+ end
+
+ def test_loading_from_an_association
+ posts = authors(:david).posts.merge(includes: :comments, order: "posts.id").to_a
+ assert_equal 2, posts.first.comments.size
+ end
+
+ def test_loading_from_an_association_that_has_a_hash_of_conditions
+ assert_not_empty Author.all.merge!(includes: :hello_posts_with_hash_conditions).find(authors(:david).id).hello_posts
+ end
+
+ def test_loading_with_no_associations
+ assert_nil Post.all.merge!(includes: :author).find(posts(:authorless).id).author
+ end
+
+ # Regression test for 21c75e5
+ def test_nested_loading_does_not_raise_exception_when_association_does_not_exist
+ assert_nothing_raised do
+ Post.all.merge!(includes: { author: :author_addresss }).find(posts(:authorless).id)
+ end
+ end
+
+ def test_three_level_nested_preloading_does_not_raise_exception_when_association_does_not_exist
+ post_id = Comment.where(author_id: nil).where.not(post_id: nil).first.post_id
+
+ assert_nothing_raised do
+ Post.preload(comments: [{ author: :essays }]).find(post_id)
+ end
+ end
+
+ def test_nested_loading_through_has_one_association
+ aa = AuthorAddress.all.merge!(includes: { author: :posts }).find(author_addresses(:david_address).id)
+ assert_equal aa.author.posts.count, aa.author.posts.length
+ end
+
+ def test_nested_loading_through_has_one_association_with_order
+ aa = AuthorAddress.all.merge!(includes: { author: :posts }, order: "author_addresses.id").find(author_addresses(:david_address).id)
+ assert_equal aa.author.posts.count, aa.author.posts.length
+ end
+
+ def test_nested_loading_through_has_one_association_with_order_on_association
+ aa = AuthorAddress.all.merge!(includes: { author: :posts }, order: "authors.id").find(author_addresses(:david_address).id)
+ assert_equal aa.author.posts.count, aa.author.posts.length
+ end
+
+ def test_nested_loading_through_has_one_association_with_order_on_nested_association
+ aa = AuthorAddress.all.merge!(includes: { author: :posts }, order: "posts.id").find(author_addresses(:david_address).id)
+ assert_equal aa.author.posts.count, aa.author.posts.length
+ end
+
+ def test_nested_loading_through_has_one_association_with_conditions
+ aa = AuthorAddress.references(:author_addresses).merge(
+ includes: { author: :posts },
+ where: "author_addresses.id > 0"
+ ).find author_addresses(:david_address).id
+ assert_equal aa.author.posts.count, aa.author.posts.length
+ end
+
+ def test_nested_loading_through_has_one_association_with_conditions_on_association
+ aa = AuthorAddress.references(:authors).merge(
+ includes: { author: :posts },
+ where: "authors.id > 0"
+ ).find author_addresses(:david_address).id
+ assert_equal aa.author.posts.count, aa.author.posts.length
+ end
+
+ def test_nested_loading_through_has_one_association_with_conditions_on_nested_association
+ aa = AuthorAddress.references(:posts).merge(
+ includes: { author: :posts },
+ where: "posts.id > 0"
+ ).find author_addresses(:david_address).id
+ assert_equal aa.author.posts.count, aa.author.posts.length
+ end
+
+ def test_eager_association_loading_with_belongs_to_and_foreign_keys
+ pets = Pet.all.merge!(includes: :owner).to_a
+ assert_equal 4, pets.length
+ end
+
+ def test_eager_association_loading_with_belongs_to
+ comments = Comment.all.merge!(includes: :post).to_a
+ assert_equal 11, comments.length
+ titles = comments.map { |c| c.post.title }
+ assert_includes titles, posts(:welcome).title
+ assert_includes titles, posts(:sti_post_and_comments).title
+ end
+
+ def test_eager_association_loading_with_belongs_to_and_limit
+ comments = Comment.all.merge!(includes: :post, limit: 5, order: "comments.id").to_a
+ assert_equal 5, comments.length
+ assert_equal [1, 2, 3, 5, 6], comments.collect(&:id)
+ end
+
+ def test_eager_association_loading_with_belongs_to_and_limit_and_conditions
+ comments = Comment.all.merge!(includes: :post, where: "post_id = 4", limit: 3, order: "comments.id").to_a
+ assert_equal 3, comments.length
+ assert_equal [5, 6, 7], comments.collect(&:id)
+ end
+
+ def test_eager_association_loading_with_belongs_to_and_limit_and_offset
+ comments = Comment.all.merge!(includes: :post, limit: 3, offset: 2, order: "comments.id").to_a
+ assert_equal 3, comments.length
+ assert_equal [3, 5, 6], comments.collect(&:id)
+ end
+
+ def test_eager_association_loading_with_belongs_to_and_limit_and_offset_and_conditions
+ comments = Comment.all.merge!(includes: :post, where: "post_id = 4", limit: 3, offset: 1, order: "comments.id").to_a
+ assert_equal 3, comments.length
+ assert_equal [6, 7, 8], comments.collect(&:id)
+ end
+
+ def test_eager_association_loading_with_belongs_to_and_limit_and_offset_and_conditions_array
+ comments = Comment.all.merge!(includes: :post, where: ["post_id = ?", 4], limit: 3, offset: 1, order: "comments.id").to_a
+ assert_equal 3, comments.length
+ assert_equal [6, 7, 8], comments.collect(&:id)
+ end
+
+ def test_eager_association_loading_with_belongs_to_and_conditions_string_with_unquoted_table_name
+ assert_nothing_raised do
+ Comment.includes(:post).references(:posts).where("posts.id = ?", 4)
+ end
+ end
+
+ def test_eager_association_loading_with_belongs_to_and_conditions_hash
+ comments = []
+ assert_nothing_raised do
+ comments = Comment.all.merge!(includes: :post, where: { posts: { id: 4 } }, limit: 3, order: "comments.id").to_a
+ end
+ assert_equal 3, comments.length
+ assert_equal [5, 6, 7], comments.collect(&:id)
+ assert_no_queries do
+ comments.first.post
+ end
+ end
+
+ def test_eager_association_loading_with_belongs_to_and_conditions_string_with_quoted_table_name
+ quoted_posts_id = Comment.connection.quote_table_name("posts") + "." + Comment.connection.quote_column_name("id")
+ assert_nothing_raised do
+ Comment.includes(:post).references(:posts).where("#{quoted_posts_id} = ?", 4)
+ end
+ end
+
+ def test_eager_association_loading_with_belongs_to_and_order_string_with_unquoted_table_name
+ assert_nothing_raised do
+ Comment.all.merge!(includes: :post, order: "posts.id").to_a
+ end
+ end
+
+ def test_eager_association_loading_with_belongs_to_and_order_string_with_quoted_table_name
+ quoted_posts_id = Comment.connection.quote_table_name("posts") + "." + Comment.connection.quote_column_name("id")
+ assert_nothing_raised do
+ Comment.includes(:post).references(:posts).order(Arel.sql(quoted_posts_id))
+ end
+ end
+
+ def test_eager_association_loading_with_belongs_to_and_limit_and_multiple_associations
+ posts = Post.all.merge!(includes: [:author, :very_special_comment], limit: 1, order: "posts.id").to_a
+ assert_equal 1, posts.length
+ assert_equal [1], posts.collect(&:id)
+ end
+
+ def test_eager_association_loading_with_belongs_to_and_limit_and_offset_and_multiple_associations
+ posts = Post.all.merge!(includes: [:author, :very_special_comment], limit: 1, offset: 1, order: "posts.id").to_a
+ assert_equal 1, posts.length
+ assert_equal [2], posts.collect(&:id)
+ end
+
+ def test_eager_association_loading_with_belongs_to_inferred_foreign_key_from_association_name
+ author_favorite = AuthorFavorite.all.merge!(includes: :favorite_author).first
+ assert_equal authors(:mary), assert_no_queries { author_favorite.favorite_author }
+ end
+
+ def test_eager_load_belongs_to_quotes_table_and_column_names
+ job = Job.includes(:ideal_reference).find jobs(:unicyclist).id
+ references(:michael_unicyclist)
+ assert_no_queries { assert_equal references(:michael_unicyclist), job.ideal_reference }
+ end
+
+ def test_eager_load_has_one_quotes_table_and_column_names
+ michael = Person.all.merge!(includes: :favourite_reference).find(people(:michael).id)
+ references(:michael_unicyclist)
+ assert_no_queries { assert_equal references(:michael_unicyclist), michael.favourite_reference }
+ end
+
+ def test_eager_load_has_many_quotes_table_and_column_names
+ michael = Person.all.merge!(includes: :references).find(people(:michael).id)
+ references(:michael_magician, :michael_unicyclist)
+ assert_no_queries { assert_equal references(:michael_magician, :michael_unicyclist), michael.references.sort_by(&:id) }
+ end
+
+ def test_eager_load_has_many_through_quotes_table_and_column_names
+ michael = Person.all.merge!(includes: :jobs).find(people(:michael).id)
+ jobs(:magician, :unicyclist)
+ assert_no_queries { assert_equal jobs(:unicyclist, :magician), michael.jobs.sort_by(&:id) }
+ end
+
+ def test_eager_load_has_many_with_string_keys
+ subscriptions = subscriptions(:webster_awdr, :webster_rfr)
+ subscriber = Subscriber.all.merge!(includes: :subscriptions).find(subscribers(:second).id)
+ assert_equal subscriptions, subscriber.subscriptions.sort_by(&:id)
+ end
+
+ def test_string_id_column_joins
+ s = Subscriber.create! do |c|
+ c.id = "PL"
+ end
+
+ b = Book.create!
+
+ Subscription.create!(subscriber_id: "PL", book_id: b.id)
+ s.reload
+ s.book_ids = s.book_ids
+ end
+
+ def test_eager_load_has_many_through_with_string_keys
+ books = books(:awdr, :rfr)
+ subscriber = Subscriber.all.merge!(includes: :books).find(subscribers(:second).id)
+ assert_equal books, subscriber.books.sort_by(&:id)
+ end
+
+ def test_eager_load_belongs_to_with_string_keys
+ subscriber = subscribers(:second)
+ subscription = Subscription.all.merge!(includes: :subscriber).find(subscriptions(:webster_awdr).id)
+ assert_equal subscriber, subscription.subscriber
+ end
+
+ def test_eager_association_loading_with_explicit_join
+ posts = Post.all.merge!(includes: :comments, joins: "INNER JOIN authors ON posts.author_id = authors.id AND authors.name = 'Mary'", limit: 1, order: "author_id").to_a
+ assert_equal 1, posts.length
+ end
+
+ def test_eager_with_has_many_through
+ posts_with_comments = people(:michael).posts.merge(includes: :comments, order: "posts.id").to_a
+ posts_with_author = people(:michael).posts.merge(includes: :author, order: "posts.id").to_a
+ posts_with_comments_and_author = people(:michael).posts.merge(includes: [ :comments, :author ], order: "posts.id").to_a
+ assert_equal 2, posts_with_comments.inject(0) { |sum, post| sum + post.comments.size }
+ assert_equal authors(:david), assert_no_queries { posts_with_author.first.author }
+ assert_equal authors(:david), assert_no_queries { posts_with_comments_and_author.first.author }
+ end
+
+ def test_eager_with_has_many_through_a_belongs_to_association
+ author = authors(:mary)
+ Post.create!(author: author, title: "TITLE", body: "BODY")
+ author.author_favorites.create(favorite_author_id: 1)
+ author.author_favorites.create(favorite_author_id: 2)
+ posts_with_author_favorites = author.posts.merge(includes: :author_favorites).to_a
+ assert_no_queries { posts_with_author_favorites.first.author_favorites.first.author_id }
+ end
+
+ def test_eager_with_has_many_through_an_sti_join_model
+ author = Author.all.merge!(includes: :special_post_comments, order: "authors.id").first
+ assert_equal [comments(:does_it_hurt)], assert_no_queries { author.special_post_comments }
+ end
+
+ def test_preloading_has_many_through_with_implicit_source
+ authors = Author.includes(:very_special_comments).to_a
+ assert_no_queries do
+ special_comment_authors = authors.map { |author| [author.name, author.very_special_comments.size] }
+ assert_equal [["David", 1], ["Mary", 0], ["Bob", 0]], special_comment_authors
+ end
+ end
+
+ def test_eager_with_has_many_through_an_sti_join_model_with_conditions_on_both
+ author = Author.all.merge!(includes: :special_nonexistent_post_comments, order: "authors.id").first
+ assert_equal [], author.special_nonexistent_post_comments
+ end
+
+ def test_eager_with_has_many_through_join_model_with_conditions
+ assert_equal Author.all.merge!(includes: :hello_post_comments,
+ order: "authors.id").first.hello_post_comments.sort_by(&:id),
+ Author.all.merge!(order: "authors.id").first.hello_post_comments.sort_by(&:id)
+ end
+
+ def test_eager_with_has_many_through_join_model_with_conditions_on_top_level
+ assert_equal comments(:more_greetings), Author.all.merge!(includes: :comments_with_order_and_conditions).find(authors(:david).id).comments_with_order_and_conditions.first
+ end
+
+ def test_eager_with_has_many_through_join_model_with_include
+ author_comments = Author.all.merge!(includes: :comments_with_include).find(authors(:david).id).comments_with_include.to_a
+ assert_no_queries do
+ author_comments.first.post.title
+ end
+ end
+
+ def test_eager_with_has_many_through_with_conditions_join_model_with_include
+ post_tags = Post.find(posts(:welcome).id).misc_tags
+ eager_post_tags = Post.all.merge!(includes: :misc_tags).find(1).misc_tags
+ assert_equal post_tags, eager_post_tags
+ end
+
+ def test_eager_with_has_many_through_join_model_ignores_default_includes
+ assert_nothing_raised do
+ authors(:david).comments_on_posts_with_default_include.to_a
+ end
+ end
+
+ def test_eager_with_has_many_and_limit
+ posts = Post.all.merge!(order: "posts.id asc", includes: [ :author, :comments ], limit: 2).to_a
+ assert_equal 2, posts.size
+ assert_equal 3, posts.inject(0) { |sum, post| sum + post.comments.size }
+ end
+
+ def test_eager_with_has_many_and_limit_and_conditions
+ posts = Post.all.merge!(includes: [ :author, :comments ], limit: 2, where: "posts.body = 'hello'", order: "posts.id").to_a
+ assert_equal 2, posts.size
+ assert_equal [4, 5], posts.collect(&:id)
+ end
+
+ def test_eager_with_has_many_and_limit_and_conditions_array
+ posts = Post.all.merge!(includes: [ :author, :comments ], limit: 2, where: [ "posts.body = ?", "hello" ], order: "posts.id").to_a
+ assert_equal 2, posts.size
+ assert_equal [4, 5], posts.collect(&:id)
+ end
+
+ def test_eager_with_has_many_and_limit_and_conditions_array_on_the_eagers
+ posts = Post.includes(:author, :comments).limit(2).references(:author).where("authors.name = ?", "David")
+ assert_equal 2, posts.size
+
+ count = Post.includes(:author, :comments).limit(2).references(:author).where("authors.name = ?", "David").count
+ assert_equal posts.size, count
+ end
+
+ def test_eager_with_has_many_and_limit_and_high_offset
+ posts = Post.all.merge!(includes: [ :author, :comments ], limit: 2, offset: 10, where: { "authors.name" => "David" }).to_a
+ assert_equal 0, posts.size
+ end
+
+ def test_eager_with_has_many_and_limit_and_high_offset_and_multiple_array_conditions
+ assert_queries(1) do
+ posts = Post.references(:authors, :comments).
+ merge(includes: [ :author, :comments ], limit: 2, offset: 10,
+ where: [ "authors.name = ? and comments.body = ?", "David", "go crazy" ]).to_a
+ assert_equal 0, posts.size
+ end
+ end
+
+ def test_eager_with_has_many_and_limit_and_high_offset_and_multiple_hash_conditions
+ assert_queries(1) do
+ posts = Post.all.merge!(includes: [ :author, :comments ], limit: 2, offset: 10,
+ where: { "authors.name" => "David", "comments.body" => "go crazy" }).to_a
+ assert_equal 0, posts.size
+ end
+ end
+
+ def test_count_eager_with_has_many_and_limit_and_high_offset
+ posts = Post.all.merge!(includes: [ :author, :comments ], limit: 2, offset: 10, where: { "authors.name" => "David" }).count(:all)
+ assert_equal 0, posts
+ end
+
+ def test_eager_with_has_many_and_limit_with_no_results
+ posts = Post.all.merge!(includes: [ :author, :comments ], limit: 2, where: "posts.title = 'magic forest'").to_a
+ assert_equal 0, posts.size
+ end
+
+ def test_eager_count_performed_on_a_has_many_association_with_multi_table_conditional
+ author = authors(:david)
+ author_posts_without_comments = author.posts.select { |post| post.comments.blank? }
+ assert_equal author_posts_without_comments.size, author.posts.includes(:comments).where("comments.id is null").references(:comments).count
+ end
+
+ def test_eager_count_performed_on_a_has_many_through_association_with_multi_table_conditional
+ person = people(:michael)
+ person_posts_without_comments = person.posts.select { |post| post.comments.blank? }
+ assert_equal person_posts_without_comments.size, person.posts_with_no_comments.count
+ end
+
+ def test_eager_with_has_and_belongs_to_many_and_limit
+ posts = Post.all.merge!(includes: :categories, order: "posts.id", limit: 3).to_a
+ assert_equal 3, posts.size
+ assert_equal 2, posts[0].categories.size
+ assert_equal 1, posts[1].categories.size
+ assert_equal 0, posts[2].categories.size
+ assert_includes posts[0].categories, categories(:technology)
+ assert_includes posts[1].categories, categories(:general)
+ end
+
+ # Since the preloader for habtm gets raw row hashes from the database and then
+ # instantiates them, this test ensures that it only instantiates one actual
+ # object per record from the database.
+ def test_has_and_belongs_to_many_should_not_instantiate_same_records_multiple_times
+ welcome = posts(:welcome)
+ categories = Category.includes(:posts)
+
+ general = categories.find { |c| c == categories(:general) }
+ technology = categories.find { |c| c == categories(:technology) }
+
+ post1 = general.posts.to_a.find { |p| p == welcome }
+ post2 = technology.posts.to_a.find { |p| p == welcome }
+
+ assert_equal post1.object_id, post2.object_id
+ end
+
+ def test_eager_with_has_many_and_limit_and_conditions_on_the_eagers
+ posts =
+ authors(:david).posts
+ .includes(:comments)
+ .where("comments.body like 'Normal%' OR comments.#{QUOTED_TYPE}= 'SpecialComment'")
+ .references(:comments)
+ .limit(2)
+ .to_a
+ assert_equal 2, posts.size
+
+ count =
+ Post.includes(:comments, :author)
+ .where("authors.name = 'David' AND (comments.body like 'Normal%' OR comments.#{QUOTED_TYPE}= 'SpecialComment')")
+ .references(:authors, :comments)
+ .limit(2)
+ .count
+ assert_equal count, posts.size
+ end
+
+ def test_eager_with_has_many_and_limit_and_scoped_conditions_on_the_eagers
+ posts = nil
+ Post.includes(:comments)
+ .where("comments.body like 'Normal%' OR comments.#{QUOTED_TYPE}= 'SpecialComment'")
+ .references(:comments)
+ .scoping do
+
+ posts = authors(:david).posts.limit(2).to_a
+ assert_equal 2, posts.size
+ end
+
+ Post.includes(:comments, :author)
+ .where("authors.name = 'David' AND (comments.body like 'Normal%' OR comments.#{QUOTED_TYPE}= 'SpecialComment')")
+ .references(:authors, :comments)
+ .scoping do
+
+ count = Post.limit(2).count
+ assert_equal count, posts.size
+ end
+ end
+
+ def test_eager_association_loading_with_habtm
+ posts = Post.all.merge!(includes: :categories, order: "posts.id").to_a
+ assert_equal 2, posts[0].categories.size
+ assert_equal 1, posts[1].categories.size
+ assert_equal 0, posts[2].categories.size
+ assert_includes posts[0].categories, categories(:technology)
+ assert_includes posts[1].categories, categories(:general)
+ end
+
+ def test_eager_with_inheritance
+ SpecialPost.all.merge!(includes: [ :comments ]).to_a
+ end
+
+ def test_eager_has_one_with_association_inheritance
+ post = Post.all.merge!(includes: [ :very_special_comment ]).find(4)
+ assert_equal "VerySpecialComment", post.very_special_comment.class.to_s
+ end
+
+ def test_eager_has_many_with_association_inheritance
+ post = Post.all.merge!(includes: [ :special_comments ]).find(4)
+ post.special_comments.each do |special_comment|
+ assert special_comment.is_a?(SpecialComment)
+ end
+ end
+
+ def test_eager_habtm_with_association_inheritance
+ post = Post.all.merge!(includes: [ :special_categories ]).find(6)
+ assert_equal 1, post.special_categories.size
+ post.special_categories.each do |special_category|
+ assert_equal "SpecialCategory", special_category.class.to_s
+ end
+ end
+
+ def test_eager_with_has_one_dependent_does_not_destroy_dependent
+ assert_not_nil companies(:first_firm).account
+ f = Firm.all.merge!(includes: :account,
+ where: ["companies.name = ?", "37signals"]).first
+ assert_not_nil f.account
+ assert_equal companies(:first_firm, :reload).account, f.account
+ end
+
+ def test_eager_with_multi_table_conditional_properly_counts_the_records_when_using_size
+ author = authors(:david)
+ posts_with_no_comments = author.posts.select { |post| post.comments.blank? }
+ assert_equal posts_with_no_comments.size, author.posts_with_no_comments.size
+ assert_equal posts_with_no_comments, author.posts_with_no_comments
+ end
+
+ def test_eager_with_invalid_association_reference
+ e = assert_raise(ActiveRecord::AssociationNotFoundError) {
+ Post.all.merge!(includes: :monkeys).find(6)
+ }
+ assert_equal("Association named 'monkeys' was not found on Post; perhaps you misspelled it?", e.message)
+
+ e = assert_raise(ActiveRecord::AssociationNotFoundError) {
+ Post.all.merge!(includes: [ :monkeys ]).find(6)
+ }
+ assert_equal("Association named 'monkeys' was not found on Post; perhaps you misspelled it?", e.message)
+
+ e = assert_raise(ActiveRecord::AssociationNotFoundError) {
+ Post.all.merge!(includes: [ "monkeys" ]).find(6)
+ }
+ assert_equal("Association named 'monkeys' was not found on Post; perhaps you misspelled it?", e.message)
+
+ e = assert_raise(ActiveRecord::AssociationNotFoundError) {
+ Post.all.merge!(includes: [ :monkeys, :elephants ]).find(6)
+ }
+ assert_equal("Association named 'monkeys' was not found on Post; perhaps you misspelled it?", e.message)
+ end
+
+ def test_eager_has_many_through_with_order
+ tag = OrderedTag.create(name: "Foo")
+ post1 = Post.create!(title: "Beaches", body: "I like beaches!")
+ post2 = Post.create!(title: "Pools", body: "I like pools!")
+
+ Tagging.create!(taggable_type: "Post", taggable_id: post1.id, tag: tag)
+ Tagging.create!(taggable_type: "Post", taggable_id: post2.id, tag: tag)
+
+ tag_with_includes = OrderedTag.includes(:tagged_posts).find(tag.id)
+ assert_equal tag_with_includes.ordered_taggings.map(&:taggable).map(&:title), tag_with_includes.tagged_posts.map(&:title)
+ end
+
+ def test_eager_has_many_through_multiple_with_order
+ tag1 = OrderedTag.create!(name: "Bar")
+ tag2 = OrderedTag.create!(name: "Foo")
+
+ post1 = Post.create!(title: "Beaches", body: "I like beaches!")
+ post2 = Post.create!(title: "Pools", body: "I like pools!")
+
+ Tagging.create!(taggable: post1, tag: tag1)
+ Tagging.create!(taggable: post2, tag: tag1)
+ Tagging.create!(taggable: post2, tag: tag2)
+ Tagging.create!(taggable: post1, tag: tag2)
+
+ tags_with_includes = OrderedTag.where(id: [tag1, tag2].map(&:id)).includes(:tagged_posts).order(:id).to_a
+ tag1_with_includes = tags_with_includes.first
+ tag2_with_includes = tags_with_includes.last
+
+ assert_equal([post2, post1].map(&:title), tag1_with_includes.tagged_posts.map(&:title))
+ assert_equal([post1, post2].map(&:title), tag2_with_includes.tagged_posts.map(&:title))
+ end
+
+ def test_eager_with_default_scope
+ developer = EagerDeveloperWithDefaultScope.where(name: "David").first
+ projects = Project.order(:id).to_a
+ assert_no_queries do
+ assert_equal(projects, developer.projects)
+ end
+ end
+
+ def test_eager_with_default_scope_as_class_method
+ developer = EagerDeveloperWithClassMethodDefaultScope.where(name: "David").first
+ projects = Project.order(:id).to_a
+ assert_no_queries do
+ assert_equal(projects, developer.projects)
+ end
+ end
+
+ def test_eager_with_default_scope_as_class_method_using_find_method
+ david = developers(:david)
+ developer = EagerDeveloperWithClassMethodDefaultScope.find(david.id)
+ projects = Project.order(:id).to_a
+ assert_no_queries do
+ assert_equal(projects, developer.projects)
+ end
+ end
+
+ def test_eager_with_default_scope_as_class_method_using_find_by_method
+ developer = EagerDeveloperWithClassMethodDefaultScope.find_by(name: "David")
+ projects = Project.order(:id).to_a
+ assert_no_queries do
+ assert_equal(projects, developer.projects)
+ end
+ end
+
+ def test_eager_with_default_scope_as_lambda
+ developer = EagerDeveloperWithLambdaDefaultScope.where(name: "David").first
+ projects = Project.order(:id).to_a
+ assert_no_queries do
+ assert_equal(projects, developer.projects)
+ end
+ end
+
+ def test_eager_with_default_scope_as_block
+ # warm up the habtm cache
+ EagerDeveloperWithBlockDefaultScope.where(name: "David").first.projects
+ developer = EagerDeveloperWithBlockDefaultScope.where(name: "David").first
+ projects = Project.order(:id).to_a
+ assert_no_queries do
+ assert_equal(projects, developer.projects)
+ end
+ end
+
+ def test_eager_with_default_scope_as_callable
+ developer = EagerDeveloperWithCallableDefaultScope.where(name: "David").first
+ projects = Project.order(:id).to_a
+ assert_no_queries do
+ assert_equal(projects, developer.projects)
+ end
+ end
+
+ def test_limited_eager_with_order
+ assert_equal(
+ posts(:thinking, :sti_comments),
+ Post.all.merge!(
+ includes: [:author, :comments], where: { "authors.name" => "David" },
+ order: Arel.sql("UPPER(posts.title)"), limit: 2, offset: 1
+ ).to_a
+ )
+ assert_equal(
+ posts(:sti_post_and_comments, :sti_comments),
+ Post.all.merge!(
+ includes: [:author, :comments], where: { "authors.name" => "David" },
+ order: Arel.sql("UPPER(posts.title) DESC"), limit: 2, offset: 1
+ ).to_a
+ )
+ end
+
+ def test_limited_eager_with_multiple_order_columns
+ assert_equal(
+ posts(:thinking, :sti_comments),
+ Post.all.merge!(
+ includes: [:author, :comments], where: { "authors.name" => "David" },
+ order: [Arel.sql("UPPER(posts.title)"), "posts.id"], limit: 2, offset: 1
+ ).to_a
+ )
+ assert_equal(
+ posts(:sti_post_and_comments, :sti_comments),
+ Post.all.merge!(
+ includes: [:author, :comments], where: { "authors.name" => "David" },
+ order: [Arel.sql("UPPER(posts.title) DESC"), "posts.id"], limit: 2, offset: 1
+ ).to_a
+ )
+ end
+
+ def test_limited_eager_with_numeric_in_association
+ assert_equal(
+ people(:david, :susan),
+ Person.references(:number1_fans_people).merge(
+ includes: [:readers, :primary_contact, :number1_fan],
+ where: "number1_fans_people.first_name like 'M%'",
+ order: "people.id", limit: 2, offset: 0
+ ).to_a
+ )
+ end
+
+ def test_polymorphic_type_condition
+ post = Post.all.merge!(includes: :taggings).find(posts(:thinking).id)
+ assert_includes post.taggings, taggings(:thinking_general)
+ post = SpecialPost.all.merge!(includes: :taggings).find(posts(:thinking).id)
+ assert_includes post.taggings, taggings(:thinking_general)
+ end
+
+ def test_eager_with_multiple_associations_with_same_table_has_many_and_habtm
+ # Eager includes of has many and habtm associations aren't necessarily sorted in the same way
+ def assert_equal_after_sort(item1, item2, item3 = nil)
+ assert_equal(item1.sort { |a, b| a.id <=> b.id }, item2.sort { |a, b| a.id <=> b.id })
+ assert_equal(item3.sort { |a, b| a.id <=> b.id }, item2.sort { |a, b| a.id <=> b.id }) if item3
+ end
+ # Test regular association, association with conditions, association with
+ # STI, and association with conditions assured not to be true
+ post_types = [:posts, :other_posts, :special_posts]
+ # test both has_many and has_and_belongs_to_many
+ [Author, Category].each do |className|
+ d1 = find_all_ordered(className)
+ # test including all post types at once
+ d2 = find_all_ordered(className, post_types)
+ d1.each_index do |i|
+ assert_equal(d1[i], d2[i])
+ assert_equal_after_sort(d1[i].posts, d2[i].posts)
+ post_types[1..-1].each do |post_type|
+ # test including post_types together
+ d3 = find_all_ordered(className, [:posts, post_type])
+ assert_equal(d1[i], d3[i])
+ assert_equal_after_sort(d1[i].posts, d3[i].posts)
+ assert_equal_after_sort(d1[i].send(post_type), d2[i].send(post_type), d3[i].send(post_type))
+ end
+ end
+ end
+ end
+
+ def test_eager_with_multiple_associations_with_same_table_has_one
+ d1 = find_all_ordered(Firm)
+ d2 = find_all_ordered(Firm, :account)
+ d1.each_index do |i|
+ assert_equal(d1[i], d2[i])
+ if d1[i].account.nil?
+ assert_nil(d2[i].account)
+ else
+ assert_equal(d1[i].account, d2[i].account)
+ end
+ end
+ end
+
+ def test_eager_with_multiple_associations_with_same_table_belongs_to
+ firm_types = [:firm, :firm_with_basic_id, :firm_with_other_name, :firm_with_condition]
+ d1 = find_all_ordered(Client)
+ d2 = find_all_ordered(Client, firm_types)
+ d1.each_index do |i|
+ assert_equal(d1[i], d2[i])
+ firm_types.each do |type|
+ if (expected = d1[i].send(type)).nil?
+ assert_nil(d2[i].send(type))
+ else
+ assert_equal(expected, d2[i].send(type))
+ end
+ end
+ end
+ end
+ def test_eager_with_valid_association_as_string_not_symbol
+ assert_nothing_raised { Post.all.merge!(includes: "comments").to_a }
+ end
+
+ def test_eager_with_floating_point_numbers
+ assert_queries(2) do
+ # Before changes, the floating point numbers will be interpreted as table names and will cause this to run in one query
+ Comment.all.merge!(where: "123.456 = 123.456", includes: :post).to_a
+ end
+ end
+
+ def test_preconfigured_includes_with_belongs_to
+ author = posts(:welcome).author_with_posts
+ assert_no_queries { assert_equal 5, author.posts.size }
+ end
+
+ def test_preconfigured_includes_with_has_one
+ comment = posts(:sti_comments).very_special_comment_with_post
+ assert_no_queries { assert_equal posts(:sti_comments), comment.post }
+ end
+
+ def test_eager_association_with_scope_with_joins
+ assert_nothing_raised do
+ Post.includes(:very_special_comment_with_post_with_joins).to_a
+ end
+ end
+
+ def test_preconfigured_includes_with_has_many
+ posts = authors(:david).posts_with_comments
+ one = posts.detect { |p| p.id == 1 }
+ assert_no_queries do
+ assert_equal 5, posts.size
+ assert_equal 2, one.comments.size
+ end
+ end
+
+ def test_preconfigured_includes_with_habtm
+ posts = authors(:david).posts_with_categories
+ one = posts.detect { |p| p.id == 1 }
+ assert_no_queries do
+ assert_equal 5, posts.size
+ assert_equal 2, one.categories.size
+ end
+ end
+
+ def test_preconfigured_includes_with_has_many_and_habtm
+ posts = authors(:david).posts_with_comments_and_categories
+ one = posts.detect { |p| p.id == 1 }
+ assert_no_queries do
+ assert_equal 5, posts.size
+ assert_equal 2, one.comments.size
+ assert_equal 2, one.categories.size
+ end
+ end
+
+ def test_count_with_include
+ assert_equal 3, authors(:david).posts_with_comments.where("length(comments.body) > 15").references(:comments).count
+ end
+
+ def test_association_loading_notification
+ notifications = messages_for("instantiation.active_record") do
+ Developer.all.merge!(includes: "projects", where: { "developers_projects.access_level" => 1 }, limit: 5).to_a.size
+ end
+
+ message = notifications.first
+ payload = message.last
+ count = Developer.all.merge!(includes: "projects", where: { "developers_projects.access_level" => 1 }, limit: 5).to_a.size
+
+ # eagerloaded row count should be greater than just developer count
+ assert_operator payload[:record_count], :>, count
+ assert_equal Developer.name, payload[:class_name]
+ end
+
+ def test_base_messages
+ notifications = messages_for("instantiation.active_record") do
+ Developer.all.to_a
+ end
+ message = notifications.first
+ payload = message.last
+
+ assert_equal Developer.all.to_a.count, payload[:record_count]
+ assert_equal Developer.name, payload[:class_name]
+ end
+
+ def messages_for(name)
+ notifications = []
+ ActiveSupport::Notifications.subscribe(name) do |*args|
+ notifications << args
+ end
+ yield
+ notifications
+ ensure
+ ActiveSupport::Notifications.unsubscribe(name)
+ end
+
+ def test_load_with_sti_sharing_association
+ assert_queries(2) do # should not do 1 query per subclass
+ Comment.includes(:post).to_a
+ end
+ end
+
+ def test_conditions_on_join_table_with_include_and_limit
+ assert_equal 3, Developer.all.merge!(includes: "projects", where: { "developers_projects.access_level" => 1 }, limit: 5).to_a.size
+ end
+
+ def test_dont_create_temporary_active_record_instances
+ Developer.instance_count = 0
+ developers = Developer.all.merge!(includes: "projects", where: { "developers_projects.access_level" => 1 }, limit: 5).to_a
+ assert_equal developers.count, Developer.instance_count
+ end
+
+ def test_order_on_join_table_with_include_and_limit
+ assert_equal 5, Developer.all.merge!(includes: "projects", order: "developers_projects.joined_on DESC", limit: 5).to_a.size
+ end
+
+ def test_eager_loading_with_order_on_joined_table_preloads
+ posts = assert_queries(2) do
+ Post.all.merge!(joins: :comments, includes: :author, order: "comments.id DESC").to_a
+ end
+ assert_equal posts(:eager_other), posts[1]
+ assert_equal authors(:mary), assert_no_queries { posts[1].author }
+ end
+
+ def test_eager_loading_with_conditions_on_joined_table_preloads
+ posts = assert_queries(2) do
+ Post.all.merge!(select: "distinct posts.*", includes: :author, joins: [:comments], where: "comments.body like 'Thank you%'", order: "posts.id").to_a
+ end
+ assert_equal [posts(:welcome)], posts
+ assert_equal authors(:david), assert_no_queries { posts[0].author }
+
+ posts = assert_queries(2) do
+ Post.all.merge!(includes: :author, joins: { taggings: :tag }, where: "tags.name = 'General'", order: "posts.id").to_a
+ end
+ assert_equal posts(:welcome, :thinking), posts
+
+ posts = assert_queries(2) do
+ Post.all.merge!(includes: :author, joins: { taggings: { tag: :taggings } }, where: "taggings_tags.super_tag_id=2", order: "posts.id").to_a
+ end
+ assert_equal posts(:welcome, :thinking), posts
+ end
+
+ def test_preload_has_many_with_association_condition_and_default_scope
+ post = Post.create!(title: "Beaches", body: "I like beaches!")
+ Reader.create! person: people(:david), post: post
+ LazyReader.create! person: people(:susan), post: post
+
+ assert_equal 1, post.lazy_readers.to_a.size
+ assert_equal 2, post.lazy_readers_skimmers_or_not.to_a.size
+
+ post_with_readers = Post.includes(:lazy_readers_skimmers_or_not).find(post.id)
+ assert_equal 2, post_with_readers.lazy_readers_skimmers_or_not.to_a.size
+ end
+
+ def test_eager_loading_with_conditions_on_string_joined_table_preloads
+ posts = assert_queries(2) do
+ Post.all.merge!(select: "distinct posts.*", includes: :author, joins: "INNER JOIN comments on comments.post_id = posts.id", where: "comments.body like 'Thank you%'", order: "posts.id").to_a
+ end
+ assert_equal [posts(:welcome)], posts
+ assert_equal authors(:david), assert_no_queries { posts[0].author }
+
+ posts = assert_queries(2) do
+ Post.all.merge!(select: "distinct posts.*", includes: :author, joins: ["INNER JOIN comments on comments.post_id = posts.id"], where: "comments.body like 'Thank you%'", order: "posts.id").to_a
+ end
+ assert_equal [posts(:welcome)], posts
+ assert_equal authors(:david), assert_no_queries { posts[0].author }
+ end
+
+ def test_eager_loading_with_select_on_joined_table_preloads
+ posts = assert_queries(2) do
+ Post.all.merge!(select: "posts.*, authors.name as author_name", includes: :comments, joins: :author, order: "posts.id").to_a
+ end
+ assert_equal "David", posts[0].author_name
+ assert_equal posts(:welcome).comments, assert_no_queries { posts[0].comments }
+ end
+
+ def test_eager_loading_with_conditions_on_join_model_preloads
+ authors = assert_queries(2) do
+ Author.all.merge!(includes: :author_address, joins: :comments, where: "posts.title like 'Welcome%'").to_a
+ end
+ assert_equal authors(:david), authors[0]
+ assert_equal author_addresses(:david_address), authors[0].author_address
+ end
+
+ def test_preload_belongs_to_uses_exclusive_scope
+ people = Person.males.merge(includes: :primary_contact).to_a
+ assert_not_equal people.length, 0
+ people.each do |person|
+ assert_no_queries { assert_not_nil person.primary_contact }
+ assert_equal Person.find(person.id).primary_contact, person.primary_contact
+ end
+ end
+
+ def test_preload_has_many_uses_exclusive_scope
+ people = Person.males.includes(:agents).to_a
+ people.each do |person|
+ assert_equal Person.find(person.id).agents, person.agents
+ end
+ end
+
+ def test_preload_has_many_using_primary_key
+ expected = Firm.first.clients_using_primary_key.to_a
+ firm = Firm.includes(:clients_using_primary_key).first
+ assert_no_queries do
+ assert_equal expected, firm.clients_using_primary_key
+ end
+ end
+
+ def test_include_has_many_using_primary_key
+ expected = Firm.find(1).clients_using_primary_key.sort_by(&:name)
+ # Oracle adapter truncates alias to 30 characters
+ if current_adapter?(:OracleAdapter)
+ firm = Firm.all.merge!(includes: :clients_using_primary_key, order: "clients_using_primary_keys_companies"[0, 30] + ".name").find(1)
+ else
+ firm = Firm.all.merge!(includes: :clients_using_primary_key, order: "clients_using_primary_keys_companies.name").find(1)
+ end
+ assert_no_queries do
+ assert_equal expected, firm.clients_using_primary_key
+ end
+ end
+
+ def test_preload_has_one_using_primary_key
+ expected = accounts(:signals37)
+ firm = Firm.all.merge!(includes: :account_using_primary_key, order: "companies.id").first
+ assert_no_queries do
+ assert_equal expected, firm.account_using_primary_key
+ end
+ end
+
+ def test_include_has_one_using_primary_key
+ expected = accounts(:signals37)
+ firm = Firm.all.merge!(includes: :account_using_primary_key, order: "accounts.id").to_a.detect { |f| f.id == 1 }
+ assert_no_queries do
+ assert_equal expected, firm.account_using_primary_key
+ end
+ end
+
+ def test_preloading_empty_belongs_to
+ c = Client.create!(name: "Foo", client_of: Company.maximum(:id) + 1)
+
+ client = assert_queries(2) { Client.preload(:firm).find(c.id) }
+ assert_no_queries { assert_nil client.firm }
+ assert_equal c.client_of, client.client_of
+ end
+
+ def test_preloading_empty_belongs_to_polymorphic
+ t = Tagging.create!(taggable_type: "Post", taggable_id: Post.maximum(:id) + 1, tag: tags(:general))
+
+ tagging = assert_queries(2) { Tagging.preload(:taggable).find(t.id) }
+ assert_no_queries { assert_nil tagging.taggable }
+ assert_equal t.taggable_id, tagging.taggable_id
+ end
+
+ def test_preloading_through_empty_belongs_to
+ c = Client.create!(name: "Foo", client_of: Company.maximum(:id) + 1)
+
+ client = assert_queries(2) { Client.preload(:accounts).find(c.id) }
+ assert_no_queries { assert client.accounts.empty? }
+ end
+
+ def test_preloading_has_many_through_with_distinct
+ mary = Author.includes(:unique_categorized_posts).where(id: authors(:mary).id).first
+ assert_equal 1, mary.unique_categorized_posts.length
+ assert_equal 1, mary.unique_categorized_post_ids.length
+ end
+
+ def test_preloading_has_one_using_reorder
+ klass = Class.new(ActiveRecord::Base) do
+ def self.name; "TempAuthor"; end
+ self.table_name = "authors"
+ has_one :post, class_name: "PostWithDefaultScope", foreign_key: :author_id
+ has_one :reorderd_post, -> { reorder(title: :desc) }, class_name: "PostWithDefaultScope", foreign_key: :author_id
+ end
+
+ author = klass.first
+ # PRECONDITION: make sure ordering results in different results
+ assert_not_equal author.post, author.reorderd_post
+
+ preloaded_reorderd_post = klass.preload(:reorderd_post).first.reorderd_post
+
+ assert_equal author.reorderd_post, preloaded_reorderd_post
+ assert_equal Post.order(title: :desc).first.title, preloaded_reorderd_post.title
+ end
+
+ def test_preloading_polymorphic_with_custom_foreign_type
+ sponsor = sponsors(:moustache_club_sponsor_for_groucho)
+ groucho = members(:groucho)
+
+ sponsor = assert_queries(2) {
+ Sponsor.includes(:thing).where(id: sponsor.id).first
+ }
+ assert_no_queries { assert_equal groucho, sponsor.thing }
+ end
+
+ def test_joins_with_includes_should_preload_via_joins
+ post = assert_queries(1) { Post.includes(:comments).joins(:comments).order("posts.id desc").to_a.first }
+
+ assert_no_queries do
+ assert_not_equal 0, post.comments.to_a.count
+ end
+ end
+
+ def test_join_eager_with_empty_order_should_generate_valid_sql
+ assert_nothing_raised do
+ Post.includes(:comments).order("").where(comments: { body: "Thank you for the welcome" }).first
+ end
+ end
+
+ def test_deep_including_through_habtm
+ # warm up habtm cache
+ posts = Post.all.merge!(includes: { categories: :categorizations }, order: "posts.id").to_a
+ posts[0].categories[0].categorizations.length
+
+ posts = Post.all.merge!(includes: { categories: :categorizations }, order: "posts.id").to_a
+ assert_no_queries { assert_equal 2, posts[0].categories[0].categorizations.length }
+ assert_no_queries { assert_equal 1, posts[0].categories[1].categorizations.length }
+ assert_no_queries { assert_equal 2, posts[1].categories[0].categorizations.length }
+ end
+
+ def test_eager_load_multiple_associations_with_references
+ mentor = Mentor.create!(name: "Barış Can DAYLIK")
+ developer = Developer.create!(name: "Mehmet Emin İNAÇ", mentor: mentor)
+ Contract.create!(developer: developer)
+ project = Project.create!(name: "VNGRS", mentor: mentor)
+ project.developers << developer
+ projects = Project.references(:mentors).includes(mentor: { developers: :contracts }, developers: :contracts)
+ assert_equal projects.last.mentor.developers.first.contracts, projects.last.developers.last.contracts
+ end
+
+ def test_preloading_has_many_through_with_custom_scope
+ project = Project.includes(:developers_named_david_with_hash_conditions).find(projects(:active_record).id)
+ assert_equal [developers(:david)], project.developers_named_david_with_hash_conditions
+ end
+
+ test "scoping with a circular preload" do
+ assert_equal Comment.find(1), Comment.preload(post: :comments).scoping { Comment.find(1) }
+ end
+
+ test "circular preload does not modify unscoped" do
+ expected = FirstPost.unscoped.find(2)
+ FirstPost.preload(comments: :first_post).find(1)
+ assert_equal expected, FirstPost.unscoped.find(2)
+ end
+
+ test "preload ignores the scoping" do
+ assert_equal(
+ Comment.find(1).post,
+ Post.where("1 = 0").scoping { Comment.preload(:post).find(1).post }
+ )
+ end
+
+ test "deep preload" do
+ post = Post.preload(author: :posts, comments: :post).first
+
+ assert_predicate post.author.association(:posts), :loaded?
+ assert_predicate post.comments.first.association(:post), :loaded?
+ end
+
+ test "preloading does not cache has many association subset when preloaded with a through association" do
+ author = Author.includes(:comments_with_order_and_conditions, :posts).first
+ assert_no_queries { assert_equal 2, author.comments_with_order_and_conditions.size }
+ assert_no_queries { assert_equal 5, author.posts.size, "should not cache a subset of the association" }
+ end
+
+ test "preloading a through association twice does not reset it" do
+ members = Member.includes(current_membership: :club).includes(:club).to_a
+ assert_no_queries {
+ assert_equal 3, members.map(&:current_membership).map(&:club).size
+ }
+ end
+
+ test "works in combination with order(:symbol) and reorder(:symbol)" do
+ author = Author.includes(:posts).references(:posts).order(:name).find_by("posts.title IS NOT NULL")
+ assert_equal authors(:bob), author
+
+ author = Author.includes(:posts).references(:posts).reorder(:name).find_by("posts.title IS NOT NULL")
+ assert_equal authors(:bob), author
+ end
+
+ test "preloading with a polymorphic association and using the existential predicate but also using a select" do
+ assert_equal authors(:david), authors(:david).essays.includes(:writer).first.writer
+
+ assert_nothing_raised do
+ authors(:david).essays.includes(:writer).select(:name).any?
+ end
+ end
+
+ test "preloading the same association twice works" do
+ Member.create!
+ members = Member.preload(:current_membership).includes(current_membership: :club).all.to_a
+ assert_no_queries {
+ members_with_membership = members.select(&:current_membership)
+ assert_equal 3, members_with_membership.map(&:current_membership).map(&:club).size
+ }
+ end
+
+ test "preloading with a polymorphic association and using the existential predicate" do
+ assert_equal authors(:david), authors(:david).essays.includes(:writer).first.writer
+
+ assert_nothing_raised do
+ authors(:david).essays.includes(:writer).any?
+ authors(:david).essays.includes(:writer).exists?
+ authors(:david).essays.includes(:owner).where("name IS NOT NULL").exists?
+ end
+ end
+
+ test "preloading associations with string joins and order references" do
+ author = assert_queries(2) {
+ Author.includes(:posts).joins("LEFT JOIN posts ON posts.author_id = authors.id").order("posts.title DESC").first
+ }
+ assert_no_queries {
+ assert_equal 5, author.posts.size
+ }
+ end
+
+ test "including associations with where.not adds implicit references" do
+ author = assert_queries(2) {
+ Author.includes(:posts).where.not(posts: { title: "Welcome to the weblog" }).last
+ }
+
+ assert_no_queries {
+ assert_equal 2, author.posts.size
+ }
+ end
+
+ test "including association based on sql condition and no database column" do
+ assert_equal pets(:parrot), Owner.including_last_pet.first.last_pet
+ end
+
+ test "preloading and eager loading of instance dependent associations is not supported" do
+ message = "association scope 'posts_with_signature' is"
+ error = assert_raises(ArgumentError) do
+ Author.includes(:posts_with_signature).to_a
+ end
+ assert_match message, error.message
+
+ error = assert_raises(ArgumentError) do
+ Author.preload(:posts_with_signature).to_a
+ end
+ assert_match message, error.message
+
+ error = assert_raises(ArgumentError) do
+ Author.eager_load(:posts_with_signature).to_a
+ end
+ assert_match message, error.message
+ end
+
+ test "preload with invalid argument" do
+ exception = assert_raises(ArgumentError) do
+ Author.preload(10).to_a
+ end
+ assert_equal("10 was not recognized for preload", exception.message)
+ end
+
+ test "associations with extensions are not instance dependent" do
+ assert_nothing_raised do
+ Author.includes(:posts_with_extension).to_a
+ end
+ end
+
+ test "including associations with extensions and an instance dependent scope is not supported" do
+ e = assert_raises(ArgumentError) do
+ Author.includes(:posts_with_extension_and_instance).to_a
+ end
+ assert_match(/Preloading instance dependent scopes is not supported/, e.message)
+ end
+
+ test "preloading readonly association" do
+ # has-one
+ firm = Firm.where(id: "1").preload(:readonly_account).first!
+ assert_predicate firm.readonly_account, :readonly?
+
+ # has_and_belongs_to_many
+ project = Project.where(id: "2").preload(:readonly_developers).first!
+ assert_predicate project.readonly_developers.first, :readonly?
+
+ # has-many :through
+ david = Author.where(id: "1").preload(:readonly_comments).first!
+ assert_predicate david.readonly_comments.first, :readonly?
+ end
+
+ test "eager-loading non-readonly association" do
+ # has_one
+ firm = Firm.where(id: "1").eager_load(:account).first!
+ assert_not_predicate firm.account, :readonly?
+
+ # has_and_belongs_to_many
+ project = Project.where(id: "2").eager_load(:developers).first!
+ assert_not_predicate project.developers.first, :readonly?
+
+ # has_many :through
+ david = Author.where(id: "1").eager_load(:comments).first!
+ assert_not_predicate david.comments.first, :readonly?
+
+ # belongs_to
+ post = Post.where(id: "1").eager_load(:author).first!
+ assert_not_predicate post.author, :readonly?
+ end
+
+ test "eager-loading readonly association" do
+ # has-one
+ firm = Firm.where(id: "1").eager_load(:readonly_account).first!
+ assert_predicate firm.readonly_account, :readonly?
+
+ # has_and_belongs_to_many
+ project = Project.where(id: "2").eager_load(:readonly_developers).first!
+ assert_predicate project.readonly_developers.first, :readonly?
+
+ # has-many :through
+ david = Author.where(id: "1").eager_load(:readonly_comments).first!
+ assert_predicate david.readonly_comments.first, :readonly?
+
+ # belongs_to
+ post = Post.where(id: "1").eager_load(:readonly_author).first!
+ assert_predicate post.readonly_author, :readonly?
+ end
+
+ test "preloading a polymorphic association with references to the associated table" do
+ post = Post.includes(:tags).references(:tags).where("tags.name = ?", "General").first
+ assert_equal posts(:welcome), post
+ end
+
+ test "eager-loading a polymorphic association with references to the associated table" do
+ post = Post.eager_load(:tags).where("tags.name = ?", "General").first
+ assert_equal posts(:welcome), post
+ end
+
+ test "eager-loading with a polymorphic association won't work consistently" do
+ assert_raise(ActiveRecord::EagerLoadPolymorphicError) { authors(:david).essays.eager_load(:writer).to_a }
+ assert_raise(ActiveRecord::EagerLoadPolymorphicError) { authors(:david).essays.eager_load(:writer).count }
+ assert_raise(ActiveRecord::EagerLoadPolymorphicError) { authors(:david).essays.eager_load(:writer).exists? }
+ end
+
+ # CollectionProxy#reader is expensive, so the preloader avoids calling it.
+ test "preloading has_many_through association avoids calling association.reader" do
+ assert_not_called_on_instance_of(ActiveRecord::Associations::HasManyAssociation, :reader) do
+ Author.preload(:readonly_comments).first!
+ end
+ end
+
+ test "preloading through a polymorphic association doesn't require the association to exist" do
+ sponsors = []
+ assert_queries 5 do
+ sponsors = Sponsor.where(sponsorable_id: 1).preload(sponsorable: [:post, :membership]).to_a
+ end
+ # check the preload worked
+ assert_queries 0 do
+ sponsors.map(&:sponsorable).map { |s| s.respond_to?(:posts) ? s.post.author : s.membership }
+ end
+ end
+
+ test "preloading a regular association through a polymorphic association doesn't require the association to exist on all types" do
+ sponsors = []
+ assert_queries 6 do
+ sponsors = Sponsor.where(sponsorable_id: 1).preload(sponsorable: [{ post: :first_comment }, :membership]).to_a
+ end
+ # check the preload worked
+ assert_queries 0 do
+ sponsors.map(&:sponsorable).map { |s| s.respond_to?(:posts) ? s.post.author : s.membership }
+ end
+ end
+
+ test "preloading a regular association with a typo through a polymorphic association still raises" do
+ # this test contains an intentional typo of first -> fist
+ assert_raises(ActiveRecord::AssociationNotFoundError) do
+ Sponsor.where(sponsorable_id: 1).preload(sponsorable: [{ post: :fist_comment }, :membership]).to_a
+ end
+ end
+
+ private
+ def find_all_ordered(klass, include = nil)
+ klass.order("#{klass.table_name}.#{klass.primary_key}").includes(include).to_a
+ end
+end
diff --git a/activerecord/test/cases/associations/extension_test.rb b/activerecord/test/cases/associations/extension_test.rb
new file mode 100644
index 0000000000..aef8f31112
--- /dev/null
+++ b/activerecord/test/cases/associations/extension_test.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+require "models/comment"
+require "models/project"
+require "models/developer"
+require "models/computer"
+require "models/company_in_module"
+
+class AssociationsExtensionsTest < ActiveRecord::TestCase
+ fixtures :projects, :developers, :developers_projects, :comments, :posts
+
+ def test_extension_on_has_many
+ assert_equal comments(:more_greetings), posts(:welcome).comments.find_most_recent
+ end
+
+ def test_extension_on_habtm
+ assert_equal projects(:action_controller), developers(:david).projects.find_most_recent
+ end
+
+ def test_named_extension_on_habtm
+ assert_equal projects(:action_controller), developers(:david).projects_extended_by_name.find_most_recent
+ end
+
+ def test_named_two_extensions_on_habtm
+ assert_equal projects(:action_controller), developers(:david).projects_extended_by_name_twice.find_most_recent
+ assert_equal projects(:active_record), developers(:david).projects_extended_by_name_twice.find_least_recent
+ end
+
+ def test_named_extension_and_block_on_habtm
+ assert_equal projects(:action_controller), developers(:david).projects_extended_by_name_and_block.find_most_recent
+ assert_equal projects(:active_record), developers(:david).projects_extended_by_name_and_block.find_least_recent
+ end
+
+ def test_extension_with_scopes
+ assert_equal comments(:greetings), posts(:welcome).comments.offset(1).find_most_recent
+ assert_equal comments(:greetings), posts(:welcome).comments.not_again.find_most_recent
+ end
+
+ def test_extension_with_dirty_target
+ comment = posts(:welcome).comments.build(body: "New comment")
+ assert_equal comment, posts(:welcome).comments.with_content("New comment")
+ end
+
+ def test_marshalling_extensions
+ david = developers(:david)
+ assert_equal projects(:action_controller), david.projects.find_most_recent
+
+ marshalled = Marshal.dump(david)
+
+ # Marshaling an association shouldn't make it unusable by wiping its reflection.
+ assert_not_nil david.association(:projects).reflection
+
+ david_too = Marshal.load(marshalled)
+ assert_equal projects(:action_controller), david_too.projects.find_most_recent
+ end
+
+ def test_marshalling_named_extensions
+ david = developers(:david)
+ assert_equal projects(:action_controller), david.projects_extended_by_name.find_most_recent
+
+ marshalled = Marshal.dump(david)
+ david = Marshal.load(marshalled)
+
+ assert_equal projects(:action_controller), david.projects_extended_by_name.find_most_recent
+ end
+
+ def test_extension_name
+ extend!(Developer)
+ extend!(MyApplication::Business::Developer)
+
+ assert Object.const_get "DeveloperAssociationNameAssociationExtension"
+ assert MyApplication::Business.const_get "DeveloperAssociationNameAssociationExtension"
+ end
+
+ def test_proxy_association_after_scoped
+ post = posts(:welcome)
+ assert_equal post.association(:comments), post.comments.the_association
+ assert_equal post.association(:comments), post.comments.where("1=1").the_association
+ end
+
+ def test_association_with_default_scope
+ assert_raises OopsError do
+ posts(:welcome).comments.destroy_all
+ end
+ end
+
+ private
+
+ def extend!(model)
+ ActiveRecord::Associations::Builder::HasMany.define_extensions(model, :association_name) { }
+ end
+end
diff --git a/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb b/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb
new file mode 100644
index 0000000000..fe8bdd03ba
--- /dev/null
+++ b/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb
@@ -0,0 +1,1024 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/developer"
+require "models/computer"
+require "models/project"
+require "models/company"
+require "models/course"
+require "models/customer"
+require "models/order"
+require "models/categorization"
+require "models/category"
+require "models/post"
+require "models/author"
+require "models/tag"
+require "models/tagging"
+require "models/parrot"
+require "models/person"
+require "models/pirate"
+require "models/professor"
+require "models/treasure"
+require "models/price_estimate"
+require "models/club"
+require "models/user"
+require "models/member"
+require "models/membership"
+require "models/sponsor"
+require "models/lesson"
+require "models/student"
+require "models/country"
+require "models/treaty"
+require "models/vertex"
+require "models/publisher"
+require "models/publisher/article"
+require "models/publisher/magazine"
+require "active_support/core_ext/string/conversions"
+
+class ProjectWithAfterCreateHook < ActiveRecord::Base
+ self.table_name = "projects"
+ has_and_belongs_to_many :developers,
+ class_name: "DeveloperForProjectWithAfterCreateHook",
+ join_table: "developers_projects",
+ foreign_key: "project_id",
+ association_foreign_key: "developer_id"
+
+ after_create :add_david
+
+ def add_david
+ david = DeveloperForProjectWithAfterCreateHook.find_by_name("David")
+ david.projects << self
+ end
+end
+
+class DeveloperForProjectWithAfterCreateHook < ActiveRecord::Base
+ self.table_name = "developers"
+ has_and_belongs_to_many :projects,
+ class_name: "ProjectWithAfterCreateHook",
+ join_table: "developers_projects",
+ association_foreign_key: "project_id",
+ foreign_key: "developer_id"
+end
+
+class ProjectWithSymbolsForKeys < ActiveRecord::Base
+ self.table_name = "projects"
+ has_and_belongs_to_many :developers,
+ class_name: "DeveloperWithSymbolsForKeys",
+ join_table: :developers_projects,
+ foreign_key: :project_id,
+ association_foreign_key: "developer_id"
+end
+
+class DeveloperWithSymbolsForKeys < ActiveRecord::Base
+ self.table_name = "developers"
+ has_and_belongs_to_many :projects,
+ class_name: "ProjectWithSymbolsForKeys",
+ join_table: :developers_projects,
+ association_foreign_key: :project_id,
+ foreign_key: "developer_id"
+end
+
+class SubDeveloper < Developer
+ self.table_name = "developers"
+ has_and_belongs_to_many :special_projects,
+ join_table: "developers_projects",
+ foreign_key: "project_id",
+ association_foreign_key: "developer_id"
+end
+
+class DeveloperWithSymbolClassName < Developer
+ has_and_belongs_to_many :projects, class_name: :ProjectWithSymbolsForKeys
+end
+
+class DeveloperWithExtendOption < Developer
+ module NamedExtension
+ def category
+ "sns"
+ end
+ end
+
+ has_and_belongs_to_many :projects, extend: NamedExtension
+end
+
+class ProjectUnscopingDavidDefaultScope < ActiveRecord::Base
+ self.table_name = "projects"
+ has_and_belongs_to_many :developers, -> { unscope(where: "name") },
+ class_name: "LazyBlockDeveloperCalledDavid",
+ join_table: "developers_projects",
+ foreign_key: "project_id",
+ association_foreign_key: "developer_id"
+end
+
+class Kitchen < ActiveRecord::Base
+ has_one :sink
+end
+
+class Sink < ActiveRecord::Base
+ has_and_belongs_to_many :sources, join_table: :edges
+ belongs_to :kitchen
+ accepts_nested_attributes_for :kitchen
+end
+
+class Source < ActiveRecord::Base
+ self.table_name = "men"
+ has_and_belongs_to_many :sinks, join_table: :edges
+end
+
+class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase
+ fixtures :accounts, :companies, :categories, :posts, :categories_posts, :developers, :projects, :developers_projects,
+ :parrots, :pirates, :parrots_pirates, :treasures, :price_estimates, :tags, :taggings, :computers
+
+ def setup_data_for_habtm_case
+ ActiveRecord::Base.connection.execute("delete from countries_treaties")
+
+ country = Country.new(name: "India")
+ country.country_id = "c1"
+ country.save!
+
+ treaty = Treaty.new(name: "peace")
+ treaty.treaty_id = "t1"
+ country.treaties << treaty
+ end
+
+ def test_marshal_dump
+ post = posts :welcome
+ preloaded = Post.includes(:categories).find post.id
+ assert_equal preloaded, Marshal.load(Marshal.dump(preloaded))
+ end
+
+ def test_should_property_quote_string_primary_keys
+ setup_data_for_habtm_case
+
+ con = ActiveRecord::Base.connection
+ sql = "select * from countries_treaties"
+ record = con.select_rows(sql).last
+ assert_equal "c1", record[0]
+ assert_equal "t1", record[1]
+ end
+
+ def test_proper_usage_of_primary_keys_and_join_table
+ setup_data_for_habtm_case
+
+ assert_equal "country_id", Country.primary_key
+ assert_equal "treaty_id", Treaty.primary_key
+
+ country = Country.first
+ assert_equal 1, country.treaties.count
+ end
+
+ def test_join_table_composite_primary_key_should_not_warn
+ country = Country.new(name: "India")
+ country.country_id = "c1"
+ country.save!
+
+ treaty = Treaty.new(name: "peace")
+ treaty.treaty_id = "t1"
+ warning = capture(:stderr) do
+ country.treaties << treaty
+ end
+ assert_no_match(/WARNING: Active Record does not support composite primary key\./, warning)
+ end
+
+ def test_has_and_belongs_to_many
+ david = Developer.find(1)
+
+ assert_not_empty david.projects
+ assert_equal 2, david.projects.size
+
+ active_record = Project.find(1)
+ assert_not_empty active_record.developers
+ assert_equal 3, active_record.developers.size
+ assert_includes active_record.developers, david
+ end
+
+ def test_adding_single
+ jamis = Developer.find(2)
+ jamis.projects.reload # causing the collection to load
+ action_controller = Project.find(2)
+ assert_equal 1, jamis.projects.size
+ assert_equal 1, action_controller.developers.size
+
+ jamis.projects << action_controller
+
+ assert_equal 2, jamis.projects.size
+ assert_equal 2, jamis.projects.reload.size
+ assert_equal 2, action_controller.developers.reload.size
+ end
+
+ def test_adding_type_mismatch
+ jamis = Developer.find(2)
+ assert_raise(ActiveRecord::AssociationTypeMismatch) { jamis.projects << nil }
+ assert_raise(ActiveRecord::AssociationTypeMismatch) { jamis.projects << 1 }
+ end
+
+ def test_adding_from_the_project
+ jamis = Developer.find(2)
+ action_controller = Project.find(2)
+ action_controller.developers.reload
+ assert_equal 1, jamis.projects.size
+ assert_equal 1, action_controller.developers.size
+
+ action_controller.developers << jamis
+
+ assert_equal 2, jamis.projects.reload.size
+ assert_equal 2, action_controller.developers.size
+ assert_equal 2, action_controller.developers.reload.size
+ end
+
+ def test_adding_from_the_project_fixed_timestamp
+ jamis = Developer.find(2)
+ action_controller = Project.find(2)
+ action_controller.developers.reload
+ assert_equal 1, jamis.projects.size
+ assert_equal 1, action_controller.developers.size
+ updated_at = jamis.updated_at
+
+ action_controller.developers << jamis
+
+ assert_equal updated_at, jamis.updated_at
+ assert_equal 2, jamis.projects.reload.size
+ assert_equal 2, action_controller.developers.size
+ assert_equal 2, action_controller.developers.reload.size
+ end
+
+ def test_adding_multiple
+ aredridel = Developer.new("name" => "Aredridel")
+ aredridel.save
+ aredridel.projects.reload
+ aredridel.projects.push(Project.find(1), Project.find(2))
+ assert_equal 2, aredridel.projects.size
+ assert_equal 2, aredridel.projects.reload.size
+ end
+
+ def test_adding_a_collection
+ aredridel = Developer.new("name" => "Aredridel")
+ aredridel.save
+ aredridel.projects.reload
+ aredridel.projects.concat([Project.find(1), Project.find(2)])
+ assert_equal 2, aredridel.projects.size
+ assert_equal 2, aredridel.projects.reload.size
+ end
+
+ def test_habtm_adding_before_save
+ no_of_devels = Developer.count
+ no_of_projects = Project.count
+ aredridel = Developer.new("name" => "Aredridel")
+ aredridel.projects.concat([Project.find(1), p = Project.new("name" => "Projekt")])
+ assert_not_predicate aredridel, :persisted?
+ assert_not_predicate p, :persisted?
+ assert aredridel.save
+ assert_predicate aredridel, :persisted?
+ assert_equal no_of_devels + 1, Developer.count
+ assert_equal no_of_projects + 1, Project.count
+ assert_equal 2, aredridel.projects.size
+ assert_equal 2, aredridel.projects.reload.size
+ end
+
+ def test_habtm_saving_multiple_relationships
+ new_project = Project.new("name" => "Grimetime")
+ amount_of_developers = 4
+ developers = (0...amount_of_developers).reverse_each.map { |i| Developer.create(name: "JME #{i}") }
+
+ new_project.developer_ids = [developers[0].id, developers[1].id]
+ new_project.developers_with_callback_ids = [developers[2].id, developers[3].id]
+ assert new_project.save
+
+ new_project.reload
+ assert_equal amount_of_developers, new_project.developers.size
+ assert_equal developers, new_project.developers
+ end
+
+ def test_habtm_distinct_order_preserved
+ assert_equal developers(:poor_jamis, :jamis, :david), projects(:active_record).non_unique_developers
+ assert_equal developers(:poor_jamis, :jamis, :david), projects(:active_record).developers
+ end
+
+ def test_habtm_collection_size_from_build
+ devel = Developer.create("name" => "Fred Wu")
+ devel.projects << Project.create("name" => "Grimetime")
+ devel.projects.build
+
+ assert_equal 2, devel.projects.size
+ end
+
+ def test_habtm_collection_size_from_params
+ devel = Developer.new(
+ projects_attributes: {
+ "0" => {}
+ })
+
+ assert_equal 1, devel.projects.size
+ end
+
+ def test_build
+ devel = Developer.find(1)
+
+ # Load schema information so we don't query below if running just this test.
+ Project.define_attribute_methods
+
+ proj = assert_no_queries { devel.projects.build("name" => "Projekt") }
+ assert_not_predicate devel.projects, :loaded?
+
+ assert_equal devel.projects.last, proj
+ assert_predicate devel.projects, :loaded?
+
+ assert_not_predicate proj, :persisted?
+ devel.save
+ assert_predicate proj, :persisted?
+ assert_equal devel.projects.last, proj
+ assert_equal Developer.find(1).projects.sort_by(&:id).last, proj # prove join table is updated
+ end
+
+ def test_new_aliased_to_build
+ devel = Developer.find(1)
+
+ # Load schema information so we don't query below if running just this test.
+ Project.define_attribute_methods
+
+ proj = assert_no_queries { devel.projects.new("name" => "Projekt") }
+ assert_not_predicate devel.projects, :loaded?
+
+ assert_equal devel.projects.last, proj
+ assert_predicate devel.projects, :loaded?
+
+ assert_not_predicate proj, :persisted?
+ devel.save
+ assert_predicate proj, :persisted?
+ assert_equal devel.projects.last, proj
+ assert_equal Developer.find(1).projects.sort_by(&:id).last, proj # prove join table is updated
+ end
+
+ def test_build_by_new_record
+ devel = Developer.new(name: "Marcel", salary: 75000)
+ devel.projects.build(name: "Make bed")
+ proj2 = devel.projects.build(name: "Lie in it")
+ assert_equal devel.projects.last, proj2
+ assert_not_predicate proj2, :persisted?
+ devel.save
+ assert_predicate devel, :persisted?
+ assert_predicate proj2, :persisted?
+ assert_equal devel.projects.last, proj2
+ assert_equal Developer.find_by_name("Marcel").projects.last, proj2 # prove join table is updated
+ end
+
+ def test_create
+ devel = Developer.find(1)
+ proj = devel.projects.create("name" => "Projekt")
+ assert_not_predicate devel.projects, :loaded?
+
+ assert_equal devel.projects.last, proj
+ assert_not_predicate devel.projects, :loaded?
+
+ assert_predicate proj, :persisted?
+ assert_equal Developer.find(1).projects.sort_by(&:id).last, proj # prove join table is updated
+ end
+
+ def test_creation_respects_hash_condition
+ # in Oracle '' is saved as null therefore need to save ' ' in not null column
+ post = categories(:general).post_with_conditions.build(body: " ")
+
+ assert post.save
+ assert_equal "Yet Another Testing Title", post.title
+
+ # in Oracle '' is saved as null therefore need to save ' ' in not null column
+ another_post = categories(:general).post_with_conditions.create(body: " ")
+
+ assert_predicate another_post, :persisted?
+ assert_equal "Yet Another Testing Title", another_post.title
+ end
+
+ def test_distinct_after_the_fact
+ dev = developers(:jamis)
+ dev.projects << projects(:active_record)
+ dev.projects << projects(:active_record)
+
+ assert_equal 3, dev.projects.size
+ assert_equal 1, dev.projects.uniq.size
+ end
+
+ def test_distinct_before_the_fact
+ projects(:active_record).developers << developers(:jamis)
+ projects(:active_record).developers << developers(:david)
+ assert_equal 3, projects(:active_record, :reload).developers.size
+ end
+
+ def test_distinct_option_prevents_duplicate_push
+ project = projects(:active_record)
+ project.developers << developers(:jamis)
+ project.developers << developers(:david)
+ assert_equal 3, project.developers.size
+
+ project.developers << developers(:david)
+ project.developers << developers(:jamis)
+ assert_equal 3, project.developers.size
+ end
+
+ def test_distinct_when_association_already_loaded
+ project = projects(:active_record)
+ project.developers << [ developers(:jamis), developers(:david), developers(:jamis), developers(:david) ]
+ assert_equal 3, Project.includes(:developers).find(project.id).developers.size
+ end
+
+ def test_deleting
+ david = Developer.find(1)
+ active_record = Project.find(1)
+ david.projects.reload
+ assert_equal 2, david.projects.size
+ assert_equal 3, active_record.developers.size
+
+ david.projects.delete(active_record)
+
+ assert_equal 1, david.projects.size
+ assert_equal 1, david.projects.reload.size
+ assert_equal 2, active_record.developers.reload.size
+ end
+
+ def test_deleting_array
+ david = Developer.find(1)
+ david.projects.reload
+ david.projects.delete(Project.all.to_a)
+ assert_equal 0, david.projects.size
+ assert_equal 0, david.projects.reload.size
+ end
+
+ def test_deleting_all
+ david = Developer.find(1)
+ david.projects.reload
+ david.projects.clear
+ assert_equal 0, david.projects.size
+ assert_equal 0, david.projects.reload.size
+ end
+
+ def test_removing_associations_on_destroy
+ david = DeveloperWithBeforeDestroyRaise.find(1)
+ assert_not_empty david.projects
+ david.destroy
+ assert_empty david.projects
+ assert_empty DeveloperWithBeforeDestroyRaise.connection.select_all("SELECT * FROM developers_projects WHERE developer_id = 1")
+ end
+
+ def test_destroying
+ david = Developer.find(1)
+ project = Project.find(1)
+ david.projects.reload
+ assert_equal 2, david.projects.size
+ assert_equal 3, project.developers.size
+
+ assert_no_difference "Project.count" do
+ david.projects.destroy(project)
+ end
+
+ join_records = Developer.connection.select_all("SELECT * FROM developers_projects WHERE developer_id = #{david.id} AND project_id = #{project.id}")
+ assert_empty join_records
+
+ assert_equal 1, david.reload.projects.size
+ assert_equal 1, david.projects.reload.size
+ end
+
+ def test_destroying_many
+ david = Developer.find(1)
+ david.projects.reload
+ projects = Project.all.to_a
+
+ assert_no_difference "Project.count" do
+ david.projects.destroy(*projects)
+ end
+
+ join_records = Developer.connection.select_all("SELECT * FROM developers_projects WHERE developer_id = #{david.id}")
+ assert_empty join_records
+
+ assert_equal 0, david.reload.projects.size
+ assert_equal 0, david.projects.reload.size
+ end
+
+ def test_destroy_all
+ david = Developer.find(1)
+ david.projects.reload
+ assert_not_empty david.projects
+
+ assert_no_difference "Project.count" do
+ david.projects.destroy_all
+ end
+
+ join_records = Developer.connection.select_all("SELECT * FROM developers_projects WHERE developer_id = #{david.id}")
+ assert_empty join_records
+
+ assert_empty david.projects
+ assert_empty david.projects.reload
+ end
+
+ def test_destroy_associations_destroys_multiple_associations
+ george = parrots(:george)
+ assert_not_empty george.pirates
+ assert_not_empty george.treasures
+
+ assert_no_difference "Pirate.count" do
+ assert_no_difference "Treasure.count" do
+ george.destroy_associations
+ end
+ end
+
+ join_records = Parrot.connection.select_all("SELECT * FROM parrots_pirates WHERE parrot_id = #{george.id}")
+ assert_empty join_records
+ assert_empty george.pirates.reload
+
+ join_records = Parrot.connection.select_all("SELECT * FROM parrots_treasures WHERE parrot_id = #{george.id}")
+ assert_empty join_records
+ assert_empty george.treasures.reload
+ end
+
+ def test_associations_with_conditions
+ assert_equal 3, projects(:active_record).developers.size
+ assert_equal 1, projects(:active_record).developers_named_david.size
+ assert_equal 1, projects(:active_record).developers_named_david_with_hash_conditions.size
+
+ assert_equal developers(:david), projects(:active_record).developers_named_david.find(developers(:david).id)
+ assert_equal developers(:david), projects(:active_record).developers_named_david_with_hash_conditions.find(developers(:david).id)
+ assert_equal developers(:david), projects(:active_record).salaried_developers.find(developers(:david).id)
+
+ projects(:active_record).developers_named_david.clear
+ assert_equal 2, projects(:active_record, :reload).developers.size
+ end
+
+ def test_find_in_association
+ # Using sql
+ assert_equal developers(:david), projects(:active_record).developers.find(developers(:david).id), "SQL find"
+
+ # Using ruby
+ active_record = projects(:active_record)
+ active_record.developers.reload
+ assert_equal developers(:david), active_record.developers.find(developers(:david).id), "Ruby find"
+ end
+
+ def test_include_uses_array_include_after_loaded
+ project = projects(:active_record)
+ project.developers.load_target
+
+ developer = project.developers.first
+
+ assert_no_queries do
+ assert_predicate project.developers, :loaded?
+ assert_includes project.developers, developer
+ end
+ end
+
+ def test_include_checks_if_record_exists_if_target_not_loaded
+ project = projects(:active_record)
+ developer = project.developers.first
+
+ project.reload
+ assert_not_predicate project.developers, :loaded?
+ assert_queries(1) do
+ assert_includes project.developers, developer
+ end
+ assert_not_predicate project.developers, :loaded?
+ end
+
+ def test_include_returns_false_for_non_matching_record_to_verify_scoping
+ project = projects(:active_record)
+ developer = Developer.create name: "Bryan", salary: 50_000
+
+ assert_not_predicate project.developers, :loaded?
+ assert_not project.developers.include?(developer)
+ end
+
+ def test_find_with_merged_options
+ assert_equal 1, projects(:active_record).limited_developers.size
+ assert_equal 1, projects(:active_record).limited_developers.to_a.size
+ assert_equal 3, projects(:active_record).limited_developers.limit(nil).to_a.size
+ end
+
+ def test_dynamic_find_should_respect_association_order
+ # Developers are ordered 'name DESC, id DESC'
+ high_id_jamis = projects(:active_record).developers.create(name: "Jamis")
+
+ assert_equal high_id_jamis, projects(:active_record).developers.merge(where: "name = 'Jamis'").first
+ assert_equal high_id_jamis, projects(:active_record).developers.find_by_name("Jamis")
+ end
+
+ def test_find_should_append_to_association_order
+ ordered_developers = projects(:active_record).developers.order("projects.id")
+ assert_equal ["developers.name desc, developers.id desc", "projects.id"], ordered_developers.order_values
+ end
+
+ def test_dynamic_find_all_should_respect_readonly_access
+ projects(:active_record).readonly_developers.each { |d| assert_raise(ActiveRecord::ReadOnlyRecord) { d.save! } if d.valid? }
+ projects(:active_record).readonly_developers.each(&:readonly?)
+ end
+
+ def test_new_with_values_in_collection
+ jamis = DeveloperForProjectWithAfterCreateHook.find_by_name("Jamis")
+ david = DeveloperForProjectWithAfterCreateHook.find_by_name("David")
+ project = ProjectWithAfterCreateHook.new(name: "Cooking with Bertie")
+ project.developers << jamis
+ project.save!
+ project.reload
+
+ assert_includes project.developers, jamis
+ assert_includes project.developers, david
+ end
+
+ def test_find_in_association_with_options
+ developers = projects(:active_record).developers.to_a
+ assert_equal 3, developers.size
+
+ assert_equal developers(:poor_jamis), projects(:active_record).developers.where("salary < 10000").first
+ end
+
+ def test_association_with_extend_option
+ eponine = DeveloperWithExtendOption.create(name: "Eponine")
+ assert_equal "sns", eponine.projects.category
+ end
+
+ def test_replace_with_less
+ david = developers(:david)
+ david.projects = [projects(:action_controller)]
+ assert david.save
+ assert_equal 1, david.projects.length
+ end
+
+ def test_replace_with_new
+ david = developers(:david)
+ david.projects = [projects(:action_controller), Project.new("name" => "ActionWebSearch")]
+ david.save
+ assert_equal 2, david.projects.length
+ assert_not_includes david.projects, projects(:active_record)
+ end
+
+ def test_replace_on_new_object
+ new_developer = Developer.new("name" => "Matz")
+ new_developer.projects = [projects(:action_controller), Project.new("name" => "ActionWebSearch")]
+ new_developer.save
+ assert_equal 2, new_developer.projects.length
+ end
+
+ def test_consider_type
+ developer = Developer.first
+ special_project = SpecialProject.create("name" => "Special Project")
+
+ other_project = developer.projects.first
+ developer.special_projects << special_project
+ developer.reload
+
+ assert_includes developer.projects, special_project
+ assert_includes developer.special_projects, special_project
+ assert_not_includes developer.special_projects, other_project
+ end
+
+ def test_symbol_join_table
+ developer = Developer.first
+ sp = developer.sym_special_projects.create("name" => "omg")
+ developer.reload
+ assert_includes developer.sym_special_projects, sp
+ end
+
+ def test_update_columns_after_push_without_duplicate_join_table_rows
+ developer = Developer.new("name" => "Kano")
+ project = SpecialProject.create("name" => "Special Project")
+ assert developer.save
+ developer.projects << project
+ developer.update_columns("name" => "Bruza")
+ assert_equal 1, Developer.connection.select_value(<<-end_sql).to_i
+ SELECT count(*) FROM developers_projects
+ WHERE project_id = #{project.id}
+ AND developer_id = #{developer.id}
+ end_sql
+ end
+
+ def test_updating_attributes_on_non_rich_associations
+ welcome = categories(:technology).posts.first
+ welcome.title = "Something else"
+ assert welcome.save!
+ end
+
+ def test_habtm_respects_select
+ categories(:technology).select_testing_posts.reload.each do |o|
+ assert_respond_to o, :correctness_marker
+ end
+ assert_respond_to categories(:technology).select_testing_posts.first, :correctness_marker
+ end
+
+ def test_habtm_selects_all_columns_by_default
+ assert_equal Project.column_names.sort, developers(:david).projects.first.attributes.keys.sort
+ end
+
+ def test_habtm_respects_select_query_method
+ assert_equal ["id"], developers(:david).projects.select(:id).first.attributes.keys
+ end
+
+ def test_join_table_alias
+ assert_equal(
+ 3,
+ Developer.includes(projects: :developers).where.not("projects_developers_projects_join.joined_on": nil).to_a.size
+ )
+ end
+
+ def test_join_with_group
+ group = Developer.columns.inject([]) do |g, c|
+ g << "developers.#{c.name}"
+ g << "developers_projects_2.#{c.name}"
+ end
+ Project.columns.each { |c| group << "projects.#{c.name}" }
+
+ assert_equal(
+ 3,
+ Developer.includes(projects: :developers).where.not("projects_developers_projects_join.joined_on": nil).group(group.join(",")).to_a.size
+ )
+ end
+
+ def test_find_grouped
+ all_posts_from_category1 = Post.all.merge!(where: "category_id = 1", joins: :categories).to_a
+ grouped_posts_of_category1 = Post.all.merge!(where: "category_id = 1", group: "author_id", select: "count(posts.id) as posts_count", joins: :categories).to_a
+ assert_equal 5, all_posts_from_category1.size
+ assert_equal 2, grouped_posts_of_category1.size
+ end
+
+ def test_find_scoped_grouped
+ assert_equal 5, categories(:general).posts_grouped_by_title.to_a.size
+ assert_equal 1, categories(:technology).posts_grouped_by_title.to_a.size
+ end
+
+ def test_find_scoped_grouped_having
+ assert_equal 2, projects(:active_record).well_paid_salary_groups.to_a.size
+ assert projects(:active_record).well_paid_salary_groups.all? { |g| g.salary > 10000 }
+ end
+
+ def test_get_ids
+ assert_equal projects(:active_record, :action_controller).map(&:id).sort, developers(:david).project_ids.sort
+ assert_equal [projects(:active_record).id], developers(:jamis).project_ids
+ end
+
+ def test_get_ids_for_loaded_associations
+ developer = developers(:david)
+ developer.projects.reload
+ assert_no_queries do
+ developer.project_ids
+ developer.project_ids
+ end
+ end
+
+ def test_get_ids_for_unloaded_associations_does_not_load_them
+ developer = developers(:david)
+ assert_not_predicate developer.projects, :loaded?
+ assert_equal projects(:active_record, :action_controller).map(&:id).sort, developer.project_ids.sort
+ assert_not_predicate developer.projects, :loaded?
+ end
+
+ def test_assign_ids
+ developer = Developer.new("name" => "Joe")
+ developer.project_ids = projects(:active_record, :action_controller).map(&:id)
+ developer.save
+ developer.reload
+ assert_equal 2, developer.projects.length
+ assert_equal [projects(:active_record), projects(:action_controller)].map(&:id).sort, developer.project_ids.sort
+ end
+
+ def test_assign_ids_ignoring_blanks
+ developer = Developer.new("name" => "Joe")
+ developer.project_ids = [projects(:active_record).id, nil, projects(:action_controller).id, ""]
+ developer.save
+ developer.reload
+ assert_equal 2, developer.projects.length
+ assert_equal [projects(:active_record), projects(:action_controller)].map(&:id).sort, developer.project_ids.sort
+ end
+
+ def test_singular_ids_are_reloaded_after_collection_concat
+ student = Student.create(name: "Alberto Almagro")
+ student.lesson_ids
+
+ lesson = Lesson.create(name: "DSI")
+ student.lessons << lesson
+
+ assert_includes student.lesson_ids, lesson.id
+ end
+
+ def test_scoped_find_on_through_association_doesnt_return_read_only_records
+ tag = Post.find(1).tags.find_by_name("General")
+
+ assert_nothing_raised do
+ tag.save!
+ end
+ end
+
+ def test_has_many_through_polymorphic_has_manys_works
+ assert_equal ["$10.00", "$20.00"].to_set, pirates(:redbeard).treasure_estimates.map(&:price).to_set
+ end
+
+ def test_symbols_as_keys
+ developer = DeveloperWithSymbolsForKeys.new(name: "David")
+ project = ProjectWithSymbolsForKeys.new(name: "Rails Testing")
+ project.developers << developer
+ project.save!
+
+ assert_equal 1, project.developers.size
+ assert_equal 1, developer.projects.size
+ assert_equal developer, project.developers.first
+ assert_equal project, developer.projects.first
+ end
+
+ def test_dynamic_find_should_respect_association_include
+ # SQL error in sort clause if :include is not included
+ # due to Unknown column 'authors.id'
+ assert Category.find(1).posts_with_authors_sorted_by_author_id.find_by_title("Welcome to the weblog")
+ end
+
+ def test_count
+ david = Developer.find(1)
+ assert_equal 2, david.projects.count
+ end
+
+ def test_association_proxy_transaction_method_starts_transaction_in_association_class
+ assert_called(Post, :transaction) do
+ Category.first.posts.transaction do
+ # nothing
+ end
+ end
+ end
+
+ def test_caching_of_columns
+ david = Developer.find(1)
+ # clear cache possibly created by other tests
+ david.projects.reset_column_information
+
+ assert_queries(:any) { david.projects.columns }
+ assert_no_queries { david.projects.columns }
+
+ ## and again to verify that reset_column_information clears the cache correctly
+ david.projects.reset_column_information
+
+ assert_queries(:any) { david.projects.columns }
+ assert_no_queries { david.projects.columns }
+ end
+
+ def test_attributes_are_being_set_when_initialized_from_habtm_association_with_where_clause
+ new_developer = projects(:action_controller).developers.where(name: "Marcelo").build
+ assert_equal new_developer.name, "Marcelo"
+ end
+
+ def test_attributes_are_being_set_when_initialized_from_habtm_association_with_multiple_where_clauses
+ new_developer = projects(:action_controller).developers.where(name: "Marcelo").where(salary: 90_000).build
+ assert_equal new_developer.name, "Marcelo"
+ assert_equal new_developer.salary, 90_000
+ end
+
+ def test_include_method_in_has_and_belongs_to_many_association_should_return_true_for_instance_added_with_build
+ project = Project.new
+ developer = project.developers.build
+ assert_includes project.developers, developer
+ end
+
+ def test_destruction_does_not_error_without_primary_key
+ redbeard = pirates(:redbeard)
+ george = parrots(:george)
+ redbeard.parrots << george
+ assert_equal 2, george.pirates.count
+ Pirate.includes(:parrots).where(parrot: redbeard.parrot).find(redbeard.id).destroy
+ assert_equal 1, george.pirates.count
+ assert_equal [], Pirate.where(id: redbeard.id)
+ end
+
+ def test_has_and_belongs_to_many_associations_on_new_records_use_null_relations
+ projects = Developer.new.projects
+ assert_no_queries do
+ assert_equal [], projects
+ assert_equal [], projects.where(title: "omg")
+ assert_equal [], projects.pluck(:title)
+ assert_equal 0, projects.count
+ end
+ end
+
+ def test_association_with_validate_false_does_not_run_associated_validation_callbacks_on_create
+ rich_person = RichPerson.new
+
+ treasure = Treasure.new
+ treasure.rich_people << rich_person
+ treasure.valid?
+
+ assert_equal 1, treasure.rich_people.size
+ assert_nil rich_person.first_name, "should not run associated person validation on create when validate: false"
+ end
+
+ def test_association_with_validate_false_does_not_run_associated_validation_callbacks_on_update
+ rich_person = RichPerson.create!
+ person_first_name = rich_person.first_name
+ assert_not_nil person_first_name
+
+ treasure = Treasure.new
+ treasure.rich_people << rich_person
+ treasure.valid?
+
+ assert_equal 1, treasure.rich_people.size
+ assert_equal person_first_name, rich_person.first_name, "should not run associated person validation on update when validate: false"
+ end
+
+ def test_custom_join_table
+ assert_equal "edges", Vertex.reflect_on_association(:sources).join_table
+ end
+
+ def test_has_and_belongs_to_many_in_a_namespaced_model_pointing_to_a_namespaced_model
+ magazine = Publisher::Magazine.create
+ article = Publisher::Article.create
+ magazine.articles << article
+ magazine.save
+
+ assert_includes magazine.articles, article
+ end
+
+ def test_has_and_belongs_to_many_in_a_namespaced_model_pointing_to_a_non_namespaced_model
+ article = Publisher::Article.create
+ tag = Tag.create
+ article.tags << tag
+ article.save
+
+ assert_includes article.tags, tag
+ end
+
+ def test_redefine_habtm
+ child = SubDeveloper.new("name" => "Aredridel")
+ child.special_projects << SpecialProject.new("name" => "Special Project")
+ assert child.save, "child object should be saved"
+ end
+
+ def test_habtm_with_reflection_using_class_name_and_fixtures
+ assert_not_nil Developer._reflections["shared_computers"]
+ # Checking the fixture for named association is important here, because it's the only way
+ # we've been able to reproduce this bug
+ assert_not_nil File.read(File.expand_path("../../fixtures/developers.yml", __dir__)).index("shared_computers")
+ assert_equal developers(:david).shared_computers.first, computers(:laptop)
+ end
+
+ def test_with_symbol_class_name
+ assert_nothing_raised do
+ developer = DeveloperWithSymbolClassName.new
+ developer.projects
+ end
+ end
+
+ def test_alternate_database
+ professor = Professor.create(name: "Plum")
+ course = Course.create(name: "Forensics")
+ assert_equal 0, professor.courses.count
+ assert_nothing_raised do
+ professor.courses << course
+ end
+ assert_equal 1, professor.courses.count
+ end
+
+ def test_habtm_scope_can_unscope
+ project = ProjectUnscopingDavidDefaultScope.new
+ project.save!
+
+ developer = LazyBlockDeveloperCalledDavid.new(name: "Not David")
+ developer.save!
+ project.developers << developer
+
+ projects = ProjectUnscopingDavidDefaultScope.includes(:developers).where(id: project.id)
+ assert_equal 1, projects.first.developers.size
+ end
+
+ def test_preloaded_associations_size
+ assert_equal Project.first.salaried_developers.size,
+ Project.preload(:salaried_developers).first.salaried_developers.size
+
+ assert_equal Project.includes(:salaried_developers).references(:salaried_developers).first.salaried_developers.size,
+ Project.preload(:salaried_developers).first.salaried_developers.size
+
+ # Nested HATBM
+ first_project = Developer.first.projects.first
+ preloaded_first_project =
+ Developer.preload(projects: :salaried_developers).
+ first.
+ projects.
+ detect { |p| p.id == first_project.id }
+
+ assert preloaded_first_project.salaried_developers.loaded?, true
+ assert_equal first_project.salaried_developers.size, preloaded_first_project.salaried_developers.size
+ end
+
+ def test_has_and_belongs_to_many_is_useable_with_belongs_to_required_by_default
+ assert_difference "Project.first.developers_required_by_default.size", 1 do
+ Project.first.developers_required_by_default.create!(name: "Sean", salary: 50000)
+ end
+ end
+
+ def test_association_name_is_the_same_as_join_table_name
+ user = User.create!
+ assert_nothing_raised { user.jobs_pool.clear }
+ end
+
+ def test_has_and_belongs_to_many_while_partial_writes_false
+ original_partial_writes = ActiveRecord::Base.partial_writes
+ ActiveRecord::Base.partial_writes = false
+ developer = Developer.new(name: "Mehmet Emin İNAÇ")
+ developer.projects << Project.new(name: "Bounty")
+
+ assert developer.save
+ ensure
+ ActiveRecord::Base.partial_writes = original_partial_writes
+ end
+
+ def test_has_and_belongs_to_many_with_belongs_to
+ sink = Sink.create! kitchen: Kitchen.new, sources: [Source.new]
+ assert_equal 1, sink.sources.count
+ end
+end
diff --git a/activerecord/test/cases/associations/has_many_associations_test.rb b/activerecord/test/cases/associations/has_many_associations_test.rb
new file mode 100644
index 0000000000..5921193374
--- /dev/null
+++ b/activerecord/test/cases/associations/has_many_associations_test.rb
@@ -0,0 +1,2932 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/developer"
+require "models/computer"
+require "models/project"
+require "models/company"
+require "models/contract"
+require "models/topic"
+require "models/reply"
+require "models/category"
+require "models/image"
+require "models/post"
+require "models/author"
+require "models/essay"
+require "models/comment"
+require "models/person"
+require "models/reader"
+require "models/tagging"
+require "models/tag"
+require "models/invoice"
+require "models/line_item"
+require "models/car"
+require "models/bulb"
+require "models/engine"
+require "models/categorization"
+require "models/minivan"
+require "models/speedometer"
+require "models/reference"
+require "models/college"
+require "models/student"
+require "models/pirate"
+require "models/ship"
+require "models/ship_part"
+require "models/treasure"
+require "models/parrot"
+require "models/tyre"
+require "models/subscriber"
+require "models/subscription"
+require "models/zine"
+require "models/interest"
+
+class HasManyAssociationsTestForReorderWithJoinDependency < ActiveRecord::TestCase
+ fixtures :authors, :author_addresses, :posts, :comments
+
+ def test_should_generate_valid_sql
+ author = authors(:david)
+ # this can fail on adapters which require ORDER BY expressions to be included in the SELECT expression
+ # if the reorder clauses are not correctly handled
+ assert author.posts_with_comments_sorted_by_comment_id.where("comments.id > 0").reorder("posts.comments_count DESC", "posts.tags_count DESC").last
+ end
+end
+
+class HasManyAssociationsTestPrimaryKeys < ActiveRecord::TestCase
+ fixtures :authors, :author_addresses, :essays, :subscribers, :subscriptions, :people
+
+ def test_custom_primary_key_on_new_record_should_fetch_with_query
+ subscriber = Subscriber.new(nick: "webster132")
+ assert_not_predicate subscriber.subscriptions, :loaded?
+
+ assert_queries 1 do
+ assert_equal 2, subscriber.subscriptions.size
+ end
+
+ assert_equal Subscription.where(subscriber_id: "webster132"), subscriber.subscriptions
+ end
+
+ def test_association_primary_key_on_new_record_should_fetch_with_query
+ author = Author.new(name: "David")
+ assert_not_predicate author.essays, :loaded?
+
+ assert_queries 1 do
+ assert_equal 1, author.essays.size
+ end
+
+ assert_equal Essay.where(writer_id: "David"), author.essays
+ end
+
+ def test_has_many_custom_primary_key
+ david = authors(:david)
+ assert_equal Essay.where(writer_id: "David"), david.essays
+ end
+
+ def test_ids_on_unloaded_association_with_custom_primary_key
+ david = people(:david)
+ assert_equal Essay.where(writer_id: "David").pluck(:id), david.essay_ids
+ end
+
+ def test_ids_on_loaded_association_with_custom_primary_key
+ david = people(:david)
+ david.essays.load
+ assert_equal Essay.where(writer_id: "David").pluck(:id), david.essay_ids
+ end
+
+ def test_has_many_assignment_with_custom_primary_key
+ david = people(:david)
+
+ assert_equal ["A Modest Proposal"], david.essays.map(&:name)
+ david.essays = [Essay.create!(name: "Remote Work")]
+ assert_equal ["Remote Work"], david.essays.map(&:name)
+ end
+
+ def test_blank_custom_primary_key_on_new_record_should_not_run_queries
+ author = Author.new
+ assert_not_predicate author.essays, :loaded?
+
+ assert_queries 0 do
+ assert_equal 0, author.essays.size
+ end
+ end
+end
+
+class HasManyAssociationsTest < ActiveRecord::TestCase
+ fixtures :accounts, :categories, :companies, :developers, :projects,
+ :developers_projects, :topics, :authors, :author_addresses, :comments,
+ :posts, :readers, :taggings, :cars, :tags,
+ :categorizations, :zines, :interests
+
+ def setup
+ Client.destroyed_client_ids.clear
+ end
+
+ def test_sti_subselect_count
+ tag = Tag.first
+ len = Post.tagged_with(tag.id).limit(10).size
+ assert_operator len, :>, 0
+ end
+
+ def test_anonymous_has_many
+ developer = Class.new(ActiveRecord::Base) {
+ self.table_name = "developers"
+ dev = self
+
+ developer_project = Class.new(ActiveRecord::Base) {
+ self.table_name = "developers_projects"
+ belongs_to :developer, anonymous_class: dev
+ }
+ has_many :developer_projects, anonymous_class: developer_project, foreign_key: "developer_id"
+ }
+ dev = developer.first
+ named = Developer.find(dev.id)
+ assert_operator dev.developer_projects.count, :>, 0
+ assert_equal named.projects.map(&:id).sort,
+ dev.developer_projects.map(&:project_id).sort
+ end
+
+ def test_default_scope_on_relations_is_not_cached
+ counter = 0
+ posts = Class.new(ActiveRecord::Base) {
+ self.table_name = "posts"
+ self.inheritance_column = "not_there"
+ post = self
+
+ comments = Class.new(ActiveRecord::Base) {
+ self.table_name = "comments"
+ self.inheritance_column = "not_there"
+ belongs_to :post, anonymous_class: post
+ default_scope -> {
+ counter += 1
+ where("id = :inc", inc: counter)
+ }
+ }
+ has_many :comments, anonymous_class: comments, foreign_key: "post_id"
+ }
+ assert_equal 0, counter
+ post = posts.first
+ assert_equal 0, counter
+ sql = capture_sql { post.comments.to_a }
+ post.comments.reset
+ assert_not_equal sql, capture_sql { post.comments.to_a }
+ end
+
+ def test_has_many_build_with_options
+ college = College.create(name: "UFMT")
+ Student.create(active: true, college_id: college.id, name: "Sarah")
+
+ assert_equal college.students, Student.where(active: true, college_id: college.id)
+ end
+
+ def test_add_record_to_collection_should_change_its_updated_at
+ ship = Ship.create(name: "dauntless")
+ part = ShipPart.create(name: "cockpit")
+ updated_at = part.updated_at
+
+ travel(1.second) do
+ ship.parts << part
+ end
+
+ assert_equal part.ship, ship
+ assert_not_equal part.updated_at, updated_at
+ end
+
+ def test_clear_collection_should_not_change_updated_at
+ # GH#17161: .clear calls delete_all (and returns the association),
+ # which is intended to not touch associated objects's updated_at field
+ ship = Ship.create(name: "dauntless")
+ part = ShipPart.create(name: "cockpit", ship_id: ship.id)
+
+ ship.parts.clear
+ part.reload
+
+ assert_nil part.ship
+ assert_not_predicate part, :updated_at_changed?
+ end
+
+ def test_create_from_association_should_respect_default_scope
+ car = Car.create(name: "honda")
+ assert_equal "honda", car.name
+
+ bulb = Bulb.create
+ assert_equal "defaulty", bulb.name
+
+ bulb = car.bulbs.build
+ assert_equal "defaulty", bulb.name
+
+ bulb = car.bulbs.create
+ assert_equal "defaulty", bulb.name
+ end
+
+ def test_build_and_create_from_association_should_respect_passed_attributes_over_default_scope
+ car = Car.create(name: "honda")
+
+ bulb = car.bulbs.build(name: "exotic")
+ assert_equal "exotic", bulb.name
+
+ bulb = car.bulbs.create(name: "exotic")
+ assert_equal "exotic", bulb.name
+
+ bulb = car.awesome_bulbs.build(frickinawesome: false)
+ assert_equal false, bulb.frickinawesome
+
+ bulb = car.awesome_bulbs.create(frickinawesome: false)
+ assert_equal false, bulb.frickinawesome
+ end
+
+ def test_build_from_association_should_respect_scope
+ author = Author.new
+
+ post = author.thinking_posts.build
+ assert_equal "So I was thinking", post.title
+ end
+
+ def test_create_from_association_with_nil_values_should_work
+ car = Car.create(name: "honda")
+
+ bulb = car.bulbs.new(nil)
+ assert_equal "defaulty", bulb.name
+
+ bulb = car.bulbs.build(nil)
+ assert_equal "defaulty", bulb.name
+
+ bulb = car.bulbs.create(nil)
+ assert_equal "defaulty", bulb.name
+ end
+
+ def test_build_from_association_sets_inverse_instance
+ car = Car.new(name: "honda")
+
+ bulb = car.bulbs.build
+ assert_equal car, bulb.car
+ end
+
+ def test_do_not_call_callbacks_for_delete_all
+ car = Car.create(name: "honda")
+ car.funky_bulbs.create!
+ assert_equal 1, car.funky_bulbs.count
+ assert_equal 1, car.reload.funky_bulbs.delete_all
+ assert_equal 0, car.funky_bulbs.count, "bulbs should have been deleted using :delete_all strategy"
+ end
+
+ def test_delete_all_on_association_is_the_same_as_not_loaded
+ author = authors :david
+ author.thinking_posts.create!(body: "test")
+ author.reload
+ expected_sql = capture_sql { author.thinking_posts.delete_all }
+
+ author.thinking_posts.create!(body: "test")
+ author.reload
+ author.thinking_posts.inspect
+ loaded_sql = capture_sql { author.thinking_posts.delete_all }
+ assert_equal(expected_sql, loaded_sql)
+ end
+
+ def test_delete_all_on_association_with_nil_dependency_is_the_same_as_not_loaded
+ author = authors :david
+ author.posts.create!(title: "test", body: "body")
+ author.reload
+ expected_sql = capture_sql { author.posts.delete_all }
+
+ author.posts.create!(title: "test", body: "body")
+ author.reload
+ author.posts.to_a
+ loaded_sql = capture_sql { author.posts.delete_all }
+ assert_equal(expected_sql, loaded_sql)
+ end
+
+ def test_delete_all_on_association_clears_scope
+ author = Author.create!(name: "Gannon")
+ posts = author.posts
+ posts.create!(title: "test", body: "body")
+ posts.delete_all
+ assert_nil posts.first
+ end
+
+ def test_building_the_associated_object_with_implicit_sti_base_class
+ firm = DependentFirm.new
+ company = firm.companies.build
+ assert_kind_of Company, company, "Expected #{company.class} to be a Company"
+ end
+
+ def test_building_the_associated_object_with_explicit_sti_base_class
+ firm = DependentFirm.new
+ company = firm.companies.build(type: "Company")
+ assert_kind_of Company, company, "Expected #{company.class} to be a Company"
+ end
+
+ def test_building_the_associated_object_with_sti_subclass
+ firm = DependentFirm.new
+ company = firm.companies.build(type: "Client")
+ assert_kind_of Client, company, "Expected #{company.class} to be a Client"
+ end
+
+ def test_building_the_associated_object_with_an_invalid_type
+ firm = DependentFirm.new
+ assert_raise(ActiveRecord::SubclassNotFound) { firm.companies.build(type: "Invalid") }
+ end
+
+ def test_building_the_associated_object_with_an_unrelated_type
+ firm = DependentFirm.new
+ assert_raise(ActiveRecord::SubclassNotFound) { firm.companies.build(type: "Account") }
+ end
+
+ test "building the association with an array" do
+ speedometer = Speedometer.new(speedometer_id: "a")
+ data = [{ name: "first" }, { name: "second" }]
+ speedometer.minivans.build(data)
+
+ assert_equal 2, speedometer.minivans.size
+ assert speedometer.save
+ assert_equal ["first", "second"], speedometer.reload.minivans.map(&:name)
+ end
+
+ def test_association_keys_bypass_attribute_protection
+ car = Car.create(name: "honda")
+
+ bulb = car.bulbs.new
+ assert_equal car.id, bulb.car_id
+
+ bulb = car.bulbs.new car_id: car.id + 1
+ assert_equal car.id, bulb.car_id
+
+ bulb = car.bulbs.build
+ assert_equal car.id, bulb.car_id
+
+ bulb = car.bulbs.build car_id: car.id + 1
+ assert_equal car.id, bulb.car_id
+
+ bulb = car.bulbs.create
+ assert_equal car.id, bulb.car_id
+
+ bulb = car.bulbs.create car_id: car.id + 1
+ assert_equal car.id, bulb.car_id
+ end
+
+ def test_association_protect_foreign_key
+ invoice = Invoice.create
+
+ line_item = invoice.line_items.new
+ assert_equal invoice.id, line_item.invoice_id
+
+ line_item = invoice.line_items.new invoice_id: invoice.id + 1
+ assert_equal invoice.id, line_item.invoice_id
+
+ line_item = invoice.line_items.build
+ assert_equal invoice.id, line_item.invoice_id
+
+ line_item = invoice.line_items.build invoice_id: invoice.id + 1
+ assert_equal invoice.id, line_item.invoice_id
+
+ line_item = invoice.line_items.create
+ assert_equal invoice.id, line_item.invoice_id
+
+ line_item = invoice.line_items.create invoice_id: invoice.id + 1
+ assert_equal invoice.id, line_item.invoice_id
+ end
+
+ class SpecialAuthor < ActiveRecord::Base
+ self.table_name = "authors"
+ has_many :books, class_name: "SpecialBook", foreign_key: :author_id
+ end
+
+ class SpecialBook < ActiveRecord::Base
+ self.table_name = "books"
+
+ belongs_to :author
+ enum read_status: { unread: 0, reading: 2, read: 3, forgotten: nil }
+ end
+
+ def test_association_enum_works_properly
+ author = SpecialAuthor.create!(name: "Test")
+ book = SpecialBook.create!(read_status: "reading")
+ author.books << book
+
+ assert_equal "reading", book.read_status
+ assert_not_equal 0, SpecialAuthor.joins(:books).where(books: { read_status: "reading" }).count
+ end
+
+ # When creating objects on the association, we must not do it within a scope (even though it
+ # would be convenient), because this would cause that scope to be applied to any callbacks etc.
+ def test_build_and_create_should_not_happen_within_scope
+ car = cars(:honda)
+ scope = car.foo_bulbs.where_values_hash
+
+ bulb = car.foo_bulbs.build
+ assert_not_equal scope, bulb.scope_after_initialize.where_values_hash
+
+ bulb = car.foo_bulbs.create
+ assert_not_equal scope, bulb.scope_after_initialize.where_values_hash
+
+ bulb = car.foo_bulbs.create!
+ assert_not_equal scope, bulb.scope_after_initialize.where_values_hash
+ end
+
+ def test_no_sql_should_be_fired_if_association_already_loaded
+ Car.create(name: "honda")
+ bulbs = Car.first.bulbs
+ bulbs.to_a # to load all instances of bulbs
+
+ assert_no_queries do
+ bulbs.first()
+ end
+
+ assert_no_queries do
+ bulbs.second()
+ end
+
+ assert_no_queries do
+ bulbs.third()
+ end
+
+ assert_no_queries do
+ bulbs.fourth()
+ end
+
+ assert_no_queries do
+ bulbs.fifth()
+ end
+
+ assert_no_queries do
+ bulbs.forty_two()
+ end
+
+ assert_no_queries do
+ bulbs.third_to_last()
+ end
+
+ assert_no_queries do
+ bulbs.second_to_last()
+ end
+
+ assert_no_queries do
+ bulbs.last()
+ end
+ end
+
+ def test_finder_method_with_dirty_target
+ company = companies(:first_firm)
+ new_clients = []
+
+ # Load schema information so we don't query below if running just this test.
+ Client.define_attribute_methods
+
+ assert_no_queries do
+ new_clients << company.clients_of_firm.build(name: "Another Client")
+ new_clients << company.clients_of_firm.build(name: "Another Client II")
+ new_clients << company.clients_of_firm.build(name: "Another Client III")
+ end
+
+ assert_not_predicate company.clients_of_firm, :loaded?
+ assert_queries(1) do
+ assert_same new_clients[0], company.clients_of_firm.third
+ assert_same new_clients[1], company.clients_of_firm.fourth
+ assert_same new_clients[2], company.clients_of_firm.fifth
+ assert_same new_clients[0], company.clients_of_firm.third_to_last
+ assert_same new_clients[1], company.clients_of_firm.second_to_last
+ assert_same new_clients[2], company.clients_of_firm.last
+ end
+ end
+
+ def test_finder_bang_method_with_dirty_target
+ company = companies(:first_firm)
+ new_clients = []
+
+ # Load schema information so we don't query below if running just this test.
+ Client.define_attribute_methods
+
+ assert_no_queries do
+ new_clients << company.clients_of_firm.build(name: "Another Client")
+ new_clients << company.clients_of_firm.build(name: "Another Client II")
+ new_clients << company.clients_of_firm.build(name: "Another Client III")
+ end
+
+ assert_not_predicate company.clients_of_firm, :loaded?
+ assert_queries(1) do
+ assert_same new_clients[0], company.clients_of_firm.third!
+ assert_same new_clients[1], company.clients_of_firm.fourth!
+ assert_same new_clients[2], company.clients_of_firm.fifth!
+ assert_same new_clients[0], company.clients_of_firm.third_to_last!
+ assert_same new_clients[1], company.clients_of_firm.second_to_last!
+ assert_same new_clients[2], company.clients_of_firm.last!
+ end
+ end
+
+ def test_create_resets_cached_counters
+ Reader.delete_all
+
+ person = Person.create!(first_name: "tenderlove")
+
+ post = Post.first
+
+ assert_equal [], person.readers
+ assert_nil person.readers.find_by_post_id(post.id)
+
+ person.readers.create(post_id: post.id)
+
+ assert_equal 1, person.readers.count
+ assert_equal 1, person.readers.length
+ assert_equal post, person.readers.first.post
+ assert_equal person, person.readers.first.person
+ end
+
+ def test_update_all_respects_association_scope
+ person = Person.new
+ person.first_name = "Naruto"
+ person.references << Reference.new
+ person.save!
+ assert_equal 1, person.references.update_all(favourite: true)
+ end
+
+ def test_exists_respects_association_scope
+ person = Person.new
+ person.first_name = "Sasuke"
+ person.references << Reference.new
+ person.save!
+ assert_predicate person.references, :exists?
+ end
+
+ def test_counting_with_counter_sql
+ assert_equal 3, Firm.first.clients.count
+ end
+
+ def test_counting
+ assert_equal 3, Firm.first.plain_clients.count
+ end
+
+ def test_counting_with_single_hash
+ assert_equal 1, Firm.first.plain_clients.where(name: "Microsoft").count
+ end
+
+ def test_counting_with_column_name_and_hash
+ assert_equal 3, Firm.first.plain_clients.count(:name)
+ end
+
+ def test_counting_with_association_limit
+ firm = companies(:first_firm)
+ assert_equal firm.limited_clients.length, firm.limited_clients.size
+ assert_equal firm.limited_clients.length, firm.limited_clients.count
+ end
+
+ def test_finding
+ assert_equal 3, Firm.first.clients.length
+ end
+
+ def test_finding_array_compatibility
+ assert_equal 3, Firm.order(:id).find { |f| f.id > 0 }.clients.length
+ end
+
+ def test_find_many_with_merged_options
+ assert_equal 1, companies(:first_firm).limited_clients.size
+ assert_equal 1, companies(:first_firm).limited_clients.to_a.size
+ assert_equal 3, companies(:first_firm).limited_clients.limit(nil).to_a.size
+ end
+
+ def test_find_should_append_to_association_order
+ ordered_clients = companies(:first_firm).clients_sorted_desc.order("companies.id")
+ assert_equal ["id DESC", "companies.id"], ordered_clients.order_values
+ end
+
+ def test_dynamic_find_should_respect_association_order
+ assert_equal companies(:another_first_firm_client), companies(:first_firm).clients_sorted_desc.where("type = 'Client'").first
+ assert_equal companies(:another_first_firm_client), companies(:first_firm).clients_sorted_desc.find_by_type("Client")
+ end
+
+ def test_taking
+ posts(:other_by_bob).destroy
+ assert_equal posts(:misc_by_bob), authors(:bob).posts.take
+ assert_equal posts(:misc_by_bob), authors(:bob).posts.take!
+ authors(:bob).posts.to_a
+ assert_equal posts(:misc_by_bob), authors(:bob).posts.take
+ assert_equal posts(:misc_by_bob), authors(:bob).posts.take!
+ end
+
+ def test_taking_not_found
+ authors(:bob).posts.delete_all
+ assert_raise(ActiveRecord::RecordNotFound) { authors(:bob).posts.take! }
+ authors(:bob).posts.to_a
+ assert_raise(ActiveRecord::RecordNotFound) { authors(:bob).posts.take! }
+ end
+
+ def test_taking_with_a_number
+ klass = Class.new(Author) do
+ has_many :posts, -> { order(:id) }
+
+ def self.name
+ "Author"
+ end
+ end
+
+ # taking from unloaded Relation
+ bob = klass.find(authors(:bob).id)
+ new_post = bob.posts.build
+ assert_not_predicate bob.posts, :loaded?
+ assert_equal [posts(:misc_by_bob)], bob.posts.take(1)
+ assert_equal [posts(:misc_by_bob), posts(:other_by_bob)], bob.posts.take(2)
+ assert_equal [posts(:misc_by_bob), posts(:other_by_bob), new_post], bob.posts.take(3)
+
+ # taking from loaded Relation
+ bob.posts.load
+ assert_predicate bob.posts, :loaded?
+ assert_equal [posts(:misc_by_bob)], bob.posts.take(1)
+ assert_equal [posts(:misc_by_bob), posts(:other_by_bob)], bob.posts.take(2)
+ assert_equal [posts(:misc_by_bob), posts(:other_by_bob), new_post], bob.posts.take(3)
+ end
+
+ def test_taking_with_inverse_of
+ interests(:woodsmanship).destroy
+ interests(:survival).destroy
+
+ zine = zines(:going_out)
+ interest = zine.interests.take
+ assert_equal interests(:hunting), interest
+ assert_same zine, interest.zine
+ end
+
+ def test_cant_save_has_many_readonly_association
+ authors(:david).readonly_comments.each { |c| assert_raise(ActiveRecord::ReadOnlyRecord) { c.save! } }
+ authors(:david).readonly_comments.each { |c| assert c.readonly? }
+ end
+
+ def test_finding_default_orders
+ assert_equal "Summit", Firm.first.clients.first.name
+ end
+
+ def test_finding_with_different_class_name_and_order
+ assert_equal "Apex", Firm.first.clients_sorted_desc.first.name
+ end
+
+ def test_finding_with_foreign_key
+ assert_equal "Microsoft", Firm.first.clients_of_firm.first.name
+ end
+
+ def test_finding_with_condition
+ assert_equal "Microsoft", Firm.first.clients_like_ms.first.name
+ end
+
+ def test_finding_with_condition_hash
+ assert_equal "Microsoft", Firm.first.clients_like_ms_with_hash_conditions.first.name
+ end
+
+ def test_finding_using_primary_key
+ assert_equal "Summit", Firm.first.clients_using_primary_key.first.name
+ end
+
+ def test_update_all_on_association_accessed_before_save
+ firm = Firm.new(name: "Firm")
+ firm.clients << Client.first
+ firm.save!
+ assert_equal firm.clients.count, firm.clients.update_all(description: "Great!")
+ end
+
+ def test_update_all_on_association_accessed_before_save_with_explicit_foreign_key
+ firm = Firm.new(name: "Firm", id: 100)
+ firm.clients << Client.first
+ firm.save!
+ assert_equal firm.clients.count, firm.clients.update_all(description: "Great!")
+ end
+
+ def test_belongs_to_sanity
+ c = Client.new
+ assert_nil c.firm, "belongs_to failed sanity check on new object"
+ end
+
+ def test_find_ids
+ firm = Firm.first
+
+ assert_raise(ActiveRecord::RecordNotFound) { firm.clients.find }
+
+ client = firm.clients.find(2)
+ assert_kind_of Client, client
+
+ client_ary = firm.clients.find([2])
+ assert_kind_of Array, client_ary
+ assert_equal client, client_ary.first
+
+ client_ary = firm.clients.find(2, 3)
+ assert_kind_of Array, client_ary
+ assert_equal 2, client_ary.size
+ assert_equal client, client_ary.first
+
+ assert_raise(ActiveRecord::RecordNotFound) { firm.clients.find(2, 99) }
+ end
+
+ def test_find_one_message_on_primary_key
+ firm = Firm.first
+
+ e = assert_raises(ActiveRecord::RecordNotFound) do
+ firm.clients.find(0)
+ end
+ assert_equal 0, e.id
+ assert_equal "id", e.primary_key
+ assert_equal "Client", e.model
+ assert_match (/\ACouldn't find Client with 'id'=0/), e.message
+ end
+
+ def test_find_ids_and_inverse_of
+ force_signal37_to_load_all_clients_of_firm
+
+ assert_predicate companies(:first_firm).clients_of_firm, :loaded?
+
+ firm = companies(:first_firm)
+ client = firm.clients_of_firm.find(3)
+ assert_kind_of Client, client
+
+ client_ary = firm.clients_of_firm.find([3])
+ assert_kind_of Array, client_ary
+ assert_equal client, client_ary.first
+ end
+
+ def test_find_all
+ firm = Firm.first
+ assert_equal 3, firm.clients.where("#{QUOTED_TYPE} = 'Client'").to_a.length
+ assert_equal 1, firm.clients.where("name = 'Summit'").to_a.length
+ end
+
+ def test_find_each
+ firm = companies(:first_firm)
+
+ assert_not_predicate firm.clients, :loaded?
+
+ assert_queries(4) do
+ firm.clients.find_each(batch_size: 1) { |c| assert_equal firm.id, c.firm_id }
+ end
+
+ assert_not_predicate firm.clients, :loaded?
+ end
+
+ def test_find_each_with_conditions
+ firm = companies(:first_firm)
+
+ assert_queries(2) do
+ firm.clients.where(name: "Microsoft").find_each(batch_size: 1) do |c|
+ assert_equal firm.id, c.firm_id
+ assert_equal "Microsoft", c.name
+ end
+ end
+
+ assert_not_predicate firm.clients, :loaded?
+ end
+
+ def test_find_in_batches
+ firm = companies(:first_firm)
+
+ assert_not_predicate firm.clients, :loaded?
+
+ assert_queries(2) do
+ firm.clients.find_in_batches(batch_size: 2) do |clients|
+ clients.each { |c| assert_equal firm.id, c.firm_id }
+ end
+ end
+
+ assert_not_predicate firm.clients, :loaded?
+ end
+
+ def test_find_all_sanitized
+ firm = Firm.first
+ summit = firm.clients.where("name = 'Summit'").to_a
+ assert_equal summit, firm.clients.where("name = ?", "Summit").to_a
+ assert_equal summit, firm.clients.where("name = :name", name: "Summit").to_a
+ end
+
+ def test_find_first
+ firm = Firm.first
+ client2 = Client.find(2)
+ assert_equal firm.clients.first, firm.clients.order("id").first
+ assert_equal client2, firm.clients.where("#{QUOTED_TYPE} = 'Client'").order("id").first
+ end
+
+ def test_find_first_sanitized
+ firm = Firm.first
+ client2 = Client.find(2)
+ assert_equal client2, firm.clients.where("#{QUOTED_TYPE} = ?", "Client").first
+ assert_equal client2, firm.clients.where("#{QUOTED_TYPE} = :type", type: "Client").first
+ end
+
+ def test_find_first_after_reset_scope
+ firm = Firm.first
+ collection = firm.clients
+
+ original_object = collection.first
+ assert_same original_object, collection.first, "Expected second call to #first to cache the same object"
+
+ # It should return a different object, since the association has been reloaded
+ assert_not_same original_object, firm.clients.first, "Expected #first to return a new object"
+ end
+
+ def test_find_first_after_reset
+ firm = Firm.first
+ collection = firm.clients
+
+ original_object = collection.first
+ assert_same original_object, collection.first, "Expected second call to #first to cache the same object"
+ collection.reset
+
+ # It should return a different object, since the association has been reloaded
+ assert_not_same original_object, collection.first, "Expected #first after #reset to return a new object"
+ end
+
+ def test_find_first_after_reload
+ firm = Firm.first
+ collection = firm.clients
+
+ original_object = collection.first
+ assert_same original_object, collection.first, "Expected second call to #first to cache the same object"
+ collection.reload
+
+ # It should return a different object, since the association has been reloaded
+ assert_not_same original_object, collection.first, "Expected #first after #reload to return a new object"
+ end
+
+ def test_reload_with_query_cache
+ connection = ActiveRecord::Base.connection
+ connection.enable_query_cache!
+ connection.clear_query_cache
+
+ # Populate the cache with a query
+ firm = Firm.first
+ # Populate the cache with a second query
+ firm.clients.load
+
+ assert_equal 2, connection.query_cache.size
+
+ # Clear the cache and fetch the clients again, populating the cache with a query
+ assert_queries(1) { firm.clients.reload }
+ # This query is cached, so it shouldn't make a real SQL query
+ assert_queries(0) { firm.clients.load }
+
+ assert_equal 1, connection.query_cache.size
+ ensure
+ ActiveRecord::Base.connection.disable_query_cache!
+ end
+
+ def test_reloading_unloaded_associations_with_query_cache
+ connection = ActiveRecord::Base.connection
+ connection.enable_query_cache!
+ connection.clear_query_cache
+
+ firm = Firm.create!(name: "firm name")
+ client = firm.clients.create!(name: "client name")
+ firm.clients.to_a # add request to cache
+
+ connection.uncached do
+ client.update!(name: "new client name")
+ end
+
+ firm = Firm.find(firm.id)
+
+ assert_equal [client.name], firm.clients.reload.map(&:name)
+ ensure
+ ActiveRecord::Base.connection.disable_query_cache!
+ end
+
+ def test_find_all_with_include_and_conditions
+ assert_nothing_raised do
+ Developer.all.merge!(joins: :audit_logs, where: { "audit_logs.message" => nil, :name => "Smith" }).to_a
+ end
+ end
+
+ def test_find_in_collection
+ assert_equal Client.find(2).name, companies(:first_firm).clients.find(2).name
+ assert_raise(ActiveRecord::RecordNotFound) { companies(:first_firm).clients.find(6) }
+ end
+
+ def test_find_grouped
+ all_clients_of_firm1 = Client.all.merge!(where: "firm_id = 1").to_a
+ grouped_clients_of_firm1 = Client.all.merge!(where: "firm_id = 1", group: "firm_id", select: "firm_id, count(id) as clients_count").to_a
+ assert_equal 3, all_clients_of_firm1.size
+ assert_equal 1, grouped_clients_of_firm1.size
+ end
+
+ def test_find_scoped_grouped
+ assert_equal 1, companies(:first_firm).clients_grouped_by_firm_id.size
+ assert_equal 1, companies(:first_firm).clients_grouped_by_firm_id.length
+ assert_equal 3, companies(:first_firm).clients_grouped_by_name.size
+ assert_equal 3, companies(:first_firm).clients_grouped_by_name.length
+ end
+
+ def test_find_scoped_grouped_having
+ assert_equal 2, authors(:david).popular_grouped_posts.length
+ assert_equal 0, authors(:mary).popular_grouped_posts.length
+ end
+
+ def test_default_select
+ assert_equal Comment.column_names.sort, posts(:welcome).comments.first.attributes.keys.sort
+ end
+
+ def test_select_query_method
+ assert_equal ["id", "body"], posts(:welcome).comments.select(:id, :body).first.attributes.keys
+ end
+
+ def test_select_with_block
+ assert_equal [1], posts(:welcome).comments.select { |c| c.id == 1 }.map(&:id)
+ end
+
+ def test_select_with_block_and_dirty_target
+ assert_equal 2, posts(:welcome).comments.select { true }.size
+ posts(:welcome).comments.build
+ assert_equal 3, posts(:welcome).comments.select { true }.size
+ end
+
+ def test_select_without_foreign_key
+ assert_equal companies(:first_firm).accounts.first.credit_limit, companies(:first_firm).accounts.select(:credit_limit).first.credit_limit
+ end
+
+ def test_adding
+ force_signal37_to_load_all_clients_of_firm
+
+ assert_predicate companies(:first_firm).clients_of_firm, :loaded?
+
+ natural = Client.new("name" => "Natural Company")
+ companies(:first_firm).clients_of_firm << natural
+ assert_equal 3, companies(:first_firm).clients_of_firm.size # checking via the collection
+ assert_equal 3, companies(:first_firm).clients_of_firm.reload.size # checking using the db
+ assert_equal natural, companies(:first_firm).clients_of_firm.last
+ end
+
+ def test_adding_using_create
+ first_firm = companies(:first_firm)
+ assert_equal 3, first_firm.plain_clients.size
+ first_firm.plain_clients.create(name: "Natural Company")
+ assert_equal 4, first_firm.plain_clients.length
+ assert_equal 4, first_firm.plain_clients.size
+ end
+
+ def test_create_with_bang_on_has_many_when_parent_is_new_raises
+ error = assert_raise(ActiveRecord::RecordNotSaved) do
+ firm = Firm.new
+ firm.plain_clients.create! name: "Whoever"
+ end
+
+ assert_equal "You cannot call create unless the parent is saved", error.message
+ end
+
+ def test_regular_create_on_has_many_when_parent_is_new_raises
+ error = assert_raise(ActiveRecord::RecordNotSaved) do
+ firm = Firm.new
+ firm.plain_clients.create name: "Whoever"
+ end
+
+ assert_equal "You cannot call create unless the parent is saved", error.message
+ end
+
+ def test_create_with_bang_on_has_many_raises_when_record_not_saved
+ assert_raise(ActiveRecord::RecordInvalid) do
+ firm = Firm.first
+ firm.plain_clients.create!
+ end
+ end
+
+ def test_create_with_bang_on_habtm_when_parent_is_new_raises
+ error = assert_raise(ActiveRecord::RecordNotSaved) do
+ Developer.new("name" => "Aredridel").projects.create!
+ end
+
+ assert_equal "You cannot call create unless the parent is saved", error.message
+ end
+
+ def test_adding_a_mismatch_class
+ assert_raise(ActiveRecord::AssociationTypeMismatch) { companies(:first_firm).clients_of_firm << nil }
+ assert_raise(ActiveRecord::AssociationTypeMismatch) { companies(:first_firm).clients_of_firm << 1 }
+ assert_raise(ActiveRecord::AssociationTypeMismatch) { companies(:first_firm).clients_of_firm << Topic.find(1) }
+ end
+
+ def test_adding_a_collection
+ force_signal37_to_load_all_clients_of_firm
+
+ assert_predicate companies(:first_firm).clients_of_firm, :loaded?
+
+ companies(:first_firm).clients_of_firm.concat([Client.new("name" => "Natural Company"), Client.new("name" => "Apple")])
+ assert_equal 4, companies(:first_firm).clients_of_firm.size
+ assert_equal 4, companies(:first_firm).clients_of_firm.reload.size
+ end
+
+ def test_transactions_when_adding_to_persisted
+ good = Client.new(name: "Good")
+ bad = Client.new(name: "Bad", raise_on_save: true)
+
+ begin
+ companies(:first_firm).clients_of_firm.concat(good, bad)
+ rescue Client::RaisedOnSave
+ end
+
+ assert_not_includes companies(:first_firm).clients_of_firm.reload, good
+ end
+
+ def test_transactions_when_adding_to_new_record
+ # Load schema information so we don't query below if running just this test.
+ Client.define_attribute_methods
+
+ firm = Firm.new
+ assert_no_queries do
+ firm.clients_of_firm.concat(Client.new("name" => "Natural Company"))
+ end
+ end
+
+ def test_inverse_on_before_validate
+ firm = companies(:first_firm)
+ assert_queries(1) do
+ firm.clients_of_firm << Client.new("name" => "Natural Company")
+ end
+ end
+
+ def test_new_aliased_to_build
+ company = companies(:first_firm)
+
+ # Load schema information so we don't query below if running just this test.
+ Client.define_attribute_methods
+
+ new_client = assert_no_queries { company.clients_of_firm.new("name" => "Another Client") }
+ assert_not_predicate company.clients_of_firm, :loaded?
+
+ assert_equal "Another Client", new_client.name
+ assert_not_predicate new_client, :persisted?
+ assert_equal new_client, company.clients_of_firm.last
+ end
+
+ def test_build
+ company = companies(:first_firm)
+
+ # Load schema information so we don't query below if running just this test.
+ Client.define_attribute_methods
+
+ new_client = assert_no_queries { company.clients_of_firm.build("name" => "Another Client") }
+ assert_not_predicate company.clients_of_firm, :loaded?
+
+ assert_equal "Another Client", new_client.name
+ assert_not_predicate new_client, :persisted?
+ assert_equal new_client, company.clients_of_firm.last
+ end
+
+ def test_collection_size_after_building
+ company = companies(:first_firm) # company already has one client
+ company.clients_of_firm.build("name" => "Another Client")
+ company.clients_of_firm.build("name" => "Yet Another Client")
+ assert_equal 4, company.clients_of_firm.size
+ assert_equal 4, company.clients_of_firm.uniq.size
+ end
+
+ def test_collection_not_empty_after_building
+ company = companies(:first_firm)
+ assert_empty company.contracts
+ company.contracts.build
+ assert_not_empty company.contracts
+ end
+
+ def test_collection_size_with_dirty_target
+ post = posts(:thinking)
+ assert_equal [], post.reader_ids
+ assert_equal 0, post.readers.size
+ post.readers.reset
+ post.readers.build
+ assert_equal [nil], post.reader_ids
+ assert_equal 1, post.readers.size
+ end
+
+ def test_collection_empty_with_dirty_target
+ post = posts(:thinking)
+ assert_equal [], post.reader_ids
+ assert_empty post.readers
+ post.readers.reset
+ post.readers.build
+ assert_equal [nil], post.reader_ids
+ assert_not_empty post.readers
+ end
+
+ def test_collection_size_twice_for_regressions
+ post = posts(:thinking)
+ assert_equal 0, post.readers.size
+ # This test needs a post that has no readers, we assert it to ensure it holds,
+ # but need to reload the post because the very call to #size hides the bug.
+ post.reload
+ post.readers.build
+ size1 = post.readers.size
+ size2 = post.readers.size
+ assert_equal size1, size2
+ end
+
+ def test_build_many
+ company = companies(:first_firm)
+
+ # Load schema information so we don't query below if running just this test.
+ Client.define_attribute_methods
+
+ new_clients = assert_no_queries { company.clients_of_firm.build([{ "name" => "Another Client" }, { "name" => "Another Client II" }]) }
+ assert_equal 2, new_clients.size
+ end
+
+ def test_build_followed_by_save_does_not_load_target
+ companies(:first_firm).clients_of_firm.build("name" => "Another Client")
+ assert companies(:first_firm).save
+ assert_not_predicate companies(:first_firm).clients_of_firm, :loaded?
+ end
+
+ def test_build_without_loading_association
+ first_topic = topics(:first)
+
+ assert_equal 1, first_topic.replies.length
+
+ # Load schema information so we don't query below if running just this test.
+ Reply.define_attribute_methods
+
+ assert_no_queries do
+ first_topic.replies.build(title: "Not saved", content: "Superstars")
+ assert_equal 2, first_topic.replies.size
+ end
+
+ assert_equal 2, first_topic.replies.to_ary.size
+ end
+
+ def test_build_via_block
+ company = companies(:first_firm)
+
+ # Load schema information so we don't query below if running just this test.
+ Client.define_attribute_methods
+
+ new_client = assert_no_queries { company.clients_of_firm.build { |client| client.name = "Another Client" } }
+ assert_not_predicate company.clients_of_firm, :loaded?
+
+ assert_equal "Another Client", new_client.name
+ assert_not_predicate new_client, :persisted?
+ assert_equal new_client, company.clients_of_firm.last
+ end
+
+ def test_build_many_via_block
+ company = companies(:first_firm)
+
+ # Load schema information so we don't query below if running just this test.
+ Client.define_attribute_methods
+
+ new_clients = assert_no_queries do
+ company.clients_of_firm.build([{ "name" => "Another Client" }, { "name" => "Another Client II" }]) do |client|
+ client.name = "changed"
+ end
+ end
+
+ assert_equal 2, new_clients.size
+ assert_equal "changed", new_clients.first.name
+ assert_equal "changed", new_clients.last.name
+ end
+
+ def test_create_without_loading_association
+ first_firm = companies(:first_firm)
+
+ assert_equal 2, first_firm.clients_of_firm.size
+ first_firm.clients_of_firm.reset
+
+ assert_queries(1) do
+ first_firm.clients_of_firm.create(name: "Superstars")
+ end
+
+ assert_equal 3, first_firm.clients_of_firm.size
+ end
+
+ def test_create
+ force_signal37_to_load_all_clients_of_firm
+
+ assert_predicate companies(:first_firm).clients_of_firm, :loaded?
+
+ new_client = companies(:first_firm).clients_of_firm.create("name" => "Another Client")
+ assert_predicate new_client, :persisted?
+ assert_equal new_client, companies(:first_firm).clients_of_firm.last
+ assert_equal new_client, companies(:first_firm).clients_of_firm.reload.last
+ end
+
+ def test_create_many
+ companies(:first_firm).clients_of_firm.create([{ "name" => "Another Client" }, { "name" => "Another Client II" }])
+ assert_equal 4, companies(:first_firm).clients_of_firm.reload.size
+ end
+
+ def test_create_followed_by_save_does_not_load_target
+ companies(:first_firm).clients_of_firm.create("name" => "Another Client")
+ assert companies(:first_firm).save
+ assert_not_predicate companies(:first_firm).clients_of_firm, :loaded?
+ end
+
+ def test_deleting
+ force_signal37_to_load_all_clients_of_firm
+
+ assert_predicate companies(:first_firm).clients_of_firm, :loaded?
+
+ companies(:first_firm).clients_of_firm.delete(companies(:first_firm).clients_of_firm.first)
+ assert_equal 1, companies(:first_firm).clients_of_firm.size
+ assert_equal 1, companies(:first_firm).clients_of_firm.reload.size
+ end
+
+ def test_deleting_before_save
+ new_firm = Firm.new("name" => "A New Firm, Inc.")
+ new_client = new_firm.clients_of_firm.build("name" => "Another Client")
+ assert_equal 1, new_firm.clients_of_firm.size
+ new_firm.clients_of_firm.delete(new_client)
+ assert_equal 0, new_firm.clients_of_firm.size
+ end
+
+ def test_has_many_without_counter_cache_option
+ # Ship has a conventionally named `treasures_count` column, but the counter_cache
+ # option is not given on the association.
+ ship = Ship.create(name: "Countless", treasures_count: 10)
+
+ assert_not_predicate Ship.reflect_on_association(:treasures), :has_cached_counter?
+
+ # Count should come from sql count() of treasures rather than treasures_count attribute
+ assert_equal ship.treasures.size, 0
+
+ assert_no_difference lambda { ship.reload.treasures_count }, "treasures_count should not be changed" do
+ ship.treasures.create(name: "Gold")
+ end
+
+ assert_no_difference lambda { ship.reload.treasures_count }, "treasures_count should not be changed" do
+ ship.treasures.destroy_all
+ end
+ end
+
+ def test_deleting_updates_counter_cache
+ topic = Topic.order("id ASC").first
+ assert_equal topic.replies.to_a.size, topic.replies_count
+
+ topic.replies.delete(topic.replies.first)
+ topic.reload
+ assert_equal topic.replies.to_a.size, topic.replies_count
+ end
+
+ def test_counter_cache_updates_in_memory_after_concat
+ topic = Topic.create title: "Zoom-zoom-zoom"
+
+ topic.replies << Reply.create(title: "re: zoom", content: "speedy quick!")
+ assert_equal 1, topic.replies_count
+ assert_equal 1, topic.replies.size
+ assert_equal 1, topic.reload.replies.size
+ end
+
+ def test_counter_cache_updates_in_memory_after_create
+ topic = Topic.create title: "Zoom-zoom-zoom"
+
+ topic.replies.create!(title: "re: zoom", content: "speedy quick!")
+ assert_equal 1, topic.replies_count
+ assert_equal 1, topic.replies.size
+ assert_equal 1, topic.reload.replies.size
+ end
+
+ def test_counter_cache_updates_in_memory_after_create_with_array
+ topic = Topic.create title: "Zoom-zoom-zoom"
+
+ topic.replies.create!([
+ { title: "re: zoom", content: "speedy quick!" },
+ { title: "re: zoom 2", content: "OMG lol!" },
+ ])
+ assert_equal 2, topic.replies_count
+ assert_equal 2, topic.replies.size
+ assert_equal 2, topic.reload.replies.size
+ end
+
+ def test_counter_cache_updates_in_memory_after_update_with_inverse_of_disabled
+ topic = Topic.create!(title: "Zoom-zoom-zoom")
+
+ assert_equal 0, topic.replies_count
+
+ reply1 = Reply.create!(title: "re: zoom", content: "speedy quick!")
+ reply2 = Reply.create!(title: "re: zoom 2", content: "OMG lol!")
+
+ assert_queries(4) do
+ topic.replies << [reply1, reply2]
+ end
+
+ assert_equal 2, topic.replies_count
+ assert_equal 2, topic.reload.replies_count
+ end
+
+ def test_counter_cache_updates_in_memory_after_update_with_inverse_of_enabled
+ category = Category.create!(name: "Counter Cache")
+
+ assert_nil category.categorizations_count
+
+ categorization1 = Categorization.create!
+ categorization2 = Categorization.create!
+
+ assert_queries(4) do
+ category.categorizations << [categorization1, categorization2]
+ end
+
+ assert_equal 2, category.categorizations_count
+ assert_equal 2, category.reload.categorizations_count
+ end
+
+ def test_pushing_association_updates_counter_cache
+ topic = Topic.order("id ASC").first
+ reply = Reply.create!
+
+ assert_difference "topic.reload.replies_count", 1 do
+ topic.replies << reply
+ end
+ end
+
+ def test_deleting_updates_counter_cache_without_dependent_option
+ post = posts(:welcome)
+
+ assert_difference "post.reload.tags_count", -1 do
+ post.taggings.delete(post.taggings.first)
+ end
+ end
+
+ def test_deleting_updates_counter_cache_with_dependent_delete_all
+ post = posts(:welcome)
+ post.update_columns(taggings_with_delete_all_count: post.tags_count)
+
+ assert_difference "post.reload.taggings_with_delete_all_count", -1 do
+ post.taggings_with_delete_all.delete(post.taggings_with_delete_all.first)
+ end
+ end
+
+ def test_deleting_updates_counter_cache_with_dependent_destroy
+ post = posts(:welcome)
+ post.update_columns(taggings_with_destroy_count: post.tags_count)
+
+ assert_difference "post.reload.taggings_with_destroy_count", -1 do
+ post.taggings_with_destroy.delete(post.taggings_with_destroy.first)
+ end
+ end
+
+ def test_calling_empty_with_counter_cache
+ post = posts(:welcome)
+ assert_no_queries do
+ assert_not_empty post.comments
+ end
+ end
+
+ def test_custom_named_counter_cache
+ topic = topics(:first)
+
+ assert_difference "topic.reload.replies_count", -1 do
+ topic.approved_replies.clear
+ end
+ end
+
+ def test_calling_update_on_id_changes_the_counter_cache
+ topic = Topic.order("id ASC").first
+ original_count = topic.replies.to_a.size
+ assert_equal original_count, topic.replies_count
+
+ first_reply = topic.replies.first
+ first_reply.update(parent_id: nil)
+ assert_equal original_count - 1, topic.reload.replies_count
+
+ first_reply.update(parent_id: topic.id)
+ assert_equal original_count, topic.reload.replies_count
+ end
+
+ def test_calling_update_changing_ids_doesnt_change_counter_cache
+ topic1 = Topic.find(1)
+ topic2 = Topic.find(3)
+ original_count1 = topic1.replies.to_a.size
+ original_count2 = topic2.replies.to_a.size
+
+ reply1 = topic1.replies.first
+ reply2 = topic2.replies.first
+
+ reply1.update(parent_id: topic2.id)
+ assert_equal original_count1 - 1, topic1.reload.replies_count
+ assert_equal original_count2 + 1, topic2.reload.replies_count
+
+ reply2.update(parent_id: topic1.id)
+ assert_equal original_count1, topic1.reload.replies_count
+ assert_equal original_count2, topic2.reload.replies_count
+ end
+
+ def test_deleting_a_collection
+ force_signal37_to_load_all_clients_of_firm
+
+ assert_predicate companies(:first_firm).clients_of_firm, :loaded?
+
+ companies(:first_firm).clients_of_firm.create("name" => "Another Client")
+ assert_equal 3, companies(:first_firm).clients_of_firm.size
+ companies(:first_firm).clients_of_firm.delete([companies(:first_firm).clients_of_firm[0], companies(:first_firm).clients_of_firm[1], companies(:first_firm).clients_of_firm[2]])
+ assert_equal 0, companies(:first_firm).clients_of_firm.size
+ assert_equal 0, companies(:first_firm).clients_of_firm.reload.size
+ end
+
+ def test_delete_all
+ force_signal37_to_load_all_clients_of_firm
+
+ assert_predicate companies(:first_firm).clients_of_firm, :loaded?
+
+ companies(:first_firm).dependent_clients_of_firm.create("name" => "Another Client")
+ clients = companies(:first_firm).dependent_clients_of_firm.to_a
+ assert_equal 3, clients.count
+
+ assert_difference "Client.count", -(clients.count) do
+ assert_equal clients.count, companies(:first_firm).dependent_clients_of_firm.delete_all
+ end
+ end
+
+ def test_delete_all_with_not_yet_loaded_association_collection
+ force_signal37_to_load_all_clients_of_firm
+
+ assert_predicate companies(:first_firm).clients_of_firm, :loaded?
+
+ companies(:first_firm).clients_of_firm.create("name" => "Another Client")
+ assert_equal 3, companies(:first_firm).clients_of_firm.size
+ companies(:first_firm).clients_of_firm.reset
+ companies(:first_firm).clients_of_firm.delete_all
+ assert_equal 0, companies(:first_firm).clients_of_firm.size
+ assert_equal 0, companies(:first_firm).clients_of_firm.reload.size
+ end
+
+ def test_transaction_when_deleting_persisted
+ good = Client.new(name: "Good")
+ bad = Client.new(name: "Bad", raise_on_destroy: true)
+
+ companies(:first_firm).clients_of_firm = [good, bad]
+
+ begin
+ companies(:first_firm).clients_of_firm.destroy(good, bad)
+ rescue Client::RaisedOnDestroy
+ end
+
+ assert_equal [good, bad], companies(:first_firm).clients_of_firm.reload
+ end
+
+ def test_transaction_when_deleting_new_record
+ # Load schema information so we don't query below if running just this test.
+ Client.define_attribute_methods
+
+ firm = Firm.new
+ assert_no_queries do
+ client = Client.new("name" => "New Client")
+ firm.clients_of_firm << client
+ firm.clients_of_firm.destroy(client)
+ end
+ end
+
+ def test_clearing_an_association_collection
+ firm = companies(:first_firm)
+ client_id = firm.clients_of_firm.first.id
+ assert_equal 2, firm.clients_of_firm.size
+
+ firm.clients_of_firm.clear
+
+ assert_equal 0, firm.clients_of_firm.size
+ assert_equal 0, firm.clients_of_firm.reload.size
+ assert_equal [], Client.destroyed_client_ids[firm.id]
+
+ # Should not be destroyed since the association is not dependent.
+ assert_nothing_raised do
+ assert_nil Client.find(client_id).firm
+ end
+ end
+
+ def test_clearing_updates_counter_cache
+ topic = Topic.first
+
+ assert_difference "topic.reload.replies_count", -1 do
+ topic.replies.clear
+ end
+ end
+
+ def test_clearing_updates_counter_cache_when_inverse_counter_cache_is_a_symbol_with_dependent_destroy
+ car = Car.first
+ car.engines.create!
+
+ assert_difference "car.reload.engines_count", -1 do
+ car.engines.clear
+ end
+ end
+
+ def test_clearing_a_dependent_association_collection
+ firm = companies(:first_firm)
+ client_id = firm.dependent_clients_of_firm.first.id
+ assert_equal 2, firm.dependent_clients_of_firm.size
+ assert_equal 1, Client.find_by_id(client_id).client_of
+
+ # :delete_all is called on each client since the dependent options is :destroy
+ firm.dependent_clients_of_firm.clear
+
+ assert_equal 0, firm.dependent_clients_of_firm.size
+ assert_equal 0, firm.dependent_clients_of_firm.reload.size
+ assert_equal [], Client.destroyed_client_ids[firm.id]
+
+ # Should be destroyed since the association is dependent.
+ assert_nil Client.find_by_id(client_id)
+ end
+
+ def test_delete_all_with_option_delete_all
+ firm = companies(:first_firm)
+ client_id = firm.dependent_clients_of_firm.first.id
+ count = firm.dependent_clients_of_firm.count
+ assert_equal count, firm.dependent_clients_of_firm.delete_all(:delete_all)
+ assert_nil Client.find_by_id(client_id)
+ end
+
+ def test_delete_all_with_option_nullify
+ firm = companies(:first_firm)
+ client_id = firm.dependent_clients_of_firm.first.id
+ count = firm.dependent_clients_of_firm.count
+ assert_equal firm, Client.find(client_id).firm
+ assert_equal count, firm.dependent_clients_of_firm.delete_all(:nullify)
+ assert_nil Client.find(client_id).firm
+ end
+
+ def test_delete_all_accepts_limited_parameters
+ firm = companies(:first_firm)
+ assert_raise(ArgumentError) do
+ firm.dependent_clients_of_firm.delete_all(:destroy)
+ end
+ end
+
+ def test_clearing_an_exclusively_dependent_association_collection
+ firm = companies(:first_firm)
+ client_id = firm.exclusively_dependent_clients_of_firm.first.id
+ assert_equal 2, firm.exclusively_dependent_clients_of_firm.size
+
+ assert_equal [], Client.destroyed_client_ids[firm.id]
+
+ # :exclusively_dependent means each client is deleted directly from
+ # the database without looping through them calling destroy.
+ firm.exclusively_dependent_clients_of_firm.clear
+
+ assert_equal 0, firm.exclusively_dependent_clients_of_firm.size
+ assert_equal 0, firm.exclusively_dependent_clients_of_firm.reload.size
+ # no destroy-filters should have been called
+ assert_equal [], Client.destroyed_client_ids[firm.id]
+
+ # Should be destroyed since the association is exclusively dependent.
+ assert_nil Client.find_by_id(client_id)
+ end
+
+ def test_dependent_association_respects_optional_conditions_on_delete
+ firm = companies(:odegy)
+ Client.create(client_of: firm.id, name: "BigShot Inc.")
+ Client.create(client_of: firm.id, name: "SmallTime Inc.")
+ # only one of two clients is included in the association due to the :conditions key
+ assert_equal 2, Client.where(client_of: firm.id).size
+ assert_equal 1, firm.dependent_conditional_clients_of_firm.size
+ firm.destroy
+ # only the correctly associated client should have been deleted
+ assert_equal 1, Client.where(client_of: firm.id).size
+ end
+
+ def test_dependent_association_respects_optional_sanitized_conditions_on_delete
+ firm = companies(:odegy)
+ Client.create(client_of: firm.id, name: "BigShot Inc.")
+ Client.create(client_of: firm.id, name: "SmallTime Inc.")
+ # only one of two clients is included in the association due to the :conditions key
+ assert_equal 2, Client.where(client_of: firm.id).size
+ assert_equal 1, firm.dependent_sanitized_conditional_clients_of_firm.size
+ firm.destroy
+ # only the correctly associated client should have been deleted
+ assert_equal 1, Client.where(client_of: firm.id).size
+ end
+
+ def test_dependent_association_respects_optional_hash_conditions_on_delete
+ firm = companies(:odegy)
+ Client.create(client_of: firm.id, name: "BigShot Inc.")
+ Client.create(client_of: firm.id, name: "SmallTime Inc.")
+ # only one of two clients is included in the association due to the :conditions key
+ assert_equal 2, Client.where(client_of: firm.id).size
+ assert_equal 1, firm.dependent_hash_conditional_clients_of_firm.size
+ firm.destroy
+ # only the correctly associated client should have been deleted
+ assert_equal 1, Client.where(client_of: firm.id).size
+ end
+
+ def test_delete_all_association_with_primary_key_deletes_correct_records
+ firm = Firm.first
+ # break the vanilla firm_id foreign key
+ assert_equal 3, firm.clients.count
+ firm.clients.first.update_columns(firm_id: nil)
+ assert_equal 2, firm.clients.reload.count
+ assert_equal 2, firm.clients_using_primary_key_with_delete_all.count
+ old_record = firm.clients_using_primary_key_with_delete_all.first
+ firm = Firm.first
+ firm.destroy
+ assert_nil Client.find_by_id(old_record.id)
+ end
+
+ def test_creation_respects_hash_condition
+ ms_client = companies(:first_firm).clients_like_ms_with_hash_conditions.build
+
+ assert ms_client.save
+ assert_equal "Microsoft", ms_client.name
+
+ another_ms_client = companies(:first_firm).clients_like_ms_with_hash_conditions.create
+
+ assert_predicate another_ms_client, :persisted?
+ assert_equal "Microsoft", another_ms_client.name
+ end
+
+ def test_clearing_without_initial_access
+ firm = companies(:first_firm)
+
+ firm.clients_of_firm.clear
+
+ assert_equal 0, firm.clients_of_firm.size
+ assert_equal 0, firm.clients_of_firm.reload.size
+ end
+
+ def test_deleting_a_item_which_is_not_in_the_collection
+ force_signal37_to_load_all_clients_of_firm
+
+ assert_predicate companies(:first_firm).clients_of_firm, :loaded?
+
+ summit = Client.find_by_name("Summit")
+ companies(:first_firm).clients_of_firm.delete(summit)
+ assert_equal 2, companies(:first_firm).clients_of_firm.size
+ assert_equal 2, companies(:first_firm).clients_of_firm.reload.size
+ assert_equal 2, summit.client_of
+ end
+
+ def test_deleting_by_integer_id
+ david = Developer.find(1)
+
+ assert_difference "david.projects.count", -1 do
+ assert_equal 1, david.projects.delete(1).size
+ end
+
+ assert_equal 1, david.projects.size
+ end
+
+ def test_deleting_by_string_id
+ david = Developer.find(1)
+
+ assert_difference "david.projects.count", -1 do
+ assert_equal 1, david.projects.delete("1").size
+ end
+
+ assert_equal 1, david.projects.size
+ end
+
+ def test_deleting_self_type_mismatch
+ david = Developer.find(1)
+ david.projects.reload
+ assert_raise(ActiveRecord::AssociationTypeMismatch) { david.projects.delete(Project.find(1).developers) }
+ end
+
+ def test_destroying
+ force_signal37_to_load_all_clients_of_firm
+
+ assert_predicate companies(:first_firm).clients_of_firm, :loaded?
+
+ assert_difference "Client.count", -1 do
+ companies(:first_firm).clients_of_firm.destroy(companies(:first_firm).clients_of_firm.first)
+ end
+
+ assert_equal 1, companies(:first_firm).reload.clients_of_firm.size
+ assert_equal 1, companies(:first_firm).clients_of_firm.reload.size
+ end
+
+ def test_destroying_by_integer_id
+ force_signal37_to_load_all_clients_of_firm
+
+ assert_predicate companies(:first_firm).clients_of_firm, :loaded?
+
+ assert_difference "Client.count", -1 do
+ companies(:first_firm).clients_of_firm.destroy(companies(:first_firm).clients_of_firm.first.id)
+ end
+
+ assert_equal 1, companies(:first_firm).reload.clients_of_firm.size
+ assert_equal 1, companies(:first_firm).clients_of_firm.reload.size
+ end
+
+ def test_destroying_by_string_id
+ force_signal37_to_load_all_clients_of_firm
+
+ assert_predicate companies(:first_firm).clients_of_firm, :loaded?
+
+ assert_difference "Client.count", -1 do
+ companies(:first_firm).clients_of_firm.destroy(companies(:first_firm).clients_of_firm.first.id.to_s)
+ end
+
+ assert_equal 1, companies(:first_firm).reload.clients_of_firm.size
+ assert_equal 1, companies(:first_firm).clients_of_firm.reload.size
+ end
+
+ def test_destroying_a_collection
+ force_signal37_to_load_all_clients_of_firm
+
+ assert_predicate companies(:first_firm).clients_of_firm, :loaded?
+
+ companies(:first_firm).clients_of_firm.create("name" => "Another Client")
+ assert_equal 3, companies(:first_firm).clients_of_firm.size
+
+ assert_difference "Client.count", -2 do
+ companies(:first_firm).clients_of_firm.destroy([companies(:first_firm).clients_of_firm[0], companies(:first_firm).clients_of_firm[1]])
+ end
+
+ assert_equal 1, companies(:first_firm).reload.clients_of_firm.size
+ assert_equal 1, companies(:first_firm).clients_of_firm.reload.size
+ end
+
+ def test_destroy_all
+ force_signal37_to_load_all_clients_of_firm
+
+ assert_predicate companies(:first_firm).clients_of_firm, :loaded?
+
+ clients = companies(:first_firm).clients_of_firm.to_a
+ assert_not clients.empty?, "37signals has clients after load"
+ destroyed = companies(:first_firm).clients_of_firm.destroy_all
+ assert_equal clients.sort_by(&:id), destroyed.sort_by(&:id)
+ assert destroyed.all?(&:frozen?), "destroyed clients should be frozen"
+ assert companies(:first_firm).clients_of_firm.empty?, "37signals has no clients after destroy all"
+ assert companies(:first_firm).clients_of_firm.reload.empty?, "37signals has no clients after destroy all and refresh"
+ end
+
+ def test_destroy_all_on_association_clears_scope
+ author = Author.create!(name: "Gannon")
+ posts = author.posts
+ posts.create!(title: "test", body: "body")
+ posts.destroy_all
+ assert_nil posts.first
+ end
+
+ def test_destroy_on_association_clears_scope
+ author = Author.create!(name: "Gannon")
+ posts = author.posts
+ post = posts.create!(title: "test", body: "body")
+ posts.destroy(post)
+ assert_nil posts.first
+ end
+
+ def test_delete_on_association_clears_scope
+ author = Author.create!(name: "Gannon")
+ posts = author.posts
+ post = posts.create!(title: "test", body: "body")
+ posts.delete(post)
+ assert_nil posts.first
+ end
+
+ def test_dependence
+ firm = companies(:first_firm)
+ assert_equal 3, firm.clients.size
+ firm.destroy
+ assert_empty Client.all.merge!(where: "firm_id=#{firm.id}").to_a
+ end
+
+ def test_dependence_for_associations_with_hash_condition
+ david = authors(:david)
+ assert_difference("Post.count", -1) { assert david.destroy }
+ end
+
+ def test_destroy_dependent_when_deleted_from_association
+ firm = Firm.first
+ assert_equal 3, firm.clients.size
+
+ client = firm.clients.first
+ firm.clients.delete(client)
+
+ assert_raise(ActiveRecord::RecordNotFound) { Client.find(client.id) }
+ assert_raise(ActiveRecord::RecordNotFound) { firm.clients.find(client.id) }
+ assert_equal 2, firm.clients.size
+ end
+
+ def test_three_levels_of_dependence
+ topic = Topic.create "title" => "neat and simple"
+ reply = topic.replies.create "title" => "neat and simple", "content" => "still digging it"
+ reply.replies.create "title" => "neat and simple", "content" => "ain't complaining"
+
+ assert_nothing_raised { topic.destroy }
+ end
+
+ def test_dependence_with_transaction_support_on_failure
+ firm = companies(:first_firm)
+ clients = firm.clients
+ assert_equal 3, clients.length
+ clients.last.instance_eval { def overwrite_to_raise() raise "Trigger rollback" end }
+
+ firm.destroy rescue "do nothing"
+
+ assert_equal 3, Client.all.merge!(where: "firm_id=#{firm.id}").to_a.size
+ end
+
+ def test_dependence_on_account
+ num_accounts = Account.count
+ companies(:first_firm).destroy
+ assert_equal num_accounts - 1, Account.count
+ end
+
+ def test_depends_and_nullify
+ num_accounts = Account.count
+
+ core = companies(:rails_core)
+ assert_equal accounts(:rails_core_account), core.account
+ assert_equal companies(:leetsoft, :jadedpixel), core.companies
+ core.destroy
+ assert_nil accounts(:rails_core_account).reload.firm_id
+ assert_nil companies(:leetsoft).reload.client_of
+ assert_nil companies(:jadedpixel).reload.client_of
+
+ assert_equal num_accounts, Account.count
+ end
+
+ def test_restrict_with_exception
+ firm = RestrictedWithExceptionFirm.create!(name: "restrict")
+ firm.companies.create(name: "child")
+
+ assert_not_empty firm.companies
+ assert_raise(ActiveRecord::DeleteRestrictionError) { firm.destroy }
+ assert RestrictedWithExceptionFirm.exists?(name: "restrict")
+ assert firm.companies.exists?(name: "child")
+ end
+
+ def test_restrict_with_error
+ firm = RestrictedWithErrorFirm.create!(name: "restrict")
+ firm.companies.create(name: "child")
+
+ assert_not_empty firm.companies
+
+ firm.destroy
+
+ assert_not_empty firm.errors
+
+ assert_equal "Cannot delete record because dependent companies exist", firm.errors[:base].first
+ assert RestrictedWithErrorFirm.exists?(name: "restrict")
+ assert firm.companies.exists?(name: "child")
+ end
+
+ def test_restrict_with_error_with_locale
+ I18n.backend = I18n::Backend::Simple.new
+ I18n.backend.store_translations "en", activerecord: { attributes: { restricted_with_error_firm: { companies: "client companies" } } }
+ firm = RestrictedWithErrorFirm.create!(name: "restrict")
+ firm.companies.create(name: "child")
+
+ assert_not_empty firm.companies
+
+ firm.destroy
+
+ assert_not_empty firm.errors
+
+ assert_equal "Cannot delete record because dependent client companies exist", firm.errors[:base].first
+ assert RestrictedWithErrorFirm.exists?(name: "restrict")
+ assert firm.companies.exists?(name: "child")
+ ensure
+ I18n.backend.reload!
+ end
+
+ def test_included_in_collection
+ assert_equal true, companies(:first_firm).clients.include?(Client.find(2))
+ end
+
+ def test_included_in_collection_for_new_records
+ client = Client.create(name: "Persisted")
+ assert_nil client.client_of
+ assert_equal false, Firm.new.clients_of_firm.include?(client),
+ "includes a client that does not belong to any firm"
+ end
+
+ def test_adding_array_and_collection
+ assert_nothing_raised { Firm.first.clients + Firm.all.last.clients }
+ end
+
+ def test_replace_with_less
+ firm = Firm.first
+ firm.clients = [companies(:first_client)]
+ assert firm.save, "Could not save firm"
+ firm.reload
+ assert_equal 1, firm.clients.length
+ end
+
+ def test_replace_with_less_and_dependent_nullify
+ num_companies = Company.count
+ companies(:rails_core).companies = []
+ assert_equal num_companies, Company.count
+ end
+
+ def test_replace_with_new
+ firm = Firm.first
+ firm.clients = [companies(:second_client), Client.new("name" => "New Client")]
+ firm.save
+ firm.reload
+ assert_equal 2, firm.clients.length
+ assert_equal false, firm.clients.include?(:first_client)
+ end
+
+ def test_replace_failure
+ firm = companies(:first_firm)
+ account = Account.new
+ orig_accounts = firm.accounts.to_a
+
+ assert_not_predicate account, :valid?
+ assert_not_empty orig_accounts
+ error = assert_raise ActiveRecord::RecordNotSaved do
+ firm.accounts = [account]
+ end
+
+ assert_equal orig_accounts, firm.accounts
+ assert_equal "Failed to replace accounts because one or more of the " \
+ "new records could not be saved.", error.message
+ end
+
+ def test_replace_with_same_content
+ firm = Firm.first
+ firm.clients = []
+ firm.save
+
+ assert_no_queries do
+ firm.clients = []
+ end
+
+ assert_equal [], firm.send("clients=", [])
+ end
+
+ def test_transactions_when_replacing_on_persisted
+ good = Client.new(name: "Good")
+ bad = Client.new(name: "Bad", raise_on_save: true)
+
+ companies(:first_firm).clients_of_firm = [good]
+
+ begin
+ companies(:first_firm).clients_of_firm = [bad]
+ rescue Client::RaisedOnSave
+ end
+
+ assert_equal [good], companies(:first_firm).clients_of_firm.reload
+ end
+
+ def test_transactions_when_replacing_on_new_record
+ # Load schema information so we don't query below if running just this test.
+ Client.define_attribute_methods
+
+ firm = Firm.new
+ assert_no_queries do
+ firm.clients_of_firm = [Client.new("name" => "New Client")]
+ end
+ end
+
+ def test_get_ids
+ assert_equal [companies(:first_client).id, companies(:second_client).id, companies(:another_first_firm_client).id], companies(:first_firm).client_ids
+ end
+
+ def test_get_ids_for_loaded_associations
+ company = companies(:first_firm)
+ company.clients.reload
+ assert_no_queries do
+ company.client_ids
+ company.client_ids
+ end
+ end
+
+ def test_get_ids_for_unloaded_associations_does_not_load_them
+ company = companies(:first_firm)
+ assert_not_predicate company.clients, :loaded?
+ assert_equal [companies(:first_client).id, companies(:second_client).id, companies(:another_first_firm_client).id], company.client_ids
+ assert_not_predicate company.clients, :loaded?
+ end
+
+ def test_counter_cache_on_unloaded_association
+ car = Car.create(name: "My AppliCar")
+ assert_equal car.engines.size, 0
+ end
+
+ def test_get_ids_ignores_include_option
+ assert_equal [readers(:michael_welcome).id], posts(:welcome).readers_with_person_ids
+ end
+
+ def test_get_ids_for_ordered_association
+ assert_equal [companies(:another_first_firm_client).id, companies(:second_client).id, companies(:first_client).id], companies(:first_firm).clients_ordered_by_name_ids
+ end
+
+ def test_get_ids_for_association_on_new_record_does_not_try_to_find_records
+ # Load schema information so we don't query below if running just this test.
+ companies(:first_client).contract_ids
+
+ company = Company.new
+ assert_no_queries do
+ company.contract_ids
+ end
+
+ assert_equal [], company.contract_ids
+ end
+
+ def test_set_ids_for_association_on_new_record_applies_association_correctly
+ contract_a = Contract.create!
+ contract_b = Contract.create!
+ Contract.create! # another contract
+ company = Company.new(name: "Some Company")
+
+ company.contract_ids = [contract_a.id, contract_b.id]
+ assert_equal [contract_a.id, contract_b.id], company.contract_ids
+ assert_equal [contract_a, contract_b], company.contracts
+
+ company.save!
+ assert_equal company, contract_a.reload.company
+ assert_equal company, contract_b.reload.company
+ end
+
+ def test_assign_ids_ignoring_blanks
+ firm = Firm.create!(name: "Apple")
+ firm.client_ids = [companies(:first_client).id, nil, companies(:second_client).id, ""]
+ firm.save!
+
+ assert_equal 2, firm.clients.reload.size
+ assert_equal true, firm.clients.include?(companies(:second_client))
+ end
+
+ def test_get_ids_for_through
+ assert_equal [comments(:eager_other_comment1).id], authors(:mary).comment_ids
+ end
+
+ def test_modifying_a_through_a_has_many_should_raise
+ [
+ lambda { authors(:mary).comment_ids = [comments(:greetings).id, comments(:more_greetings).id] },
+ lambda { authors(:mary).comments = [comments(:greetings), comments(:more_greetings)] },
+ lambda { authors(:mary).comments << Comment.create!(body: "Yay", post_id: 424242) },
+ lambda { authors(:mary).comments.delete(authors(:mary).comments.first) },
+ ].each { |block| assert_raise(ActiveRecord::HasManyThroughCantAssociateThroughHasOneOrManyReflection, &block) }
+ end
+
+ def test_associations_order_should_be_priority_over_throughs_order
+ david = authors(:david)
+ expected = [12, 10, 9, 8, 7, 6, 5, 3, 2, 1]
+ assert_equal expected, david.comments_desc.map(&:id)
+ assert_equal expected, Author.includes(:comments_desc).find(david.id).comments_desc.map(&:id)
+ end
+
+ def test_dynamic_find_should_respect_association_order_for_through
+ assert_equal Comment.find(10), authors(:david).comments_desc.where("comments.type = 'SpecialComment'").first
+ assert_equal Comment.find(10), authors(:david).comments_desc.find_by_type("SpecialComment")
+ end
+
+ def test_has_many_through_respects_hash_conditions
+ assert_equal authors(:david).hello_posts.sort_by(&:id), authors(:david).hello_posts_with_hash_conditions.sort_by(&:id)
+ assert_equal authors(:david).hello_post_comments.sort_by(&:id), authors(:david).hello_post_comments_with_hash_conditions.sort_by(&:id)
+ end
+
+ def test_include_uses_array_include_after_loaded
+ firm = companies(:first_firm)
+ firm.clients.load_target
+
+ client = firm.clients.first
+
+ assert_no_queries do
+ assert_predicate firm.clients, :loaded?
+ assert_equal true, firm.clients.include?(client)
+ end
+ end
+
+ def test_include_checks_if_record_exists_if_target_not_loaded
+ firm = companies(:first_firm)
+ client = firm.clients.first
+
+ firm.reload
+ assert_not_predicate firm.clients, :loaded?
+ assert_queries(1) do
+ assert_equal true, firm.clients.include?(client)
+ end
+ assert_not_predicate firm.clients, :loaded?
+ end
+
+ def test_include_returns_false_for_non_matching_record_to_verify_scoping
+ firm = companies(:first_firm)
+ client = Client.create!(name: "Not Associated")
+
+ assert_not_predicate firm.clients, :loaded?
+ assert_equal false, firm.clients.include?(client)
+ end
+
+ def test_calling_first_nth_or_last_on_association_should_not_load_association
+ firm = companies(:first_firm)
+ firm.clients.first
+ firm.clients.second
+ firm.clients.last
+ assert_not_predicate firm.clients, :loaded?
+ end
+
+ def test_calling_first_or_last_on_loaded_association_should_not_fetch_with_query
+ firm = companies(:first_firm)
+ firm.clients.load_target
+ assert_predicate firm.clients, :loaded?
+
+ assert_no_queries do
+ firm.clients.first
+ assert_equal 2, firm.clients.first(2).size
+ firm.clients.last
+ assert_equal 2, firm.clients.last(2).size
+ end
+ end
+
+ def test_calling_first_or_last_on_existing_record_with_build_should_load_association
+ firm = companies(:first_firm)
+ firm.clients.build(name: "Foo")
+ assert_not_predicate firm.clients, :loaded?
+
+ assert_queries 1 do
+ firm.clients.first
+ firm.clients.second
+ firm.clients.last
+ end
+
+ assert_predicate firm.clients, :loaded?
+ end
+
+ def test_calling_first_nth_or_last_on_existing_record_with_create_should_not_load_association
+ firm = companies(:first_firm)
+ firm.clients.create(name: "Foo")
+ assert_not_predicate firm.clients, :loaded?
+
+ assert_queries 3 do
+ firm.clients.first
+ firm.clients.second
+ firm.clients.last
+ end
+
+ assert_not_predicate firm.clients, :loaded?
+ end
+
+ def test_calling_first_nth_or_last_on_new_record_should_not_run_queries
+ firm = Firm.new
+
+ assert_no_queries do
+ firm.clients.first
+ firm.clients.second
+ firm.clients.last
+ end
+ end
+
+ def test_calling_first_or_last_with_integer_on_association_should_not_load_association
+ firm = companies(:first_firm)
+ firm.clients.create(name: "Foo")
+ assert_not_predicate firm.clients, :loaded?
+
+ assert_queries 2 do
+ firm.clients.first(2)
+ firm.clients.last(2)
+ end
+
+ assert_not_predicate firm.clients, :loaded?
+ end
+
+ def test_calling_many_should_count_instead_of_loading_association
+ firm = companies(:first_firm)
+ assert_queries(1) do
+ firm.clients.many? # use count query
+ end
+ assert_not_predicate firm.clients, :loaded?
+ end
+
+ def test_calling_many_on_loaded_association_should_not_use_query
+ firm = companies(:first_firm)
+ firm.clients.load # force load
+ assert_no_queries { assert firm.clients.many? }
+ end
+
+ def test_calling_many_should_defer_to_collection_if_using_a_block
+ firm = companies(:first_firm)
+ assert_queries(1) do
+ assert_not_called(firm.clients, :size) do
+ firm.clients.many? { true }
+ end
+ end
+ assert_predicate firm.clients, :loaded?
+ end
+
+ def test_calling_many_should_return_false_if_none_or_one
+ firm = companies(:another_firm)
+ assert_not_predicate firm.clients_like_ms, :many?
+ assert_equal 0, firm.clients_like_ms.size
+
+ firm = companies(:first_firm)
+ assert_not_predicate firm.limited_clients, :many?
+ assert_equal 1, firm.limited_clients.size
+ end
+
+ def test_calling_many_should_return_true_if_more_than_one
+ firm = companies(:first_firm)
+ assert_predicate firm.clients, :many?
+ assert_equal 3, firm.clients.size
+ end
+
+ def test_calling_none_should_count_instead_of_loading_association
+ firm = companies(:first_firm)
+ assert_queries(1) do
+ firm.clients.none? # use count query
+ end
+ assert_not_predicate firm.clients, :loaded?
+ end
+
+ def test_calling_none_on_loaded_association_should_not_use_query
+ firm = companies(:first_firm)
+ firm.clients.load # force load
+ assert_no_queries { assert_not firm.clients.none? }
+ end
+
+ def test_calling_none_should_defer_to_collection_if_using_a_block
+ firm = companies(:first_firm)
+ assert_queries(1) do
+ assert_not_called(firm.clients, :size) do
+ firm.clients.none? { true }
+ end
+ end
+ assert_predicate firm.clients, :loaded?
+ end
+
+ def test_calling_none_should_return_true_if_none
+ firm = companies(:another_firm)
+ assert_predicate firm.clients_like_ms, :none?
+ assert_equal 0, firm.clients_like_ms.size
+ end
+
+ def test_calling_none_should_return_false_if_any
+ firm = companies(:first_firm)
+ assert_not_predicate firm.limited_clients, :none?
+ assert_equal 1, firm.limited_clients.size
+ end
+
+ def test_calling_one_should_count_instead_of_loading_association
+ firm = companies(:first_firm)
+ assert_queries(1) do
+ firm.clients.one? # use count query
+ end
+ assert_not_predicate firm.clients, :loaded?
+ end
+
+ def test_calling_one_on_loaded_association_should_not_use_query
+ firm = companies(:first_firm)
+ firm.clients.load # force load
+ assert_no_queries { assert_not firm.clients.one? }
+ end
+
+ def test_calling_one_should_defer_to_collection_if_using_a_block
+ firm = companies(:first_firm)
+ assert_queries(1) do
+ assert_not_called(firm.clients, :size) do
+ firm.clients.one? { true }
+ end
+ end
+ assert_predicate firm.clients, :loaded?
+ end
+
+ def test_calling_one_should_return_false_if_zero
+ firm = companies(:another_firm)
+ assert_not_predicate firm.clients_like_ms, :one?
+ assert_equal 0, firm.clients_like_ms.size
+ end
+
+ def test_calling_one_should_return_true_if_one
+ firm = companies(:first_firm)
+ assert_predicate firm.limited_clients, :one?
+ assert_equal 1, firm.limited_clients.size
+ end
+
+ def test_calling_one_should_return_false_if_more_than_one
+ firm = companies(:first_firm)
+ assert_not_predicate firm.clients, :one?
+ assert_equal 3, firm.clients.size
+ end
+
+ def test_joins_with_namespaced_model_should_use_correct_type
+ old = ActiveRecord::Base.store_full_sti_class
+ ActiveRecord::Base.store_full_sti_class = true
+
+ firm = Namespaced::Firm.create(name: "Some Company")
+ firm.clients.create(name: "Some Client")
+
+ stats = Namespaced::Firm.all.merge!(
+ select: "#{Namespaced::Firm.table_name}.id, COUNT(#{Namespaced::Client.table_name}.id) AS num_clients",
+ joins: :clients,
+ group: "#{Namespaced::Firm.table_name}.id"
+ ).find firm.id
+ assert_equal 1, stats.num_clients.to_i
+ ensure
+ ActiveRecord::Base.store_full_sti_class = old
+ end
+
+ def test_association_proxy_transaction_method_starts_transaction_in_association_class
+ assert_called(Comment, :transaction) do
+ Post.first.comments.transaction do
+ # nothing
+ end
+ end
+ end
+
+ def test_sending_new_to_association_proxy_should_have_same_effect_as_calling_new
+ client_association = companies(:first_firm).clients
+ assert_equal client_association.new.attributes, client_association.send(:new).attributes
+ end
+
+ def test_creating_using_primary_key
+ firm = Firm.first
+ client = firm.clients_using_primary_key.create!(name: "test")
+ assert_equal firm.name, client.firm_name
+ end
+
+ def test_defining_has_many_association_with_delete_all_dependency_lazily_evaluates_target_class
+ assert_not_called_on_instance_of(
+ ActiveRecord::Reflection::AssociationReflection,
+ :class_name,
+ ) do
+ class_eval(<<-EOF, __FILE__, __LINE__ + 1)
+ class DeleteAllModel < ActiveRecord::Base
+ has_many :nonentities, :dependent => :delete_all
+ end
+ EOF
+ end
+ end
+
+ def test_defining_has_many_association_with_nullify_dependency_lazily_evaluates_target_class
+ assert_not_called_on_instance_of(
+ ActiveRecord::Reflection::AssociationReflection,
+ :class_name,
+ ) do
+ class_eval(<<-EOF, __FILE__, __LINE__ + 1)
+ class NullifyModel < ActiveRecord::Base
+ has_many :nonentities, :dependent => :nullify
+ end
+ EOF
+ end
+ end
+
+ def test_attributes_are_being_set_when_initialized_from_has_many_association_with_where_clause
+ new_comment = posts(:welcome).comments.where(body: "Some content").build
+ assert_equal new_comment.body, "Some content"
+ end
+
+ def test_attributes_are_being_set_when_initialized_from_has_many_association_with_multiple_where_clauses
+ new_comment = posts(:welcome).comments.where(body: "Some content").where(type: "SpecialComment").build
+ assert_equal new_comment.body, "Some content"
+ assert_equal new_comment.type, "SpecialComment"
+ assert_equal new_comment.post_id, posts(:welcome).id
+ end
+
+ def test_include_method_in_has_many_association_should_return_true_for_instance_added_with_build
+ post = Post.new
+ comment = post.comments.build
+ assert_equal true, post.comments.include?(comment)
+ end
+
+ def test_load_target_respects_protected_attributes
+ topic = Topic.create!
+ reply = topic.replies.create(title: "reply 1")
+ reply.approved = false
+ reply.save!
+
+ # Save with a different object instance, so the instance that's still held
+ # in topic.relies doesn't know about the changed attribute.
+ reply2 = Reply.find(reply.id)
+ reply2.approved = true
+ reply2.save!
+
+ # Force loading the collection from the db. This will merge the existing
+ # object (reply) with what gets loaded from the db (which includes the
+ # changed approved attribute). approved is a protected attribute, so if mass
+ # assignment is used, it won't get updated and will still be false.
+ first = topic.replies.to_a.first
+ assert_equal reply.id, first.id
+ assert_equal true, first.approved?
+ end
+
+ def test_to_a_should_dup_target
+ ary = topics(:first).replies.to_a
+ target = topics(:first).replies.target
+
+ assert_not_equal target.object_id, ary.object_id
+ end
+
+ def test_merging_with_custom_attribute_writer
+ bulb = Bulb.new(color: "red")
+ assert_equal "RED!", bulb.color
+
+ car = Car.create!
+ car.bulbs << bulb
+
+ assert_equal "RED!", car.bulbs.to_a.first.color
+ end
+
+ def test_abstract_class_with_polymorphic_has_many
+ post = SubStiPost.create! title: "fooo", body: "baa"
+ tagging = Tagging.create! taggable: post
+ assert_equal [tagging], post.taggings
+ end
+
+ def test_with_polymorphic_has_many_with_custom_columns_name
+ post = Post.create! title: "foo", body: "bar"
+ image = Image.create!
+
+ post.images << image
+
+ assert_equal [image], post.images
+ end
+
+ def test_build_with_polymorphic_has_many_does_not_allow_to_override_type_and_id
+ welcome = posts(:welcome)
+ tagging = welcome.taggings.build(taggable_id: 99, taggable_type: "ShouldNotChange")
+
+ assert_equal welcome.id, tagging.taggable_id
+ assert_equal "Post", tagging.taggable_type
+ end
+
+ def test_build_from_polymorphic_association_sets_inverse_instance
+ post = Post.new
+ tagging = post.taggings.build
+
+ assert_equal post, tagging.taggable
+ end
+
+ def test_dont_call_save_callbacks_twice_on_has_many
+ firm = companies(:first_firm)
+ contract = firm.contracts.create!
+
+ assert_equal 1, contract.hi_count
+ assert_equal 1, contract.bye_count
+ end
+
+ def test_association_attributes_are_available_to_after_initialize
+ car = Car.create(name: "honda")
+ bulb = car.bulbs.build
+
+ assert_equal car.id, bulb.attributes_after_initialize["car_id"]
+ end
+
+ def test_attributes_are_set_when_initialized_from_has_many_null_relationship
+ car = Car.new name: "honda"
+ bulb = car.bulbs.where(name: "headlight").first_or_initialize
+ assert_equal "headlight", bulb.name
+ end
+
+ def test_attributes_are_set_when_initialized_from_polymorphic_has_many_null_relationship
+ post = Post.new title: "title", body: "bar"
+ tag = Tag.create!(name: "foo")
+
+ tagging = post.taggings.where(tag: tag).first_or_initialize
+
+ assert_equal tag.id, tagging.tag_id
+ assert_equal "Post", tagging.taggable_type
+ end
+
+ def test_replace
+ car = Car.create(name: "honda")
+ bulb1 = car.bulbs.create
+ bulb2 = Bulb.create
+
+ assert_equal [bulb1], car.bulbs
+ car.bulbs.replace([bulb2])
+ assert_equal [bulb2], car.bulbs
+ assert_equal [bulb2], car.reload.bulbs
+ end
+
+ def test_replace_returns_target
+ car = Car.create(name: "honda")
+ bulb1 = car.bulbs.create
+ bulb2 = car.bulbs.create
+ bulb3 = Bulb.create
+
+ assert_equal [bulb1, bulb2], car.bulbs
+ result = car.bulbs.replace([bulb3, bulb1])
+ assert_equal [bulb1, bulb3], car.bulbs
+ assert_equal [bulb1, bulb3], result
+ end
+
+ def test_collection_association_with_private_kernel_method
+ firm = companies(:first_firm)
+ assert_equal [accounts(:signals37)], firm.accounts.open
+ assert_equal [accounts(:signals37)], firm.accounts.available
+ end
+
+ def test_association_with_or_doesnt_set_inverse_instance_key
+ firm = companies(:first_firm)
+ accounts = firm.accounts.or(Account.where(firm_id: nil)).order(:id)
+ assert_equal [firm.id, nil], accounts.map(&:firm_id)
+ end
+
+ def test_association_with_rewhere_doesnt_set_inverse_instance_key
+ firm = companies(:first_firm)
+ accounts = firm.accounts.rewhere(firm_id: [firm.id, nil]).order(:id)
+ assert_equal [firm.id, nil], accounts.map(&:firm_id)
+ end
+
+ test "first_or_initialize adds the record to the association" do
+ firm = Firm.create! name: "omg"
+ client = firm.clients_of_firm.first_or_initialize
+ assert_equal [client], firm.clients_of_firm
+ end
+
+ test "first_or_create adds the record to the association" do
+ firm = Firm.create! name: "omg"
+ firm.clients_of_firm.load_target
+ client = firm.clients_of_firm.first_or_create name: "lol"
+ assert_equal [client], firm.clients_of_firm
+ assert_equal [client], firm.reload.clients_of_firm
+ end
+
+ test "delete_all, when not loaded, doesn't load the records" do
+ post = posts(:welcome)
+
+ assert post.taggings_with_delete_all.count > 0
+ assert_not_predicate post.taggings_with_delete_all, :loaded?
+
+ # 2 queries: one DELETE and another to update the counter cache
+ assert_queries(2) do
+ post.taggings_with_delete_all.delete_all
+ end
+ end
+
+ test "has many associations on new records use null relations" do
+ post = Post.new
+
+ assert_no_queries do
+ assert_equal [], post.comments
+ assert_equal [], post.comments.where(body: "omg")
+ assert_equal [], post.comments.pluck(:body)
+ assert_equal 0, post.comments.sum(:id)
+ assert_equal 0, post.comments.count
+ end
+ end
+
+ test "collection proxy respects default scope" do
+ author = authors(:mary)
+ assert_not_predicate author.first_posts, :exists?
+ end
+
+ test "association with extend option" do
+ post = posts(:welcome)
+ assert_equal "lifo", post.comments_with_extend.author
+ assert_equal "hello", post.comments_with_extend.greeting
+ end
+
+ test "association with extend option with multiple extensions" do
+ post = posts(:welcome)
+ assert_equal "lifo", post.comments_with_extend_2.author
+ assert_equal "hullo", post.comments_with_extend_2.greeting
+ end
+
+ test "extend option affects per association" do
+ post = posts(:welcome)
+ assert_equal "lifo", post.comments_with_extend.author
+ assert_equal "lifo", post.comments_with_extend_2.author
+ assert_equal "hello", post.comments_with_extend.greeting
+ assert_equal "hullo", post.comments_with_extend_2.greeting
+ end
+
+ test "delete record with complex joins" do
+ david = authors(:david)
+
+ post = david.posts.first
+ post.type = "PostWithSpecialCategorization"
+ post.save
+
+ categorization = post.categorizations.first
+ categorization.special = true
+ categorization.save
+
+ assert_not_equal [], david.posts_with_special_categorizations
+ david.posts_with_special_categorizations = []
+ assert_equal [], david.posts_with_special_categorizations
+ end
+
+ test "does not duplicate associations when used with natural primary keys" do
+ speedometer = Speedometer.create!(id: "4")
+ speedometer.minivans.create!(minivan_id: "a-van-red", name: "a van", color: "red")
+
+ assert_equal 1, speedometer.minivans.to_a.size, "Only one association should be present:\n#{speedometer.minivans.to_a}"
+ assert_equal 1, speedometer.reload.minivans.to_a.size
+ end
+
+ test "can unscope the default scope of the associated model" do
+ car = Car.create!
+ bulb1 = Bulb.create! name: "defaulty", car: car
+ bulb2 = Bulb.create! name: "other", car: car
+
+ assert_equal [bulb1], car.bulbs
+ assert_equal [bulb1, bulb2], car.all_bulbs.sort_by(&:id)
+ end
+
+ test "can unscope and where the default scope of the associated model" do
+ Car.has_many :other_bulbs, -> { unscope(where: [:name]).where(name: "other") }, class_name: "Bulb"
+ car = Car.create!
+ bulb1 = Bulb.create! name: "defaulty", car: car
+ bulb2 = Bulb.create! name: "other", car: car
+
+ assert_equal [bulb1], car.bulbs
+ assert_equal [bulb2], car.other_bulbs
+ end
+
+ test "can rewhere the default scope of the associated model" do
+ Car.has_many :old_bulbs, -> { rewhere(name: "old") }, class_name: "Bulb"
+ car = Car.create!
+ bulb1 = Bulb.create! name: "defaulty", car: car
+ bulb2 = Bulb.create! name: "old", car: car
+
+ assert_equal [bulb1], car.bulbs
+ assert_equal [bulb2], car.old_bulbs
+ end
+
+ test "unscopes the default scope of associated model when used with include" do
+ car = Car.create!
+ bulb = Bulb.create! name: "other", car: car
+
+ assert_equal [bulb], Car.find(car.id).all_bulbs
+ assert_equal [bulb], Car.includes(:all_bulbs).find(car.id).all_bulbs
+ assert_equal [bulb], Car.eager_load(:all_bulbs).find(car.id).all_bulbs
+ end
+
+ test "raises RecordNotDestroyed when replaced child can't be destroyed" do
+ car = Car.create!
+ original_child = FailedBulb.create!(car: car)
+
+ error = assert_raise(ActiveRecord::RecordNotDestroyed) do
+ car.failed_bulbs = [FailedBulb.create!]
+ end
+
+ assert_equal [original_child], car.reload.failed_bulbs
+ assert_equal "Failed to destroy the record", error.message
+ end
+
+ test "updates counter cache when default scope is given" do
+ topic = DefaultRejectedTopic.create approved: true
+
+ assert_difference "topic.reload.replies_count", 1 do
+ topic.approved_replies.create!
+ end
+ 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
+ has_many name
+ end
+ end
+ end
+ end
+
+ test "passes custom context validation to validate children" do
+ pirate = FamousPirate.new
+ pirate.famous_ships << ship = FamousShip.new
+
+ assert_predicate pirate, :valid?
+ assert_not pirate.valid?(:conference)
+ assert_equal "can't be blank", ship.errors[:name].first
+ end
+
+ test "association with instance dependent scope" do
+ bob = authors(:bob)
+ Post.create!(title: "signed post by bob", body: "stuff", author: authors(:bob))
+ Post.create!(title: "anonymous post", body: "more stuff", author: authors(:bob))
+ assert_equal ["misc post by bob", "other post by bob",
+ "signed post by bob"], bob.posts_with_signature.map(&:title).sort
+
+ assert_equal [], authors(:david).posts_with_signature.map(&:title)
+ end
+
+ test "associations autosaves when object is already persisted" do
+ bulb = Bulb.create!
+ tyre = Tyre.create!
+
+ car = Car.create! do |c|
+ c.bulbs << bulb
+ c.tyres << tyre
+ end
+
+ assert_equal 1, car.bulbs.count
+ assert_equal 1, car.tyres.count
+ end
+
+ test "associations replace in memory when records have the same id" do
+ bulb = Bulb.create!
+ car = Car.create!(bulbs: [bulb])
+
+ new_bulb = Bulb.find(bulb.id)
+ new_bulb.name = "foo"
+ car.bulbs = [new_bulb]
+
+ assert_equal "foo", car.bulbs.first.name
+ end
+
+ test "in memory replacement executes no queries" do
+ bulb = Bulb.create!
+ car = Car.create!(bulbs: [bulb])
+
+ new_bulb = Bulb.find(bulb.id)
+
+ assert_no_queries do
+ car.bulbs = [new_bulb]
+ end
+ end
+
+ test "in memory replacements do not execute callbacks" do
+ raise_after_add = false
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = :cars
+ has_many :bulbs, after_add: proc { raise if raise_after_add }
+
+ def self.name
+ "Car"
+ end
+ end
+ bulb = Bulb.create!
+ car = klass.create!(bulbs: [bulb])
+
+ new_bulb = Bulb.find(bulb.id)
+ raise_after_add = true
+
+ assert_nothing_raised do
+ car.bulbs = [new_bulb]
+ end
+ end
+
+ test "in memory replacements sets inverse instance" do
+ bulb = Bulb.create!
+ car = Car.create!(bulbs: [bulb])
+
+ new_bulb = Bulb.find(bulb.id)
+ car.bulbs = [new_bulb]
+
+ assert_same car, new_bulb.car
+ end
+
+ test "reattach to new objects replaces inverse association and foreign key" do
+ bulb = Bulb.create!(car: Car.create!)
+ assert bulb.car_id
+ car = Car.new
+ car.bulbs << bulb
+ assert_equal car, bulb.car
+ assert_nil bulb.car_id
+ end
+
+ test "in memory replacement maintains order" do
+ first_bulb = Bulb.create!
+ second_bulb = Bulb.create!
+ car = Car.create!(bulbs: [first_bulb, second_bulb])
+
+ same_bulb = Bulb.find(first_bulb.id)
+ car.bulbs = [second_bulb, same_bulb]
+
+ assert_equal [first_bulb, second_bulb], car.bulbs
+ end
+
+ test "association size calculation works with default scoped selects when not previously fetched" do
+ firm = Firm.create!(name: "Firm")
+ 5.times { firm.developers_with_select << Developer.create!(name: "Developer") }
+
+ same_firm = Firm.find(firm.id)
+ assert_equal 5, same_firm.developers_with_select.size
+ end
+
+ test "prevent double insertion of new object when the parent association loaded in the after save callback" do
+ reset_callbacks(:save, Bulb) do
+ Bulb.after_save { |record| record.car.bulbs.load }
+
+ car = Car.create!
+ car.bulbs << Bulb.new
+
+ assert_equal 1, car.bulbs.size
+ end
+ end
+
+ test "prevent double firing the before save callback of new object when the parent association saved in the callback" do
+ reset_callbacks(:save, Bulb) do
+ count = 0
+ Bulb.before_save { |record| record.car.save && count += 1 }
+
+ car = Car.create!
+ car.bulbs.create!
+
+ assert_equal 1, count
+ end
+ end
+
+ test "calling size on an association that has not been loaded performs a query" do
+ car = Car.create!
+ Bulb.create(car_id: car.id)
+
+ car_two = Car.create!
+
+ assert_queries(1) do
+ assert_equal 1, car.bulbs.size
+ end
+
+ assert_queries(1) do
+ assert_equal 0, car_two.bulbs.size
+ end
+ end
+
+ test "calling size on an association that has been loaded does not perform query" do
+ car = Car.create!
+ Bulb.create(car_id: car.id)
+ car.bulb_ids
+
+ car_two = Car.create!
+ car_two.bulb_ids
+
+ assert_no_queries do
+ assert_equal 1, car.bulbs.size
+ end
+
+ assert_no_queries do
+ assert_equal 0, car_two.bulbs.size
+ end
+ end
+
+ test "calling empty on an association that has not been loaded performs a query" do
+ car = Car.create!
+ Bulb.create(car_id: car.id)
+
+ car_two = Car.create!
+
+ assert_queries(1) do
+ assert_not_empty car.bulbs
+ end
+
+ assert_queries(1) do
+ assert_empty car_two.bulbs
+ end
+ end
+
+ test "calling empty on an association that has been loaded does not performs query" do
+ car = Car.create!
+ Bulb.create(car_id: car.id)
+ car.bulb_ids
+
+ car_two = Car.create!
+ car_two.bulb_ids
+
+ assert_no_queries do
+ assert_not_empty car.bulbs
+ end
+
+ assert_no_queries do
+ assert_empty car_two.bulbs
+ end
+ end
+
+ class AuthorWithErrorDestroyingAssociation < ActiveRecord::Base
+ self.table_name = "authors"
+ has_many :posts_with_error_destroying,
+ class_name: "PostWithErrorDestroying",
+ foreign_key: :author_id,
+ dependent: :destroy
+ end
+
+ class PostWithErrorDestroying < ActiveRecord::Base
+ self.table_name = "posts"
+ self.inheritance_column = nil
+ before_destroy -> { throw :abort }
+ end
+
+ def test_destroy_does_not_raise_when_association_errors_on_destroy
+ assert_no_difference "AuthorWithErrorDestroyingAssociation.count" do
+ author = AuthorWithErrorDestroyingAssociation.first
+
+ assert_not author.destroy
+ end
+ end
+
+ def test_destroy_with_bang_bubbles_errors_from_associations
+ error = assert_raises ActiveRecord::RecordNotDestroyed do
+ AuthorWithErrorDestroyingAssociation.first.destroy!
+ end
+
+ assert_instance_of PostWithErrorDestroying, error.record
+ end
+
+ def test_ids_reader_memoization
+ car = Car.create!(name: "Tofaş")
+ bulb = Bulb.create!(car: car)
+
+ assert_equal [bulb.id], car.bulb_ids
+ assert_no_queries { car.bulb_ids }
+
+ bulb2 = car.bulbs.create!
+
+ assert_equal [bulb.id, bulb2.id], car.bulb_ids
+ assert_no_queries { car.bulb_ids }
+ end
+
+ def test_loading_association_in_validate_callback_doesnt_affect_persistence
+ reset_callbacks(:validation, Bulb) do
+ Bulb.after_validation { |record| record.car.bulbs.load }
+
+ car = Car.create!(name: "Car")
+ bulb = car.bulbs.create!
+
+ assert_equal [bulb], car.bulbs
+ end
+ end
+
+ def test_create_children_could_be_rolled_back_by_after_save
+ firm = Firm.create!(name: "A New Firm, Inc")
+ assert_no_difference "Client.count" do
+ client = firm.clients.create(name: "New Client") do |cli|
+ cli.rollback_on_save = true
+ assert_not cli.rollback_on_create_called
+ end
+ assert client.rollback_on_create_called
+ end
+ end
+
+ private
+
+ def force_signal37_to_load_all_clients_of_firm
+ companies(:first_firm).clients_of_firm.load_target
+ end
+
+ def reset_callbacks(kind, klass)
+ old_callbacks = {}
+ old_callbacks[klass] = klass.send("_#{kind}_callbacks").dup
+ klass.subclasses.each do |subclass|
+ old_callbacks[subclass] = subclass.send("_#{kind}_callbacks").dup
+ end
+ yield
+ ensure
+ klass.send("_#{kind}_callbacks=", old_callbacks[klass])
+ klass.subclasses.each do |subclass|
+ subclass.send("_#{kind}_callbacks=", old_callbacks[subclass])
+ end
+ end
+end
diff --git a/activerecord/test/cases/associations/has_many_through_associations_test.rb b/activerecord/test/cases/associations/has_many_through_associations_test.rb
new file mode 100644
index 0000000000..bd535357ee
--- /dev/null
+++ b/activerecord/test/cases/associations/has_many_through_associations_test.rb
@@ -0,0 +1,1481 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+require "models/person"
+require "models/reference"
+require "models/job"
+require "models/reader"
+require "models/comment"
+require "models/rating"
+require "models/tag"
+require "models/tagging"
+require "models/author"
+require "models/owner"
+require "models/pet"
+require "models/pet_treasure"
+require "models/toy"
+require "models/treasure"
+require "models/contract"
+require "models/company"
+require "models/developer"
+require "models/computer"
+require "models/subscriber"
+require "models/book"
+require "models/subscription"
+require "models/essay"
+require "models/category"
+require "models/categorization"
+require "models/member"
+require "models/membership"
+require "models/club"
+require "models/organization"
+require "models/user"
+require "models/family"
+require "models/family_tree"
+
+class HasManyThroughAssociationsTest < ActiveRecord::TestCase
+ fixtures :posts, :readers, :people, :comments, :authors, :categories, :taggings, :tags,
+ :owners, :pets, :toys, :jobs, :references, :companies, :members, :author_addresses,
+ :subscribers, :books, :subscriptions, :developers, :categorizations, :essays,
+ :categories_posts, :clubs, :memberships, :organizations
+
+ # Dummies to force column loads so query counts are clean.
+ def setup
+ Person.create first_name: "gummy"
+ Reader.create person_id: 0, post_id: 0
+ end
+
+ def test_marshal_dump
+ preloaded = Post.includes(:first_blue_tags).first
+ assert_equal preloaded, Marshal.load(Marshal.dump(preloaded))
+ end
+
+ def test_preload_sti_rhs_class
+ developers = Developer.includes(:firms).all.to_a
+ assert_no_queries do
+ developers.each(&:firms)
+ end
+ end
+
+ def test_preload_sti_middle_relation
+ club = Club.create!(name: "Aaron cool banana club")
+ member1 = Member.create!(name: "Aaron")
+ member2 = Member.create!(name: "Cat")
+
+ SuperMembership.create! club: club, member: member1
+ CurrentMembership.create! club: club, member: member2
+
+ club1 = Club.includes(:members).find_by_id club.id
+ assert_equal [member1, member2].sort_by(&:id),
+ club1.members.sort_by(&:id)
+ end
+
+ def test_preload_multiple_instances_of_the_same_record
+ club = Club.create!(name: "Aaron cool banana club")
+ Membership.create! club: club, member: Member.create!(name: "Aaron")
+ Membership.create! club: club, member: Member.create!(name: "Bob")
+
+ preloaded_clubs = Club.joins(:memberships).preload(:membership).to_a
+ assert_no_queries { preloaded_clubs.each(&:membership) }
+ end
+
+ def test_ordered_has_many_through
+ person_prime = Class.new(ActiveRecord::Base) do
+ def self.name; "Person"; end
+
+ has_many :readers
+ has_many :posts, -> { order("posts.id DESC") }, through: :readers
+ end
+ posts = person_prime.includes(:posts).first.posts
+
+ assert_operator posts.length, :>, 1
+ posts.each_cons(2) do |left, right|
+ assert_operator left.id, :>, right.id
+ end
+ end
+
+ def test_singleton_has_many_through
+ book = make_model "Book"
+ subscription = make_model "Subscription"
+ subscriber = make_model "Subscriber"
+
+ subscriber.primary_key = "nick"
+ subscription.belongs_to :book, anonymous_class: book
+ subscription.belongs_to :subscriber, anonymous_class: subscriber
+
+ book.has_many :subscriptions, anonymous_class: subscription
+ book.has_many :subscribers, through: :subscriptions, anonymous_class: subscriber
+
+ anonbook = book.first
+ namebook = Book.find anonbook.id
+
+ assert_operator anonbook.subscribers.count, :>, 0
+ anonbook.subscribers.each do |s|
+ assert_instance_of subscriber, s
+ end
+ assert_equal namebook.subscribers.map(&:id).sort,
+ anonbook.subscribers.map(&:id).sort
+ end
+
+ def test_no_pk_join_table_append
+ lesson, _, student = make_no_pk_hm_t
+
+ sicp = lesson.new(name: "SICP")
+ ben = student.new(name: "Ben Bitdiddle")
+ sicp.students << ben
+ assert sicp.save!
+ end
+
+ def test_no_pk_join_table_delete
+ lesson, lesson_student, student = make_no_pk_hm_t
+
+ sicp = lesson.new(name: "SICP")
+ ben = student.new(name: "Ben Bitdiddle")
+ louis = student.new(name: "Louis Reasoner")
+ sicp.students << ben
+ sicp.students << louis
+ assert sicp.save!
+
+ sicp.students.reload
+ assert_operator lesson_student.count, :>=, 2
+ assert_no_difference("student.count") do
+ assert_difference("lesson_student.count", -2) do
+ sicp.students.destroy(*student.all.to_a)
+ end
+ end
+ end
+
+ def test_no_pk_join_model_callbacks
+ lesson, lesson_student, student = make_no_pk_hm_t
+
+ after_destroy_called = false
+ lesson_student.after_destroy do
+ after_destroy_called = true
+ end
+
+ sicp = lesson.new(name: "SICP")
+ ben = student.new(name: "Ben Bitdiddle")
+ sicp.students << ben
+ assert sicp.save!
+
+ sicp.students.reload
+ sicp.students.destroy(*student.all.to_a)
+ assert after_destroy_called, "after destroy should be called"
+ end
+
+ def test_pk_is_not_required_for_join
+ post = Post.includes(:scategories).first
+ post2 = Post.includes(:categories).first
+
+ assert_operator post.categories.length, :>, 0
+ assert_equal post2.categories, post.categories
+ end
+
+ def test_include?
+ person = Person.new
+ post = Post.new
+ person.posts << post
+ assert_includes person.posts, post
+ end
+
+ def test_associate_existing
+ post = posts(:thinking)
+ person = people(:david)
+
+ assert_queries(1) do
+ post.people << person
+ end
+
+ assert_queries(1) do
+ assert_includes post.people, person
+ end
+
+ assert_includes post.reload.people.reload, person
+ end
+
+ def test_delete_all_for_with_dependent_option_destroy
+ person = people(:david)
+ assert_equal 1, person.jobs_with_dependent_destroy.count
+
+ assert_no_difference "Job.count" do
+ assert_difference "Reference.count", -1 do
+ assert_equal 1, person.reload.jobs_with_dependent_destroy.delete_all
+ end
+ end
+ end
+
+ def test_delete_all_for_with_dependent_option_nullify
+ person = people(:david)
+ assert_equal 1, person.jobs_with_dependent_nullify.count
+
+ assert_no_difference "Job.count" do
+ assert_no_difference "Reference.count" do
+ assert_equal 1, person.reload.jobs_with_dependent_nullify.delete_all
+ end
+ end
+ end
+
+ def test_delete_all_for_with_dependent_option_delete_all
+ person = people(:david)
+ assert_equal 1, person.jobs_with_dependent_delete_all.count
+
+ assert_no_difference "Job.count" do
+ assert_difference "Reference.count", -1 do
+ assert_equal 1, person.reload.jobs_with_dependent_delete_all.delete_all
+ end
+ end
+ end
+
+ def test_delete_all_on_association_clears_scope
+ post = Post.create!(title: "Rails 6", body: "")
+ people = post.people
+ people.create!(first_name: "Jeb")
+ people.delete_all
+ assert_nil people.first
+ end
+
+ def test_concat
+ person = people(:david)
+ post = posts(:thinking)
+ post.people.concat [person]
+ assert_equal 1, post.people.size
+ assert_equal 1, post.people.reload.size
+ end
+
+ def test_associate_existing_record_twice_should_add_to_target_twice
+ post = posts(:thinking)
+ person = people(:david)
+
+ assert_difference "post.people.to_a.count", 2 do
+ post.people << person
+ post.people << person
+ end
+ end
+
+ def test_associate_existing_record_twice_should_add_records_twice
+ post = posts(:thinking)
+ person = people(:david)
+
+ assert_difference "post.people.count", 2 do
+ post.people << person
+ post.people << person
+ end
+ end
+
+ def test_add_two_instance_and_then_deleting
+ post = posts(:thinking)
+ person = people(:david)
+
+ post.people << person
+ post.people << person
+
+ counts = ["post.people.count", "post.people.to_a.count", "post.readers.count", "post.readers.to_a.count"]
+ assert_difference counts, -2 do
+ post.people.delete(person)
+ end
+
+ assert_not_includes post.people.reload, person
+ end
+
+ def test_associating_new
+ assert_queries(1) { posts(:thinking) }
+ new_person = nil # so block binding catches it
+
+ # Load schema information so we don't query below if running just this test.
+ Person.define_attribute_methods
+
+ assert_no_queries do
+ new_person = Person.new first_name: "bob"
+ end
+
+ # Associating new records always saves them
+ # Thus, 1 query for the new person record, 1 query for the new join table record
+ assert_queries(2) do
+ posts(:thinking).people << new_person
+ end
+
+ assert_queries(1) do
+ assert_includes posts(:thinking).people, new_person
+ end
+
+ assert_includes posts(:thinking).reload.people.reload, new_person
+ end
+
+ def test_associate_new_by_building
+ assert_queries(1) { posts(:thinking) }
+
+ # Load schema information so we don't query below if running just this test.
+ Person.define_attribute_methods
+
+ assert_no_queries do
+ posts(:thinking).people.build(first_name: "Bob")
+ posts(:thinking).people.new(first_name: "Ted")
+ end
+
+ # Should only need to load the association once
+ assert_queries(1) do
+ assert_includes posts(:thinking).people.collect(&:first_name), "Bob"
+ assert_includes posts(:thinking).people.collect(&:first_name), "Ted"
+ end
+
+ # 2 queries for each new record (1 to save the record itself, 1 for the join model)
+ # * 2 new records = 4
+ # + 1 query to save the actual post = 5
+ assert_queries(5) do
+ posts(:thinking).body += "-changed"
+ posts(:thinking).save
+ end
+
+ assert_includes posts(:thinking).reload.people.reload.collect(&:first_name), "Bob"
+ assert_includes posts(:thinking).reload.people.reload.collect(&:first_name), "Ted"
+ end
+
+ def test_build_then_save_with_has_many_inverse
+ post = posts(:thinking)
+ person = post.people.build(first_name: "Bob")
+ person.save
+ post.reload
+
+ assert_includes post.people, person
+ end
+
+ def test_build_then_save_with_has_one_inverse
+ post = posts(:thinking)
+ person = post.single_people.build(first_name: "Bob")
+ person.save
+ post.reload
+
+ assert_includes post.single_people, person
+ end
+
+ def test_build_then_remove_then_save
+ post = posts(:thinking)
+ post.people.build(first_name: "Bob")
+ ted = post.people.build(first_name: "Ted")
+ post.people.delete(ted)
+ post.save!
+ post.reload
+
+ assert_equal ["Bob"], post.people.collect(&:first_name)
+ end
+
+ def test_both_parent_ids_set_when_saving_new
+ post = Post.new(title: "Hello", body: "world")
+ person = Person.new(first_name: "Sean")
+
+ post.people = [person]
+ post.save
+
+ assert post.id
+ assert person.id
+ assert_equal post.id, post.readers.first.post_id
+ assert_equal person.id, post.readers.first.person_id
+ end
+
+ def test_delete_association
+ assert_queries(2) { posts(:welcome); people(:michael); }
+
+ assert_queries(1) do
+ posts(:welcome).people.delete(people(:michael))
+ end
+
+ assert_queries(1) do
+ assert_empty posts(:welcome).people
+ end
+
+ assert_empty posts(:welcome).reload.people.reload
+ end
+
+ def test_destroy_association
+ assert_no_difference "Person.count" do
+ assert_difference "Reader.count", -1 do
+ posts(:welcome).people.destroy(people(:michael))
+ end
+ end
+
+ assert_empty posts(:welcome).reload.people
+ assert_empty posts(:welcome).people.reload
+ end
+
+ def test_destroy_all
+ assert_no_difference "Person.count" do
+ assert_difference "Reader.count", -1 do
+ posts(:welcome).people.destroy_all
+ end
+ end
+
+ assert_empty posts(:welcome).reload.people
+ assert_empty posts(:welcome).people.reload
+ end
+
+ def test_destroy_all_on_association_clears_scope
+ post = Post.create!(title: "Rails 6", body: "")
+ people = post.people
+ people.create!(first_name: "Jeb")
+ people.destroy_all
+ assert_nil people.first
+ end
+
+ def test_destroy_on_association_clears_scope
+ post = Post.create!(title: "Rails 6", body: "")
+ people = post.people
+ person = people.create!(first_name: "Jeb")
+ people.destroy(person)
+ assert_nil people.first
+ end
+
+ def test_delete_on_association_clears_scope
+ post = Post.create!(title: "Rails 6", body: "")
+ people = post.people
+ person = people.create!(first_name: "Jeb")
+ people.delete(person)
+ assert_nil people.first
+ end
+
+ def test_should_raise_exception_for_destroying_mismatching_records
+ assert_no_difference ["Person.count", "Reader.count"] do
+ assert_raise(ActiveRecord::AssociationTypeMismatch) { posts(:welcome).people.destroy(posts(:thinking)) }
+ end
+ end
+
+ def test_delete_through_belongs_to_with_dependent_nullify
+ Reference.make_comments = true
+
+ person = people(:michael)
+ job = jobs(:magician)
+ reference = Reference.where(job_id: job.id, person_id: person.id).first
+
+ assert_no_difference ["Job.count", "Reference.count"] do
+ assert_difference "person.jobs.count", -1 do
+ person.jobs_with_dependent_nullify.delete(job)
+ end
+ end
+
+ assert_nil reference.reload.job_id
+ ensure
+ Reference.make_comments = false
+ end
+
+ def test_delete_through_belongs_to_with_dependent_delete_all
+ Reference.make_comments = true
+
+ person = people(:michael)
+ job = jobs(:magician)
+
+ # Make sure we're not deleting everything
+ assert person.jobs.count >= 2
+
+ assert_no_difference "Job.count" do
+ assert_difference ["person.jobs.count", "Reference.count"], -1 do
+ person.jobs_with_dependent_delete_all.delete(job)
+ end
+ end
+
+ # Check that the destroy callback on Reference did not run
+ assert_nil person.reload.comments
+ ensure
+ Reference.make_comments = false
+ end
+
+ def test_delete_through_belongs_to_with_dependent_destroy
+ Reference.make_comments = true
+
+ person = people(:michael)
+ job = jobs(:magician)
+
+ # Make sure we're not deleting everything
+ assert person.jobs.count >= 2
+
+ assert_no_difference "Job.count" do
+ assert_difference ["person.jobs.count", "Reference.count"], -1 do
+ person.jobs_with_dependent_destroy.delete(job)
+ end
+ end
+
+ # Check that the destroy callback on Reference ran
+ assert_equal "Reference destroyed", person.reload.comments
+ ensure
+ Reference.make_comments = false
+ end
+
+ def test_belongs_to_with_dependent_destroy
+ person = PersonWithDependentDestroyJobs.find(1)
+
+ # Create a reference which is not linked to a job. This should not be destroyed.
+ person.references.create!
+
+ assert_no_difference "Job.count" do
+ assert_difference "Reference.count", -person.jobs.count do
+ person.destroy
+ end
+ end
+ end
+
+ def test_belongs_to_with_dependent_delete_all
+ person = PersonWithDependentDeleteAllJobs.find(1)
+
+ # Create a reference which is not linked to a job. This should not be destroyed.
+ person.references.create!
+
+ assert_no_difference "Job.count" do
+ assert_difference "Reference.count", -person.jobs.count do
+ person.destroy
+ end
+ end
+ end
+
+ def test_belongs_to_with_dependent_nullify
+ person = PersonWithDependentNullifyJobs.find(1)
+
+ references = person.references.to_a
+
+ assert_no_difference ["Reference.count", "Job.count"] do
+ person.destroy
+ end
+
+ references.each do |reference|
+ assert_nil reference.reload.job_id
+ end
+ end
+
+ def test_update_counter_caches_on_delete
+ post = posts(:welcome)
+ tag = post.tags.create!(name: "doomed")
+
+ assert_difference ["post.reload.tags_count"], -1 do
+ posts(:welcome).tags.delete(tag)
+ end
+ end
+
+ def test_update_counter_caches_on_delete_with_dependent_destroy
+ post = posts(:welcome)
+ tag = post.tags.create!(name: "doomed")
+ post.update_columns(tags_with_destroy_count: post.tags.count)
+
+ assert_difference ["post.reload.tags_with_destroy_count"], -1 do
+ posts(:welcome).tags_with_destroy.delete(tag)
+ end
+ end
+
+ def test_update_counter_caches_on_delete_with_dependent_nullify
+ post = posts(:welcome)
+ tag = post.tags.create!(name: "doomed")
+ post.update_columns(tags_with_nullify_count: post.tags.count)
+
+ assert_no_difference "post.reload.tags_count" do
+ assert_difference "post.reload.tags_with_nullify_count", -1 do
+ posts(:welcome).tags_with_nullify.delete(tag)
+ end
+ end
+ end
+
+ def test_update_counter_caches_on_replace_association
+ post = posts(:welcome)
+ tag = post.tags.create!(name: "doomed")
+ tag.tagged_posts << posts(:thinking)
+
+ tag.tagged_posts = []
+ post.reload
+
+ assert_equal(post.taggings.count, post.tags_count)
+ end
+
+ def test_update_counter_caches_on_destroy
+ post = posts(:welcome)
+ tag = post.tags.create!(name: "doomed")
+
+ assert_difference "post.reload.tags_count", -1 do
+ tag.tagged_posts.destroy(post)
+ end
+ end
+
+ def test_update_counter_caches_on_destroy_with_indestructible_through_record
+ post = posts(:welcome)
+ tag = post.indestructible_tags.create!(name: "doomed")
+ post.update_columns(indestructible_tags_count: post.indestructible_tags.count)
+
+ assert_no_difference "post.reload.indestructible_tags_count" do
+ posts(:welcome).indestructible_tags.destroy(tag)
+ end
+ end
+
+ def test_replace_association
+ assert_queries(4) { posts(:welcome); people(:david); people(:michael); posts(:welcome).people.reload }
+
+ # 1 query to delete the existing reader (michael)
+ # 1 query to associate the new reader (david)
+ assert_queries(2) do
+ posts(:welcome).people = [people(:david)]
+ end
+
+ assert_no_queries do
+ assert_includes posts(:welcome).people, people(:david)
+ assert_not_includes posts(:welcome).people, people(:michael)
+ end
+
+ assert_includes posts(:welcome).reload.people.reload, people(:david)
+ assert_not_includes posts(:welcome).reload.people.reload, people(:michael)
+ end
+
+ def test_replace_association_with_duplicates
+ post = posts(:thinking)
+ person = people(:david)
+
+ assert_difference "post.people.count", 2 do
+ post.people = [person]
+ post.people = [person, person]
+ end
+ end
+
+ def test_replace_order_is_preserved
+ posts(:welcome).people.clear
+ posts(:welcome).people = [people(:david), people(:michael)]
+ assert_equal [people(:david).id, people(:michael).id], posts(:welcome).readers.order("id").map(&:person_id)
+
+ # Test the inverse order in case the first success was a coincidence
+ posts(:welcome).people.clear
+ posts(:welcome).people = [people(:michael), people(:david)]
+ assert_equal [people(:michael).id, people(:david).id], posts(:welcome).readers.order("id").map(&:person_id)
+ end
+
+ def test_replace_by_id_order_is_preserved
+ posts(:welcome).people.clear
+ posts(:welcome).person_ids = [people(:david).id, people(:michael).id]
+ assert_equal [people(:david).id, people(:michael).id], posts(:welcome).readers.order("id").map(&:person_id)
+
+ # Test the inverse order in case the first success was a coincidence
+ posts(:welcome).people.clear
+ posts(:welcome).person_ids = [people(:michael).id, people(:david).id]
+ assert_equal [people(:michael).id, people(:david).id], posts(:welcome).readers.order("id").map(&:person_id)
+ end
+
+ def test_associate_with_create
+ assert_queries(1) { posts(:thinking) }
+
+ # 1 query for the new record, 1 for the join table record
+ # No need to update the actual collection yet!
+ assert_queries(2) do
+ posts(:thinking).people.create(first_name: "Jeb")
+ end
+
+ # *Now* we actually need the collection so it's loaded
+ assert_queries(1) do
+ assert_includes posts(:thinking).people.collect(&:first_name), "Jeb"
+ end
+
+ assert_includes posts(:thinking).reload.people.reload.collect(&:first_name), "Jeb"
+ end
+
+ def test_through_record_is_built_when_created_with_where
+ assert_difference("posts(:thinking).readers.count", 1) do
+ posts(:thinking).people.where(first_name: "Jeb").create
+ end
+ end
+
+ def test_associate_with_create_and_no_options
+ peeps = posts(:thinking).people.count
+ posts(:thinking).people.create(first_name: "foo")
+ assert_equal peeps + 1, posts(:thinking).people.count
+ end
+
+ def test_associate_with_create_with_through_having_conditions
+ impatient_people = posts(:thinking).impatient_people.count
+ posts(:thinking).impatient_people.create!(first_name: "foo")
+ assert_equal impatient_people + 1, posts(:thinking).impatient_people.count
+ end
+
+ def test_associate_with_create_exclamation_and_no_options
+ peeps = posts(:thinking).people.count
+ posts(:thinking).people.create!(first_name: "foo")
+ assert_equal peeps + 1, posts(:thinking).people.count
+ end
+
+ def test_create_on_new_record
+ p = Post.new
+
+ error = assert_raises(ActiveRecord::RecordNotSaved) { p.people.create(first_name: "mew") }
+ assert_equal "You cannot call create unless the parent is saved", error.message
+
+ error = assert_raises(ActiveRecord::RecordNotSaved) { p.people.create!(first_name: "snow") }
+ assert_equal "You cannot call create unless the parent is saved", error.message
+ end
+
+ def test_associate_with_create_and_invalid_options
+ firm = companies(:first_firm)
+ assert_no_difference("firm.developers.count") { assert_nothing_raised { firm.developers.create(name: "0") } }
+ end
+
+ def test_associate_with_create_and_valid_options
+ firm = companies(:first_firm)
+ assert_difference("firm.developers.count", 1) { firm.developers.create(name: "developer") }
+ end
+
+ def test_associate_with_create_bang_and_invalid_options
+ firm = companies(:first_firm)
+ assert_no_difference("firm.developers.count") { assert_raises(ActiveRecord::RecordInvalid) { firm.developers.create!(name: "0") } }
+ end
+
+ def test_associate_with_create_bang_and_valid_options
+ firm = companies(:first_firm)
+ assert_difference("firm.developers.count", 1) { firm.developers.create!(name: "developer") }
+ end
+
+ def test_push_with_invalid_record
+ firm = companies(:first_firm)
+ assert_raises(ActiveRecord::RecordInvalid) { firm.developers << Developer.new(name: "0") }
+ end
+
+ def test_push_with_invalid_join_record
+ repair_validations(Contract) do
+ Contract.validate { |r| r.errors[:base] << "Invalid Contract" }
+
+ firm = companies(:first_firm)
+ lifo = Developer.new(name: "lifo")
+ assert_raises(ActiveRecord::RecordInvalid) { firm.developers << lifo }
+
+ lifo = Developer.create!(name: "lifo")
+ assert_raises(ActiveRecord::RecordInvalid) { firm.developers << lifo }
+ end
+ end
+
+ def test_clear_associations
+ assert_queries(2) { posts(:welcome); posts(:welcome).people.reload }
+
+ assert_queries(1) do
+ posts(:welcome).people.clear
+ end
+
+ assert_no_queries do
+ assert_empty posts(:welcome).people
+ end
+
+ assert_empty posts(:welcome).reload.people.reload
+ end
+
+ def test_association_callback_ordering
+ Post.reset_log
+ log = Post.log
+ post = posts(:thinking)
+
+ post.people_with_callbacks << people(:michael)
+ assert_equal [
+ [:added, :before, "Michael"],
+ [:added, :after, "Michael"]
+ ], log.last(2)
+
+ post.people_with_callbacks.push(people(:david), Person.create!(first_name: "Bob"), Person.new(first_name: "Lary"))
+ assert_equal [
+ [:added, :before, "David"],
+ [:added, :after, "David"],
+ [:added, :before, "Bob"],
+ [:added, :after, "Bob"],
+ [:added, :before, "Lary"],
+ [:added, :after, "Lary"]
+ ], log.last(6)
+
+ post.people_with_callbacks.build(first_name: "Ted")
+ assert_equal [
+ [:added, :before, "Ted"],
+ [:added, :after, "Ted"]
+ ], log.last(2)
+
+ post.people_with_callbacks.create(first_name: "Sam")
+ assert_equal [
+ [:added, :before, "Sam"],
+ [:added, :after, "Sam"]
+ ], log.last(2)
+
+ post.people_with_callbacks = [people(:michael), people(:david), Person.new(first_name: "Julian"), Person.create!(first_name: "Roger")]
+ assert_equal((%w(Ted Bob Sam Lary) * 2).sort, log[-12..-5].collect(&:last).sort)
+ assert_equal [
+ [:added, :before, "Julian"],
+ [:added, :after, "Julian"],
+ [:added, :before, "Roger"],
+ [:added, :after, "Roger"]
+ ], log.last(4)
+
+ post.people_with_callbacks.build { |person| person.first_name = "Ted" }
+ assert_equal [
+ [:added, :before, "Ted"],
+ [:added, :after, "Ted"]
+ ], log.last(2)
+
+ post.people_with_callbacks.create { |person| person.first_name = "Sam" }
+ assert_equal [
+ [:added, :before, "Sam"],
+ [:added, :after, "Sam"]
+ ], log.last(2)
+ end
+
+ def test_dynamic_find_should_respect_association_include
+ # SQL error in sort clause if :include is not included
+ # due to Unknown column 'comments.id'
+ assert Person.find(1).posts_with_comments_sorted_by_comment_id.find_by_title("Welcome to the weblog")
+ end
+
+ def test_count_with_include_should_alias_join_table
+ assert_equal 2, people(:michael).posts.includes(:readers).count
+ end
+
+ def test_inner_join_with_quoted_table_name
+ assert_equal 2, people(:michael).jobs.size
+ end
+
+ def test_get_ids
+ assert_equal [posts(:welcome).id, posts(:authorless).id].sort, people(:michael).post_ids.sort
+ end
+
+ def test_get_ids_for_has_many_through_with_conditions_should_not_preload
+ Tagging.create!(taggable_type: "Post", taggable_id: posts(:welcome).id, tag: tags(:misc))
+ assert_not_called(ActiveRecord::Associations::Preloader, :new) do
+ posts(:welcome).misc_tag_ids
+ end
+ end
+
+ def test_get_ids_for_loaded_associations
+ person = people(:michael)
+ person.posts.reload
+ assert_no_queries do
+ person.post_ids
+ person.post_ids
+ end
+ end
+
+ def test_get_ids_for_unloaded_associations_does_not_load_them
+ person = people(:michael)
+ assert_not_predicate person.posts, :loaded?
+ assert_equal [posts(:welcome).id, posts(:authorless).id].sort, person.post_ids.sort
+ assert_not_predicate person.posts, :loaded?
+ end
+
+ def test_association_proxy_transaction_method_starts_transaction_in_association_class
+ assert_called(Tag, :transaction) do
+ Post.first.tags.transaction do
+ # nothing
+ end
+ end
+ end
+
+ def test_has_many_association_through_a_belongs_to_association_where_the_association_doesnt_exist
+ post = Post.create!(title: "TITLE", body: "BODY")
+ assert_equal [], post.author_favorites
+ end
+
+ def test_has_many_association_through_a_belongs_to_association
+ author = authors(:mary)
+ post = Post.create!(author: author, title: "TITLE", body: "BODY")
+ author.author_favorites.create(favorite_author_id: 1)
+ author.author_favorites.create(favorite_author_id: 2)
+ author.author_favorites.create(favorite_author_id: 3)
+ assert_equal post.author.author_favorites, post.author_favorites
+ end
+
+ def test_merge_join_association_with_has_many_through_association_proxy
+ author = authors(:mary)
+ assert_nothing_raised { author.comments.ratings.to_sql }
+ end
+
+ def test_has_many_association_through_a_has_many_association_with_nonstandard_primary_keys
+ assert_equal 2, owners(:blackbeard).toys.count
+ end
+
+ def test_find_on_has_many_association_collection_with_include_and_conditions
+ post_with_no_comments = people(:michael).posts_with_no_comments.first
+ assert_equal post_with_no_comments, posts(:authorless)
+ end
+
+ def test_has_many_through_has_one_reflection
+ assert_equal [comments(:eager_sti_on_associations_vs_comment)], authors(:david).very_special_comments
+ end
+
+ def test_modifying_has_many_through_has_one_reflection_should_raise
+ [
+ lambda { authors(:david).very_special_comments = [VerySpecialComment.create!(body: "Gorp!", post_id: 1011), VerySpecialComment.create!(body: "Eep!", post_id: 1012)] },
+ lambda { authors(:david).very_special_comments << VerySpecialComment.create!(body: "Hoohah!", post_id: 1013) },
+ lambda { authors(:david).very_special_comments.delete(authors(:david).very_special_comments.first) },
+ ].each { |block| assert_raise(ActiveRecord::HasManyThroughCantAssociateThroughHasOneOrManyReflection, &block) }
+ end
+
+ def test_has_many_association_through_a_has_many_association_to_self
+ sarah = Person.create!(first_name: "Sarah", primary_contact_id: people(:susan).id, gender: "F", number1_fan_id: 1)
+ john = Person.create!(first_name: "John", primary_contact_id: sarah.id, gender: "M", number1_fan_id: 1)
+ assert_equal sarah.agents, [john]
+ assert_equal people(:susan).agents.flat_map(&:agents).sort, people(:susan).agents_of_agents.sort
+ end
+
+ def test_associate_existing_with_nonstandard_primary_key_on_belongs_to
+ Categorization.create(author: authors(:mary), named_category_name: categories(:general).name)
+ assert_equal categories(:general), authors(:mary).named_categories.first
+ end
+
+ def test_collection_build_with_nonstandard_primary_key_on_belongs_to
+ author = authors(:mary)
+ category = author.named_categories.build(name: "Primary")
+ author.save
+ assert Categorization.exists?(author_id: author.id, named_category_name: category.name)
+ assert_includes author.named_categories.reload, category
+ end
+
+ def test_collection_create_with_nonstandard_primary_key_on_belongs_to
+ author = authors(:mary)
+ category = author.named_categories.create(name: "Primary")
+ assert Categorization.exists?(author_id: author.id, named_category_name: category.name)
+ assert_includes author.named_categories.reload, category
+ end
+
+ def test_collection_exists
+ author = authors(:mary)
+ category = Category.create!(author_ids: [author.id], name: "Primary")
+ assert category.authors.exists?(id: author.id)
+ assert category.reload.authors.exists?(id: author.id)
+ end
+
+ def test_collection_delete_with_nonstandard_primary_key_on_belongs_to
+ author = authors(:mary)
+ category = author.named_categories.create(name: "Primary")
+ author.named_categories.delete(category)
+ assert_not Categorization.exists?(author_id: author.id, named_category_name: category.name)
+ assert_empty author.named_categories.reload
+ end
+
+ def test_collection_singular_ids_getter_with_string_primary_keys
+ book = books(:awdr)
+ assert_equal 2, book.subscriber_ids.size
+ assert_equal [subscribers(:first).nick, subscribers(:second).nick].sort, book.subscriber_ids.sort
+ end
+
+ def test_collection_singular_ids_setter
+ company = companies(:rails_core)
+ dev = Developer.first
+
+ company.developer_ids = [dev.id]
+ assert_equal [dev], company.developers
+ end
+
+ def test_collection_singular_ids_setter_with_required_type_cast
+ company = companies(:rails_core)
+ dev = Developer.first
+
+ company.developer_ids = [dev.id.to_s]
+ assert_equal [dev], company.developers
+ end
+
+ def test_collection_singular_ids_setter_with_string_primary_keys
+ assert_nothing_raised do
+ book = books(:awdr)
+ book.subscriber_ids = [subscribers(:second).nick]
+ assert_equal [subscribers(:second)], book.subscribers.reload
+
+ book.subscriber_ids = []
+ assert_equal [], book.subscribers.reload
+ end
+ end
+
+ def test_collection_singular_ids_setter_raises_exception_when_invalid_ids_set
+ company = companies(:rails_core)
+ ids = [Developer.first.id, -9999]
+ e = assert_raises(ActiveRecord::RecordNotFound) { company.developer_ids = ids }
+ msg = "Couldn't find all Developers with 'id': (1, -9999) (found 1 results, but was looking for 2). Couldn't find Developer with id -9999."
+ assert_equal(msg, e.message)
+ end
+
+ def test_collection_singular_ids_through_setter_raises_exception_when_invalid_ids_set
+ author = authors(:david)
+ ids = [categories(:general).name, "Unknown"]
+ e = assert_raises(ActiveRecord::RecordNotFound) { author.essay_category_ids = ids }
+ msg = "Couldn't find all Categories with 'name': (General, Unknown) (found 1 results, but was looking for 2). Couldn't find Category with name Unknown."
+ assert_equal msg, e.message
+ end
+
+ def test_build_a_model_from_hm_through_association_with_where_clause
+ assert_nothing_raised { books(:awdr).subscribers.where(nick: "marklazz").build }
+ end
+
+ def test_attributes_are_being_set_when_initialized_from_hm_through_association_with_where_clause
+ new_subscriber = books(:awdr).subscribers.where(nick: "marklazz").build
+ assert_equal new_subscriber.nick, "marklazz"
+ end
+
+ def test_attributes_are_being_set_when_initialized_from_hm_through_association_with_multiple_where_clauses
+ new_subscriber = books(:awdr).subscribers.where(nick: "marklazz").where(name: "Marcelo Giorgi").build
+ assert_equal new_subscriber.nick, "marklazz"
+ assert_equal new_subscriber.name, "Marcelo Giorgi"
+ end
+
+ def test_include_method_in_association_through_should_return_true_for_instance_added_with_build
+ person = Person.new
+ reference = person.references.build
+ job = reference.build_job
+ assert_includes person.jobs, job
+ end
+
+ def test_include_method_in_association_through_should_return_true_for_instance_added_with_nested_builds
+ author = Author.new
+ post = author.posts.build
+ comment = post.comments.build
+ assert_includes author.comments, comment
+ end
+
+ def test_through_association_readonly_should_be_false
+ assert_not_predicate people(:michael).posts.first, :readonly?
+ assert_not_predicate people(:michael).posts.to_a.first, :readonly?
+ end
+
+ def test_can_update_through_association
+ assert_nothing_raised do
+ people(:michael).posts.first.update!(title: "Can write")
+ end
+ end
+
+ def test_has_many_through_polymorphic_with_rewhere
+ post = TaggedPost.create!(title: "Tagged", body: "Post")
+ tag = post.tags.create!(name: "Tag")
+ assert_equal [tag], TaggedPost.preload(:tags).last.tags
+ assert_equal [tag], TaggedPost.eager_load(:tags).last.tags
+ end
+
+ def test_has_many_through_polymorphic_with_primary_key_option
+ assert_equal [categories(:general)], authors(:david).essay_categories
+
+ authors = Author.joins(:essay_categories).where("categories.id" => categories(:general).id)
+ assert_equal authors(:david), authors.first
+
+ assert_equal [owners(:blackbeard)], authors(:david).essay_owners
+
+ authors = Author.joins(:essay_owners).where("owners.name = 'blackbeard'")
+ assert_equal authors(:david), authors.first
+ end
+
+ def test_has_many_through_with_primary_key_option
+ assert_equal [categories(:general)], authors(:david).essay_categories_2
+
+ authors = Author.joins(:essay_categories_2).where("categories.id" => categories(:general).id)
+ assert_equal authors(:david), authors.first
+ end
+
+ def test_size_of_through_association_should_increase_correctly_when_has_many_association_is_added
+ post = posts(:thinking)
+ readers = post.readers.size
+ post.people << people(:michael)
+ assert_equal readers + 1, post.readers.size
+ end
+
+ def test_has_many_through_with_default_scope_on_join_model
+ assert_equal posts(:welcome).comments.order("id").to_a, authors(:david).comments_on_first_posts
+ end
+
+ def test_create_has_many_through_with_default_scope_on_join_model
+ category = authors(:david).special_categories.create(name: "Foo")
+ assert_equal 1, category.categorizations.where(special: true).count
+ end
+
+ def test_joining_has_many_through_with_distinct
+ mary = Author.joins(:unique_categorized_posts).where(id: authors(:mary).id).first
+ assert_equal 1, mary.unique_categorized_posts.length
+ assert_equal 1, mary.unique_categorized_post_ids.length
+ end
+
+ def test_joining_has_many_through_belongs_to
+ posts = Post.joins(:author_categorizations).order("posts.id").
+ where("categorizations.id" => categorizations(:mary_thinking_sti).id)
+
+ assert_equal [posts(:eager_other), posts(:misc_by_mary), posts(:other_by_mary)], posts
+ end
+
+ def test_select_chosen_fields_only
+ author = authors(:david)
+ assert_equal ["body", "id"].sort, author.comments.select("comments.body").first.attributes.keys.sort
+ end
+
+ def test_get_has_many_through_belongs_to_ids_with_conditions
+ assert_equal [categories(:general).id], authors(:mary).categories_like_general_ids
+ end
+
+ def test_get_collection_singular_ids_on_has_many_through_with_conditions_and_include
+ person = Person.first
+ assert_equal person.posts_with_no_comment_ids, person.posts_with_no_comments.map(&:id)
+ end
+
+ def test_count_has_many_through_with_named_scope
+ assert_equal 2, authors(:mary).categories.count
+ assert_equal 1, authors(:mary).categories.general.count
+ end
+
+ def test_has_many_through_belongs_to_should_update_when_the_through_foreign_key_changes
+ post = posts(:eager_other)
+
+ post.author_categorizations
+ proxy = post.send(:association_instance_get, :author_categorizations)
+
+ assert_not_predicate proxy, :stale_target?
+ assert_equal authors(:mary).categorizations.sort_by(&:id), post.author_categorizations.sort_by(&:id)
+
+ post.author_id = authors(:david).id
+
+ assert_predicate proxy, :stale_target?
+ assert_equal authors(:david).categorizations.sort_by(&:id), post.author_categorizations.sort_by(&:id)
+ end
+
+ def test_create_with_conditions_hash_on_through_association
+ member = members(:groucho)
+ club = member.clubs.create!
+
+ assert_equal true, club.reload.membership.favourite
+ end
+
+ def test_deleting_from_has_many_through_a_belongs_to_should_not_try_to_update_counter
+ post = posts(:welcome)
+ address = author_addresses(:david_address)
+
+ assert_includes post.author_addresses, address
+ post.author_addresses.delete(address)
+ assert_predicate post[:author_count], :nil?
+ end
+
+ def test_primary_key_option_on_source
+ post = posts(:welcome)
+ category = categories(:general)
+ Categorization.create!(post_id: post.id, named_category_name: category.name)
+
+ assert_equal [category], post.named_categories
+ assert_equal [category.name], post.named_category_ids # checks when target loaded
+ assert_equal [category.name], post.reload.named_category_ids # checks when target no loaded
+ end
+
+ def test_create_should_not_raise_exception_when_join_record_has_errors
+ repair_validations(Categorization) do
+ Categorization.validate { |r| r.errors[:base] << "Invalid Categorization" }
+ Category.create(name: "Fishing", authors: [Author.first])
+ end
+ end
+
+ def test_assign_array_to_new_record_builds_join_records
+ c = Category.new(name: "Fishing", authors: [Author.first])
+ assert_equal 1, c.categorizations.size
+ end
+
+ def test_create_bang_should_raise_exception_when_join_record_has_errors
+ repair_validations(Categorization) do
+ Categorization.validate { |r| r.errors[:base] << "Invalid Categorization" }
+ assert_raises(ActiveRecord::RecordInvalid) do
+ Category.create!(name: "Fishing", authors: [Author.first])
+ end
+ end
+ end
+
+ def test_save_bang_should_raise_exception_when_join_record_has_errors
+ repair_validations(Categorization) do
+ Categorization.validate { |r| r.errors[:base] << "Invalid Categorization" }
+ c = Category.new(name: "Fishing", authors: [Author.first])
+ assert_raises(ActiveRecord::RecordInvalid) do
+ c.save!
+ end
+ end
+ end
+
+ def test_save_returns_falsy_when_join_record_has_errors
+ repair_validations(Categorization) do
+ Categorization.validate { |r| r.errors[:base] << "Invalid Categorization" }
+ c = Category.new(name: "Fishing", authors: [Author.first])
+ assert_not c.save
+ end
+ end
+
+ def test_preloading_empty_through_association_via_joins
+ person = Person.create!(first_name: "Gaga")
+ person = Person.where(id: person.id).where("readers.id = 1 or 1=1").references(:readers).includes(:posts).to_a.first
+
+ assert person.posts.loaded?, "person.posts should be loaded"
+ assert_equal [], person.posts
+ end
+
+ def test_preloading_empty_through_with_polymorphic_source_association
+ owner = Owner.create!(name: "Rainbow Unicat")
+ pet = Pet.create!(owner: owner)
+ person = Person.create!(first_name: "Gaga")
+ treasure = Treasure.create!(looter: person)
+ non_looted_treasure = Treasure.create!()
+ PetTreasure.create!(pet: pet, treasure: treasure, rainbow_color: "Ultra violet indigo")
+ PetTreasure.create!(pet: pet, treasure: non_looted_treasure, rainbow_color: "Ultra violet indigo")
+
+ assert_equal [person], Owner.where(name: "Rainbow Unicat").includes(pets: :persons).first.persons.to_a
+ end
+
+ def test_explicitly_joining_join_table
+ assert_equal owners(:blackbeard).toys, owners(:blackbeard).toys.with_pet
+ end
+
+ def test_has_many_through_with_polymorphic_source
+ post = tags(:general).tagged_posts.create! title: "foo", body: "bar"
+ assert_equal [tags(:general)], post.reload.tags
+ end
+
+ def test_has_many_through_obeys_order_on_through_association
+ owner = owners(:blackbeard)
+ assert_includes owner.toys.to_sql, "pets.name desc"
+ assert_equal ["parrot", "bulbul"], owner.toys.map { |r| r.pet.name }
+ end
+
+ def test_has_many_through_associations_sum_on_columns
+ post1 = Post.create(title: "active", body: "sample")
+ post2 = Post.create(title: "inactive", body: "sample")
+
+ person1 = Person.create(first_name: "aaron", followers_count: 1)
+ person2 = Person.create(first_name: "schmit", followers_count: 2)
+ person3 = Person.create(first_name: "bill", followers_count: 3)
+ person4 = Person.create(first_name: "cal", followers_count: 4)
+
+ Reader.create(post_id: post1.id, person_id: person1.id)
+ Reader.create(post_id: post1.id, person_id: person2.id)
+ Reader.create(post_id: post1.id, person_id: person3.id)
+ Reader.create(post_id: post1.id, person_id: person4.id)
+
+ Reader.create(post_id: post2.id, person_id: person1.id)
+ Reader.create(post_id: post2.id, person_id: person2.id)
+ Reader.create(post_id: post2.id, person_id: person3.id)
+ Reader.create(post_id: post2.id, person_id: person4.id)
+
+ active_persons = Person.joins(:readers).joins(:posts).distinct(true).where("posts.title" => "active")
+
+ assert_equal active_persons.map(&:followers_count).reduce(:+), 10
+ assert_equal active_persons.sum(:followers_count), 10
+ assert_equal active_persons.sum(:followers_count), active_persons.map(&:followers_count).reduce(:+)
+ end
+
+ def test_has_many_through_associations_on_new_records_use_null_relations
+ person = Person.new
+
+ assert_no_queries do
+ assert_equal [], person.posts
+ assert_equal [], person.posts.where(body: "omg")
+ assert_equal [], person.posts.pluck(:body)
+ assert_equal 0, person.posts.sum(:tags_count)
+ assert_equal 0, person.posts.count
+ end
+ end
+
+ def test_has_many_through_with_default_scope_on_the_target
+ person = people(:michael)
+ assert_equal [posts(:thinking).id], person.first_posts.map(&:id)
+
+ readers(:michael_authorless).update(first_post_id: 1)
+ assert_equal [posts(:thinking).id], person.reload.first_posts.map(&:id)
+ end
+
+ def test_has_many_through_with_includes_in_through_association_scope
+ assert_not_empty posts(:welcome).author_address_extra_with_address
+ end
+
+ def test_insert_records_via_has_many_through_association_with_scope
+ club = Club.create!
+ member = Member.create!
+ Membership.create!(club: club, member: member)
+
+ club.favourites << member
+ assert_equal [member], club.favourites
+
+ club.reload
+ assert_equal [member], club.favourites
+ end
+
+ def test_has_many_through_unscope_default_scope
+ post = Post.create!(title: "Beaches", body: "I like beaches!")
+ Reader.create! person: people(:david), post: post
+ LazyReader.create! person: people(:susan), post: post
+
+ assert_equal 2, post.people.to_a.size
+ assert_equal 1, post.lazy_people.to_a.size
+
+ assert_equal 2, post.lazy_readers_unscope_skimmers.to_a.size
+ assert_equal 2, post.lazy_people_unscope_skimmers.to_a.size
+ end
+
+ def test_has_many_through_add_with_sti_middle_relation
+ club = SuperClub.create!(name: "Fight Club")
+ member = Member.create!(name: "Tyler Durden")
+
+ club.members << member
+ assert_equal 1, SuperMembership.where(member_id: member.id, club_id: club.id).count
+ end
+
+ def test_build_for_has_many_through_association
+ organization = organizations(:nsa)
+ author = organization.author
+ post_direct = author.posts.build
+ post_through = organization.posts.build
+ assert_equal post_direct.author_id, post_through.author_id
+ end
+
+ def test_has_many_through_with_scope_that_should_not_be_fully_merged
+ Club.has_many :distinct_memberships, -> { distinct }, class_name: "Membership"
+ Club.has_many :special_favourites, through: :distinct_memberships, source: :member
+
+ assert_nil Club.new.special_favourites.distinct_value
+ end
+
+ def test_has_many_through_do_not_cache_association_reader_if_the_though_method_has_default_scopes
+ member = Member.create!
+ club = Club.create!
+ TenantMembership.create!(
+ member: member,
+ club: club
+ )
+
+ TenantMembership.current_member = member
+
+ tenant_clubs = member.tenant_clubs
+ assert_equal [club], tenant_clubs
+
+ TenantMembership.current_member = nil
+
+ other_member = Member.create!
+ other_club = Club.create!
+ TenantMembership.create!(
+ member: other_member,
+ club: other_club
+ )
+
+ tenant_clubs = other_member.tenant_clubs
+ assert_equal [other_club], tenant_clubs
+ ensure
+ TenantMembership.current_member = nil
+ end
+
+ def test_has_many_through_with_scope_that_has_joined_same_table_with_parent_relation
+ assert_equal authors(:david), Author.joins(:comments_for_first_author).take
+ end
+
+ def test_has_many_through_with_left_joined_same_table_with_through_table
+ assert_equal [comments(:eager_other_comment1)], authors(:mary).comments.left_joins(:post)
+ end
+
+ def test_has_many_through_with_unscope_should_affect_to_through_scope
+ assert_equal [comments(:eager_other_comment1)], authors(:mary).unordered_comments
+ end
+
+ def test_has_many_through_with_scope_should_accept_string_and_hash_join
+ assert_equal authors(:david), Author.joins({ comments_for_first_author: :post }, "inner join posts posts_alias on authors.id = posts_alias.author_id").eager_load(:categories).take
+ end
+
+ def test_has_many_through_with_scope_should_respect_table_alias
+ family = Family.create!
+ users = 3.times.map { User.create! }
+ FamilyTree.create!(member: users[0], family: family)
+ FamilyTree.create!(member: users[1], family: family)
+ FamilyTree.create!(member: users[2], family: family, token: "wat")
+
+ assert_equal 2, users[0].family_members.to_a.size
+ assert_equal 0, users[2].family_members.to_a.size
+ end
+
+ def test_through_scope_is_affected_by_unscoping
+ author = authors(:david)
+
+ expected = author.comments.to_a
+ FirstPost.unscoped do
+ assert_equal expected.sort_by(&:id), author.comments_on_first_posts.sort_by(&:id)
+ end
+ end
+
+ def test_through_scope_isnt_affected_by_scoping
+ author = authors(:david)
+
+ expected = author.comments_on_first_posts.to_a
+ FirstPost.where(id: 2).scoping do
+ author.comments_on_first_posts.reset
+ assert_equal expected.sort_by(&:id), author.comments_on_first_posts.sort_by(&:id)
+ end
+ end
+
+ def test_incorrectly_ordered_through_associations
+ assert_raises(ActiveRecord::HasManyThroughOrderError) do
+ DeveloperWithIncorrectlyOrderedHasManyThrough.create(
+ companies: [Company.create]
+ )
+ end
+ end
+
+ def test_has_many_through_update_ids_with_conditions
+ author = Author.create!(name: "Bill")
+ category = categories(:general)
+
+ author.update(
+ special_categories_with_condition_ids: [category.id],
+ nonspecial_categories_with_condition_ids: [category.id]
+ )
+
+ assert_equal [category.id], author.special_categories_with_condition_ids
+ assert_equal [category.id], author.nonspecial_categories_with_condition_ids
+
+ author.update(nonspecial_categories_with_condition_ids: [])
+ author.reload
+
+ assert_equal [category.id], author.special_categories_with_condition_ids
+ assert_equal [], author.nonspecial_categories_with_condition_ids
+ end
+
+ def test_single_has_many_through_association_with_unpersisted_parent_instance
+ post_with_single_has_many_through = Class.new(Post) do
+ def self.name; "PostWithSingleHasManyThrough"; end
+ has_many :subscriptions, through: :author
+ end
+ post = post_with_single_has_many_through.new
+
+ post.author = authors(:mary)
+ book1 = Book.create!(name: "essays on single has many through associations 1")
+ post.author.books << book1
+ subscription1 = Subscription.first
+ book1.subscriptions << subscription1
+ assert_equal [subscription1], post.subscriptions.to_a
+
+ post.author = authors(:bob)
+ book2 = Book.create!(name: "essays on single has many through associations 2")
+ post.author.books << book2
+ subscription2 = Subscription.second
+ book2.subscriptions << subscription2
+ assert_equal [subscription2], post.subscriptions.to_a
+ end
+
+ def test_nested_has_many_through_association_with_unpersisted_parent_instance
+ post_with_nested_has_many_through = Class.new(Post) do
+ def self.name; "PostWithNestedHasManyThrough"; end
+ has_many :books, through: :author
+ has_many :subscriptions, through: :books
+ end
+ post = post_with_nested_has_many_through.new
+
+ post.author = authors(:mary)
+ book1 = Book.create!(name: "essays on nested has many through associations 1")
+ post.author.books << book1
+ subscription1 = Subscription.first
+ book1.subscriptions << subscription1
+ assert_equal [subscription1], post.subscriptions.to_a
+
+ post.author = authors(:bob)
+ book2 = Book.create!(name: "essays on nested has many through associations 2")
+ post.author.books << book2
+ subscription2 = Subscription.second
+ book2.subscriptions << subscription2
+ assert_equal [subscription2], post.subscriptions.to_a
+ end
+
+ private
+ def make_model(name)
+ Class.new(ActiveRecord::Base) { define_singleton_method(:name) { name } }
+ end
+
+ def make_no_pk_hm_t
+ lesson = make_model "Lesson"
+ student = make_model "Student"
+
+ lesson_student = make_model "LessonStudent"
+ lesson_student.table_name = "lessons_students"
+
+ lesson_student.belongs_to :lesson, anonymous_class: lesson
+ lesson_student.belongs_to :student, anonymous_class: student
+ lesson.has_many :lesson_students, anonymous_class: lesson_student
+ lesson.has_many :students, through: :lesson_students, anonymous_class: student
+ [lesson, lesson_student, student]
+ end
+end
diff --git a/activerecord/test/cases/associations/has_one_associations_test.rb b/activerecord/test/cases/associations/has_one_associations_test.rb
new file mode 100644
index 0000000000..bf574f6637
--- /dev/null
+++ b/activerecord/test/cases/associations/has_one_associations_test.rb
@@ -0,0 +1,786 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/developer"
+require "models/computer"
+require "models/project"
+require "models/company"
+require "models/ship"
+require "models/pirate"
+require "models/car"
+require "models/bulb"
+require "models/author"
+require "models/image"
+require "models/post"
+
+class HasOneAssociationsTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false unless supports_savepoints?
+ fixtures :accounts, :companies, :developers, :projects, :developers_projects, :ships, :pirates, :authors, :author_addresses
+
+ def setup
+ Account.destroyed_account_ids.clear
+ end
+
+ def test_has_one
+ firm = companies(:first_firm)
+ first_account = Account.find(1)
+ assert_sql(/LIMIT|ROWNUM <=|FETCH FIRST/) do
+ assert_equal first_account, firm.account
+ assert_equal first_account.credit_limit, firm.account.credit_limit
+ end
+ end
+
+ def test_has_one_does_not_use_order_by
+ ActiveRecord::SQLCounter.clear_log
+ companies(:first_firm).account
+ ensure
+ log_all = ActiveRecord::SQLCounter.log_all
+ assert log_all.all? { |sql| /order by/i !~ sql }, "ORDER BY was used in the query: #{log_all}"
+ end
+
+ def test_has_one_cache_nils
+ firm = companies(:another_firm)
+ assert_queries(1) { assert_nil firm.account }
+ assert_no_queries { assert_nil firm.account }
+
+ firms = Firm.includes(:account).to_a
+ assert_no_queries { firms.each(&:account) }
+ end
+
+ def test_with_select
+ assert_equal Firm.find(1).account_with_select.attributes.size, 2
+ assert_equal Firm.all.merge!(includes: :account_with_select).find(1).account_with_select.attributes.size, 2
+ end
+
+ def test_finding_using_primary_key
+ firm = companies(:first_firm)
+ assert_equal Account.find_by_firm_id(firm.id), firm.account
+ firm.firm_id = companies(:rails_core).id
+ assert_equal accounts(:rails_core_account), firm.account_using_primary_key
+ end
+
+ def test_update_with_foreign_and_primary_keys
+ firm = companies(:first_firm)
+ account = firm.account_using_foreign_and_primary_keys
+ assert_equal Account.find_by_firm_name(firm.name), account
+ firm.save
+ firm.reload
+ assert_equal account, firm.account_using_foreign_and_primary_keys
+ end
+
+ def test_can_marshal_has_one_association_with_nil_target
+ firm = Firm.new
+ assert_nothing_raised do
+ assert_equal firm.attributes, Marshal.load(Marshal.dump(firm)).attributes
+ end
+
+ firm.account
+ assert_nothing_raised do
+ assert_equal firm.attributes, Marshal.load(Marshal.dump(firm)).attributes
+ end
+ end
+
+ def test_proxy_assignment
+ company = companies(:first_firm)
+ assert_nothing_raised { company.account = company.account }
+ end
+
+ def test_type_mismatch
+ assert_raise(ActiveRecord::AssociationTypeMismatch) { companies(:first_firm).account = 1 }
+ assert_raise(ActiveRecord::AssociationTypeMismatch) { companies(:first_firm).account = Project.find(1) }
+ end
+
+ def test_natural_assignment
+ apple = Firm.create("name" => "Apple")
+ citibank = Account.create("credit_limit" => 10)
+ apple.account = citibank
+ assert_equal apple.id, citibank.firm_id
+ end
+
+ def test_natural_assignment_to_nil
+ old_account_id = companies(:first_firm).account.id
+ companies(:first_firm).account = nil
+ companies(:first_firm).save
+ assert_nil companies(:first_firm).account
+ # account is dependent, therefore is destroyed when reference to owner is lost
+ assert_raise(ActiveRecord::RecordNotFound) { Account.find(old_account_id) }
+ end
+
+ def test_nullification_on_association_change
+ firm = companies(:rails_core)
+ old_account_id = firm.account.id
+ firm.account = Account.new(credit_limit: 5)
+ # account is dependent with nullify, therefore its firm_id should be nil
+ assert_nil Account.find(old_account_id).firm_id
+ end
+
+ def test_nullification_on_destroyed_association
+ developer = Developer.create!(name: "Someone")
+ ship = Ship.create!(name: "Planet Caravan", developer: developer)
+ ship.destroy
+ assert_not_predicate ship, :persisted?
+ assert_not_predicate developer, :persisted?
+ end
+
+ def test_natural_assignment_to_nil_after_destroy
+ firm = companies(:rails_core)
+ old_account_id = firm.account.id
+ firm.account.destroy
+ firm.account = nil
+ assert_nil companies(:rails_core).account
+ assert_raise(ActiveRecord::RecordNotFound) { Account.find(old_account_id) }
+ end
+
+ def test_association_change_calls_delete
+ companies(:first_firm).deletable_account = Account.new(credit_limit: 5)
+ assert_equal [], Account.destroyed_account_ids[companies(:first_firm).id]
+ end
+
+ def test_association_change_calls_destroy
+ companies(:first_firm).account = Account.new(credit_limit: 5)
+ assert_equal [companies(:first_firm).id], Account.destroyed_account_ids[companies(:first_firm).id]
+ end
+
+ def test_natural_assignment_to_already_associated_record
+ company = companies(:first_firm)
+ account = accounts(:signals37)
+ assert_equal company.account, account
+ company.account = account
+ company.reload
+ account.reload
+ assert_equal company.account, account
+ end
+
+ def test_dependence
+ num_accounts = Account.count
+
+ firm = Firm.find(1)
+ assert_not_nil firm.account
+ account_id = firm.account.id
+ assert_equal [], Account.destroyed_account_ids[firm.id]
+
+ firm.destroy
+ assert_equal num_accounts - 1, Account.count
+ assert_equal [account_id], Account.destroyed_account_ids[firm.id]
+ end
+
+ def test_exclusive_dependence
+ num_accounts = Account.count
+
+ firm = ExclusivelyDependentFirm.find(9)
+ assert_not_nil firm.account
+ assert_equal [], Account.destroyed_account_ids[firm.id]
+
+ firm.destroy
+ assert_equal num_accounts - 1, Account.count
+ assert_equal [], Account.destroyed_account_ids[firm.id]
+ end
+
+ def test_dependence_with_nil_associate
+ firm = DependentFirm.new(name: "nullify")
+ firm.save!
+ assert_nothing_raised { firm.destroy }
+ end
+
+ def test_restrict_with_exception
+ firm = RestrictedWithExceptionFirm.create!(name: "restrict")
+ firm.create_account(credit_limit: 10)
+
+ assert_not_nil firm.account
+
+ assert_raise(ActiveRecord::DeleteRestrictionError) { firm.destroy }
+ assert RestrictedWithExceptionFirm.exists?(name: "restrict")
+ assert_predicate firm.account, :present?
+ end
+
+ def test_restrict_with_error
+ firm = RestrictedWithErrorFirm.create!(name: "restrict")
+ firm.create_account(credit_limit: 10)
+
+ assert_not_nil firm.account
+
+ firm.destroy
+
+ assert_not_empty firm.errors
+ assert_equal "Cannot delete record because a dependent account exists", firm.errors[:base].first
+ assert RestrictedWithErrorFirm.exists?(name: "restrict")
+ assert_predicate firm.account, :present?
+ end
+
+ def test_restrict_with_error_with_locale
+ I18n.backend = I18n::Backend::Simple.new
+ I18n.backend.store_translations "en", activerecord: { attributes: { restricted_with_error_firm: { account: "firm account" } } }
+ firm = RestrictedWithErrorFirm.create!(name: "restrict")
+ firm.create_account(credit_limit: 10)
+
+ assert_not_nil firm.account
+
+ firm.destroy
+
+ assert_not_empty firm.errors
+ assert_equal "Cannot delete record because a dependent firm account exists", firm.errors[:base].first
+ assert RestrictedWithErrorFirm.exists?(name: "restrict")
+ assert_predicate firm.account, :present?
+ ensure
+ I18n.backend.reload!
+ end
+
+ def test_successful_build_association
+ firm = Firm.new("name" => "GlobalMegaCorp")
+ firm.save
+
+ account = firm.build_account("credit_limit" => 1000)
+ assert account.save
+ assert_equal account, firm.account
+ end
+
+ def test_build_association_dont_create_transaction
+ # Load schema information so we don't query below if running just this test.
+ Account.define_attribute_methods
+
+ firm = Firm.new
+ assert_no_queries do
+ firm.build_account
+ end
+ end
+
+ def test_building_the_associated_object_with_implicit_sti_base_class
+ firm = DependentFirm.new
+ company = firm.build_company
+ assert_kind_of Company, company, "Expected #{company.class} to be a Company"
+ end
+
+ def test_building_the_associated_object_with_explicit_sti_base_class
+ firm = DependentFirm.new
+ company = firm.build_company(type: "Company")
+ assert_kind_of Company, company, "Expected #{company.class} to be a Company"
+ end
+
+ def test_building_the_associated_object_with_sti_subclass
+ firm = DependentFirm.new
+ company = firm.build_company(type: "Client")
+ assert_kind_of Client, company, "Expected #{company.class} to be a Client"
+ end
+
+ def test_building_the_associated_object_with_an_invalid_type
+ firm = DependentFirm.new
+ assert_raise(ActiveRecord::SubclassNotFound) { firm.build_company(type: "Invalid") }
+ end
+
+ def test_building_the_associated_object_with_an_unrelated_type
+ firm = DependentFirm.new
+ assert_raise(ActiveRecord::SubclassNotFound) { firm.build_company(type: "Account") }
+ end
+
+ def test_build_and_create_should_not_happen_within_scope
+ pirate = pirates(:blackbeard)
+ scope = pirate.association(:foo_bulb).scope.where_values_hash
+
+ bulb = pirate.build_foo_bulb
+ assert_not_equal scope, bulb.scope_after_initialize.where_values_hash
+
+ bulb = pirate.create_foo_bulb
+ assert_not_equal scope, bulb.scope_after_initialize.where_values_hash
+
+ bulb = pirate.create_foo_bulb!
+ assert_not_equal scope, bulb.scope_after_initialize.where_values_hash
+ end
+
+ def test_create_association
+ firm = Firm.create(name: "GlobalMegaCorp")
+ account = firm.create_account(credit_limit: 1000)
+ assert_equal account, firm.reload.account
+ end
+
+ def test_create_association_with_bang
+ firm = Firm.create(name: "GlobalMegaCorp")
+ account = firm.create_account!(credit_limit: 1000)
+ assert_equal account, firm.reload.account
+ end
+
+ def test_create_association_with_bang_failing
+ firm = Firm.create(name: "GlobalMegaCorp")
+ assert_raise ActiveRecord::RecordInvalid do
+ firm.create_account!
+ end
+ account = firm.account
+ assert_not_nil account
+ account.credit_limit = 5
+ account.save
+ assert_equal account, firm.reload.account
+ end
+
+ def test_create_with_inexistent_foreign_key_failing
+ firm = Firm.create(name: "GlobalMegaCorp")
+
+ assert_raises(ActiveRecord::UnknownAttributeError) do
+ firm.create_account_with_inexistent_foreign_key
+ end
+ end
+
+ def test_create_when_parent_is_new_raises
+ firm = Firm.new
+ error = assert_raise(ActiveRecord::RecordNotSaved) do
+ firm.create_account
+ end
+
+ assert_equal "You cannot call create unless the parent is saved", error.message
+ end
+
+ def test_reload_association
+ odegy = companies(:odegy)
+
+ assert_equal 53, odegy.account.credit_limit
+ Account.where(id: odegy.account.id).update_all(credit_limit: 80)
+ assert_equal 53, odegy.account.credit_limit
+
+ assert_equal 80, odegy.reload_account.credit_limit
+ end
+
+ def test_reload_association_with_query_cache
+ odegy_id = companies(:odegy).id
+
+ connection = ActiveRecord::Base.connection
+ connection.enable_query_cache!
+ connection.clear_query_cache
+
+ # Populate the cache with a query
+ odegy = Company.find(odegy_id)
+ # Populate the cache with a second query
+ odegy.account
+
+ assert_equal 2, connection.query_cache.size
+
+ # Clear the cache and fetch the account again, populating the cache with a query
+ assert_queries(1) { odegy.reload_account }
+
+ # This query is not cached anymore, so it should make a real SQL query
+ assert_queries(1) { Company.find(odegy_id) }
+ ensure
+ ActiveRecord::Base.connection.disable_query_cache!
+ end
+
+ def test_build
+ firm = Firm.new("name" => "GlobalMegaCorp")
+ firm.save
+
+ firm.account = account = Account.new("credit_limit" => 1000)
+ assert_equal account, firm.account
+ assert account.save
+ assert_equal account, firm.account
+ end
+
+ def test_create
+ firm = Firm.new("name" => "GlobalMegaCorp")
+ firm.save
+ firm.account = account = Account.create("credit_limit" => 1000)
+ assert_equal account, firm.account
+ end
+
+ def test_create_before_save
+ firm = Firm.new("name" => "GlobalMegaCorp")
+ firm.account = account = Account.create("credit_limit" => 1000)
+ assert_equal account, firm.account
+ end
+
+ def test_dependence_with_missing_association
+ Account.destroy_all
+ firm = Firm.find(1)
+ assert_nil firm.account
+ firm.destroy
+ end
+
+ def test_dependence_with_missing_association_and_nullify
+ Account.destroy_all
+ firm = DependentFirm.first
+ assert_nil firm.account
+ firm.destroy
+ end
+
+ def test_finding_with_interpolated_condition
+ firm = Firm.first
+ superior = firm.clients.create(name: "SuperiorCo")
+ superior.rating = 10
+ superior.save
+ assert_equal 10, firm.clients_with_interpolated_conditions.first.rating
+ end
+
+ def test_assignment_before_child_saved
+ firm = Firm.find(1)
+ firm.account = a = Account.new("credit_limit" => 1000)
+ assert_predicate a, :persisted?
+ assert_equal a, firm.account
+ assert_equal a, firm.account
+ firm.association(:account).reload
+ assert_equal a, firm.account
+ end
+
+ def test_save_still_works_after_accessing_nil_has_one
+ jp = Company.new name: "Jaded Pixel"
+ jp.dummy_account.nil?
+
+ assert_nothing_raised do
+ jp.save!
+ end
+ end
+
+ def test_cant_save_readonly_association
+ assert_raise(ActiveRecord::ReadOnlyRecord) { companies(:first_firm).readonly_account.save! }
+ assert_predicate companies(:first_firm).readonly_account, :readonly?
+ end
+
+ def test_has_one_proxy_should_not_respond_to_private_methods
+ assert_raise(NoMethodError) { accounts(:signals37).private_method }
+ assert_raise(NoMethodError) { companies(:first_firm).account.private_method }
+ end
+
+ def test_has_one_proxy_should_respond_to_private_methods_via_send
+ accounts(:signals37).send(:private_method)
+ companies(:first_firm).account.send(:private_method)
+ end
+
+ def test_save_of_record_with_loaded_has_one
+ @firm = companies(:first_firm)
+ assert_not_nil @firm.account
+
+ assert_nothing_raised do
+ Firm.find(@firm.id).save!
+ Firm.all.merge!(includes: :account).find(@firm.id).save!
+ end
+
+ @firm.account.destroy
+
+ assert_nothing_raised do
+ Firm.find(@firm.id).save!
+ Firm.all.merge!(includes: :account).find(@firm.id).save!
+ end
+ end
+
+ def test_build_respects_hash_condition
+ account = companies(:first_firm).build_account_limit_500_with_hash_conditions
+ assert account.save
+ assert_equal 500, account.credit_limit
+ end
+
+ def test_create_respects_hash_condition
+ account = companies(:first_firm).create_account_limit_500_with_hash_conditions
+ assert_predicate account, :persisted?
+ assert_equal 500, account.credit_limit
+ end
+
+ def test_attributes_are_being_set_when_initialized_from_has_one_association_with_where_clause
+ new_account = companies(:first_firm).build_account(firm_name: "Account")
+ assert_equal new_account.firm_name, "Account"
+ end
+
+ def test_creation_failure_without_dependent_option
+ pirate = pirates(:blackbeard)
+ orig_ship = pirate.ship
+
+ assert_equal ships(:black_pearl), orig_ship
+ new_ship = pirate.create_ship
+ assert_not_equal ships(:black_pearl), new_ship
+ assert_equal new_ship, pirate.ship
+ assert_predicate new_ship, :new_record?
+ assert_nil orig_ship.pirate_id
+ assert_not orig_ship.changed? # check it was saved
+ end
+
+ def test_creation_failure_with_dependent_option
+ pirate = pirates(:blackbeard).becomes(DestructivePirate)
+ orig_ship = pirate.dependent_ship
+
+ new_ship = pirate.create_dependent_ship
+ assert_predicate new_ship, :new_record?
+ assert_predicate orig_ship, :destroyed?
+ end
+
+ def test_creation_failure_due_to_new_record_should_raise_error
+ pirate = pirates(:redbeard)
+ new_ship = Ship.new
+
+ error = assert_raise(ActiveRecord::RecordNotSaved) do
+ pirate.ship = new_ship
+ end
+
+ assert_equal "Failed to save the new associated ship.", error.message
+ assert_nil pirate.ship
+ assert_nil new_ship.pirate_id
+ end
+
+ def test_replacement_failure_due_to_existing_record_should_raise_error
+ pirate = pirates(:blackbeard)
+ pirate.ship.name = nil
+
+ assert_not_predicate pirate.ship, :valid?
+ error = assert_raise(ActiveRecord::RecordNotSaved) do
+ pirate.ship = ships(:interceptor)
+ end
+
+ assert_equal ships(:black_pearl), pirate.ship
+ assert_equal pirate.id, pirate.ship.pirate_id
+ assert_equal "Failed to remove the existing associated ship. " \
+ "The record failed to save after its foreign key was set to nil.", error.message
+ end
+
+ def test_replacement_failure_due_to_new_record_should_raise_error
+ pirate = pirates(:blackbeard)
+ new_ship = Ship.new
+
+ error = assert_raise(ActiveRecord::RecordNotSaved) do
+ pirate.ship = new_ship
+ end
+
+ assert_equal "Failed to save the new associated ship.", error.message
+ assert_equal ships(:black_pearl), pirate.ship
+ assert_equal pirate.id, pirate.ship.pirate_id
+ assert_equal pirate.id, ships(:black_pearl).reload.pirate_id
+ assert_nil new_ship.pirate_id
+ end
+
+ def test_association_keys_bypass_attribute_protection
+ car = Car.create(name: "honda")
+
+ bulb = car.build_bulb
+ assert_equal car.id, bulb.car_id
+
+ bulb = car.build_bulb car_id: car.id + 1
+ assert_equal car.id, bulb.car_id
+
+ bulb = car.create_bulb
+ assert_equal car.id, bulb.car_id
+
+ bulb = car.create_bulb car_id: car.id + 1
+ assert_equal car.id, bulb.car_id
+ end
+
+ def test_association_protect_foreign_key
+ pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?")
+
+ ship = pirate.build_ship
+ assert_equal pirate.id, ship.pirate_id
+
+ ship = pirate.build_ship pirate_id: pirate.id + 1
+ assert_equal pirate.id, ship.pirate_id
+
+ ship = pirate.create_ship
+ assert_equal pirate.id, ship.pirate_id
+
+ ship = pirate.create_ship pirate_id: pirate.id + 1
+ assert_equal pirate.id, ship.pirate_id
+ end
+
+ def test_build_with_block
+ car = Car.create(name: "honda")
+
+ bulb = car.build_bulb { |b| b.color = "Red" }
+ assert_equal "RED!", bulb.color
+ end
+
+ def test_create_with_block
+ car = Car.create(name: "honda")
+
+ bulb = car.create_bulb { |b| b.color = "Red" }
+ assert_equal "RED!", bulb.color
+ end
+
+ def test_create_bang_with_block
+ car = Car.create(name: "honda")
+
+ bulb = car.create_bulb! { |b| b.color = "Red" }
+ assert_equal "RED!", bulb.color
+ end
+
+ def test_association_attributes_are_available_to_after_initialize
+ car = Car.create(name: "honda")
+ bulb = car.create_bulb
+
+ assert_equal car.id, bulb.attributes_after_initialize["car_id"]
+ end
+
+ def test_has_one_transaction
+ company = companies(:first_firm)
+ account = Account.find(1)
+
+ company.account # force loading
+ assert_no_queries { company.account = account }
+
+ company.account = nil
+ assert_no_queries { company.account = nil }
+ account = Account.find(2)
+ assert_queries { company.account = account }
+
+ assert_no_queries { Firm.new.account = account }
+ end
+
+ def test_has_one_assignment_dont_trigger_save_on_change_of_same_object
+ pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?")
+ ship = pirate.build_ship(name: "old name")
+ ship.save!
+
+ ship.name = "new name"
+ assert_predicate ship, :changed?
+ assert_queries(1) do
+ # One query for updating name, not triggering query for updating pirate_id
+ pirate.ship = ship
+ end
+
+ assert_equal "new name", pirate.ship.reload.name
+ end
+
+ def test_has_one_assignment_triggers_save_on_change_on_replacing_object
+ pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?")
+ ship = pirate.build_ship(name: "old name")
+ ship.save!
+
+ new_ship = Ship.create(name: "new name")
+ assert_queries(2) do
+ # One query to nullify the old ship, one query to update the new ship
+ pirate.ship = new_ship
+ end
+
+ assert_equal "new name", pirate.ship.reload.name
+ end
+
+ def test_has_one_autosave_with_primary_key_manually_set
+ post = Post.create(id: 1234, title: "Some title", body: "Some content")
+ author = Author.new(id: 33, name: "Hank Moody")
+
+ author.post = post
+ author.save
+ author.reload
+
+ assert_not_nil author.post
+ assert_equal author.post, post
+ end
+
+ def test_has_one_loading_for_new_record
+ post = Post.create!(author_id: 42, title: "foo", body: "bar")
+ author = Author.new(id: 42)
+ assert_equal post, author.post
+ end
+
+ def test_has_one_relationship_cannot_have_a_counter_cache
+ assert_raise(ArgumentError) do
+ Class.new(ActiveRecord::Base) do
+ has_one :thing, counter_cache: true
+ end
+ end
+ end
+
+ def test_with_polymorphic_has_one_with_custom_columns_name
+ post = Post.create! title: "foo", body: "bar"
+ image = Image.create!
+
+ post.main_image = image
+ post.reload
+
+ assert_equal image, post.main_image
+ 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
+ has_one name
+ end
+ end
+ end
+ end
+
+ class SpecialBook < ActiveRecord::Base
+ self.table_name = "books"
+ belongs_to :author, class_name: "SpecialAuthor"
+ has_one :subscription, class_name: "SpecialSupscription", foreign_key: "subscriber_id"
+
+ enum status: [:proposed, :written, :published]
+ end
+
+ class SpecialAuthor < ActiveRecord::Base
+ self.table_name = "authors"
+ has_one :book, class_name: "SpecialBook", foreign_key: "author_id"
+ end
+
+ class SpecialSupscription < ActiveRecord::Base
+ self.table_name = "subscriptions"
+ belongs_to :book, class_name: "SpecialBook"
+ end
+
+ def test_association_enum_works_properly
+ author = SpecialAuthor.create!(name: "Test")
+ book = SpecialBook.create!(status: "published")
+ author.book = book
+
+ assert_equal "published", book.status
+ assert_not_equal 0, SpecialAuthor.joins(:book).where(books: { status: "published" }).count
+ end
+
+ def test_association_enum_works_properly_with_nested_join
+ author = SpecialAuthor.create!(name: "Test")
+ book = SpecialBook.create!(status: "published")
+ author.book = book
+
+ where_clause = { books: { subscriptions: { subscriber_id: nil } } }
+ assert_nothing_raised do
+ SpecialAuthor.joins(book: :subscription).where.not(where_clause)
+ end
+ end
+
+ class DestroyByParentBook < ActiveRecord::Base
+ self.table_name = "books"
+ belongs_to :author, class_name: "DestroyByParentAuthor"
+ before_destroy :dont, unless: :destroyed_by_association
+
+ def dont
+ throw(:abort)
+ end
+ end
+
+ class DestroyByParentAuthor < ActiveRecord::Base
+ self.table_name = "authors"
+ has_one :book, class_name: "DestroyByParentBook", foreign_key: "author_id", dependent: :destroy
+ end
+
+ test "destroyed_by_association set in child destroy callback on parent destroy" do
+ author = DestroyByParentAuthor.create!(name: "Test")
+ book = DestroyByParentBook.create!(author: author)
+
+ author.destroy
+
+ assert_not DestroyByParentBook.exists?(book.id)
+ end
+
+ test "destroyed_by_association set in child destroy callback on replace" do
+ author = DestroyByParentAuthor.create!(name: "Test")
+ book = DestroyByParentBook.create!(author: author)
+
+ author.book = DestroyByParentBook.create!
+ author.save!
+
+ assert_not DestroyByParentBook.exists?(book.id)
+ end
+
+ class UndestroyableBook < ActiveRecord::Base
+ self.table_name = "books"
+ belongs_to :author, class_name: "DestroyableAuthor"
+ before_destroy :dont
+
+ def dont
+ throw(:abort)
+ end
+ end
+
+ class DestroyableAuthor < ActiveRecord::Base
+ self.table_name = "authors"
+ has_one :book, class_name: "UndestroyableBook", foreign_key: "author_id", dependent: :destroy
+ end
+
+ def test_dependency_should_halt_parent_destruction
+ author = DestroyableAuthor.create!(name: "Test")
+ UndestroyableBook.create!(author: author)
+
+ assert_no_difference ["DestroyableAuthor.count", "UndestroyableBook.count"] do
+ assert_not author.destroy
+ end
+ end
+end
diff --git a/activerecord/test/cases/associations/has_one_through_associations_test.rb b/activerecord/test/cases/associations/has_one_through_associations_test.rb
new file mode 100644
index 0000000000..69b4872519
--- /dev/null
+++ b/activerecord/test/cases/associations/has_one_through_associations_test.rb
@@ -0,0 +1,429 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/club"
+require "models/member_type"
+require "models/member"
+require "models/membership"
+require "models/sponsor"
+require "models/organization"
+require "models/member_detail"
+require "models/minivan"
+require "models/dashboard"
+require "models/speedometer"
+require "models/category"
+require "models/author"
+require "models/essay"
+require "models/owner"
+require "models/post"
+require "models/comment"
+require "models/categorization"
+require "models/customer"
+require "models/carrier"
+require "models/shop_account"
+require "models/customer_carrier"
+
+class HasOneThroughAssociationsTest < ActiveRecord::TestCase
+ fixtures :member_types, :members, :clubs, :memberships, :sponsors, :organizations, :minivans,
+ :dashboards, :speedometers, :authors, :author_addresses, :posts, :comments, :categories, :essays, :owners
+
+ def setup
+ @member = members(:groucho)
+ end
+
+ def test_has_one_through_with_has_one
+ assert_equal clubs(:boring_club), @member.club
+ end
+
+ def test_has_one_through_executes_limited_query
+ boring_club = clubs(:boring_club)
+ assert_sql(/LIMIT|ROWNUM <=|FETCH FIRST/) do
+ assert_equal boring_club, @member.general_club
+ end
+ end
+
+ def test_creating_association_creates_through_record
+ new_member = Member.create(name: "Chris")
+ new_member.club = Club.create(name: "LRUG")
+ assert_not_nil new_member.current_membership
+ assert_not_nil new_member.club
+ end
+
+ def test_creating_association_builds_through_record
+ new_member = Member.create(name: "Chris")
+ new_club = new_member.association(:club).build
+ assert new_member.current_membership
+ assert_equal new_club, new_member.club
+ assert_predicate new_club, :new_record?
+ assert_predicate new_member.current_membership, :new_record?
+ assert new_member.save
+ assert_predicate new_club, :persisted?
+ assert_predicate new_member.current_membership, :persisted?
+ end
+
+ def test_creating_association_builds_through_record_for_new
+ new_member = Member.new(name: "Jane")
+ new_member.club = clubs(:moustache_club)
+ assert new_member.current_membership
+ assert_equal clubs(:moustache_club), new_member.current_membership.club
+ assert_equal clubs(:moustache_club), new_member.club
+ assert new_member.save
+ assert_equal clubs(:moustache_club), new_member.club
+ end
+
+ def test_building_multiple_associations_builds_through_record
+ member_type = MemberType.create!
+ member = Member.create!
+ member_detail_with_one_association = MemberDetail.new(member_type: member_type)
+ assert_predicate member_detail_with_one_association.member, :new_record?
+ member_detail_with_two_associations = MemberDetail.new(member_type: member_type, admittable: member)
+ assert_predicate member_detail_with_two_associations.member, :new_record?
+ end
+
+ def test_creating_multiple_associations_creates_through_record
+ member_type = MemberType.create!
+ member = Member.create!
+ member_detail_with_one_association = MemberDetail.create!(member_type: member_type)
+ assert_not_predicate member_detail_with_one_association.member, :new_record?
+ member_detail_with_two_associations = MemberDetail.create!(member_type: member_type, admittable: member)
+ assert_not_predicate member_detail_with_two_associations.member, :new_record?
+ end
+
+ def test_creating_association_sets_both_parent_ids_for_new
+ member = Member.new(name: "Sean Griffin")
+ club = Club.new(name: "Da Club")
+
+ member.club = club
+
+ member.save!
+
+ assert member.id
+ assert club.id
+ assert_equal member.id, member.current_membership.member_id
+ assert_equal club.id, member.current_membership.club_id
+ end
+
+ def test_replace_target_record
+ new_club = Club.create(name: "Marx Bros")
+ @member.club = new_club
+ @member.reload
+ assert_equal new_club, @member.club
+ end
+
+ def test_replacing_target_record_deletes_old_association
+ assert_no_difference "Membership.count" do
+ new_club = Club.create(name: "Bananarama")
+ @member.club = new_club
+ @member.reload
+ end
+ end
+
+ def test_set_record_to_nil_should_delete_association
+ @member.club = nil
+ @member.reload
+ assert_nil @member.current_membership
+ assert_nil @member.club
+ end
+
+ def test_set_record_after_delete_association
+ @member.club = nil
+ @member.club = clubs(:moustache_club)
+ @member.reload
+ assert_equal clubs(:moustache_club), @member.club
+ end
+
+ def test_has_one_through_polymorphic
+ assert_equal clubs(:moustache_club), @member.sponsor_club
+ end
+
+ def test_has_one_through_eager_loading
+ members = assert_queries(3) do # base table, through table, clubs table
+ Member.all.merge!(includes: :club, where: ["name = ?", "Groucho Marx"]).to_a
+ end
+ assert_equal 1, members.size
+ assert_not_nil assert_no_queries { members[0].club }
+ end
+
+ def test_has_one_through_eager_loading_through_polymorphic
+ members = assert_queries(3) do # base table, through table, clubs table
+ Member.all.merge!(includes: :sponsor_club, where: ["name = ?", "Groucho Marx"]).to_a
+ end
+ assert_equal 1, members.size
+ assert_not_nil assert_no_queries { members[0].sponsor_club }
+ end
+
+ def test_has_one_through_with_conditions_eager_loading
+ # conditions on the through table
+ assert_equal clubs(:moustache_club), Member.all.merge!(includes: :favourite_club).find(@member.id).favourite_club
+ memberships(:membership_of_favourite_club).update_columns(favourite: false)
+ assert_nil Member.all.merge!(includes: :favourite_club).find(@member.id).reload.favourite_club
+
+ # conditions on the source table
+ assert_equal clubs(:moustache_club), Member.all.merge!(includes: :hairy_club).find(@member.id).hairy_club
+ clubs(:moustache_club).update_columns(name: "Association of Clean-Shaven Persons")
+ assert_nil Member.all.merge!(includes: :hairy_club).find(@member.id).reload.hairy_club
+ end
+
+ def test_has_one_through_polymorphic_with_source_type
+ assert_equal members(:groucho), clubs(:moustache_club).sponsored_member
+ end
+
+ def test_eager_has_one_through_polymorphic_with_source_type
+ clubs = Club.all.merge!(includes: :sponsored_member, where: ["name = ?", "Moustache and Eyebrow Fancier Club"]).to_a
+ # Only the eyebrow fanciers club has a sponsored_member
+ assert_not_nil assert_no_queries { clubs[0].sponsored_member }
+ end
+
+ def test_has_one_through_nonpreload_eagerloading
+ members = assert_queries(1) do
+ Member.all.merge!(includes: :club, where: ["members.name = ?", "Groucho Marx"], order: "clubs.name").to_a # force fallback
+ end
+ assert_equal 1, members.size
+ assert_not_nil assert_no_queries { members[0].club }
+ end
+
+ def test_has_one_through_nonpreload_eager_loading_through_polymorphic
+ members = assert_queries(1) do
+ Member.all.merge!(includes: :sponsor_club, where: ["members.name = ?", "Groucho Marx"], order: "clubs.name").to_a # force fallback
+ end
+ assert_equal 1, members.size
+ assert_not_nil assert_no_queries { members[0].sponsor_club }
+ end
+
+ def test_has_one_through_nonpreload_eager_loading_through_polymorphic_with_more_than_one_through_record
+ Sponsor.new(sponsor_club: clubs(:crazy_club), sponsorable: members(:groucho)).save!
+ members = assert_queries(1) do
+ Member.all.merge!(includes: :sponsor_club, where: ["members.name = ?", "Groucho Marx"], order: "clubs.name DESC").to_a # force fallback
+ end
+ assert_equal 1, members.size
+ assert_not_nil assert_no_queries { members[0].sponsor_club }
+ assert_equal clubs(:crazy_club), members[0].sponsor_club
+ end
+
+ def test_uninitialized_has_one_through_should_return_nil_for_unsaved_record
+ assert_nil Member.new.club
+ end
+
+ def test_assigning_association_correctly_assigns_target
+ new_member = Member.create(name: "Chris")
+ new_member.club = new_club = Club.create(name: "LRUG")
+ assert_equal new_club, new_member.association(:club).target
+ end
+
+ def test_has_one_through_proxy_should_not_respond_to_private_methods
+ assert_raise(NoMethodError) { clubs(:moustache_club).private_method }
+ assert_raise(NoMethodError) { @member.club.private_method }
+ end
+
+ def test_has_one_through_proxy_should_respond_to_private_methods_via_send
+ clubs(:moustache_club).send(:private_method)
+ @member.club.send(:private_method)
+ end
+
+ def test_assigning_to_has_one_through_preserves_decorated_join_record
+ @organization = organizations(:nsa)
+ assert_difference "MemberDetail.count", 1 do
+ @member_detail = MemberDetail.new(extra_data: "Extra")
+ @member.member_detail = @member_detail
+ @member.organization = @organization
+ end
+ assert_equal @organization, @member.organization
+ assert_includes @organization.members, @member
+ assert_equal "Extra", @member.member_detail.extra_data
+ end
+
+ def test_reassigning_has_one_through
+ @organization = organizations(:nsa)
+ @new_organization = organizations(:discordians)
+
+ assert_difference "MemberDetail.count", 1 do
+ @member_detail = MemberDetail.new(extra_data: "Extra")
+ @member.member_detail = @member_detail
+ @member.organization = @organization
+ end
+ assert_equal @organization, @member.organization
+ assert_equal "Extra", @member.member_detail.extra_data
+ assert_includes @organization.members, @member
+ assert_not_includes @new_organization.members, @member
+
+ assert_no_difference "MemberDetail.count" do
+ @member.organization = @new_organization
+ end
+ assert_equal @new_organization, @member.organization
+ assert_equal "Extra", @member.member_detail.extra_data
+ assert_not_includes @organization.members, @member
+ assert_includes @new_organization.members, @member
+ end
+
+ def test_preloading_has_one_through_on_belongs_to
+ MemberDetail.delete_all
+ assert_not_nil @member.member_type
+ @organization = organizations(:nsa)
+ @member_detail = MemberDetail.new
+ @member.member_detail = @member_detail
+ @member.organization = @organization
+ @member_details = assert_queries(3) do
+ MemberDetail.all.merge!(includes: :member_type).to_a
+ end
+ @new_detail = @member_details[0]
+ assert_predicate @new_detail.send(:association, :member_type), :loaded?
+ assert_no_queries { @new_detail.member_type }
+ end
+
+ def test_save_of_record_with_loaded_has_one_through
+ @club = @member.club
+ assert_not_nil @club.sponsored_member
+
+ assert_nothing_raised do
+ Club.find(@club.id).save!
+ Club.all.merge!(includes: :sponsored_member).find(@club.id).save!
+ end
+
+ @club.sponsor.destroy
+
+ assert_nothing_raised do
+ Club.find(@club.id).save!
+ Club.all.merge!(includes: :sponsored_member).find(@club.id).save!
+ end
+ end
+
+ def test_through_belongs_to_after_destroy
+ @member_detail = MemberDetail.new(extra_data: "Extra")
+ @member.member_detail = @member_detail
+ @member.save!
+
+ assert_not_nil @member_detail.member_type
+ @member_detail.destroy
+ assert_queries(1) do
+ @member_detail.association(:member_type).reload
+ assert_not_nil @member_detail.member_type
+ end
+
+ @member_detail.member.destroy
+ assert_queries(1) do
+ @member_detail.association(:member_type).reload
+ assert_nil @member_detail.member_type
+ end
+ end
+
+ def test_value_is_properly_quoted
+ minivan = Minivan.find("m1")
+ assert_nothing_raised do
+ minivan.dashboard
+ end
+ end
+
+ def test_has_one_through_polymorphic_with_primary_key_option
+ assert_equal categories(:general), authors(:david).essay_category
+
+ authors = Author.joins(:essay_category).where("categories.id" => categories(:general).id)
+ assert_equal authors(:david), authors.first
+
+ assert_equal owners(:blackbeard), authors(:david).essay_owner
+
+ authors = Author.joins(:essay_owner).where("owners.name = 'blackbeard'")
+ assert_equal authors(:david), authors.first
+ end
+
+ def test_has_one_through_with_primary_key_option
+ assert_equal categories(:general), authors(:david).essay_category_2
+
+ authors = Author.joins(:essay_category_2).where("categories.id" => categories(:general).id)
+ assert_equal authors(:david), authors.first
+ end
+
+ def test_has_one_through_with_default_scope_on_join_model
+ assert_equal posts(:welcome).comments.order("id").first, authors(:david).comment_on_first_post
+ end
+
+ def test_has_one_through_many_raises_exception
+ assert_raise(ActiveRecord::HasOneThroughCantAssociateThroughCollection) do
+ members(:groucho).club_through_many
+ end
+ end
+
+ def test_has_one_through_polymorphic_association
+ assert_raise(ActiveRecord::HasOneAssociationPolymorphicThroughError) do
+ @member.premium_club
+ end
+ end
+
+ def test_has_one_through_belongs_to_should_update_when_the_through_foreign_key_changes
+ minivan = minivans(:cool_first)
+
+ minivan.dashboard
+ proxy = minivan.send(:association_instance_get, :dashboard)
+
+ assert_not_predicate proxy, :stale_target?
+ assert_equal dashboards(:cool_first), minivan.dashboard
+
+ minivan.speedometer_id = speedometers(:second).id
+
+ assert_predicate proxy, :stale_target?
+ assert_equal dashboards(:second), minivan.dashboard
+ end
+
+ def test_has_one_through_belongs_to_setting_belongs_to_foreign_key_after_nil_target_loaded
+ minivan = Minivan.new
+
+ minivan.dashboard
+ proxy = minivan.send(:association_instance_get, :dashboard)
+
+ minivan.speedometer_id = speedometers(:second).id
+
+ assert_predicate proxy, :stale_target?
+ assert_equal dashboards(:second), minivan.dashboard
+ end
+
+ def test_assigning_has_one_through_belongs_to_with_new_record_owner
+ minivan = Minivan.new
+ dashboard = dashboards(:cool_first)
+
+ minivan.dashboard = dashboard
+
+ assert_equal dashboard, minivan.dashboard
+ assert_equal dashboard, minivan.speedometer.dashboard
+ end
+
+ def test_has_one_through_with_custom_select_on_join_model_default_scope
+ assert_equal clubs(:boring_club), members(:groucho).selected_club
+ end
+
+ def test_has_one_through_relationship_cannot_have_a_counter_cache
+ assert_raise(ArgumentError) do
+ Class.new(ActiveRecord::Base) do
+ has_one :thing, through: :other_thing, counter_cache: true
+ end
+ end
+ end
+
+ def test_has_one_through_do_not_cache_association_reader_if_the_though_method_has_default_scopes
+ customer = Customer.create!
+ carrier = Carrier.create!
+ customer_carrier = CustomerCarrier.create!(
+ customer: customer,
+ carrier: carrier,
+ )
+ account = ShopAccount.create!(customer_carrier: customer_carrier)
+
+ CustomerCarrier.current_customer = customer
+
+ account_carrier = account.carrier
+ assert_equal carrier, account_carrier
+
+ CustomerCarrier.current_customer = nil
+
+ other_carrier = Carrier.create!
+ other_customer = Customer.create!
+ other_customer_carrier = CustomerCarrier.create!(
+ customer: other_customer,
+ carrier: other_carrier,
+ )
+ other_account = ShopAccount.create!(customer_carrier: other_customer_carrier)
+
+ account_carrier = other_account.carrier
+ assert_equal other_carrier, account_carrier
+ ensure
+ CustomerCarrier.current_customer = nil
+ end
+end
diff --git a/activerecord/test/cases/associations/inner_join_association_test.rb b/activerecord/test/cases/associations/inner_join_association_test.rb
new file mode 100644
index 0000000000..c33dcdee61
--- /dev/null
+++ b/activerecord/test/cases/associations/inner_join_association_test.rb
@@ -0,0 +1,159 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+require "models/comment"
+require "models/author"
+require "models/essay"
+require "models/category"
+require "models/categorization"
+require "models/person"
+require "models/tagging"
+require "models/tag"
+
+class InnerJoinAssociationTest < ActiveRecord::TestCase
+ fixtures :authors, :author_addresses, :essays, :posts, :comments, :categories, :categories_posts, :categorizations,
+ :taggings, :tags
+
+ def test_construct_finder_sql_applies_aliases_tables_on_association_conditions
+ result = Author.joins(:thinking_posts, :welcome_posts).to_a
+ assert_equal authors(:david), result.first
+ end
+
+ def test_construct_finder_sql_does_not_table_name_collide_on_duplicate_associations
+ assert_nothing_raised do
+ sql = Person.joins(agents: { agents: :agents }).joins(agents: { agents: { primary_contact: :agents } }).to_sql
+ assert_match(/agents_people_4/i, sql)
+ end
+ end
+
+ def test_construct_finder_sql_does_not_table_name_collide_on_duplicate_associations_with_left_outer_joins
+ sql = Person.joins(agents: :agents).left_outer_joins(agents: :agents).to_sql
+ assert_match(/agents_people_4/i, sql)
+ end
+
+ def test_construct_finder_sql_does_not_table_name_collide_with_string_joins
+ sql = Person.joins(:agents).joins("JOIN people agents_people ON agents_people.primary_contact_id = people.id").to_sql
+ assert_match(/agents_people_2/i, sql)
+ end
+
+ def test_construct_finder_sql_does_not_table_name_collide_with_aliased_joins
+ people = Person.arel_table
+ agents = people.alias("agents_people")
+ constraint = agents[:primary_contact_id].eq(people[:id])
+ sql = Person.joins(:agents).joins(agents.create_join(agents, agents.create_on(constraint))).to_sql
+ assert_match(/agents_people_2/i, sql)
+ end
+
+ def test_construct_finder_sql_ignores_empty_joins_hash
+ sql = Author.joins({}).to_sql
+ assert_no_match(/JOIN/i, sql)
+ end
+
+ def test_construct_finder_sql_ignores_empty_joins_array
+ sql = Author.joins([]).to_sql
+ assert_no_match(/JOIN/i, sql)
+ end
+
+ def test_join_conditions_added_to_join_clause
+ sql = Author.joins(:essays).to_sql
+ assert_match(/writer_type.*?=.*?Author/i, sql)
+ assert_no_match(/WHERE/i, sql)
+ end
+
+ def test_join_association_conditions_support_string_and_arel_expressions
+ assert_equal 0, Author.joins(:welcome_posts_with_one_comment).count
+ assert_equal 1, Author.joins(:welcome_posts_with_comments).count
+ end
+
+ def test_join_conditions_allow_nil_associations
+ authors = Author.includes(:essays).where(essays: { id: nil })
+ assert_equal 2, authors.count
+ end
+
+ def test_find_with_implicit_inner_joins_without_select_does_not_imply_readonly
+ authors = Author.joins(:posts)
+ assert_not authors.empty?, "expected authors to be non-empty"
+ assert authors.none?(&:readonly?), "expected no authors to be readonly"
+ end
+
+ def test_find_with_implicit_inner_joins_honors_readonly_with_select
+ authors = Author.joins(:posts).select("authors.*").to_a
+ assert_not authors.empty?, "expected authors to be non-empty"
+ assert authors.all? { |a| !a.readonly? }, "expected no authors to be readonly"
+ end
+
+ def test_find_with_implicit_inner_joins_honors_readonly_false
+ authors = Author.joins(:posts).readonly(false).to_a
+ assert_not authors.empty?, "expected authors to be non-empty"
+ assert authors.all? { |a| !a.readonly? }, "expected no authors to be readonly"
+ end
+
+ def test_find_with_implicit_inner_joins_does_not_set_associations
+ authors = Author.joins(:posts).select("authors.*").to_a
+ assert_not authors.empty?, "expected authors to be non-empty"
+ assert authors.all? { |a| !a.instance_variable_defined?(:@posts) }, "expected no authors to have the @posts association loaded"
+ end
+
+ def test_count_honors_implicit_inner_joins
+ real_count = Author.all.to_a.sum { |a| a.posts.count }
+ assert_equal real_count, Author.joins(:posts).count, "plain inner join count should match the number of referenced posts records"
+ end
+
+ def test_calculate_honors_implicit_inner_joins
+ real_count = Author.all.to_a.sum { |a| a.posts.count }
+ assert_equal real_count, Author.joins(:posts).calculate(:count, "authors.id"), "plain inner join count should match the number of referenced posts records"
+ end
+
+ def test_calculate_honors_implicit_inner_joins_and_distinct_and_conditions
+ real_count = Author.all.to_a.select { |a| a.posts.any? { |p| p.title.start_with?("Welcome") } }.length
+ authors_with_welcoming_post_titles = Author.all.merge!(joins: :posts, where: "posts.title like 'Welcome%'").distinct.calculate(:count, "authors.id")
+ assert_equal real_count, authors_with_welcoming_post_titles, "inner join and conditions should have only returned authors posting titles starting with 'Welcome'"
+ end
+
+ def test_find_with_sti_join
+ scope = Post.joins(:special_comments).where(id: posts(:sti_comments).id)
+
+ # The join should match SpecialComment and its subclasses only
+ assert_empty scope.where("comments.type" => "Comment")
+ assert_not_empty scope.where("comments.type" => "SpecialComment")
+ assert_not_empty scope.where("comments.type" => "SubSpecialComment")
+ end
+
+ def test_find_with_conditions_on_reflection
+ assert_not_empty posts(:welcome).comments
+ assert Post.joins(:nonexistent_comments).where(id: posts(:welcome).id).empty? # [sic!]
+ end
+
+ def test_find_with_conditions_on_through_reflection
+ assert_not_empty posts(:welcome).tags
+ assert_empty Post.joins(:misc_tags).where(id: posts(:welcome).id)
+ end
+
+ test "the default scope of the target is applied when joining associations" do
+ author = Author.create! name: "Jon"
+ author.categorizations.create!
+ author.categorizations.create! special: true
+
+ assert_equal [author], Author.where(id: author).joins(:special_categorizations)
+ end
+
+ test "the default scope of the target is correctly aliased when joining associations" do
+ author = Author.create! name: "Jon"
+ author.categories.create! name: "Not Special"
+ author.special_categories.create! name: "Special"
+
+ categories = author.categories.includes(:special_categorizations).references(:special_categorizations).to_a
+ assert_equal 2, categories.size
+ end
+
+ test "the correct records are loaded when including an aliased association" do
+ author = Author.create! name: "Jon"
+ author.categories.create! name: "Not Special"
+ author.special_categories.create! name: "Special"
+
+ categories = author.categories.eager_load(:special_categorizations).order(:name).to_a
+ assert_equal 0, categories.first.special_categorizations.size
+ assert_equal 1, categories.second.special_categorizations.size
+ end
+end
diff --git a/activerecord/test/cases/associations/inverse_associations_test.rb b/activerecord/test/cases/associations/inverse_associations_test.rb
new file mode 100644
index 0000000000..eb4dc73423
--- /dev/null
+++ b/activerecord/test/cases/associations/inverse_associations_test.rb
@@ -0,0 +1,762 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/man"
+require "models/face"
+require "models/interest"
+require "models/zine"
+require "models/club"
+require "models/sponsor"
+require "models/rating"
+require "models/comment"
+require "models/car"
+require "models/bulb"
+require "models/mixed_case_monkey"
+require "models/admin"
+require "models/admin/account"
+require "models/admin/user"
+require "models/developer"
+require "models/company"
+require "models/project"
+require "models/author"
+require "models/post"
+require "models/department"
+require "models/hotel"
+
+class AutomaticInverseFindingTests < ActiveRecord::TestCase
+ fixtures :ratings, :comments, :cars
+
+ def test_has_one_and_belongs_to_should_find_inverse_automatically_on_multiple_word_name
+ monkey_reflection = MixedCaseMonkey.reflect_on_association(:man)
+ man_reflection = Man.reflect_on_association(:mixed_case_monkey)
+
+ assert monkey_reflection.has_inverse?, "The monkey reflection should have an inverse"
+ assert_equal man_reflection, monkey_reflection.inverse_of, "The monkey reflection's inverse should be the man reflection"
+
+ assert man_reflection.has_inverse?, "The man reflection should have an inverse"
+ assert_equal monkey_reflection, man_reflection.inverse_of, "The man reflection's inverse should be the monkey reflection"
+ end
+
+ def test_has_many_and_belongs_to_should_find_inverse_automatically_for_model_in_module
+ account_reflection = Admin::Account.reflect_on_association(:users)
+ user_reflection = Admin::User.reflect_on_association(:account)
+
+ assert account_reflection.has_inverse?, "The Admin::Account reflection should have an inverse"
+ assert_equal user_reflection, account_reflection.inverse_of, "The Admin::Account reflection's inverse should be the Admin::User reflection"
+ end
+
+ def test_has_one_and_belongs_to_should_find_inverse_automatically
+ car_reflection = Car.reflect_on_association(:bulb)
+ bulb_reflection = Bulb.reflect_on_association(:car)
+
+ assert car_reflection.has_inverse?, "The Car reflection should have an inverse"
+ assert_equal bulb_reflection, car_reflection.inverse_of, "The Car reflection's inverse should be the Bulb reflection"
+
+ assert bulb_reflection.has_inverse?, "The Bulb reflection should have an inverse"
+ assert_equal car_reflection, bulb_reflection.inverse_of, "The Bulb reflection's inverse should be the Car reflection"
+ end
+
+ def test_has_many_and_belongs_to_should_find_inverse_automatically
+ comment_reflection = Comment.reflect_on_association(:ratings)
+ rating_reflection = Rating.reflect_on_association(:comment)
+
+ assert comment_reflection.has_inverse?, "The Comment reflection should have an inverse"
+ assert_equal rating_reflection, comment_reflection.inverse_of, "The Comment reflection's inverse should be the Rating reflection"
+ end
+
+ def test_has_many_and_belongs_to_should_find_inverse_automatically_for_sti
+ author_reflection = Author.reflect_on_association(:posts)
+ author_child_reflection = Author.reflect_on_association(:special_posts)
+ post_reflection = Post.reflect_on_association(:author)
+
+ assert_respond_to author_reflection, :has_inverse?
+ assert author_reflection.has_inverse?, "The Author reflection should have an inverse"
+ assert_equal post_reflection, author_reflection.inverse_of, "The Author reflection's inverse should be the Post reflection"
+
+ assert_respond_to author_child_reflection, :has_inverse?
+ assert author_child_reflection.has_inverse?, "The Author reflection should have an inverse"
+ assert_equal post_reflection, author_child_reflection.inverse_of, "The Author reflection's inverse should be the Post reflection"
+ end
+
+ def test_has_one_and_belongs_to_automatic_inverse_shares_objects
+ car = Car.first
+ bulb = Bulb.create!(car: car)
+
+ assert_equal car.bulb, bulb, "The Car's bulb should be the original bulb"
+
+ car.bulb.color = "Blue"
+ assert_equal car.bulb.color, bulb.color, "Changing the bulb's color on the car association should change the bulb's color"
+
+ bulb.color = "Red"
+ assert_equal bulb.color, car.bulb.color, "Changing the bulb's color should change the bulb's color on the car association"
+ end
+
+ def test_has_many_and_belongs_to_automatic_inverse_shares_objects_on_rating
+ comment = Comment.first
+ rating = Rating.create!(comment: comment)
+
+ assert_equal rating.comment, comment, "The Rating's comment should be the original Comment"
+
+ rating.comment.body = "Fennec foxes are the smallest of the foxes."
+ assert_equal rating.comment.body, comment.body, "Changing the Comment's body on the association should change the original Comment's body"
+
+ comment.body = "Kittens are adorable."
+ assert_equal comment.body, rating.comment.body, "Changing the original Comment's body should change the Comment's body on the association"
+ end
+
+ def test_has_many_and_belongs_to_automatic_inverse_shares_objects_on_comment
+ rating = Rating.create!
+ comment = Comment.first
+ rating.comment = comment
+
+ assert_equal rating.comment, comment, "The Rating's comment should be the original Comment"
+
+ rating.comment.body = "Fennec foxes are the smallest of the foxes."
+ assert_equal rating.comment.body, comment.body, "Changing the Comment's body on the association should change the original Comment's body"
+
+ comment.body = "Kittens are adorable."
+ assert_equal comment.body, rating.comment.body, "Changing the original Comment's body should change the Comment's body on the association"
+ end
+
+ def test_polymorphic_and_has_many_through_relationships_should_not_have_inverses
+ sponsor_reflection = Sponsor.reflect_on_association(:sponsorable)
+
+ assert_not sponsor_reflection.has_inverse?, "A polymorphic association should not find an inverse automatically"
+
+ club_reflection = Club.reflect_on_association(:members)
+
+ assert_not club_reflection.has_inverse?, "A has_many_through association should not find an inverse automatically"
+ end
+
+ def test_polymorphic_has_one_should_find_inverse_automatically
+ man_reflection = Man.reflect_on_association(:polymorphic_face_without_inverse)
+
+ assert_predicate man_reflection, :has_inverse?
+ end
+end
+
+class InverseAssociationTests < ActiveRecord::TestCase
+ def test_should_allow_for_inverse_of_options_in_associations
+ assert_nothing_raised do
+ Class.new(ActiveRecord::Base).has_many(:wheels, inverse_of: :car)
+ end
+
+ assert_nothing_raised do
+ Class.new(ActiveRecord::Base).has_one(:engine, inverse_of: :car)
+ end
+
+ assert_nothing_raised do
+ Class.new(ActiveRecord::Base).belongs_to(:car, inverse_of: :driver)
+ end
+ end
+
+ def test_should_be_able_to_ask_a_reflection_if_it_has_an_inverse
+ has_one_with_inverse_ref = Man.reflect_on_association(:face)
+ assert_predicate has_one_with_inverse_ref, :has_inverse?
+
+ has_many_with_inverse_ref = Man.reflect_on_association(:interests)
+ assert_predicate has_many_with_inverse_ref, :has_inverse?
+
+ belongs_to_with_inverse_ref = Face.reflect_on_association(:man)
+ assert_predicate belongs_to_with_inverse_ref, :has_inverse?
+
+ has_one_without_inverse_ref = Club.reflect_on_association(:sponsor)
+ assert_not_predicate has_one_without_inverse_ref, :has_inverse?
+
+ has_many_without_inverse_ref = Club.reflect_on_association(:memberships)
+ assert_not_predicate has_many_without_inverse_ref, :has_inverse?
+
+ belongs_to_without_inverse_ref = Sponsor.reflect_on_association(:sponsor_club)
+ assert_not_predicate belongs_to_without_inverse_ref, :has_inverse?
+ end
+
+ def test_inverse_of_method_should_supply_the_actual_reflection_instance_it_is_the_inverse_of
+ has_one_ref = Man.reflect_on_association(:face)
+ assert_equal Face.reflect_on_association(:man), has_one_ref.inverse_of
+
+ has_many_ref = Man.reflect_on_association(:interests)
+ assert_equal Interest.reflect_on_association(:man), has_many_ref.inverse_of
+
+ belongs_to_ref = Face.reflect_on_association(:man)
+ assert_equal Man.reflect_on_association(:face), belongs_to_ref.inverse_of
+ end
+
+ def test_associations_with_no_inverse_of_should_return_nil
+ has_one_ref = Club.reflect_on_association(:sponsor)
+ assert_nil has_one_ref.inverse_of
+
+ has_many_ref = Club.reflect_on_association(:memberships)
+ assert_nil has_many_ref.inverse_of
+
+ belongs_to_ref = Sponsor.reflect_on_association(:sponsor_club)
+ assert_nil belongs_to_ref.inverse_of
+ end
+
+ def test_polymorphic_associations_dont_attempt_to_find_inverse_of
+ belongs_to_ref = Sponsor.reflect_on_association(:sponsor)
+ assert_raise(ArgumentError) { belongs_to_ref.klass }
+ assert_nil belongs_to_ref.inverse_of
+
+ belongs_to_ref = Face.reflect_on_association(:human)
+ assert_raise(ArgumentError) { belongs_to_ref.klass }
+ assert_nil belongs_to_ref.inverse_of
+ end
+
+ def test_this_inverse_stuff
+ firm = Firm.create!(name: "Adequate Holdings")
+ Project.create!(name: "Project 1", firm: firm)
+ Developer.create!(name: "Gorbypuff", firm: firm)
+
+ new_project = Project.last
+ assert Project.reflect_on_association(:lead_developer).inverse_of.present?, "Expected inverse of to be present"
+ assert new_project.lead_developer.present?, "Expected lead developer to be present on the project"
+ end
+end
+
+class InverseHasOneTests < ActiveRecord::TestCase
+ fixtures :men, :faces
+
+ def test_parent_instance_should_be_shared_with_child_on_find
+ m = men(:gordon)
+ f = m.face
+ assert_equal m.name, f.man.name, "Name of man should be the same before changes to parent instance"
+ m.name = "Bongo"
+ assert_equal m.name, f.man.name, "Name of man should be the same after changes to parent instance"
+ f.man.name = "Mungo"
+ assert_equal m.name, f.man.name, "Name of man should be the same after changes to child-owned instance"
+ end
+
+ def test_parent_instance_should_be_shared_with_eager_loaded_child_on_find
+ m = Man.all.merge!(where: { name: "Gordon" }, includes: :face).first
+ f = m.face
+ assert_equal m.name, f.man.name, "Name of man should be the same before changes to parent instance"
+ m.name = "Bongo"
+ assert_equal m.name, f.man.name, "Name of man should be the same after changes to parent instance"
+ f.man.name = "Mungo"
+ assert_equal m.name, f.man.name, "Name of man should be the same after changes to child-owned instance"
+
+ m = Man.all.merge!(where: { name: "Gordon" }, includes: :face, order: "faces.id").first
+ f = m.face
+ assert_equal m.name, f.man.name, "Name of man should be the same before changes to parent instance"
+ m.name = "Bongo"
+ assert_equal m.name, f.man.name, "Name of man should be the same after changes to parent instance"
+ f.man.name = "Mungo"
+ assert_equal m.name, f.man.name, "Name of man should be the same after changes to child-owned instance"
+ end
+
+ def test_parent_instance_should_be_shared_with_newly_built_child
+ m = Man.first
+ f = m.build_face(description: "haunted")
+ assert_not_nil f.man
+ assert_equal m.name, f.man.name, "Name of man should be the same before changes to parent instance"
+ m.name = "Bongo"
+ assert_equal m.name, f.man.name, "Name of man should be the same after changes to parent instance"
+ f.man.name = "Mungo"
+ assert_equal m.name, f.man.name, "Name of man should be the same after changes to just-built-child-owned instance"
+ end
+
+ def test_parent_instance_should_be_shared_with_newly_created_child
+ m = Man.first
+ f = m.create_face(description: "haunted")
+ assert_not_nil f.man
+ assert_equal m.name, f.man.name, "Name of man should be the same before changes to parent instance"
+ m.name = "Bongo"
+ assert_equal m.name, f.man.name, "Name of man should be the same after changes to parent instance"
+ f.man.name = "Mungo"
+ assert_equal m.name, f.man.name, "Name of man should be the same after changes to newly-created-child-owned instance"
+ end
+
+ def test_parent_instance_should_be_shared_with_newly_created_child_via_bang_method
+ m = Man.first
+ f = m.create_face!(description: "haunted")
+ assert_not_nil f.man
+ assert_equal m.name, f.man.name, "Name of man should be the same before changes to parent instance"
+ m.name = "Bongo"
+ assert_equal m.name, f.man.name, "Name of man should be the same after changes to parent instance"
+ f.man.name = "Mungo"
+ assert_equal m.name, f.man.name, "Name of man should be the same after changes to newly-created-child-owned instance"
+ end
+
+ def test_parent_instance_should_be_shared_with_replaced_via_accessor_child
+ m = Man.first
+ f = Face.new(description: "haunted")
+ m.face = f
+ assert_not_nil f.man
+ assert_equal m.name, f.man.name, "Name of man should be the same before changes to parent instance"
+ m.name = "Bongo"
+ assert_equal m.name, f.man.name, "Name of man should be the same after changes to parent instance"
+ f.man.name = "Mungo"
+ assert_equal m.name, f.man.name, "Name of man should be the same after changes to replaced-child-owned instance"
+ end
+
+ def test_trying_to_use_inverses_that_dont_exist_should_raise_an_error
+ assert_raise(ActiveRecord::InverseOfAssociationNotFoundError) { Man.first.dirty_face }
+ end
+end
+
+class InverseHasManyTests < ActiveRecord::TestCase
+ fixtures :men, :interests, :posts, :authors, :author_addresses
+
+ def test_parent_instance_should_be_shared_with_every_child_on_find
+ m = men(:gordon)
+ is = m.interests
+ is.each do |i|
+ assert_equal m.name, i.man.name, "Name of man should be the same before changes to parent instance"
+ m.name = "Bongo"
+ assert_equal m.name, i.man.name, "Name of man should be the same after changes to parent instance"
+ i.man.name = "Mungo"
+ assert_equal m.name, i.man.name, "Name of man should be the same after changes to child-owned instance"
+ end
+ end
+
+ def test_parent_instance_should_be_shared_with_every_child_on_find_for_sti
+ a = authors(:david)
+ ps = a.posts
+ ps.each do |p|
+ assert_equal a.name, p.author.name, "Name of man should be the same before changes to parent instance"
+ a.name = "Bongo"
+ assert_equal a.name, p.author.name, "Name of man should be the same after changes to parent instance"
+ p.author.name = "Mungo"
+ assert_equal a.name, p.author.name, "Name of man should be the same after changes to child-owned instance"
+ end
+
+ sps = a.special_posts
+ sps.each do |sp|
+ assert_equal a.name, sp.author.name, "Name of man should be the same before changes to parent instance"
+ a.name = "Bongo"
+ assert_equal a.name, sp.author.name, "Name of man should be the same after changes to parent instance"
+ sp.author.name = "Mungo"
+ assert_equal a.name, sp.author.name, "Name of man should be the same after changes to child-owned instance"
+ end
+ end
+
+ def test_parent_instance_should_be_shared_with_eager_loaded_children
+ m = Man.all.merge!(where: { name: "Gordon" }, includes: :interests).first
+ is = m.interests
+ is.each do |i|
+ assert_equal m.name, i.man.name, "Name of man should be the same before changes to parent instance"
+ m.name = "Bongo"
+ assert_equal m.name, i.man.name, "Name of man should be the same after changes to parent instance"
+ i.man.name = "Mungo"
+ assert_equal m.name, i.man.name, "Name of man should be the same after changes to child-owned instance"
+ end
+
+ m = Man.all.merge!(where: { name: "Gordon" }, includes: :interests, order: "interests.id").first
+ is = m.interests
+ is.each do |i|
+ assert_equal m.name, i.man.name, "Name of man should be the same before changes to parent instance"
+ m.name = "Bongo"
+ assert_equal m.name, i.man.name, "Name of man should be the same after changes to parent instance"
+ i.man.name = "Mungo"
+ assert_equal m.name, i.man.name, "Name of man should be the same after changes to child-owned instance"
+ end
+ end
+
+ def test_parent_instance_should_be_shared_with_newly_block_style_built_child
+ m = Man.first
+ i = m.interests.build { |ii| ii.topic = "Industrial Revolution Re-enactment" }
+ assert_not_nil i.topic, "Child attributes supplied to build via blocks should be populated"
+ assert_not_nil i.man
+ assert_equal m.name, i.man.name, "Name of man should be the same before changes to parent instance"
+ m.name = "Bongo"
+ assert_equal m.name, i.man.name, "Name of man should be the same after changes to parent instance"
+ i.man.name = "Mungo"
+ assert_equal m.name, i.man.name, "Name of man should be the same after changes to just-built-child-owned instance"
+ end
+
+ def test_parent_instance_should_be_shared_with_newly_created_via_bang_method_child
+ m = Man.first
+ i = m.interests.create!(topic: "Industrial Revolution Re-enactment")
+ assert_not_nil i.man
+ assert_equal m.name, i.man.name, "Name of man should be the same before changes to parent instance"
+ m.name = "Bongo"
+ assert_equal m.name, i.man.name, "Name of man should be the same after changes to parent instance"
+ i.man.name = "Mungo"
+ assert_equal m.name, i.man.name, "Name of man should be the same after changes to newly-created-child-owned instance"
+ end
+
+ def test_parent_instance_should_be_shared_with_newly_block_style_created_child
+ m = Man.first
+ i = m.interests.create { |ii| ii.topic = "Industrial Revolution Re-enactment" }
+ assert_not_nil i.topic, "Child attributes supplied to create via blocks should be populated"
+ assert_not_nil i.man
+ assert_equal m.name, i.man.name, "Name of man should be the same before changes to parent instance"
+ m.name = "Bongo"
+ assert_equal m.name, i.man.name, "Name of man should be the same after changes to parent instance"
+ i.man.name = "Mungo"
+ assert_equal m.name, i.man.name, "Name of man should be the same after changes to newly-created-child-owned instance"
+ end
+
+ def test_parent_instance_should_be_shared_within_create_block_of_new_child
+ man = Man.first
+ interest = man.interests.create do |i|
+ assert i.man.equal?(man), "Man of child should be the same instance as a parent"
+ end
+ assert interest.man.equal?(man), "Man of the child should still be the same instance as a parent"
+ end
+
+ def test_parent_instance_should_be_shared_within_build_block_of_new_child
+ man = Man.first
+ interest = man.interests.build do |i|
+ assert i.man.equal?(man), "Man of child should be the same instance as a parent"
+ end
+ assert interest.man.equal?(man), "Man of the child should still be the same instance as a parent"
+ end
+
+ def test_parent_instance_should_be_shared_with_poked_in_child
+ m = men(:gordon)
+ i = Interest.create(topic: "Industrial Revolution Re-enactment")
+ m.interests << i
+ assert_not_nil i.man
+ assert_equal m.name, i.man.name, "Name of man should be the same before changes to parent instance"
+ m.name = "Bongo"
+ assert_equal m.name, i.man.name, "Name of man should be the same after changes to parent instance"
+ i.man.name = "Mungo"
+ assert_equal m.name, i.man.name, "Name of man should be the same after changes to newly-created-child-owned instance"
+ end
+
+ def test_parent_instance_should_be_shared_with_replaced_via_accessor_children
+ m = Man.first
+ i = Interest.new(topic: "Industrial Revolution Re-enactment")
+ m.interests = [i]
+ assert_not_nil i.man
+ assert_equal m.name, i.man.name, "Name of man should be the same before changes to parent instance"
+ m.name = "Bongo"
+ assert_equal m.name, i.man.name, "Name of man should be the same after changes to parent instance"
+ i.man.name = "Mungo"
+ assert_equal m.name, i.man.name, "Name of man should be the same after changes to replaced-child-owned instance"
+ end
+
+ def test_parent_instance_should_be_shared_with_first_and_last_child
+ man = Man.first
+
+ assert man.interests.first.man.equal? man
+ assert man.interests.last.man.equal? man
+ end
+
+ def test_parent_instance_should_be_shared_with_first_n_and_last_n_children
+ man = Man.first
+
+ interests = man.interests.first(2)
+ assert interests[0].man.equal? man
+ assert interests[1].man.equal? man
+
+ interests = man.interests.last(2)
+ assert interests[0].man.equal? man
+ assert interests[1].man.equal? man
+ end
+
+ def test_parent_instance_should_find_child_instance_using_child_instance_id
+ man = Man.create!
+ interest = Interest.create!
+ man.interests = [interest]
+
+ assert interest.equal?(man.interests.first), "The inverse association should use the interest already created and held in memory"
+ assert interest.equal?(man.interests.find(interest.id)), "The inverse association should use the interest already created and held in memory"
+ assert man.equal?(man.interests.first.man), "Two inversion should lead back to the same object that was originally held"
+ assert man.equal?(man.interests.find(interest.id).man), "Two inversions should lead back to the same object that was originally held"
+ end
+
+ def test_parent_instance_should_find_child_instance_using_child_instance_id_when_created
+ man = Man.create!
+ interest = Interest.create!(man: man)
+
+ assert man.equal?(man.interests.first.man), "Two inverses should lead back to the same object that was originally held"
+ assert man.equal?(man.interests.find(interest.id).man), "Two inversions should lead back to the same object that was originally held"
+
+ assert_nil man.interests.find(interest.id).man.name, "The name of the man should match before the name is changed"
+ man.name = "Ben Bitdiddle"
+ assert_equal man.name, man.interests.find(interest.id).man.name, "The name of the man should match after the parent name is changed"
+ man.interests.find(interest.id).man.name = "Alyssa P. Hacker"
+ assert_equal man.name, man.interests.find(interest.id).man.name, "The name of the man should match after the child name is changed"
+ end
+
+ def test_find_on_child_instance_with_id_should_not_load_all_child_records
+ man = Man.create!
+ interest = Interest.create!(man: man)
+
+ man.interests.find(interest.id)
+ assert_not_predicate man.interests, :loaded?
+ end
+
+ def test_raise_record_not_found_error_when_invalid_ids_are_passed
+ # delete all interest records to ensure that hard coded invalid_id(s)
+ # are indeed invalid.
+ Interest.delete_all
+
+ man = Man.create!
+
+ invalid_id = 245324523
+ assert_raise(ActiveRecord::RecordNotFound) { man.interests.find(invalid_id) }
+
+ invalid_ids = [8432342, 2390102913, 2453245234523452]
+ assert_raise(ActiveRecord::RecordNotFound) { man.interests.find(invalid_ids) }
+ end
+
+ def test_raise_record_not_found_error_when_no_ids_are_passed
+ man = Man.create!
+
+ exception = assert_raise(ActiveRecord::RecordNotFound) { man.interests.load.find() }
+
+ assert_equal exception.model, "Interest"
+ assert_equal exception.primary_key, "id"
+ end
+
+ def test_trying_to_use_inverses_that_dont_exist_should_raise_an_error
+ assert_raise(ActiveRecord::InverseOfAssociationNotFoundError) { Man.first.secret_interests }
+ end
+
+ def test_child_instance_should_point_to_parent_without_saving
+ man = Man.new
+ i = Interest.create(topic: "Industrial Revolution Re-enactment")
+
+ man.interests << i
+ assert_not_nil i.man
+
+ i.man.name = "Charles"
+ assert_equal i.man.name, man.name
+
+ assert_not_predicate man, :persisted?
+ end
+
+ def test_inverse_instance_should_be_set_before_find_callbacks_are_run
+ reset_callbacks(Interest, :find) do
+ Interest.after_find { raise unless association(:man).loaded? && man.present? }
+
+ assert_predicate Man.first.interests.reload, :any?
+ assert_predicate Man.includes(:interests).first.interests, :any?
+ assert_predicate Man.joins(:interests).includes(:interests).first.interests, :any?
+ end
+ end
+
+ def test_inverse_instance_should_be_set_before_initialize_callbacks_are_run
+ reset_callbacks(Interest, :initialize) do
+ Interest.after_initialize { raise unless association(:man).loaded? && man.present? }
+
+ assert_predicate Man.first.interests.reload, :any?
+ assert_predicate Man.includes(:interests).first.interests, :any?
+ assert_predicate Man.joins(:interests).includes(:interests).first.interests, :any?
+ end
+ end
+
+ def reset_callbacks(target, type)
+ old_callbacks = target.send(:get_callbacks, type).deep_dup
+ yield
+ ensure
+ target.send(:set_callbacks, type, old_callbacks) if old_callbacks
+ end
+end
+
+class InverseBelongsToTests < ActiveRecord::TestCase
+ fixtures :men, :faces, :interests
+
+ def test_child_instance_should_be_shared_with_parent_on_find
+ f = faces(:trusting)
+ m = f.man
+ assert_equal f.description, m.face.description, "Description of face should be the same before changes to child instance"
+ f.description = "gormless"
+ assert_equal f.description, m.face.description, "Description of face should be the same after changes to child instance"
+ m.face.description = "pleasing"
+ assert_equal f.description, m.face.description, "Description of face should be the same after changes to parent-owned instance"
+ end
+
+ def test_eager_loaded_child_instance_should_be_shared_with_parent_on_find
+ f = Face.all.merge!(includes: :man, where: { description: "trusting" }).first
+ m = f.man
+ assert_equal f.description, m.face.description, "Description of face should be the same before changes to child instance"
+ f.description = "gormless"
+ assert_equal f.description, m.face.description, "Description of face should be the same after changes to child instance"
+ m.face.description = "pleasing"
+ assert_equal f.description, m.face.description, "Description of face should be the same after changes to parent-owned instance"
+
+ f = Face.all.merge!(includes: :man, order: "men.id", where: { description: "trusting" }).first
+ m = f.man
+ assert_equal f.description, m.face.description, "Description of face should be the same before changes to child instance"
+ f.description = "gormless"
+ assert_equal f.description, m.face.description, "Description of face should be the same after changes to child instance"
+ m.face.description = "pleasing"
+ assert_equal f.description, m.face.description, "Description of face should be the same after changes to parent-owned instance"
+ end
+
+ def test_child_instance_should_be_shared_with_newly_built_parent
+ f = faces(:trusting)
+ m = f.build_man(name: "Charles")
+ assert_not_nil m.face
+ assert_equal f.description, m.face.description, "Description of face should be the same before changes to child instance"
+ f.description = "gormless"
+ assert_equal f.description, m.face.description, "Description of face should be the same after changes to child instance"
+ m.face.description = "pleasing"
+ assert_equal f.description, m.face.description, "Description of face should be the same after changes to just-built-parent-owned instance"
+ end
+
+ def test_child_instance_should_be_shared_with_newly_created_parent
+ f = faces(:trusting)
+ m = f.create_man(name: "Charles")
+ assert_not_nil m.face
+ assert_equal f.description, m.face.description, "Description of face should be the same before changes to child instance"
+ f.description = "gormless"
+ assert_equal f.description, m.face.description, "Description of face should be the same after changes to child instance"
+ m.face.description = "pleasing"
+ assert_equal f.description, m.face.description, "Description of face should be the same after changes to newly-created-parent-owned instance"
+ end
+
+ def test_should_not_try_to_set_inverse_instances_when_the_inverse_is_a_has_many
+ i = interests(:trainspotting)
+ m = i.man
+ assert_not_nil m.interests
+ iz = m.interests.detect { |_iz| _iz.id == i.id }
+ assert_not_nil iz
+ assert_equal i.topic, iz.topic, "Interest topics should be the same before changes to child"
+ i.topic = "Eating cheese with a spoon"
+ assert_not_equal i.topic, iz.topic, "Interest topics should not be the same after changes to child"
+ iz.topic = "Cow tipping"
+ assert_not_equal i.topic, iz.topic, "Interest topics should not be the same after changes to parent-owned instance"
+ end
+
+ def test_child_instance_should_be_shared_with_replaced_via_accessor_parent
+ f = Face.first
+ m = Man.new(name: "Charles")
+ f.man = m
+ assert_not_nil m.face
+ assert_equal f.description, m.face.description, "Description of face should be the same before changes to child instance"
+ f.description = "gormless"
+ assert_equal f.description, m.face.description, "Description of face should be the same after changes to child instance"
+ m.face.description = "pleasing"
+ assert_equal f.description, m.face.description, "Description of face should be the same after changes to replaced-parent-owned instance"
+ end
+
+ def test_trying_to_use_inverses_that_dont_exist_should_raise_an_error
+ assert_raise(ActiveRecord::InverseOfAssociationNotFoundError) { Face.first.horrible_man }
+ end
+end
+
+class InversePolymorphicBelongsToTests < ActiveRecord::TestCase
+ fixtures :men, :faces, :interests
+
+ def test_child_instance_should_be_shared_with_parent_on_find
+ f = Face.all.merge!(where: { description: "confused" }).first
+ m = f.polymorphic_man
+ assert_equal f.description, m.polymorphic_face.description, "Description of face should be the same before changes to child instance"
+ f.description = "gormless"
+ assert_equal f.description, m.polymorphic_face.description, "Description of face should be the same after changes to child instance"
+ m.polymorphic_face.description = "pleasing"
+ assert_equal f.description, m.polymorphic_face.description, "Description of face should be the same after changes to parent-owned instance"
+ end
+
+ def test_eager_loaded_child_instance_should_be_shared_with_parent_on_find
+ f = Face.all.merge!(where: { description: "confused" }, includes: :man).first
+ m = f.polymorphic_man
+ assert_equal f.description, m.polymorphic_face.description, "Description of face should be the same before changes to child instance"
+ f.description = "gormless"
+ assert_equal f.description, m.polymorphic_face.description, "Description of face should be the same after changes to child instance"
+ m.polymorphic_face.description = "pleasing"
+ assert_equal f.description, m.polymorphic_face.description, "Description of face should be the same after changes to parent-owned instance"
+
+ f = Face.all.merge!(where: { description: "confused" }, includes: :man, order: "men.id").first
+ m = f.polymorphic_man
+ assert_equal f.description, m.polymorphic_face.description, "Description of face should be the same before changes to child instance"
+ f.description = "gormless"
+ assert_equal f.description, m.polymorphic_face.description, "Description of face should be the same after changes to child instance"
+ m.polymorphic_face.description = "pleasing"
+ assert_equal f.description, m.polymorphic_face.description, "Description of face should be the same after changes to parent-owned instance"
+ end
+
+ def test_child_instance_should_be_shared_with_replaced_via_accessor_parent
+ face = faces(:confused)
+ new_man = Man.new
+
+ assert_not_nil face.polymorphic_man
+ face.polymorphic_man = new_man
+
+ assert_equal face.description, new_man.polymorphic_face.description, "Description of face should be the same before changes to parent instance"
+ face.description = "Bongo"
+ assert_equal face.description, new_man.polymorphic_face.description, "Description of face should be the same after changes to parent instance"
+ new_man.polymorphic_face.description = "Mungo"
+ assert_equal face.description, new_man.polymorphic_face.description, "Description of face should be the same after changes to replaced-parent-owned instance"
+ end
+
+ def test_inversed_instance_should_not_be_reloaded_after_stale_state_changed
+ new_man = Man.new
+ face = Face.new
+ new_man.face = face
+
+ old_inversed_man = face.man
+ new_man.save!
+ new_inversed_man = face.man
+
+ assert_equal old_inversed_man.object_id, new_inversed_man.object_id
+ end
+
+ def test_inversed_instance_should_not_be_reloaded_after_stale_state_changed_with_validation
+ face = Face.new man: Man.new
+
+ old_inversed_man = face.man
+ face.save!
+ new_inversed_man = face.man
+
+ assert_equal old_inversed_man.object_id, new_inversed_man.object_id
+ end
+
+ def test_should_not_try_to_set_inverse_instances_when_the_inverse_is_a_has_many
+ i = interests(:llama_wrangling)
+ m = i.polymorphic_man
+ assert_not_nil m.polymorphic_interests
+ iz = m.polymorphic_interests.detect { |_iz| _iz.id == i.id }
+ assert_not_nil iz
+ assert_equal i.topic, iz.topic, "Interest topics should be the same before changes to child"
+ i.topic = "Eating cheese with a spoon"
+ assert_not_equal i.topic, iz.topic, "Interest topics should not be the same after changes to child"
+ iz.topic = "Cow tipping"
+ assert_not_equal i.topic, iz.topic, "Interest topics should not be the same after changes to parent-owned instance"
+ end
+
+ def test_trying_to_access_inverses_that_dont_exist_shouldnt_raise_an_error
+ # Ideally this would, if only for symmetry's sake with other association types
+ assert_nothing_raised { Face.first.horrible_polymorphic_man }
+ end
+
+ def test_trying_to_set_polymorphic_inverses_that_dont_exist_at_all_should_raise_an_error
+ # fails because no class has the correct inverse_of for horrible_polymorphic_man
+ assert_raise(ActiveRecord::InverseOfAssociationNotFoundError) { Face.first.horrible_polymorphic_man = Man.first }
+ end
+
+ def test_trying_to_set_polymorphic_inverses_that_dont_exist_on_the_instance_being_set_should_raise_an_error
+ # passes because Man does have the correct inverse_of
+ assert_nothing_raised { Face.first.polymorphic_man = Man.first }
+ # fails because Interest does have the correct inverse_of
+ assert_raise(ActiveRecord::InverseOfAssociationNotFoundError) { Face.first.polymorphic_man = Interest.first }
+ end
+
+ def test_favors_has_one_associations_for_inverse_of
+ inverse_name = Post.reflect_on_association(:author).inverse_of.name
+ assert_equal :post, inverse_name
+ end
+
+ def test_finds_inverse_of_for_plural_associations
+ inverse_name = Department.reflect_on_association(:hotel).inverse_of.name
+ assert_equal :departments, inverse_name
+ end
+end
+
+# NOTE - these tests might not be meaningful, ripped as they were from the parental_control plugin
+# which would guess the inverse rather than look for an explicit configuration option.
+class InverseMultipleHasManyInversesForSameModel < ActiveRecord::TestCase
+ fixtures :men, :interests, :zines
+
+ def test_that_we_can_load_associations_that_have_the_same_reciprocal_name_from_different_models
+ assert_nothing_raised do
+ i = Interest.first
+ i.zine
+ i.man
+ end
+ end
+
+ def test_that_we_can_create_associations_that_have_the_same_reciprocal_name_from_different_models
+ assert_nothing_raised do
+ i = Interest.first
+ i.build_zine(title: "Get Some in Winter! 2008")
+ i.build_man(name: "Gordon")
+ i.save!
+ end
+ end
+end
diff --git a/activerecord/test/cases/associations/join_model_test.rb b/activerecord/test/cases/associations/join_model_test.rb
new file mode 100644
index 0000000000..9d1c73c33b
--- /dev/null
+++ b/activerecord/test/cases/associations/join_model_test.rb
@@ -0,0 +1,778 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/tag"
+require "models/tagging"
+require "models/post"
+require "models/rating"
+require "models/item"
+require "models/comment"
+require "models/author"
+require "models/category"
+require "models/categorization"
+require "models/vertex"
+require "models/edge"
+require "models/book"
+require "models/citation"
+require "models/aircraft"
+require "models/engine"
+require "models/car"
+
+class AssociationsJoinModelTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false unless supports_savepoints?
+
+ fixtures :posts, :authors, :author_addresses, :categories, :categorizations, :comments, :tags, :taggings, :author_favorites, :vertices, :items, :books,
+ # Reload edges table from fixtures as otherwise repeated test was failing
+ :edges
+
+ def test_has_many
+ assert_includes authors(:david).categories, categories(:general)
+ end
+
+ def test_has_many_inherited
+ assert_includes authors(:mary).categories, categories(:sti_test)
+ end
+
+ def test_inherited_has_many
+ assert_includes categories(:sti_test).authors, authors(:mary)
+ end
+
+ def test_has_many_distinct_through_join_model
+ assert_equal 2, authors(:mary).categorized_posts.size
+ assert_equal 1, authors(:mary).unique_categorized_posts.size
+ end
+
+ def test_has_many_distinct_through_count
+ author = authors(:mary)
+ assert_not_predicate authors(:mary).unique_categorized_posts, :loaded?
+ assert_queries(1) { assert_equal 1, author.unique_categorized_posts.count }
+ assert_queries(1) { assert_equal 1, author.unique_categorized_posts.count(:title) }
+ assert_queries(1) { assert_equal 0, author.unique_categorized_posts.where(title: nil).count(:title) }
+ assert_not_predicate authors(:mary).unique_categorized_posts, :loaded?
+ end
+
+ def test_has_many_distinct_through_find
+ assert_equal 1, authors(:mary).unique_categorized_posts.to_a.size
+ end
+
+ def test_polymorphic_has_many_going_through_join_model
+ assert_equal tags(:general), tag = posts(:welcome).tags.first
+ assert_no_queries do
+ tag.tagging
+ end
+ end
+
+ def test_count_polymorphic_has_many
+ assert_equal 1, posts(:welcome).taggings.count
+ assert_equal 1, posts(:welcome).tags.count
+ end
+
+ def test_polymorphic_has_many_going_through_join_model_with_find
+ assert_equal tags(:general), tag = posts(:welcome).tags.first
+ assert_no_queries do
+ tag.tagging
+ end
+ end
+
+ def test_polymorphic_has_many_going_through_join_model_with_include_on_source_reflection
+ assert_equal tags(:general), tag = posts(:welcome).funky_tags.first
+ assert_no_queries do
+ tag.tagging
+ end
+ end
+
+ def test_polymorphic_has_many_going_through_join_model_with_include_on_source_reflection_with_find
+ assert_equal tags(:general), tag = posts(:welcome).funky_tags.first
+ assert_no_queries do
+ tag.tagging
+ end
+ end
+
+ def test_polymorphic_has_many_going_through_join_model_with_custom_select_and_joins
+ assert_equal tags(:general), tag = posts(:welcome).tags.add_joins_and_select.first
+ assert_nothing_raised { tag.author_id }
+ end
+
+ def test_polymorphic_has_many_going_through_join_model_with_custom_foreign_key
+ assert_equal tags(:misc), taggings(:welcome_general).super_tag
+ assert_equal tags(:misc), posts(:welcome).super_tags.first
+ end
+
+ def test_polymorphic_has_many_create_model_with_inheritance_and_custom_base_class
+ post = SubAbstractStiPost.create title: "SubAbstractStiPost", body: "SubAbstractStiPost body"
+ assert_instance_of SubAbstractStiPost, post
+
+ tagging = tags(:misc).taggings.create(taggable: post)
+ assert_equal "SubAbstractStiPost", tagging.taggable_type
+ end
+
+ def test_polymorphic_has_many_going_through_join_model_with_inheritance
+ assert_equal tags(:general), posts(:thinking).tags.first
+ end
+
+ def test_polymorphic_has_many_going_through_join_model_with_inheritance_with_custom_class_name
+ assert_equal tags(:general), posts(:thinking).funky_tags.first
+ end
+
+ def test_polymorphic_has_many_create_model_with_inheritance
+ post = posts(:thinking)
+ assert_instance_of SpecialPost, post
+
+ tagging = tags(:misc).taggings.create(taggable: post)
+ assert_equal "Post", tagging.taggable_type
+ end
+
+ def test_polymorphic_has_one_create_model_with_inheritance
+ tagging = tags(:misc).create_tagging(taggable: posts(:thinking))
+ assert_equal "Post", tagging.taggable_type
+ end
+
+ def test_set_polymorphic_has_many
+ tagging = tags(:misc).taggings.create
+ posts(:thinking).taggings << tagging
+ assert_equal "Post", tagging.taggable_type
+ end
+
+ def test_set_polymorphic_has_one
+ tagging = tags(:misc).taggings.create
+ posts(:thinking).tagging = tagging
+
+ assert_equal "Post", tagging.taggable_type
+ assert_equal posts(:thinking).id, tagging.taggable_id
+ assert_equal posts(:thinking), tagging.taggable
+ end
+
+ def test_set_polymorphic_has_one_on_new_record
+ tagging = tags(:misc).taggings.create
+ post = Post.new title: "foo", body: "bar"
+ post.tagging = tagging
+ post.save!
+
+ assert_equal "Post", tagging.taggable_type
+ assert_equal post.id, tagging.taggable_id
+ assert_equal post, tagging.taggable
+ end
+
+ def test_create_polymorphic_has_many_with_scope
+ old_count = posts(:welcome).taggings.count
+ tagging = posts(:welcome).taggings.create(tag: tags(:misc))
+ assert_equal "Post", tagging.taggable_type
+ assert_equal old_count + 1, posts(:welcome).taggings.count
+ end
+
+ def test_create_bang_polymorphic_with_has_many_scope
+ old_count = posts(:welcome).taggings.count
+ tagging = posts(:welcome).taggings.create!(tag: tags(:misc))
+ assert_equal "Post", tagging.taggable_type
+ assert_equal old_count + 1, posts(:welcome).taggings.count
+ end
+
+ def test_create_polymorphic_has_one_with_scope
+ old_count = Tagging.count
+ tagging = posts(:welcome).create_tagging(tag: tags(:misc))
+ assert_equal "Post", tagging.taggable_type
+ assert_equal old_count + 1, Tagging.count
+ end
+
+ def test_delete_polymorphic_has_many_with_delete_all
+ assert_equal 1, posts(:welcome).taggings.count
+ posts(:welcome).taggings.first.update_columns taggable_type: "PostWithHasManyDeleteAll"
+ post = find_post_with_dependency(1, :has_many, :taggings, :delete_all)
+
+ old_count = Tagging.count
+ post.destroy
+ assert_equal old_count - 1, Tagging.count
+ assert_equal 0, posts(:welcome).taggings.count
+ end
+
+ def test_delete_polymorphic_has_many_with_destroy
+ assert_equal 1, posts(:welcome).taggings.count
+ posts(:welcome).taggings.first.update_columns taggable_type: "PostWithHasManyDestroy"
+ post = find_post_with_dependency(1, :has_many, :taggings, :destroy)
+
+ old_count = Tagging.count
+ post.destroy
+ assert_equal old_count - 1, Tagging.count
+ assert_equal 0, posts(:welcome).taggings.count
+ end
+
+ def test_delete_polymorphic_has_many_with_nullify
+ assert_equal 1, posts(:welcome).taggings.count
+ posts(:welcome).taggings.first.update_columns taggable_type: "PostWithHasManyNullify"
+ post = find_post_with_dependency(1, :has_many, :taggings, :nullify)
+
+ old_count = Tagging.count
+ post.destroy
+ assert_equal old_count, Tagging.count
+ assert_equal 0, posts(:welcome).taggings.count
+ end
+
+ def test_delete_polymorphic_has_one_with_destroy
+ assert posts(:welcome).tagging
+ posts(:welcome).tagging.update_columns taggable_type: "PostWithHasOneDestroy"
+ post = find_post_with_dependency(1, :has_one, :tagging, :destroy)
+
+ old_count = Tagging.count
+ post.destroy
+ assert_equal old_count - 1, Tagging.count
+ posts(:welcome).association(:tagging).reload
+ assert_nil posts(:welcome).tagging
+ end
+
+ def test_delete_polymorphic_has_one_with_nullify
+ assert posts(:welcome).tagging
+ posts(:welcome).tagging.update_columns taggable_type: "PostWithHasOneNullify"
+ post = find_post_with_dependency(1, :has_one, :tagging, :nullify)
+
+ old_count = Tagging.count
+ post.destroy
+ assert_equal old_count, Tagging.count
+ posts(:welcome).association(:tagging).reload
+ assert_nil posts(:welcome).tagging
+ end
+
+ def test_has_many_with_piggyback
+ assert_equal "2", categories(:sti_test).authors_with_select.first.post_id.to_s
+ end
+
+ def test_create_through_has_many_with_piggyback
+ category = categories(:sti_test)
+ ernie = category.authors_with_select.create(name: "Ernie")
+ assert_nothing_raised do
+ assert_equal ernie, category.authors_with_select.detect { |a| a.name == "Ernie" }
+ end
+ end
+
+ def test_include_has_many_through
+ posts = Post.all.merge!(order: "posts.id").to_a
+ posts_with_authors = Post.all.merge!(includes: :authors, order: "posts.id").to_a
+ assert_equal posts.length, posts_with_authors.length
+ posts.length.times do |i|
+ assert_equal posts[i].authors.length, assert_no_queries { posts_with_authors[i].authors.length }
+ end
+ end
+
+ def test_include_polymorphic_has_one
+ post = Post.includes(:tagging).find posts(:welcome).id
+ tagging = taggings(:welcome_general)
+ assert_no_queries do
+ assert_equal tagging, post.tagging
+ end
+ end
+
+ def test_include_polymorphic_has_one_defined_in_abstract_parent
+ item = Item.includes(:tagging).find items(:dvd).id
+ tagging = taggings(:godfather)
+ assert_no_queries do
+ assert_equal tagging, item.tagging
+ end
+ end
+
+ def test_include_polymorphic_has_many_through
+ posts = Post.all.merge!(order: "posts.id").to_a
+ posts_with_tags = Post.all.merge!(includes: :tags, order: "posts.id").to_a
+ assert_equal posts.length, posts_with_tags.length
+ posts.length.times do |i|
+ assert_equal posts[i].tags.length, assert_no_queries { posts_with_tags[i].tags.length }
+ end
+ end
+
+ def test_include_polymorphic_has_many
+ posts = Post.all.merge!(order: "posts.id").to_a
+ posts_with_taggings = Post.all.merge!(includes: :taggings, order: "posts.id").to_a
+ assert_equal posts.length, posts_with_taggings.length
+ posts.length.times do |i|
+ assert_equal posts[i].taggings.length, assert_no_queries { posts_with_taggings[i].taggings.length }
+ end
+ end
+
+ def test_has_many_find_all
+ assert_equal [categories(:general)], authors(:david).categories.to_a
+ end
+
+ def test_has_many_find_first
+ assert_equal categories(:general), authors(:david).categories.first
+ end
+
+ def test_has_many_with_hash_conditions
+ assert_equal categories(:general), authors(:david).categories_like_general.first
+ end
+
+ def test_has_many_find_conditions
+ assert_equal categories(:general), authors(:david).categories.where("categories.name = 'General'").first
+ assert_nil authors(:david).categories.where("categories.name = 'Technology'").first
+ end
+
+ def test_has_many_array_methods_called_by_method_missing
+ assert authors(:david).categories.any? { |category| category.name == "General" }
+ assert_nothing_raised { authors(:david).categories.sort }
+ end
+
+ def test_has_many_going_through_join_model_with_custom_foreign_key
+ assert_equal [authors(:bob)], posts(:thinking).authors
+ assert_equal [authors(:mary)], posts(:authorless).authors
+ end
+
+ def test_has_many_going_through_join_model_with_custom_primary_key
+ assert_equal [authors(:david)], posts(:thinking).authors_using_author_id
+ end
+
+ def test_has_many_going_through_polymorphic_join_model_with_custom_primary_key
+ assert_equal [tags(:general)], posts(:eager_other).tags_using_author_id
+ end
+
+ def test_has_many_through_with_custom_primary_key_on_belongs_to_source
+ assert_equal [authors(:david), authors(:david)], posts(:thinking).author_using_custom_pk
+ end
+
+ def test_has_many_through_with_custom_primary_key_on_has_many_source
+ assert_equal [authors(:david), authors(:bob)], posts(:thinking).authors_using_custom_pk.order("authors.id")
+ end
+
+ def test_belongs_to_polymorphic_with_counter_cache
+ assert_equal 1, posts(:welcome)[:tags_count]
+ tagging = posts(:welcome).taggings.create(tag: tags(:general))
+ assert_equal 2, posts(:welcome, :reload)[:tags_count]
+ tagging.destroy
+ assert_equal 1, posts(:welcome, :reload)[:tags_count]
+ end
+
+ def test_unavailable_through_reflection
+ assert_raise(ActiveRecord::HasManyThroughAssociationNotFoundError) { authors(:david).nothings }
+ end
+
+ def test_has_many_through_join_model_with_conditions
+ assert_equal [], posts(:welcome).invalid_taggings
+ assert_equal [], posts(:welcome).invalid_tags
+ end
+
+ def test_has_many_polymorphic
+ assert_raise ActiveRecord::HasManyThroughAssociationPolymorphicSourceError do
+ tags(:general).taggables
+ end
+
+ assert_raise ActiveRecord::HasManyThroughAssociationPolymorphicThroughError do
+ taggings(:welcome_general).things
+ end
+
+ assert_raise ActiveRecord::EagerLoadPolymorphicError do
+ tags(:general).taggings.includes(:taggable).where("bogus_table.column = 1").references(:bogus_table).to_a
+ end
+ end
+
+ def test_has_many_polymorphic_with_source_type
+ # added sort by ID as otherwise Oracle select sometimes returned rows in different order
+ assert_equal posts(:welcome, :thinking).sort_by(&:id), tags(:general).tagged_posts.sort_by(&:id)
+ end
+
+ def test_has_many_polymorphic_associations_merges_through_scope
+ Tag.has_many :null_taggings, -> { none }, class_name: :Tagging
+ Tag.has_many :null_tagged_posts, through: :null_taggings, source: "taggable", source_type: "Post"
+ assert_equal [], tags(:general).null_tagged_posts
+ assert_not_equal [], tags(:general).tagged_posts
+ end
+
+ def test_eager_has_many_polymorphic_with_source_type
+ tag_with_include = Tag.all.merge!(includes: :tagged_posts).find(tags(:general).id)
+ desired = posts(:welcome, :thinking)
+ assert_no_queries do
+ # added sort by ID as otherwise test using JRuby was failing as array elements were in different order
+ assert_equal desired.sort_by(&:id), tag_with_include.tagged_posts.sort_by(&:id)
+ end
+ assert_equal 5, tag_with_include.taggings.length
+ end
+
+ def test_has_many_through_has_many_find_all
+ assert_equal comments(:greetings), authors(:david).comments.order("comments.id").to_a.first
+ end
+
+ def test_has_many_through_has_many_find_all_with_custom_class
+ assert_equal comments(:greetings), authors(:david).funky_comments.order("comments.id").to_a.first
+ end
+
+ def test_has_many_through_has_many_find_first
+ assert_equal comments(:greetings), authors(:david).comments.order("comments.id").first
+ end
+
+ def test_has_many_through_has_many_find_conditions
+ options = { where: "comments.#{QUOTED_TYPE}='SpecialComment'", order: "comments.id" }
+ assert_equal comments(:does_it_hurt), authors(:david).comments.merge(options).first
+ end
+
+ def test_has_many_through_has_many_find_by_id
+ assert_equal comments(:more_greetings), authors(:david).comments.find(2)
+ end
+
+ def test_has_many_through_polymorphic_has_one
+ assert_equal Tagging.find(1, 2).sort_by(&:id), authors(:david).taggings_2.sort_by(&:id)
+ end
+
+ def test_has_many_through_polymorphic_has_many
+ assert_equal taggings(:welcome_general, :thinking_general), authors(:david).taggings.distinct.sort_by(&:id)
+ end
+
+ def test_include_has_many_through_polymorphic_has_many
+ author = Author.includes(:taggings).find authors(:david).id
+ expected_taggings = taggings(:welcome_general, :thinking_general)
+ assert_no_queries do
+ assert_equal expected_taggings, author.taggings.uniq.sort_by(&:id)
+ end
+ end
+
+ def test_eager_load_has_many_through_has_many
+ author = Author.all.merge!(where: ["name = ?", "David"], includes: :comments, order: "comments.id").first
+ SpecialComment.new; VerySpecialComment.new
+ assert_no_queries do
+ assert_equal [1, 2, 3, 5, 6, 7, 8, 9, 10, 12], author.comments.collect(&:id)
+ end
+ end
+
+ def test_eager_load_has_many_through_has_many_with_conditions
+ post = Post.all.merge!(includes: :invalid_tags).first
+ assert_no_queries do
+ post.invalid_tags
+ end
+ end
+
+ def test_eager_belongs_to_and_has_one_not_singularized
+ assert_nothing_raised do
+ Author.all.merge!(includes: :author_address).first
+ AuthorAddress.all.merge!(includes: :author).first
+ end
+ end
+
+ def test_self_referential_has_many_through
+ assert_equal [authors(:mary)], authors(:david).favorite_authors
+ assert_equal [], authors(:mary).favorite_authors
+ end
+
+ def test_add_to_self_referential_has_many_through
+ new_author = Author.create(name: "Bob")
+ authors(:david).author_favorites.create favorite_author: new_author
+ assert_equal new_author, authors(:david).reload.favorite_authors.first
+ end
+
+ def test_has_many_through_uses_conditions_specified_on_the_has_many_association
+ author = Author.first
+ assert_predicate author.comments, :present?
+ assert_predicate author.nonexistent_comments, :blank?
+ end
+
+ def test_has_many_through_uses_correct_attributes
+ assert_nil posts(:thinking).tags.find_by_name("General").attributes["tag_id"]
+ end
+
+ def test_associating_unsaved_records_with_has_many_through
+ saved_post = posts(:thinking)
+ new_tag = Tag.new(name: "new")
+
+ saved_post.tags << new_tag
+ assert new_tag.persisted? # consistent with habtm!
+ assert_predicate saved_post, :persisted?
+ assert_includes saved_post.tags, new_tag
+
+ assert_predicate new_tag, :persisted?
+ assert_includes saved_post.reload.tags.reload, new_tag
+
+ new_post = Post.new(title: "Association replacement works!", body: "You best believe it.")
+ saved_tag = tags(:general)
+
+ new_post.tags << saved_tag
+ assert_not_predicate new_post, :persisted?
+ assert_predicate saved_tag, :persisted?
+ assert_includes new_post.tags, saved_tag
+
+ new_post.save!
+ assert_predicate new_post, :persisted?
+ assert_includes new_post.reload.tags.reload, saved_tag
+
+ assert_not_predicate posts(:thinking).tags.build, :persisted?
+ assert_not_predicate posts(:thinking).tags.new, :persisted?
+ end
+
+ def test_create_associate_when_adding_to_has_many_through
+ count = posts(:thinking).tags.count
+ push = Tag.create!(name: "pushme")
+ post_thinking = posts(:thinking)
+ assert_nothing_raised { post_thinking.tags << push }
+ assert_nil(wrong = post_thinking.tags.detect { |t| t.class != Tag },
+ "Expected a Tag in tags collection, got #{wrong.class}.")
+ assert_nil(wrong = post_thinking.taggings.detect { |t| t.class != Tagging },
+ "Expected a Tagging in taggings collection, got #{wrong.class}.")
+ assert_equal(count + 1, post_thinking.reload.tags.size)
+ assert_equal(count + 1, post_thinking.tags.reload.size)
+
+ assert_kind_of Tag, post_thinking.tags.create!(name: "foo")
+ assert_nil(wrong = post_thinking.tags.detect { |t| t.class != Tag },
+ "Expected a Tag in tags collection, got #{wrong.class}.")
+ assert_nil(wrong = post_thinking.taggings.detect { |t| t.class != Tagging },
+ "Expected a Tagging in taggings collection, got #{wrong.class}.")
+ assert_equal(count + 2, post_thinking.reload.tags.size)
+ assert_equal(count + 2, post_thinking.tags.reload.size)
+
+ assert_nothing_raised { post_thinking.tags.concat(Tag.create!(name: "abc"), Tag.create!(name: "def")) }
+ assert_nil(wrong = post_thinking.tags.detect { |t| t.class != Tag },
+ "Expected a Tag in tags collection, got #{wrong.class}.")
+ assert_nil(wrong = post_thinking.taggings.detect { |t| t.class != Tagging },
+ "Expected a Tagging in taggings collection, got #{wrong.class}.")
+ assert_equal(count + 4, post_thinking.reload.tags.size)
+ assert_equal(count + 4, post_thinking.tags.reload.size)
+
+ # Raises if the wrong reflection name is used to set the Edge belongs_to
+ assert_nothing_raised { vertices(:vertex_1).sinks << vertices(:vertex_5) }
+ end
+
+ def test_add_to_join_table_with_no_id
+ assert_nothing_raised { vertices(:vertex_1).sinks << vertices(:vertex_5) }
+ end
+
+ def test_has_many_through_collection_size_doesnt_load_target_if_not_loaded
+ author = authors(:david)
+ assert_equal 10, author.comments.size
+ assert_not_predicate author.comments, :loaded?
+ end
+
+ def test_has_many_through_collection_size_uses_counter_cache_if_it_exists
+ c = categories(:general)
+ c.categorizations_count = 100
+ assert_equal 100, c.categorizations.size
+ assert_not_predicate c.categorizations, :loaded?
+ end
+
+ def test_adding_junk_to_has_many_through_should_raise_type_mismatch
+ assert_raise(ActiveRecord::AssociationTypeMismatch) { posts(:thinking).tags << "Uhh what now?" }
+ end
+
+ def test_adding_to_has_many_through_should_return_self
+ tags = posts(:thinking).tags
+ assert_equal tags, posts(:thinking).tags.push(tags(:general))
+ end
+
+ def test_delete_associate_when_deleting_from_has_many_through_with_nonstandard_id
+ count = books(:awdr).references.count
+ references_before = books(:awdr).references
+ book = Book.create!(name: "Getting Real")
+ book_awdr = books(:awdr)
+ book_awdr.references << book
+ assert_equal(count + 1, book_awdr.references.reload.size)
+
+ assert_nothing_raised { book_awdr.references.delete(book) }
+ assert_equal(count, book_awdr.references.size)
+ assert_equal(count, book_awdr.references.reload.size)
+ assert_equal(references_before.sort, book_awdr.references.sort)
+ end
+
+ def test_delete_associate_when_deleting_from_has_many_through
+ count = posts(:thinking).tags.count
+ tags_before = posts(:thinking).tags.sort
+ tag = Tag.create!(name: "doomed")
+ post_thinking = posts(:thinking)
+ post_thinking.tags << tag
+ assert_equal(count + 1, post_thinking.taggings.reload.size)
+ assert_equal(count + 1, post_thinking.reload.tags.reload.size)
+ assert_not_equal(tags_before, post_thinking.tags.sort)
+
+ assert_nothing_raised { post_thinking.tags.delete(tag) }
+ assert_equal(count, post_thinking.tags.size)
+ assert_equal(count, post_thinking.tags.reload.size)
+ assert_equal(count, post_thinking.taggings.reload.size)
+ assert_equal(tags_before, post_thinking.tags.sort)
+ end
+
+ def test_delete_associate_when_deleting_from_has_many_through_with_multiple_tags
+ count = posts(:thinking).tags.count
+ tags_before = posts(:thinking).tags.sort
+ doomed = Tag.create!(name: "doomed")
+ doomed2 = Tag.create!(name: "doomed2")
+ quaked = Tag.create!(name: "quaked")
+ post_thinking = posts(:thinking)
+ post_thinking.tags << doomed << doomed2
+ assert_equal(count + 2, post_thinking.reload.tags.reload.size)
+
+ assert_nothing_raised { post_thinking.tags.delete(doomed, doomed2, quaked) }
+ assert_equal(count, post_thinking.tags.size)
+ assert_equal(count, post_thinking.tags.reload.size)
+ assert_equal(tags_before, post_thinking.tags.sort)
+ end
+
+ def test_deleting_junk_from_has_many_through_should_raise_type_mismatch
+ assert_raise(ActiveRecord::AssociationTypeMismatch) { posts(:thinking).tags.delete(Object.new) }
+ end
+
+ def test_deleting_by_integer_id_from_has_many_through
+ post = posts(:thinking)
+
+ assert_difference "post.tags.count", -1 do
+ assert_equal 1, post.tags.delete(1).size
+ end
+
+ assert_equal 0, post.tags.size
+ end
+
+ def test_deleting_by_string_id_from_has_many_through
+ post = posts(:thinking)
+
+ assert_difference "post.tags.count", -1 do
+ assert_equal 1, post.tags.delete("1").size
+ end
+
+ assert_equal 0, post.tags.size
+ end
+
+ def test_has_many_through_sum_uses_calculations
+ assert_nothing_raised { authors(:david).comments.sum(:post_id) }
+ end
+
+ def test_calculations_on_has_many_through_should_disambiguate_fields
+ assert_nothing_raised { authors(:david).categories.maximum(:id) }
+ end
+
+ def test_calculations_on_has_many_through_should_not_disambiguate_fields_unless_necessary
+ assert_nothing_raised { authors(:david).categories.maximum("categories.id") }
+ end
+
+ def test_has_many_through_has_many_with_sti
+ assert_equal [comments(:does_it_hurt)], authors(:david).special_post_comments
+ end
+
+ def test_distinct_has_many_through_should_retain_order
+ comment_ids = authors(:david).comments.map(&:id)
+ assert_equal comment_ids.sort, authors(:david).ordered_uniq_comments.map(&:id)
+ assert_equal comment_ids.sort.reverse, authors(:david).ordered_uniq_comments_desc.map(&:id)
+ end
+
+ def test_polymorphic_has_many
+ expected = taggings(:welcome_general)
+ p = Post.all.merge!(includes: :taggings).find(posts(:welcome).id)
+ assert_no_queries { assert_includes p.taggings, expected }
+ assert_includes posts(:welcome).taggings, taggings(:welcome_general)
+ end
+
+ def test_polymorphic_has_one
+ expected = posts(:welcome)
+
+ tagging = Tagging.all.merge!(includes: :taggable).find(taggings(:welcome_general).id)
+ assert_no_queries { assert_equal expected, tagging.taggable }
+ end
+
+ def test_polymorphic_belongs_to
+ p = Post.all.merge!(includes: { taggings: :taggable }).find(posts(:welcome).id)
+ assert_no_queries { assert_equal posts(:welcome), p.taggings.first.taggable }
+ end
+
+ def test_preload_polymorphic_has_many_through
+ posts = Post.all.merge!(order: "posts.id").to_a
+ posts_with_tags = Post.all.merge!(includes: :tags, order: "posts.id").to_a
+ assert_equal posts.length, posts_with_tags.length
+ posts.length.times do |i|
+ assert_equal posts[i].tags.length, assert_no_queries { posts_with_tags[i].tags.length }
+ end
+ end
+
+ def test_preload_polymorph_many_types
+ taggings = Tagging.all.merge!(includes: :taggable, where: ["taggable_type != ?", "FakeModel"]).to_a
+ assert_no_queries do
+ taggings.first.taggable.id
+ taggings[1].taggable.id
+ end
+
+ taggables = taggings.map(&:taggable)
+ assert_includes taggables, items(:dvd)
+ assert_includes taggables, posts(:welcome)
+ end
+
+ def test_preload_nil_polymorphic_belongs_to
+ assert_nothing_raised do
+ Tagging.all.merge!(includes: :taggable, where: ["taggable_type IS NULL"]).to_a
+ end
+ end
+
+ def test_preload_polymorphic_has_many
+ posts = Post.all.merge!(order: "posts.id").to_a
+ posts_with_taggings = Post.all.merge!(includes: :taggings, order: "posts.id").to_a
+ assert_equal posts.length, posts_with_taggings.length
+ posts.length.times do |i|
+ assert_equal posts[i].taggings.length, assert_no_queries { posts_with_taggings[i].taggings.length }
+ end
+ end
+
+ def test_belongs_to_shared_parent
+ comments = Comment.all.merge!(includes: :post, where: "post_id = 1").to_a
+ assert_no_queries do
+ assert_equal comments.first.post, comments[1].post
+ end
+ end
+
+ def test_has_many_through_include_uses_array_include_after_loaded
+ david = authors(:david)
+ david.categories.load_target
+
+ category = david.categories.first
+
+ assert_no_queries do
+ assert_predicate david.categories, :loaded?
+ assert_includes david.categories, category
+ end
+ end
+
+ def test_has_many_through_include_checks_if_record_exists_if_target_not_loaded
+ david = authors(:david)
+ category = david.categories.first
+
+ david.reload
+ assert_not_predicate david.categories, :loaded?
+ assert_queries(1) do
+ assert_includes david.categories, category
+ end
+ assert_not_predicate david.categories, :loaded?
+ end
+
+ def test_has_many_through_include_returns_false_for_non_matching_record_to_verify_scoping
+ david = authors(:david)
+ category = Category.create!(name: "Not Associated")
+
+ assert_not_predicate david.categories, :loaded?
+ assert_not david.categories.include?(category)
+ end
+
+ def test_has_many_through_goes_through_all_sti_classes
+ sub_sti_post = SubStiPost.create!(title: "test", body: "test", author_id: 1)
+ new_comment = sub_sti_post.comments.create(body: "test")
+
+ assert_equal [9, 10, new_comment.id], authors(:david).sti_post_comments.map(&:id).sort
+ end
+
+ def test_has_many_with_pluralize_table_names_false
+ aircraft = Aircraft.create!(name: "Airbus 380")
+ engine = Engine.create!(car_id: aircraft.id)
+ assert_equal aircraft.engines, [engine]
+ end
+
+ def test_proper_error_message_for_eager_load_and_includes_association_errors
+ includes_error = assert_raises(ActiveRecord::ConfigurationError) {
+ Post.includes(:nonexistent_relation).where(nonexistent_relation: { name: "Rochester" }).find(1)
+ }
+ assert_equal("Can't join 'Post' to association named 'nonexistent_relation'; perhaps you misspelled it?", includes_error.message)
+
+ eager_load_error = assert_raises(ActiveRecord::ConfigurationError) {
+ Post.eager_load(:nonexistent_relation).where(nonexistent_relation: { name: "Rochester" }).find(1)
+ }
+ assert_equal("Can't join 'Post' to association named 'nonexistent_relation'; perhaps you misspelled it?", eager_load_error.message)
+
+ includes_and_eager_load_error = assert_raises(ActiveRecord::ConfigurationError) {
+ Post.eager_load(:nonexistent_relation).includes(:nonexistent_relation).where(nonexistent_relation: { name: "Rochester" }).find(1)
+ }
+ assert_equal("Can't join 'Post' to association named 'nonexistent_relation'; perhaps you misspelled it?", includes_and_eager_load_error.message)
+ end
+
+ private
+ # create dynamic Post models to allow different dependency options
+ def find_post_with_dependency(post_id, association, association_name, dependency)
+ class_name = "PostWith#{association.to_s.classify}#{dependency.to_s.classify}"
+ Post.find(post_id).update_columns type: class_name
+ klass = Object.const_set(class_name, Class.new(ActiveRecord::Base))
+ klass.table_name = "posts"
+ klass.send(association, association_name, as: :taggable, dependent: dependency)
+ klass.find(post_id)
+ end
+end
diff --git a/activerecord/test/cases/associations/left_outer_join_association_test.rb b/activerecord/test/cases/associations/left_outer_join_association_test.rb
new file mode 100644
index 0000000000..0e54e8c1b0
--- /dev/null
+++ b/activerecord/test/cases/associations/left_outer_join_association_test.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+require "models/comment"
+require "models/author"
+require "models/essay"
+require "models/category"
+require "models/categorization"
+require "models/person"
+
+class LeftOuterJoinAssociationTest < ActiveRecord::TestCase
+ fixtures :authors, :author_addresses, :essays, :posts, :comments, :categorizations, :people
+
+ def test_construct_finder_sql_applies_aliases_tables_on_association_conditions
+ result = Author.left_outer_joins(:thinking_posts, :welcome_posts).to_a
+ assert_equal authors(:david), result.first
+ end
+
+ def test_construct_finder_sql_does_not_table_name_collide_on_duplicate_associations
+ assert_nothing_raised do
+ queries = capture_sql do
+ Person.left_outer_joins(agents: { agents: :agents })
+ .left_outer_joins(agents: { agents: { primary_contact: :agents } }).to_a
+ end
+ assert queries.any? { |sql| /agents_people_4/i.match?(sql) }
+ end
+ end
+
+ def test_left_outer_joins_count_is_same_as_size_of_loaded_results
+ assert_equal 17, Post.left_outer_joins(:comments).to_a.size
+ assert_equal 17, Post.left_outer_joins(:comments).count
+ end
+
+ def test_left_joins_aliases_left_outer_joins
+ assert_equal Post.left_outer_joins(:comments).to_sql, Post.left_joins(:comments).to_sql
+ end
+
+ def test_left_outer_joins_return_has_value_for_every_comment
+ all_post_ids = Post.pluck(:id)
+ assert_equal all_post_ids, all_post_ids & Post.left_outer_joins(:comments).pluck(:id)
+ end
+
+ def test_left_outer_joins_actually_does_a_left_outer_join
+ queries = capture_sql { Author.left_outer_joins(:posts).to_a }
+ assert queries.any? { |sql| /LEFT OUTER JOIN/i.match?(sql) }
+ end
+
+ def test_construct_finder_sql_ignores_empty_left_outer_joins_hash
+ queries = capture_sql { Author.left_outer_joins({}).to_a }
+ assert queries.none? { |sql| /LEFT OUTER JOIN/i.match?(sql) }
+ end
+
+ def test_construct_finder_sql_ignores_empty_left_outer_joins_array
+ queries = capture_sql { Author.left_outer_joins([]).to_a }
+ assert queries.none? { |sql| /LEFT OUTER JOIN/i.match?(sql) }
+ end
+
+ def test_left_outer_joins_forbids_to_use_string_as_argument
+ assert_raise(ArgumentError) { Author.left_outer_joins('LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id"').to_a }
+ end
+
+ def test_join_conditions_added_to_join_clause
+ queries = capture_sql { Author.left_outer_joins(:essays).to_a }
+ assert queries.any? { |sql| /writer_type.*?=.*?(Author|\?|\$1|\:a1)/i.match?(sql) }
+ assert queries.none? { |sql| /WHERE/i.match?(sql) }
+ end
+
+ def test_find_with_sti_join
+ scope = Post.left_outer_joins(:special_comments).where(id: posts(:sti_comments).id)
+
+ # The join should match SpecialComment and its subclasses only
+ assert_empty scope.where("comments.type" => "Comment")
+ assert_not_empty scope.where("comments.type" => "SpecialComment")
+ assert_not_empty scope.where("comments.type" => "SubSpecialComment")
+ end
+
+ def test_does_not_override_select
+ authors = Author.select("authors.name, #{%{(authors.author_address_id || ' ' || authors.author_address_extra_id) as addr_id}}").left_outer_joins(:posts)
+ assert_predicate authors, :any?
+ assert_respond_to authors.first, :addr_id
+ end
+
+ test "the default scope of the target is applied when joining associations" do
+ author = Author.create! name: "Jon"
+ author.categorizations.create!
+ author.categorizations.create! special: true
+
+ assert_equal [author], Author.where(id: author).left_outer_joins(:special_categorizations)
+ end
+end
diff --git a/activerecord/test/cases/associations/nested_through_associations_test.rb b/activerecord/test/cases/associations/nested_through_associations_test.rb
new file mode 100644
index 0000000000..5821744530
--- /dev/null
+++ b/activerecord/test/cases/associations/nested_through_associations_test.rb
@@ -0,0 +1,622 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/author"
+require "models/post"
+require "models/person"
+require "models/reference"
+require "models/job"
+require "models/reader"
+require "models/comment"
+require "models/tag"
+require "models/tagging"
+require "models/subscriber"
+require "models/book"
+require "models/subscription"
+require "models/rating"
+require "models/member"
+require "models/member_detail"
+require "models/member_type"
+require "models/sponsor"
+require "models/club"
+require "models/organization"
+require "models/category"
+require "models/categorization"
+require "models/membership"
+require "models/essay"
+require "models/hotel"
+require "models/department"
+require "models/chef"
+require "models/cake_designer"
+require "models/drink_designer"
+
+class NestedThroughAssociationsTest < ActiveRecord::TestCase
+ fixtures :authors, :author_addresses, :books, :posts, :subscriptions, :subscribers, :tags, :taggings,
+ :people, :readers, :references, :jobs, :ratings, :comments, :members, :member_details,
+ :member_types, :sponsors, :clubs, :organizations, :categories, :categories_posts,
+ :categorizations, :memberships, :essays
+
+ # Through associations can either use the has_many or has_one macros.
+ #
+ # has_many
+ # - Source reflection can be has_many, has_one, belongs_to or has_and_belongs_to_many
+ # - Through reflection can be has_many, has_one, belongs_to or has_and_belongs_to_many
+ #
+ # has_one
+ # - Source reflection can be has_one or belongs_to
+ # - Through reflection can be has_one or belongs_to
+ #
+ # Additionally, the source reflection and/or through reflection may be subject to
+ # polymorphism and/or STI.
+ #
+ # When testing these, we need to make sure it works via loading the association directly, or
+ # joining the association, or including the association. We also need to ensure that associations
+ # are readonly where relevant.
+
+ # has_many through
+ # Source: has_many through
+ # Through: has_many
+ def test_has_many_through_has_many_with_has_many_through_source_reflection
+ general = tags(:general)
+ assert_equal [general, general], authors(:david).tags
+ end
+
+ def test_has_many_through_has_many_with_has_many_through_source_reflection_preload
+ authors = assert_queries(5) { Author.includes(:tags).to_a }
+ general = tags(:general)
+
+ assert_no_queries do
+ assert_equal [general, general], authors.first.tags
+ end
+ end
+
+ def test_has_many_through_has_many_with_has_many_through_source_reflection_preload_via_joins
+ assert_includes_and_joins_equal(
+ Author.where("tags.id" => tags(:general).id),
+ [authors(:david)], :tags
+ )
+
+ # This ensures that the polymorphism of taggings is being observed correctly
+ authors = Author.joins(:tags).where("taggings.taggable_type" => "FakeModel")
+ assert_empty authors
+ end
+
+ # has_many through
+ # Source: has_many
+ # Through: has_many through
+ def test_has_many_through_has_many_through_with_has_many_source_reflection
+ luke, david = subscribers(:first), subscribers(:second)
+ assert_equal [luke, david, david], authors(:david).subscribers.order("subscribers.nick")
+ end
+
+ def test_has_many_through_has_many_through_with_has_many_source_reflection_preload
+ luke, david = subscribers(:first), subscribers(:second)
+ authors = assert_queries(4) { Author.includes(:subscribers).to_a }
+ assert_no_queries do
+ assert_equal [luke, david, david], authors.first.subscribers.sort_by(&:nick)
+ end
+ end
+
+ def test_has_many_through_has_many_through_with_has_many_source_reflection_preload_via_joins
+ # All authors with subscribers where one of the subscribers' nick is 'alterself'
+ assert_includes_and_joins_equal(
+ Author.where("subscribers.nick" => "alterself"),
+ [authors(:david)], :subscribers
+ )
+ end
+
+ # has_many through
+ # Source: has_one through
+ # Through: has_one
+ def test_has_many_through_has_one_with_has_one_through_source_reflection
+ assert_equal [member_types(:founding)], members(:groucho).nested_member_types
+ end
+
+ def test_has_many_through_has_one_with_has_one_through_source_reflection_preload
+ members = assert_queries(4) { Member.includes(:nested_member_types).to_a }
+ founding = member_types(:founding)
+ assert_no_queries do
+ assert_equal [founding], members.first.nested_member_types
+ end
+ end
+
+ def test_has_many_through_has_one_with_has_one_through_source_reflection_preload_via_joins
+ assert_includes_and_joins_equal(
+ Member.where("member_types.id" => member_types(:founding).id),
+ [members(:groucho)], :nested_member_types
+ )
+ end
+
+ # has_many through
+ # Source: has_one
+ # Through: has_one through
+ def test_has_many_through_has_one_through_with_has_one_source_reflection
+ assert_equal [sponsors(:moustache_club_sponsor_for_groucho)], members(:groucho).nested_sponsors
+ end
+
+ def test_has_many_through_has_one_through_with_has_one_source_reflection_preload
+ members = assert_queries(4) { Member.includes(:nested_sponsors).to_a }
+ mustache = sponsors(:moustache_club_sponsor_for_groucho)
+ assert_no_queries do
+ assert_equal [mustache], members.first.nested_sponsors
+ end
+ end
+
+ def test_has_many_through_has_one_through_with_has_one_source_reflection_preload_via_joins
+ assert_includes_and_joins_equal(
+ Member.where("sponsors.id" => sponsors(:moustache_club_sponsor_for_groucho).id),
+ [members(:groucho)], :nested_sponsors
+ )
+ end
+
+ # has_many through
+ # Source: has_many through
+ # Through: has_one
+ def test_has_many_through_has_one_with_has_many_through_source_reflection
+ groucho_details, other_details = member_details(:groucho), member_details(:some_other_guy)
+
+ assert_equal [groucho_details, other_details],
+ members(:groucho).organization_member_details.order("member_details.id")
+ end
+
+ def test_has_many_through_has_one_with_has_many_through_source_reflection_preload
+ ActiveRecord::Base.connection.table_alias_length # preheat cache
+ members = assert_queries(4) { Member.includes(:organization_member_details).to_a.sort_by(&:id) }
+ groucho_details, other_details = member_details(:groucho), member_details(:some_other_guy)
+
+ assert_no_queries do
+ assert_equal [groucho_details, other_details], members.first.organization_member_details.sort_by(&:id)
+ end
+ end
+
+ def test_has_many_through_has_one_with_has_many_through_source_reflection_preload_via_joins
+ assert_includes_and_joins_equal(
+ Member.where("member_details.id" => member_details(:groucho).id).order("member_details.id"),
+ [members(:groucho), members(:some_other_guy)], :organization_member_details
+ )
+
+ members = Member.joins(:organization_member_details).
+ where("member_details.id" => 9)
+ assert_empty members
+ end
+
+ # has_many through
+ # Source: has_many
+ # Through: has_one through
+ def test_has_many_through_has_one_through_with_has_many_source_reflection
+ groucho_details, other_details = member_details(:groucho), member_details(:some_other_guy)
+
+ assert_equal [groucho_details, other_details],
+ members(:groucho).organization_member_details_2.order("member_details.id")
+ end
+
+ def test_has_many_through_has_one_through_with_has_many_source_reflection_preload
+ members = assert_queries(4) { Member.includes(:organization_member_details_2).to_a.sort_by(&:id) }
+ groucho_details, other_details = member_details(:groucho), member_details(:some_other_guy)
+
+ # postgresql test if randomly executed then executes "SHOW max_identifier_length". Hence
+ # the need to ignore certain predefined sqls that deal with system calls.
+ assert_no_queries do
+ assert_equal [groucho_details, other_details], members.first.organization_member_details_2.sort_by(&:id)
+ end
+ end
+
+ def test_has_many_through_has_one_through_with_has_many_source_reflection_preload_via_joins
+ assert_includes_and_joins_equal(
+ Member.where("member_details.id" => member_details(:groucho).id).order("member_details.id"),
+ [members(:groucho), members(:some_other_guy)], :organization_member_details_2
+ )
+
+ members = Member.joins(:organization_member_details_2).
+ where("member_details.id" => 9)
+ assert_empty members
+ end
+
+ # has_many through
+ # Source: has_and_belongs_to_many
+ # Through: has_many
+ def test_has_many_through_has_many_with_has_and_belongs_to_many_source_reflection
+ general, cooking = categories(:general), categories(:cooking)
+
+ assert_equal [general, cooking], authors(:bob).post_categories.order("categories.id")
+ end
+
+ def test_has_many_through_has_many_with_has_and_belongs_to_many_source_reflection_preload
+ authors = assert_queries(4) { Author.includes(:post_categories).to_a.sort_by(&:id) }
+ general, cooking = categories(:general), categories(:cooking)
+
+ assert_no_queries do
+ assert_equal [general, cooking], authors[2].post_categories.sort_by(&:id)
+ end
+ end
+
+ def test_has_many_through_has_many_with_has_and_belongs_to_many_source_reflection_preload_via_joins
+ # preload table schemas
+ Author.joins(:post_categories).first
+
+ assert_includes_and_joins_equal(
+ Author.where("categories.id" => categories(:cooking).id),
+ [authors(:bob)], :post_categories
+ )
+ end
+
+ # has_many through
+ # Source: has_many
+ # Through: has_and_belongs_to_many
+ def test_has_many_through_has_and_belongs_to_many_with_has_many_source_reflection
+ greetings, more = comments(:greetings), comments(:more_greetings)
+
+ assert_equal [greetings, more], categories(:technology).post_comments.order("comments.id")
+ end
+
+ def test_has_many_through_has_and_belongs_to_many_with_has_many_source_reflection_preload
+ Category.includes(:post_comments).to_a # preheat cache
+ categories = assert_queries(4) { Category.includes(:post_comments).to_a.sort_by(&:id) }
+ greetings, more = comments(:greetings), comments(:more_greetings)
+
+ assert_no_queries do
+ assert_equal [greetings, more], categories[1].post_comments.sort_by(&:id)
+ end
+ end
+
+ def test_has_many_through_has_and_belongs_to_many_with_has_many_source_reflection_preload_via_joins
+ # preload table schemas
+ Category.joins(:post_comments).first
+
+ assert_includes_and_joins_equal(
+ Category.where("comments.id" => comments(:more_greetings).id).order("categories.id"),
+ [categories(:general), categories(:technology)], :post_comments
+ )
+ end
+
+ # has_many through
+ # Source: has_many through a habtm
+ # Through: has_many through
+ def test_has_many_through_has_many_with_has_many_through_habtm_source_reflection
+ greetings, more = comments(:greetings), comments(:more_greetings)
+
+ assert_equal [greetings, more], authors(:bob).category_post_comments.order("comments.id")
+ end
+
+ def test_has_many_through_has_many_with_has_many_through_habtm_source_reflection_preload
+ authors = assert_queries(6) { Author.includes(:category_post_comments).to_a.sort_by(&:id) }
+ greetings, more = comments(:greetings), comments(:more_greetings)
+
+ assert_no_queries do
+ assert_equal [greetings, more], authors[2].category_post_comments.sort_by(&:id)
+ end
+ end
+
+ def test_has_many_through_has_many_with_has_many_through_habtm_source_reflection_preload_via_joins
+ # preload table schemas
+ Author.joins(:category_post_comments).first
+
+ assert_includes_and_joins_equal(
+ Author.where("comments.id" => comments(:does_it_hurt).id).order("authors.id"),
+ [authors(:david), authors(:mary)], :category_post_comments
+ )
+ end
+
+ # has_many through
+ # Source: belongs_to
+ # Through: has_many through
+ def test_has_many_through_has_many_through_with_belongs_to_source_reflection
+ assert_equal [tags(:general), tags(:general)], authors(:david).tagging_tags
+ end
+
+ def test_has_many_through_has_many_through_with_belongs_to_source_reflection_preload
+ authors = assert_queries(5) { Author.includes(:tagging_tags).to_a }
+ general = tags(:general)
+
+ assert_no_queries do
+ assert_equal [general, general], authors.first.tagging_tags
+ end
+ end
+
+ def test_has_many_through_has_many_through_with_belongs_to_source_reflection_preload_via_joins
+ assert_includes_and_joins_equal(
+ Author.where("tags.id" => tags(:general).id),
+ [authors(:david)], :tagging_tags
+ )
+ end
+
+ # has_many through
+ # Source: has_many through
+ # Through: belongs_to
+ def test_has_many_through_belongs_to_with_has_many_through_source_reflection
+ welcome_general, thinking_general = taggings(:welcome_general), taggings(:thinking_general)
+
+ assert_equal [welcome_general, thinking_general],
+ categorizations(:david_welcome_general).post_taggings.order("taggings.id")
+ end
+
+ def test_has_many_through_belongs_to_with_has_many_through_source_reflection_preload
+ categorizations = assert_queries(4) { Categorization.includes(:post_taggings).to_a.sort_by(&:id) }
+ welcome_general, thinking_general = taggings(:welcome_general), taggings(:thinking_general)
+
+ assert_no_queries do
+ assert_equal [welcome_general, thinking_general], categorizations.first.post_taggings.sort_by(&:id)
+ end
+ end
+
+ def test_has_many_through_belongs_to_with_has_many_through_source_reflection_preload_via_joins
+ assert_includes_and_joins_equal(
+ Categorization.where("taggings.id" => taggings(:welcome_general).id).order("taggings.id"),
+ [categorizations(:david_welcome_general)], :post_taggings
+ )
+ end
+
+ # has_one through
+ # Source: has_one through
+ # Through: has_one
+ def test_has_one_through_has_one_with_has_one_through_source_reflection
+ assert_equal member_types(:founding), members(:groucho).nested_member_type
+ end
+
+ def test_has_one_through_has_one_with_has_one_through_source_reflection_preload
+ members = assert_queries(4) { Member.includes(:nested_member_type).to_a.sort_by(&:id) }
+ founding = member_types(:founding)
+
+ assert_no_queries do
+ assert_equal founding, members.first.nested_member_type
+ end
+ end
+
+ def test_has_one_through_has_one_with_has_one_through_source_reflection_preload_via_joins
+ assert_includes_and_joins_equal(
+ Member.where("member_types.id" => member_types(:founding).id),
+ [members(:groucho)], :nested_member_type
+ )
+ end
+
+ # has_one through
+ # Source: belongs_to
+ # Through: has_one through
+ def test_has_one_through_has_one_through_with_belongs_to_source_reflection
+ assert_equal categories(:general), members(:groucho).club_category
+ end
+
+ def test_joins_and_includes_from_through_models_not_included_in_association
+ prev_default_scope = Club.default_scopes
+
+ [:includes, :preload, :joins, :eager_load].each do |q|
+ Club.default_scopes = [proc { Club.send(q, :category) }]
+ assert_equal categories(:general), members(:groucho).reload.club_category
+ end
+ ensure
+ Club.default_scopes = prev_default_scope
+ end
+
+ def test_has_one_through_has_one_through_with_belongs_to_source_reflection_preload
+ members = assert_queries(4) { Member.includes(:club_category).to_a.sort_by(&:id) }
+ general = categories(:general)
+
+ assert_no_queries do
+ assert_equal general, members.first.club_category
+ end
+ end
+
+ def test_has_one_through_has_one_through_with_belongs_to_source_reflection_preload_via_joins
+ assert_includes_and_joins_equal(
+ Member.where("categories.id" => categories(:technology).id),
+ [members(:blarpy_winkup)], :club_category
+ )
+ end
+
+ def test_distinct_has_many_through_a_has_many_through_association_on_source_reflection
+ author = authors(:david)
+ assert_equal [tags(:general)], author.distinct_tags
+ end
+
+ def test_distinct_has_many_through_a_has_many_through_association_on_through_reflection
+ author = authors(:david)
+ assert_equal [subscribers(:first), subscribers(:second)],
+ author.distinct_subscribers.order("subscribers.nick")
+ end
+
+ def test_nested_has_many_through_with_a_table_referenced_multiple_times
+ author = authors(:bob)
+ assert_equal [posts(:misc_by_bob), posts(:misc_by_mary), posts(:other_by_bob), posts(:other_by_mary)],
+ author.similar_posts.sort_by(&:id)
+
+ # Mary and Bob both have posts in misc, but they are the only ones.
+ authors = Author.joins(:similar_posts).where("posts.id" => posts(:misc_by_bob).id)
+ assert_equal [authors(:mary), authors(:bob)], authors.distinct.sort_by(&:id)
+
+ # Check the polymorphism of taggings is being observed correctly (in both joins)
+ authors = Author.joins(:similar_posts).where("taggings.taggable_type" => "FakeModel")
+ assert_empty authors
+ authors = Author.joins(:similar_posts).where("taggings_authors_join.taggable_type" => "FakeModel")
+ assert_empty authors
+ end
+
+ def test_nested_has_many_through_with_scope_on_polymorphic_reflection
+ authors = Author.joins(:ordered_posts).where("posts.id" => posts(:misc_by_bob).id)
+ assert_equal [authors(:mary), authors(:bob)], authors.distinct.sort_by(&:id)
+ end
+
+ def test_has_many_through_with_foreign_key_option_on_through_reflection
+ assert_equal [posts(:welcome), posts(:authorless)], people(:david).agents_posts.order("posts.id")
+ assert_equal [authors(:david)], references(:david_unicyclist).agents_posts_authors
+
+ references = Reference.joins(:agents_posts_authors).where("authors.id" => authors(:david).id)
+ assert_equal [references(:david_unicyclist)], references
+ end
+
+ def test_has_many_through_with_foreign_key_option_on_source_reflection
+ assert_equal [people(:michael), people(:susan)], jobs(:unicyclist).agents.order("people.id")
+
+ jobs = Job.joins(:agents)
+ assert_equal [jobs(:unicyclist), jobs(:unicyclist)], jobs
+ end
+
+ def test_has_many_through_with_sti_on_through_reflection
+ ratings = posts(:sti_comments).special_comments_ratings.sort_by(&:id)
+ assert_equal [ratings(:special_comment_rating), ratings(:sub_special_comment_rating)], ratings
+
+ # Ensure STI is respected in the join
+ scope = Post.joins(:special_comments_ratings).where(id: posts(:sti_comments).id)
+ assert_empty scope.where("comments.type" => "Comment")
+ assert_not_empty scope.where("comments.type" => "SpecialComment")
+ assert_not_empty scope.where("comments.type" => "SubSpecialComment")
+ end
+
+ def test_has_many_through_with_sti_on_nested_through_reflection
+ taggings = posts(:sti_comments).special_comments_ratings_taggings
+ assert_equal [taggings(:special_comment_rating)], taggings
+
+ scope = Post.joins(:special_comments_ratings_taggings).where(id: posts(:sti_comments).id)
+ assert_empty scope.where("comments.type" => "Comment")
+ assert_not_empty scope.where("comments.type" => "SpecialComment")
+ end
+
+ def test_nested_has_many_through_writers_should_raise_error
+ david = authors(:david)
+ subscriber = subscribers(:first)
+
+ assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do
+ david.subscribers = [subscriber]
+ end
+
+ assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do
+ david.subscriber_ids = [subscriber.id]
+ end
+
+ assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do
+ david.subscribers << subscriber
+ end
+
+ assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do
+ david.subscribers.delete(subscriber)
+ end
+
+ assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do
+ david.subscribers.clear
+ end
+
+ assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do
+ david.subscribers.build
+ end
+
+ assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do
+ david.subscribers.create
+ end
+ end
+
+ def test_nested_has_one_through_writers_should_raise_error
+ groucho = members(:groucho)
+ founding = member_types(:founding)
+
+ assert_raises(ActiveRecord::HasOneThroughNestedAssociationsAreReadonly) do
+ groucho.nested_member_type = founding
+ end
+ end
+
+ def test_nested_has_many_through_with_conditions_on_through_associations
+ assert_equal [tags(:blue)], authors(:bob).misc_post_first_blue_tags
+ end
+
+ def test_nested_has_many_through_with_conditions_on_through_associations_preload
+ assert_empty Author.where("tags.id" => 100).joins(:misc_post_first_blue_tags)
+
+ authors = assert_queries(3) { Author.includes(:misc_post_first_blue_tags).to_a.sort_by(&:id) }
+ blue = tags(:blue)
+
+ assert_no_queries do
+ assert_equal [blue], authors[2].misc_post_first_blue_tags
+ end
+ end
+
+ def test_nested_has_many_through_with_conditions_on_through_associations_preload_via_joins
+ # Pointless condition to force single-query loading
+ assert_includes_and_joins_equal(
+ Author.where("tags.id = tags.id").references(:tags),
+ [authors(:bob)], :misc_post_first_blue_tags
+ )
+ end
+
+ def test_nested_has_many_through_with_conditions_on_source_associations
+ assert_equal [tags(:blue)], authors(:bob).misc_post_first_blue_tags_2
+ end
+
+ def test_nested_has_many_through_with_conditions_on_source_associations_preload
+ authors = assert_queries(4) { Author.includes(:misc_post_first_blue_tags_2).to_a.sort_by(&:id) }
+ blue = tags(:blue)
+
+ assert_no_queries do
+ assert_equal [blue], authors[2].misc_post_first_blue_tags_2
+ end
+ end
+
+ def test_nested_has_many_through_with_conditions_on_source_associations_preload_via_joins
+ # Pointless condition to force single-query loading
+ assert_includes_and_joins_equal(
+ Author.where("tags.id = tags.id").references(:tags),
+ [authors(:bob)], :misc_post_first_blue_tags_2
+ )
+ end
+
+ def test_nested_has_many_through_with_foreign_key_option_on_the_source_reflection_through_reflection
+ assert_equal [categories(:general)], organizations(:nsa).author_essay_categories
+
+ organizations = Organization.joins(:author_essay_categories).
+ where("categories.id" => categories(:general).id)
+ assert_equal [organizations(:nsa)], organizations
+
+ assert_equal categories(:general), organizations(:nsa).author_owned_essay_category
+
+ organizations = Organization.joins(:author_owned_essay_category).
+ where("categories.id" => categories(:general).id)
+ assert_equal [organizations(:nsa)], organizations
+ end
+
+ def test_nested_has_many_through_should_not_be_autosaved
+ c = Categorization.new
+ c.author = authors(:david)
+ c.post_taggings.to_a
+ assert_not_empty c.post_taggings
+ c.save
+ assert_not_empty c.post_taggings
+ end
+
+ def test_polymorphic_has_many_through_when_through_association_has_not_loaded
+ cake_designer = CakeDesigner.create!(chef: Chef.new)
+ drink_designer = DrinkDesigner.create!(chef: Chef.new)
+ department = Department.create!(chefs: [cake_designer.chef, drink_designer.chef])
+ Hotel.create!(departments: [department])
+ hotel = Hotel.includes(:cake_designers, :drink_designers).take
+
+ assert_equal [cake_designer], hotel.cake_designers
+ assert_equal [drink_designer], hotel.drink_designers
+ end
+
+ def test_polymorphic_has_many_through_when_through_association_has_already_loaded
+ cake_designer = CakeDesigner.create!(chef: Chef.new)
+ drink_designer = DrinkDesigner.create!(chef: Chef.new)
+ department = Department.create!(chefs: [cake_designer.chef, drink_designer.chef])
+ Hotel.create!(departments: [department])
+ hotel = Hotel.includes(:chefs, :cake_designers, :drink_designers).take
+
+ assert_equal [cake_designer], hotel.cake_designers
+ assert_equal [drink_designer], hotel.drink_designers
+ end
+
+ def test_polymorphic_has_many_through_joined_different_table_twice
+ cake_designer = CakeDesigner.create!(chef: Chef.new)
+ drink_designer = DrinkDesigner.create!(chef: Chef.new)
+ department = Department.create!(chefs: [cake_designer.chef, drink_designer.chef])
+ hotel = Hotel.create!(departments: [department])
+
+ assert_equal hotel, Hotel.joins(:cake_designers, :drink_designers).take
+ end
+
+ private
+
+ def assert_includes_and_joins_equal(query, expected, association)
+ actual = assert_queries(1) { query.joins(association).to_a.uniq }
+ assert_equal expected, actual
+
+ actual = assert_queries(1) { query.includes(association).to_a.uniq }
+ assert_equal expected, actual
+ end
+end
diff --git a/activerecord/test/cases/associations/required_test.rb b/activerecord/test/cases/associations/required_test.rb
new file mode 100644
index 0000000000..c7a78e6bc4
--- /dev/null
+++ b/activerecord/test/cases/associations/required_test.rb
@@ -0,0 +1,128 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class RequiredAssociationsTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ class Parent < ActiveRecord::Base
+ end
+
+ class Child < ActiveRecord::Base
+ end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table :parents, force: true
+ @connection.create_table :children, force: true do |t|
+ t.belongs_to :parent
+ end
+ end
+
+ teardown do
+ @connection.drop_table "parents", if_exists: true
+ @connection.drop_table "children", if_exists: true
+ end
+
+ test "belongs_to associations can be optional by default" do
+ original_value = ActiveRecord::Base.belongs_to_required_by_default
+ ActiveRecord::Base.belongs_to_required_by_default = false
+
+ model = subclass_of(Child) do
+ belongs_to :parent, inverse_of: false,
+ class_name: "RequiredAssociationsTest::Parent"
+ end
+
+ assert model.new.save
+ assert model.new(parent: Parent.new).save
+ ensure
+ ActiveRecord::Base.belongs_to_required_by_default = original_value
+ end
+
+ test "required belongs_to associations have presence validated" do
+ model = subclass_of(Child) do
+ belongs_to :parent, required: true, inverse_of: false,
+ class_name: "RequiredAssociationsTest::Parent"
+ end
+
+ record = model.new
+ assert_not record.save
+ assert_equal ["Parent must exist"], record.errors.full_messages
+
+ record.parent = Parent.new
+ assert record.save
+ end
+
+ test "belongs_to associations can be required by default" do
+ original_value = ActiveRecord::Base.belongs_to_required_by_default
+ ActiveRecord::Base.belongs_to_required_by_default = true
+
+ model = subclass_of(Child) do
+ belongs_to :parent, inverse_of: false,
+ class_name: "RequiredAssociationsTest::Parent"
+ end
+
+ record = model.new
+ assert_not record.save
+ assert_equal ["Parent must exist"], record.errors.full_messages
+
+ record.parent = Parent.new
+ assert record.save
+ ensure
+ ActiveRecord::Base.belongs_to_required_by_default = original_value
+ end
+
+ test "has_one associations are not required by default" do
+ model = subclass_of(Parent) do
+ has_one :child, inverse_of: false,
+ class_name: "RequiredAssociationsTest::Child"
+ end
+
+ assert model.new.save
+ assert model.new(child: Child.new).save
+ end
+
+ test "required has_one associations have presence validated" do
+ model = subclass_of(Parent) do
+ has_one :child, required: true, inverse_of: false,
+ class_name: "RequiredAssociationsTest::Child"
+ end
+
+ record = model.new
+ assert_not record.save
+ assert_equal ["Child must exist"], record.errors.full_messages
+
+ record.child = Child.new
+ assert record.save
+ end
+
+ test "required has_one associations have a correct error message" do
+ model = subclass_of(Parent) do
+ has_one :child, required: true, inverse_of: false,
+ class_name: "RequiredAssociationsTest::Child"
+ end
+
+ record = model.create
+ assert_equal ["Child must exist"], record.errors.full_messages
+ end
+
+ test "required belongs_to associations have a correct error message" do
+ model = subclass_of(Child) do
+ belongs_to :parent, required: true, inverse_of: false,
+ class_name: "RequiredAssociationsTest::Parent"
+ end
+
+ record = model.create
+ assert_equal ["Parent must exist"], record.errors.full_messages
+ end
+
+ private
+
+ def subclass_of(klass, &block)
+ subclass = Class.new(klass, &block)
+ def subclass.name
+ superclass.name
+ end
+ subclass
+ end
+end
diff --git a/activerecord/test/cases/associations_test.rb b/activerecord/test/cases/associations_test.rb
new file mode 100644
index 0000000000..081da95df7
--- /dev/null
+++ b/activerecord/test/cases/associations_test.rb
@@ -0,0 +1,370 @@
+# 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/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/man"
+require "models/interest"
+
+class AssociationsTest < ActiveRecord::TestCase
+ fixtures :accounts, :companies, :developers, :projects, :developers_projects,
+ :computers, :people, :readers, :authors, :author_addresses, :author_favorites
+
+ 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_includes firm.association_with_references.references_values, "foo"
+ 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)
+ 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
+ man = Man.create
+ man.interests.create
+
+ man = Man.find(man.id)
+
+ assert_queries(1) do
+ assert_equal man, man.interests.where("1=1").first.man
+ 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_reset_unloads_target
+ david = authors(:david)
+ david.posts.reload
+
+ assert_predicate david.posts, :loaded?
+ david.posts.reset
+ assert_not_predicate david.posts, :loaded?
+ 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
+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
diff --git a/activerecord/test/cases/attribute_decorators_test.rb b/activerecord/test/cases/attribute_decorators_test.rb
new file mode 100644
index 0000000000..42eca233ce
--- /dev/null
+++ b/activerecord/test/cases/attribute_decorators_test.rb
@@ -0,0 +1,127 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ class AttributeDecoratorsTest < ActiveRecord::TestCase
+ class Model < ActiveRecord::Base
+ self.table_name = "attribute_decorators_model"
+ end
+
+ class StringDecorator < SimpleDelegator
+ def initialize(delegate, decoration = "decorated!")
+ @decoration = decoration
+ super(delegate)
+ end
+
+ def cast(value)
+ "#{super} #{@decoration}"
+ end
+
+ alias deserialize cast
+ end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table :attribute_decorators_model, force: true do |t|
+ t.string :a_string
+ end
+ end
+
+ teardown do
+ return unless @connection
+ @connection.drop_table "attribute_decorators_model", if_exists: true
+ Model.attribute_type_decorations.clear
+ Model.reset_column_information
+ end
+
+ test "attributes can be decorated" do
+ model = Model.new(a_string: "Hello")
+ assert_equal "Hello", model.a_string
+
+ Model.decorate_attribute_type(:a_string, :test) { |t| StringDecorator.new(t) }
+
+ model = Model.new(a_string: "Hello")
+ assert_equal "Hello decorated!", model.a_string
+ end
+
+ test "decoration does not eagerly load existing columns" do
+ Model.reset_column_information
+ assert_no_queries do
+ Model.decorate_attribute_type(:a_string, :test) { |t| StringDecorator.new(t) }
+ end
+ end
+
+ test "undecorated columns are not touched" do
+ Model.attribute :another_string, :string, default: "something or other"
+ Model.decorate_attribute_type(:a_string, :test) { |t| StringDecorator.new(t) }
+
+ assert_equal "something or other", Model.new.another_string
+ end
+
+ test "decorators can be chained" do
+ Model.decorate_attribute_type(:a_string, :test) { |t| StringDecorator.new(t) }
+ Model.decorate_attribute_type(:a_string, :other) { |t| StringDecorator.new(t) }
+
+ model = Model.new(a_string: "Hello!")
+
+ assert_equal "Hello! decorated! decorated!", model.a_string
+ end
+
+ test "decoration of the same type multiple times is idempotent" do
+ Model.decorate_attribute_type(:a_string, :test) { |t| StringDecorator.new(t) }
+ Model.decorate_attribute_type(:a_string, :test) { |t| StringDecorator.new(t) }
+
+ model = Model.new(a_string: "Hello")
+ assert_equal "Hello decorated!", model.a_string
+ end
+
+ test "decorations occur in order of declaration" do
+ Model.decorate_attribute_type(:a_string, :test) { |t| StringDecorator.new(t) }
+ Model.decorate_attribute_type(:a_string, :other) do |type|
+ StringDecorator.new(type, "decorated again!")
+ end
+
+ model = Model.new(a_string: "Hello!")
+
+ assert_equal "Hello! decorated! decorated again!", model.a_string
+ end
+
+ test "decorating attributes does not modify parent classes" do
+ Model.attribute :another_string, :string, default: "whatever"
+ Model.decorate_attribute_type(:a_string, :test) { |t| StringDecorator.new(t) }
+ child_class = Class.new(Model)
+ child_class.decorate_attribute_type(:another_string, :test) { |t| StringDecorator.new(t) }
+ child_class.decorate_attribute_type(:a_string, :other) { |t| StringDecorator.new(t) }
+
+ model = Model.new(a_string: "Hello!")
+ child = child_class.new(a_string: "Hello!")
+
+ assert_equal "Hello! decorated!", model.a_string
+ assert_equal "whatever", model.another_string
+ assert_equal "Hello! decorated! decorated!", child.a_string
+ assert_equal "whatever decorated!", child.another_string
+ end
+
+ class Multiplier < SimpleDelegator
+ def cast(value)
+ return if value.nil?
+ value * 2
+ end
+ alias deserialize cast
+ end
+
+ test "decorating with a proc" do
+ Model.attribute :an_int, :integer
+ type_is_integer = proc { |_, type| type.type == :integer }
+ Model.decorate_matching_attribute_types type_is_integer, :multiplier do |type|
+ Multiplier.new(type)
+ end
+
+ model = Model.new(a_string: "whatever", an_int: 1)
+
+ assert_equal "whatever", model.a_string
+ assert_equal 2, model.an_int
+ end
+ end
+end
diff --git a/activerecord/test/cases/attribute_methods/read_test.rb b/activerecord/test/cases/attribute_methods/read_test.rb
new file mode 100644
index 0000000000..54512068ee
--- /dev/null
+++ b/activerecord/test/cases/attribute_methods/read_test.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ module AttributeMethods
+ class ReadTest < ActiveRecord::TestCase
+ FakeColumn = Struct.new(:name) do
+ def type; :integer; end
+ end
+
+ def setup
+ @klass = Class.new(Class.new { def self.initialize_generated_modules; end }) do
+ def self.superclass; Base; end
+ def self.base_class?; true; end
+ def self.decorate_matching_attribute_types(*); end
+
+ include ActiveRecord::DefineCallbacks
+ include ActiveRecord::AttributeMethods
+
+ def self.attribute_names
+ %w{ one two three }
+ end
+
+ def self.primary_key
+ end
+
+ def self.columns
+ attribute_names.map { FakeColumn.new(name) }
+ end
+
+ def self.columns_hash
+ Hash[attribute_names.map { |name|
+ [name, FakeColumn.new(name)]
+ }]
+ end
+ end
+ end
+
+ def test_define_attribute_methods
+ instance = @klass.new
+
+ @klass.attribute_names.each do |name|
+ assert_not_includes instance.methods.map(&:to_s), name
+ end
+
+ @klass.define_attribute_methods
+
+ @klass.attribute_names.each do |name|
+ assert_includes instance.methods.map(&:to_s), name, "#{name} is not defined"
+ end
+ end
+
+ def test_attribute_methods_generated?
+ assert_not @klass.method_defined?(:one)
+ @klass.define_attribute_methods
+ assert @klass.method_defined?(:one)
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/attribute_methods_test.rb b/activerecord/test/cases/attribute_methods_test.rb
new file mode 100644
index 0000000000..d341dd0083
--- /dev/null
+++ b/activerecord/test/cases/attribute_methods_test.rb
@@ -0,0 +1,1046 @@
+# 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"
+
+class AttributeMethodsTest < ActiveRecord::TestCase
+ include InTimeZone
+
+ fixtures :topics, :developers, :companies, :computers
+
+ def setup
+ @old_matchers = ActiveRecord::Base.send(:attribute_method_matchers).dup
+ @target = Class.new(ActiveRecord::Base)
+ @target.table_name = "topics"
+ end
+
+ teardown do
+ ActiveRecord::Base.send(:attribute_method_matchers).clear
+ ActiveRecord::Base.send(:attribute_method_matchers).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)
+ end
+
+ test "attribute_for_inspect with a date" do
+ t = topics(:first)
+
+ assert_equal %("#{t.written_on.to_s(:db)}"), 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?("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_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
+ # DB2 is not case-sensitive.
+ return true if current_adapter?(:DB2Adapter)
+
+ assert_equal @loaded_fixtures["computers"]["workstation"].to_hash, Computer.first.attributes
+ 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" }
+ new_topic_values = { title: "AnotherTopic" }
+
+ topic = Topic.new(new_topic)
+ assert_equal new_topic[:title], topic.title
+
+ topic.attributes = new_topic_values
+ assert_equal new_topic_values[:title], topic.title
+ 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.send(: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.send(: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.send(: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.send(: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 "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?
+ 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 "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.send(meth)
+ assert_equal ["title", "a"], topic.send(meth, "a")
+ assert_equal ["title", 1, 2, 3], topic.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.send(meth)
+ assert_equal ["title", "a"], topic.send(meth, "a")
+ assert_equal ["title", 1, 2, 3], topic.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.send(meth)
+ assert_equal ["title", "a"], topic.send(meth, "a")
+ assert_equal ["title", 1, 2, 3], topic.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, :FbAdapter)
+ 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, :FbAdapter)
+ 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"
+ assert_equal record, YAML.load(YAML.dump(record))
+ end
+ 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_equal "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)
+
+ 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
+ # Sanity check, make sure 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 class" do
+ mod = Topic.send(:generated_attribute_methods)
+ assert_match %r(GeneratedAttributeMethods), mod.inspect
+ 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
diff --git a/activerecord/test/cases/attributes_test.rb b/activerecord/test/cases/attributes_test.rb
new file mode 100644
index 0000000000..2632aec7ab
--- /dev/null
+++ b/activerecord/test/cases/attributes_test.rb
@@ -0,0 +1,285 @@
+# 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 "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
+ assert_equal %w(id overloaded_float unoverloaded_float overloaded_string_with_limit string_with_default non_existent_decimal), attribute_names
+ end
+
+ test "caches are cleared" do
+ klass = Class.new(OverloadedType)
+
+ assert_equal 6, klass.attribute_types.length
+ assert_equal 6, klass.column_defaults.length
+ assert_equal 6, klass.attribute_names.length
+ assert_not klass.attribute_types.include?("wibble")
+
+ klass.attribute :wibble, Type::Value.new
+
+ assert_equal 7, klass.attribute_types.length
+ assert_equal 7, klass.column_defaults.length
+ assert_equal 7, klass.attribute_names.length
+ assert_includes klass.attribute_types, "wibble"
+ end
+
+ 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.first
+
+ 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.first
+
+ assert_equal 123, model.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.first
+
+ 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
+ end
+end
diff --git a/activerecord/test/cases/autosave_association_test.rb b/activerecord/test/cases/autosave_association_test.rb
new file mode 100644
index 0000000000..88df0eed55
--- /dev/null
+++ b/activerecord/test/cases/autosave_association_test.rb
@@ -0,0 +1,1824 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/bird"
+require "models/post"
+require "models/comment"
+require "models/company"
+require "models/contract"
+require "models/customer"
+require "models/developer"
+require "models/computer"
+require "models/invoice"
+require "models/line_item"
+require "models/order"
+require "models/parrot"
+require "models/pirate"
+require "models/project"
+require "models/ship"
+require "models/ship_part"
+require "models/tag"
+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"
+
+class TestAutosaveAssociationsInGeneral < ActiveRecord::TestCase
+ def test_autosave_validation
+ 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.update!(first_name: "nah") # still valid because validation only applies on 'create'
+ assert_predicate reference.create!(person: u), :persisted?
+ end
+
+ def test_should_not_add_the_same_callbacks_multiple_times_for_has_one
+ 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_writes? ? 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
+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::Base.index_nested_attribute_errors
+ ActiveRecord::Base.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::Base.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::Base.index_nested_attribute_errors
+ ActiveRecord::Base.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::Base.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)
+
+ # Load schema information so we don't query below if running just this test.
+ Client.define_attribute_methods
+
+ new_client = assert_no_queries { company.clients_of_firm.build("name" => "Another Client") }
+ assert_not_predicate company.clients_of_firm, :loaded?
+
+ company.name += "-changed"
+ 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)
+
+ # Load schema information so we don't query below if running just this test.
+ Client.define_attribute_methods
+
+ assert_no_queries { company.clients_of_firm.build([{ "name" => "Another Client" }, { "name" => "Another Client II" }]) }
+
+ company.name += "-changed"
+ assert_queries(3) { assert company.save }
+ assert_equal 4, company.clients_of_firm.reload.size
+ end
+
+ def test_build_via_block_before_save
+ company = companies(:first_firm)
+
+ # Load schema information so we don't query below if running just this test.
+ Client.define_attribute_methods
+
+ new_client = assert_no_queries { company.clients_of_firm.build { |client| client.name = "Another Client" } }
+ assert_not_predicate company.clients_of_firm, :loaded?
+
+ company.name += "-changed"
+ 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)
+
+ # Load schema information so we don't query below if running just this test.
+ Client.define_attribute_methods
+
+ assert_no_queries do
+ company.clients_of_firm.build([{ "name" => "Another Client" }, { "name" => "Another Client II" }]) do |client|
+ client.name = "changed"
+ end
+ 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
+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
+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(*args)
+ 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(*args)
+ 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.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.send(association_name_with_callbacks).create!(name: "Crowe the One-Eyed")
+ @pirate.send(association_name_with_callbacks).each(&:mark_for_destruction)
+ child_id = @pirate.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.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.send(association_name_with_callbacks).create!(name: "Crowe the One-Eyed")
+ @pirate.send(association_name_with_callbacks).each(&:mark_for_destruction)
+ child_id = @pirate.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 Insanity"
+ @pirate.save
+ assert_equal "The Vile Insanity", @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 Insanity"
+ @pirate.save!
+ assert_equal "The Vile Insanity", @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 Insanity")
+ 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 Insanity"
+
+ # Stub the save method of the @pirate.ship instance to raise an exception
+ class << @pirate.ship
+ def save(*args)
+ 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 setup
+ super
+ organization = Organization.create
+ @member = Member.create
+ MemberDetail.create(organization: organization, member: @member)
+ end
+
+ def test_should_not_has_one_through_model
+ class << @member.organization
+ def save(*args)
+ super
+ raise "Oh noes!"
+ end
+ end
+ assert_nothing_raised { @member.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 Insanity"
+ @ship.save
+ assert_equal "The Vile Insanity", @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 Insanity")
+ 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 Insanity"
+
+ # Stub the save method of the @ship.pirate instance to raise an exception
+ class << @ship.pirate
+ def save(*args)
+ 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 Insanity"; @ship.save! }
+ end
+end
+
+module AutosaveAssociationOnACollectionAssociationTests
+ def test_should_automatically_save_the_associated_models
+ new_names = ["Grace OMalley", "Privateers Greed"]
+ @pirate.send(@association_name).each_with_index { |child, i| child.name = new_names[i] }
+
+ @pirate.save
+ assert_equal new_names.sort, @pirate.reload.send(@association_name).map(&:name).sort
+ end
+
+ def test_should_automatically_save_bang_the_associated_models
+ new_names = ["Grace OMalley", "Privateers Greed"]
+ @pirate.send(@association_name).each_with_index { |child, i| child.name = new_names[i] }
+
+ @pirate.save!
+ assert_equal new_names.sort, @pirate.reload.send(@association_name).map(&:name).sort
+ end
+
+ def test_should_update_children_when_autosave_is_true_and_parent_is_new_but_child_is_not
+ 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.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.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.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.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.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.send(@association_name).first.name,
+ @pirate.send(@association_name).last.name
+ ]
+ else
+ assert_equal ["", "", ""], [
+ @pirate.reload.catchphrase,
+ @pirate.send(@association_name).first.name,
+ @pirate.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.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.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.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.send(@association_name).map(&:name)]
+ new_names = ["Grace OMalley", "Privateers Greed"]
+
+ @pirate.catchphrase = "Arr"
+ @pirate.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.send(@association_name).first
+ def save(*args)
+ 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.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.send(@association_name).load_target
+
+ assert_queries(3) do
+ @pirate.catchphrase = "Yarr"
+ new_names = ["Grace OMalley", "Privateers Greed"]
+ @pirate.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")
+ end
+
+ test "should automatically validate associations" do
+ assert_predicate @pirate, :valid?
+ @pirate.birds.each { |bird| bird.name = "" }
+
+ assert_not_predicate @pirate, :valid?
+ 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
+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_asssociation_redefined_in_subclass
+ agency = Agency.create!(name: "Agency")
+ valid_project = Project.create!(firm: agency, name: "Initial")
+ agency.update!(
+ "projects_attributes" => {
+ "0" => {
+ "name" => "Updated",
+ "id" => valid_project.id
+ }
+ }
+ )
+ valid_project.reload
+
+ assert_equal "Updated", valid_project.name
+ end
+end
diff --git a/activerecord/test/cases/base_test.rb b/activerecord/test/cases/base_test.rb
new file mode 100644
index 0000000000..4938b6865f
--- /dev/null
+++ b/activerecord/test/cases/base_test.rb
@@ -0,0 +1,1551 @@
+# 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 "concurrent/atomic/count_down_latch"
+
+class FirstAbstractClass < ActiveRecord::Base
+ self.abstract_class = true
+end
+class SecondAbstractClass < FirstAbstractClass
+ 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_column_names_are_escaped
+ conn = ActiveRecord::Base.connection
+ classname = conn.class.name[/[^:]*$/]
+ badchar = {
+ "SQLite3Adapter" => '"',
+ "Mysql2Adapter" => "`",
+ "PostgreSQLAdapter" => '"',
+ "OracleAdapter" => '"',
+ "FbAdapter" => '"'
+ }.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 subsecond_precision_supported?
+ 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 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
+
+ if current_adapter?(:Mysql2Adapter)
+ def test_update_all_with_order_and_limit
+ assert_equal 1, Topic.limit(1).order("id DESC").update_all(content: "bulk updated!")
+ end
+ end
+
+ def test_null_fields
+ assert_nil Topic.find(1).parent_id
+ assert_nil Topic.create("title" => "Hey you").parent_id
+ 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_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
+
+ assert car.bulbs == Bulb.where(car_id: car.id), "CollectionProxy should be comparable with Relation"
+ assert Bulb.where(car_id: car.id) == car.bulbs, "Relation should be comparable with CollectionProxy"
+ end
+
+ def test_equality_of_relation_and_array
+ car = Car.create!
+ car.bulbs.build
+ car.save
+
+ assert Bulb.where(car_id: car.id) == 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
+
+ assert_equal Bulb.where(car_id: car.id), car.bulbs.includes(:car), "Relation should be comparable with AssociationRelation"
+ assert_equal car.bulbs.includes(:car), Bulb.where(car_id: car.id), "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.send("a$b")
+ assert_equal "value", weird.read_attribute("a$b")
+
+ weird.update_columns("a$b" => "value2")
+ weird.reload
+ assert_equal "value2", weird.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
+ 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.map do |attribute_name|
+ [attribute_name, category.public_send(attribute_name)]
+ end.to_h
+
+ 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_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
+ with_timezone_config default: :local do
+ default = Default.new
+
+ # fixed dates / times
+ assert_equal Date.new(2004, 1, 1), default.fixed_date
+ assert_equal Time.local(2004, 1, 1, 0, 0, 0, 0), default.fixed_time
+
+ # char types
+ 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
+ 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_find_last
+ last = Developer.last
+ assert_equal last, Developer.all.merge!(order: "id desc").first
+ end
+
+ def test_last
+ assert_equal Developer.all.merge!(order: "id desc").first, Developer.last
+ end
+
+ 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.all.merge!(order: "developers.salary ASC").last
+ assert_equal last, Developer.all.merge!(order: "developers.salary ASC").to_a.last
+ end
+
+ def test_find_reverse_ordered_last
+ last = Developer.all.merge!(order: "developers.salary DESC").last
+ assert_equal last, Developer.all.merge!(order: "developers.salary DESC").to_a.last
+ end
+
+ def test_find_multiple_ordered_last
+ last = Developer.all.merge!(order: "developers.name, developers.salary DESC").last
+ assert_equal last, Developer.all.merge!(order: "developers.name, developers.salary DESC").to_a.last
+ end
+
+ def test_find_keeps_multiple_order_values
+ combined = Developer.all.merge!(order: "developers.name, developers.salary").to_a
+ assert_equal combined, Developer.all.merge!(order: ["developers.name", "developers.salary"]).to_a
+ end
+
+ def test_find_keeps_multiple_group_values
+ combined = Developer.all.merge!(group: "developers.name, developers.salary, developers.id, developers.created_at, developers.updated_at, developers.created_on, developers.updated_on").to_a
+ assert_equal combined, Developer.all.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")
+ ActiveRecord::Base.clear_cache!
+ c2 = Post.connection.schema_cache.columns("posts")
+ c1.each_with_index do |v, i|
+ assert_not_same v, c2[i]
+ end
+ assert_equal c1, c2
+ end
+
+ def test_current_scope_is_reset
+ Object.const_set :UnloadablePost, Class.new(ActiveRecord::Base)
+ UnloadablePost.send(:current_scope=, UnloadablePost.all)
+
+ UnloadablePost.unloadable
+ klass = UnloadablePost
+ assert_not_nil ActiveRecord::Scoping::ScopeRegistry.value_for(:current_scope, klass)
+ ActiveSupport::Dependencies.remove_unloadable_constants!
+ assert_nil ActiveRecord::Scoping::ScopeRegistry.value_for(:current_scope, klass)
+ ensure
+ Object.class_eval { remove_const :UnloadablePost } if defined?(UnloadablePost)
+ 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_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
+ 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
+ assert_equal ["id", "type", "firm_id", "firm_name", "name", "client_of", "rating", "account_id", "description"],
+ Company.attribute_names
+ end
+
+ def test_has_attribute
+ assert Company.has_attribute?("id")
+ assert Company.has_attribute?("type")
+ assert Company.has_attribute?("name")
+ 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_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_slice
+ company = Company.new(rating: 1, name: "37signals", firm_name: "37signals")
+ hash = company.slice(:name, :rating, "arbitrary_method")
+ assert_equal hash[:name], company.name
+ assert_equal hash["name"], company.name
+ assert_equal hash[:rating], company.rating
+ assert_equal hash["arbitrary_method"], company.arbitrary_method
+ assert_equal hash[:arbitrary_method], company.arbitrary_method
+ assert_nil hash[:firm_name]
+ assert_nil hash["firm_name"]
+ end
+
+ def test_slice_accepts_array_argument
+ attrs = {
+ title: "slice",
+ author_name: "@Cohen-Carlisle",
+ content: "accept arrays so I don't have to splat"
+ }.with_indifferent_access
+ topic = Topic.new(attrs)
+ assert_equal attrs, topic.slice(attrs.keys)
+ 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)
+ 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 "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 "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 "creating a record raises if preventing writes" do
+ error = assert_raises ActiveRecord::ReadOnlyError do
+ ActiveRecord::Base.connection.while_preventing_writes do
+ Bird.create! name: "Bluejay"
+ end
+ end
+
+ assert_match %r/\AWrite query attempted while in readonly mode: INSERT /, error.message
+ end
+
+ test "updating a record raises if preventing writes" do
+ bird = Bird.create! name: "Bluejay"
+
+ error = assert_raises ActiveRecord::ReadOnlyError do
+ ActiveRecord::Base.connection.while_preventing_writes do
+ bird.update! name: "Robin"
+ end
+ end
+
+ assert_match %r/\AWrite query attempted while in readonly mode: UPDATE /, error.message
+ end
+
+ test "deleting a record raises if preventing writes" do
+ bird = Bird.create! name: "Bluejay"
+
+ error = assert_raises ActiveRecord::ReadOnlyError do
+ ActiveRecord::Base.connection.while_preventing_writes do
+ bird.destroy!
+ end
+ end
+
+ assert_match %r/\AWrite query attempted while in readonly mode: DELETE /, error.message
+ end
+
+ test "selecting a record does not raise if preventing writes" do
+ bird = Bird.create! name: "Bluejay"
+
+ ActiveRecord::Base.connection.while_preventing_writes do
+ assert_equal bird, Bird.where(name: "Bluejay").first
+ end
+ end
+
+ test "an explain query does not raise if preventing writes" do
+ Bird.create!(name: "Bluejay")
+
+ ActiveRecord::Base.connection.while_preventing_writes do
+ assert_queries(2) { Bird.where(name: "Bluejay").explain }
+ end
+ end
+
+ test "an empty transaction does not raise if preventing writes" do
+ ActiveRecord::Base.connection.while_preventing_writes do
+ assert_queries(2, ignore_none: true) do
+ Bird.transaction do
+ ActiveRecord::Base.connection.materialize_transactions
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/batches_test.rb b/activerecord/test/cases/batches_test.rb
new file mode 100644
index 0000000000..d21218a997
--- /dev/null
+++ b/activerecord/test/cases/batches_test.rb
@@ -0,0 +1,663 @@
+# 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 #{c.quote_table_name('posts')}\.#{c.quote_column_name('id')}/) do
+ Post.find_in_batches(batch_size: 1) do |batch|
+ assert_kind_of Array, batch
+ assert_kind_of Post, batch.first
+ end
+ 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::Base.error_on_ignored_order
+ ActiveRecord::Base.error_on_ignored_order = true
+ assert_nothing_raised do
+ PostWithDefaultScope.find_in_batches(error_on_ignore: false) { }
+ end
+ ensure
+ # Set back to default
+ ActiveRecord::Base.error_on_ignored_order = prev
+ end
+
+ def test_find_in_batches_should_error_on_config_specified_to_error
+ # Set the config option
+ prev = ActiveRecord::Base.error_on_ignored_order
+ ActiveRecord::Base.error_on_ignored_order = true
+ assert_raise(ArgumentError) do
+ PostWithDefaultScope.find_in_batches() { }
+ end
+ ensure
+ # Set back to default
+ ActiveRecord::Base.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
+ special_posts_ids = SpecialPostWithDefaultScope.all.map(&:id).sort
+ posts = []
+ SpecialPostWithDefaultScope.find_in_batches do |batch|
+ posts.concat(batch)
+ end
+ assert_equal special_posts_ids, posts.map(&:id)
+ end
+
+ def test_find_in_batches_should_not_modify_passed_options
+ assert_nothing_raised do
+ Post.find_in_batches({ batch_size: 42, start: 1 }.freeze) { }
+ end
+ 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_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_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_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_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
+
+ relation = [not_a_post] * relation.count
+ end
+ end
+ end
+
+ def test_in_batches_should_not_ignore_default_scope_without_order_statements
+ special_posts_ids = SpecialPostWithDefaultScope.all.map(&:id).sort
+ posts = []
+ SpecialPostWithDefaultScope.in_batches do |relation|
+ posts.concat(relation)
+ end
+ assert_equal special_posts_ids, posts.map(&:id)
+ end
+
+ def test_in_batches_should_not_modify_passed_options
+ assert_nothing_raised do
+ Post.in_batches({ of: 42, start: 1 }.freeze) { }
+ end
+ 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
diff --git a/activerecord/test/cases/binary_test.rb b/activerecord/test/cases/binary_test.rb
new file mode 100644
index 0000000000..58abdb47f7
--- /dev/null
+++ b/activerecord/test/cases/binary_test.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+# Without using prepared statements, it makes no sense to test
+# BLOB data with DB2, because the length of a statement
+# is limited to 32KB.
+unless current_adapter?(:DB2Adapter)
+ 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
+end
diff --git a/activerecord/test/cases/bind_parameter_test.rb b/activerecord/test/cases/bind_parameter_test.rb
new file mode 100644
index 0000000000..bd5f157ca1
--- /dev/null
+++ b/activerecord/test/cases/bind_parameter_test.rb
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+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
+ @pk = Topic.columns_hash[Topic.primary_key]
+ @subscription = ActiveSupport::Notifications.subscribe("sql.active_record", @subscriber)
+ end
+
+ def teardown
+ ActiveSupport::Notifications.unsubscribe(@subscription)
+ 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_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_logs_legacy_binds_after_type_cast
+ binds = [[@pk, "10"]]
+ assert_logs_binds(binds)
+ end
+
+ def test_deprecate_supports_statement_cache
+ assert_deprecated { ActiveRecord::Base.connection.supports_statement_cache? }
+ end
+
+ private
+ def assert_logs_binds(binds)
+ payload = {
+ name: "SQL",
+ sql: "select * from topics where id = ?",
+ binds: binds,
+ type_casted_binds: @connection.type_casted_binds(binds)
+ }
+
+ event = ActiveSupport::Notifications::Event.new(
+ "foo",
+ Time.now,
+ Time.now,
+ 123,
+ payload)
+
+ logger = Class.new(ActiveRecord::LogSubscriber) {
+ attr_reader :debugs
+
+ def initialize
+ super
+ @debugs = []
+ end
+
+ def debug(str)
+ @debugs << str
+ end
+ }.new
+
+ logger.sql(event)
+ assert_match([[@pk.name, 10]].inspect, logger.debugs.first)
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/boolean_test.rb b/activerecord/test/cases/boolean_test.rb
new file mode 100644
index 0000000000..ab9f974e2c
--- /dev/null
+++ b/activerecord/test/cases/boolean_test.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/boolean"
+
+class BooleanTest < ActiveRecord::TestCase
+ def test_boolean
+ b_nil = Boolean.create!(value: nil)
+ b_false = Boolean.create!(value: false)
+ b_true = Boolean.create!(value: true)
+
+ assert_nil Boolean.find(b_nil.id).value
+ assert_not_predicate Boolean.find(b_false.id), :value?
+ assert_predicate Boolean.find(b_true.id), :value?
+ end
+
+ def test_boolean_without_questionmark
+ b_true = Boolean.create!(value: true)
+
+ subclass = Class.new(Boolean).find(b_true.id)
+ superclass = Boolean.find(b_true.id)
+
+ assert_equal superclass.read_attribute(:has_fun), subclass.read_attribute(:has_fun)
+ end
+
+ def test_boolean_cast_from_string
+ b_blank = Boolean.create!(value: "")
+ b_false = Boolean.create!(value: "0")
+ b_true = Boolean.create!(value: "1")
+
+ assert_nil Boolean.find(b_blank.id).value
+ assert_not_predicate Boolean.find(b_false.id), :value?
+ assert_predicate Boolean.find(b_true.id), :value?
+ end
+
+ def test_find_by_boolean_string
+ b_false = Boolean.create!(value: "false")
+ b_true = Boolean.create!(value: "true")
+
+ assert_equal b_false, Boolean.find_by(value: "false")
+ assert_equal b_true, Boolean.find_by(value: "true")
+ end
+end
diff --git a/activerecord/test/cases/cache_key_test.rb b/activerecord/test/cases/cache_key_test.rb
new file mode 100644
index 0000000000..3a06b1c795
--- /dev/null
+++ b/activerecord/test/cases/cache_key_test.rb
@@ -0,0 +1,131 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ class CacheKeyTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ class CacheMe < ActiveRecord::Base
+ self.cache_versioning = false
+ end
+
+ class CacheMeWithVersion < ActiveRecord::Base
+ self.cache_versioning = true
+ end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table(:cache_mes, force: true) { |t| t.timestamps }
+ @connection.create_table(:cache_me_with_versions, force: true) { |t| t.timestamps }
+ end
+
+ teardown do
+ @connection.drop_table :cache_mes, if_exists: true
+ @connection.drop_table :cache_me_with_versions, if_exists: true
+ end
+
+ test "cache_key format is not too precise" do
+ record = CacheMe.create
+ key = record.cache_key
+
+ assert_equal key, record.reload.cache_key
+ end
+
+ test "cache_key has no version when versioning is on" do
+ record = CacheMeWithVersion.create
+ assert_equal "active_record/cache_key_test/cache_me_with_versions/#{record.id}", record.cache_key
+ end
+
+ test "cache_version is only there when versioning is on" do
+ assert_predicate CacheMeWithVersion.create.cache_version, :present?
+ assert_not_predicate CacheMe.create.cache_version, :present?
+ end
+
+ test "cache_key_with_version always has both key and version" do
+ r1 = CacheMeWithVersion.create
+ assert_equal "active_record/cache_key_test/cache_me_with_versions/#{r1.id}-#{r1.updated_at.utc.to_s(:usec)}", r1.cache_key_with_version
+
+ r2 = CacheMe.create
+ assert_equal "active_record/cache_key_test/cache_mes/#{r2.id}-#{r2.updated_at.utc.to_s(:usec)}", r2.cache_key_with_version
+ end
+
+ test "cache_version is the same when it comes from the DB or from the user" do
+ skip("Mysql2 does not return a string value for updated_at") if current_adapter?(:Mysql2Adapter)
+
+ record = CacheMeWithVersion.create
+ record_from_db = CacheMeWithVersion.find(record.id)
+ assert_not_called(record_from_db, :updated_at) do
+ record_from_db.cache_version
+ end
+
+ assert_equal record.cache_version, record_from_db.cache_version
+ end
+
+ test "cache_version does not truncate zeros when timestamp ends in zeros" do
+ skip("Mysql2 does not return a string value for updated_at") if current_adapter?(:Mysql2Adapter)
+
+ travel_to Time.now.beginning_of_day do
+ record = CacheMeWithVersion.create
+ record_from_db = CacheMeWithVersion.find(record.id)
+ assert_not_called(record_from_db, :updated_at) do
+ record_from_db.cache_version
+ end
+
+ assert_equal record.cache_version, record_from_db.cache_version
+ end
+ end
+
+ test "cache_version calls updated_at when the value is generated at create time" do
+ record = CacheMeWithVersion.create
+ assert_called(record, :updated_at) do
+ record.cache_version
+ end
+ end
+
+ test "cache_version does NOT call updated_at when value is from the database" do
+ skip("Mysql2 does not return a string value for updated_at") if current_adapter?(:Mysql2Adapter)
+
+ record = CacheMeWithVersion.create
+ record_from_db = CacheMeWithVersion.find(record.id)
+ assert_not_called(record_from_db, :updated_at) do
+ record_from_db.cache_version
+ end
+ end
+
+ test "cache_version does call updated_at when it is assigned via a Time object" do
+ record = CacheMeWithVersion.create
+ record_from_db = CacheMeWithVersion.find(record.id)
+ assert_called(record_from_db, :updated_at) do
+ record_from_db.updated_at = Time.now
+ record_from_db.cache_version
+ end
+ end
+
+ test "cache_version does call updated_at when it is assigned via a string" do
+ record = CacheMeWithVersion.create
+ record_from_db = CacheMeWithVersion.find(record.id)
+ assert_called(record_from_db, :updated_at) do
+ record_from_db.updated_at = Time.now.to_s
+ record_from_db.cache_version
+ end
+ end
+
+ test "cache_version does call updated_at when it is assigned via a hash" do
+ record = CacheMeWithVersion.create
+ record_from_db = CacheMeWithVersion.find(record.id)
+ assert_called(record_from_db, :updated_at) do
+ record_from_db.updated_at = { 1 => 2016, 2 => 11, 3 => 12, 4 => 1, 5 => 2, 6 => 3, 7 => 22 }
+ record_from_db.cache_version
+ end
+ end
+
+ test "updated_at on class but not on instance raises an error" do
+ record = CacheMeWithVersion.create
+ record_from_db = CacheMeWithVersion.where(id: record.id).select(:id).first
+ assert_raises(ActiveModel::MissingAttributeError) do
+ record_from_db.cache_version
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/calculations_test.rb b/activerecord/test/cases/calculations_test.rb
new file mode 100644
index 0000000000..4d3db912c5
--- /dev/null
+++ b/activerecord/test/cases/calculations_test.rb
@@ -0,0 +1,975 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/book"
+require "models/club"
+require "models/company"
+require "models/contract"
+require "models/edge"
+require "models/organization"
+require "models/possession"
+require "models/topic"
+require "models/reply"
+require "models/numeric_data"
+require "models/minivan"
+require "models/speedometer"
+require "models/ship_part"
+require "models/treasure"
+require "models/developer"
+require "models/post"
+require "models/comment"
+require "models/rating"
+
+class CalculationsTest < ActiveRecord::TestCase
+ fixtures :companies, :accounts, :topics, :speedometers, :minivans, :books, :posts, :comments
+
+ def test_should_sum_field
+ assert_equal 318, Account.sum(:credit_limit)
+ end
+
+ def test_should_sum_arel_attribute
+ assert_equal 318, Account.sum(Account.arel_table[:credit_limit])
+ end
+
+ def test_should_average_field
+ value = Account.average(:credit_limit)
+ assert_equal 53.0, value
+ end
+
+ def test_should_average_arel_attribute
+ value = Account.average(Account.arel_table[:credit_limit])
+ assert_equal 53.0, value
+ end
+
+ def test_should_resolve_aliased_attributes
+ assert_equal 318, Account.sum(:available_credit)
+ end
+
+ def test_should_return_decimal_average_of_integer_field
+ value = Account.average(:id)
+ assert_equal 3.5, value
+ end
+
+ def test_should_return_integer_average_if_db_returns_such
+ ShipPart.delete_all
+ ShipPart.create!(id: 3, name: "foo")
+ value = ShipPart.average(:id)
+ assert_equal 3, value
+ end
+
+ def test_should_return_nil_to_d_as_average
+ if nil.respond_to?(:to_d)
+ assert_equal BigDecimal(0), NumericData.average(:bank_balance)
+ else
+ assert_nil NumericData.average(:bank_balance)
+ end
+ end
+
+ def test_should_get_maximum_of_field
+ assert_equal 60, Account.maximum(:credit_limit)
+ end
+
+ def test_should_get_maximum_of_arel_attribute
+ assert_equal 60, Account.maximum(Account.arel_table[:credit_limit])
+ end
+
+ def test_should_get_maximum_of_field_with_include
+ assert_equal 55, Account.where("companies.name != 'Summit'").references(:companies).includes(:firm).maximum(:credit_limit)
+ end
+
+ def test_should_get_maximum_of_arel_attribute_with_include
+ assert_equal 55, Account.where("companies.name != 'Summit'").references(:companies).includes(:firm).maximum(Account.arel_table[:credit_limit])
+ end
+
+ def test_should_get_minimum_of_field
+ assert_equal 50, Account.minimum(:credit_limit)
+ end
+
+ def test_should_get_minimum_of_arel_attribute
+ assert_equal 50, Account.minimum(Account.arel_table[:credit_limit])
+ end
+
+ def test_should_group_by_field
+ c = Account.group(:firm_id).sum(:credit_limit)
+ [1, 6, 2].each do |firm_id|
+ assert_includes c.keys, firm_id, "Group #{c.inspect} does not contain firm_id #{firm_id}"
+ end
+ end
+
+ def test_should_group_by_arel_attribute
+ c = Account.group(Account.arel_table[:firm_id]).sum(:credit_limit)
+ [1, 6, 2].each do |firm_id|
+ assert_includes c.keys, firm_id, "Group #{c.inspect} does not contain firm_id #{firm_id}"
+ end
+ end
+
+ def test_should_group_by_multiple_fields
+ c = Account.group("firm_id", :credit_limit).count(:all)
+ [ [nil, 50], [1, 50], [6, 50], [6, 55], [9, 53], [2, 60] ].each { |firm_and_limit| assert_includes c.keys, firm_and_limit }
+ end
+
+ def test_should_group_by_multiple_fields_having_functions
+ c = Topic.group(:author_name, "COALESCE(type, title)").count(:all)
+ assert_equal 1, c[["Carl", "The Third Topic of the day"]]
+ assert_equal 1, c[["Mary", "Reply"]]
+ assert_equal 1, c[["David", "The First Topic"]]
+ assert_equal 1, c[["Carl", "Reply"]]
+ end
+
+ def test_should_group_by_summed_field
+ c = Account.group(:firm_id).sum(:credit_limit)
+ assert_equal 50, c[1]
+ assert_equal 105, c[6]
+ assert_equal 60, c[2]
+ end
+
+ def test_should_generate_valid_sql_with_joins_and_group
+ assert_nothing_raised do
+ AuditLog.joins(:developer).group(:id).count
+ end
+ end
+
+ def test_should_calculate_against_given_relation
+ developer = Developer.create!(name: "developer")
+ developer.audit_logs.create!(message: "first log")
+ developer.audit_logs.create!(message: "second log")
+
+ c = developer.audit_logs.joins(:developer).group(:id).count
+
+ assert_equal developer.audit_logs.count, c.size
+ developer.audit_logs.each do |log|
+ assert_equal 1, c[log.id]
+ end
+ end
+
+ def test_should_order_by_grouped_field
+ c = Account.group(:firm_id).order("firm_id").sum(:credit_limit)
+ assert_equal [1, 2, 6, 9], c.keys.compact
+ end
+
+ def test_should_order_by_calculation
+ c = Account.group(:firm_id).order("sum_credit_limit desc, firm_id").sum(:credit_limit)
+ assert_equal [105, 60, 53, 50, 50], c.keys.collect { |k| c[k] }
+ assert_equal [6, 2, 9, 1], c.keys.compact
+ end
+
+ def test_should_limit_calculation
+ c = Account.where("firm_id IS NOT NULL").group(:firm_id).order("firm_id").limit(2).sum(:credit_limit)
+ assert_equal [1, 2], c.keys.compact
+ end
+
+ def test_should_limit_calculation_with_offset
+ c = Account.where("firm_id IS NOT NULL").group(:firm_id).order("firm_id").
+ limit(2).offset(1).sum(:credit_limit)
+ assert_equal [2, 6], c.keys.compact
+ end
+
+ def test_limit_should_apply_before_count
+ accounts = Account.limit(4)
+
+ assert_equal 3, accounts.count(:firm_id)
+ assert_equal 3, accounts.select(:firm_id).count
+ end
+
+ def test_limit_should_apply_before_count_arel_attribute
+ accounts = Account.limit(4)
+
+ firm_id_attribute = Account.arel_table[:firm_id]
+ assert_equal 3, accounts.count(firm_id_attribute)
+ assert_equal 3, accounts.select(firm_id_attribute).count
+ end
+
+ def test_count_should_shortcut_with_limit_zero
+ accounts = Account.limit(0)
+
+ assert_no_queries { assert_equal 0, accounts.count }
+ end
+
+ def test_limit_is_kept
+ return if current_adapter?(:OracleAdapter)
+
+ queries = assert_sql { Account.limit(1).count }
+ assert_equal 1, queries.length
+ assert_match(/LIMIT/, queries.first)
+ end
+
+ def test_offset_is_kept
+ return if current_adapter?(:OracleAdapter)
+
+ queries = assert_sql { Account.offset(1).count }
+ assert_equal 1, queries.length
+ assert_match(/OFFSET/, queries.first)
+ end
+
+ def test_limit_with_offset_is_kept
+ return if current_adapter?(:OracleAdapter)
+
+ queries = assert_sql { Account.limit(1).offset(1).count }
+ assert_equal 1, queries.length
+ assert_match(/LIMIT/, queries.first)
+ assert_match(/OFFSET/, queries.first)
+ end
+
+ def test_no_limit_no_offset
+ queries = assert_sql { Account.count }
+ assert_equal 1, queries.length
+ assert_no_match(/LIMIT/, queries.first)
+ assert_no_match(/OFFSET/, queries.first)
+ end
+
+ def test_count_on_invalid_columns_raises
+ e = assert_raises(ActiveRecord::StatementInvalid) {
+ Account.select("credit_limit, firm_name").count
+ }
+
+ assert_match %r{accounts}i, e.sql
+ assert_match "credit_limit, firm_name", e.sql
+ end
+
+ def test_apply_distinct_in_count
+ queries = assert_sql do
+ Account.distinct.count
+ Account.group(:firm_id).distinct.count
+ end
+
+ queries.each do |query|
+ # `table_alias_length` in `column_alias_for` would execute
+ # "SHOW max_identifier_length" statement in PostgreSQL adapter.
+ next if query == "SHOW max_identifier_length"
+ assert_match %r{\ASELECT(?! DISTINCT) COUNT\(DISTINCT\b}, query
+ end
+ end
+
+ def test_count_with_eager_loading_and_custom_order
+ posts = Post.includes(:comments).order("comments.id")
+ assert_queries(1) { assert_equal 11, posts.count }
+ assert_queries(1) { assert_equal 11, posts.count(:all) }
+ end
+
+ def test_count_with_eager_loading_and_custom_order_and_distinct
+ posts = Post.includes(:comments).order("comments.id").distinct
+ assert_queries(1) { assert_equal 11, posts.count }
+ assert_queries(1) { assert_equal 11, posts.count(:all) }
+ end
+
+ def test_distinct_count_all_with_custom_select_and_order
+ accounts = Account.distinct.select("credit_limit % 10").order(Arel.sql("credit_limit % 10"))
+ assert_queries(1) { assert_equal 3, accounts.count(:all) }
+ assert_queries(1) { assert_equal 3, accounts.load.size }
+ end
+
+ def test_distinct_count_with_order_and_limit
+ assert_equal 4, Account.distinct.order(:firm_id).limit(4).count
+ end
+
+ def test_distinct_count_with_order_and_offset
+ assert_equal 4, Account.distinct.order(:firm_id).offset(2).count
+ end
+
+ def test_distinct_count_with_order_and_limit_and_offset
+ assert_equal 4, Account.distinct.order(:firm_id).limit(4).offset(2).count
+ end
+
+ def test_distinct_joins_count_with_order_and_limit
+ assert_equal 3, Account.joins(:firm).distinct.order(:firm_id).limit(3).count
+ end
+
+ def test_distinct_joins_count_with_order_and_offset
+ assert_equal 3, Account.joins(:firm).distinct.order(:firm_id).offset(2).count
+ end
+
+ def test_distinct_joins_count_with_order_and_limit_and_offset
+ assert_equal 3, Account.joins(:firm).distinct.order(:firm_id).limit(3).offset(2).count
+ end
+
+ def test_distinct_count_with_group_by_and_order_and_limit
+ assert_equal({ 6 => 2 }, Account.group(:firm_id).distinct.order("1 DESC").limit(1).count)
+ end
+
+ def test_should_group_by_summed_field_having_condition
+ c = Account.group(:firm_id).having("sum(credit_limit) > 50").sum(:credit_limit)
+ assert_nil c[1]
+ assert_equal 105, c[6]
+ assert_equal 60, c[2]
+ end
+
+ def test_should_group_by_summed_field_having_condition_from_select
+ skip unless current_adapter?(:Mysql2Adapter, :SQLite3Adapter)
+ c = Account.select("MIN(credit_limit) AS min_credit_limit").group(:firm_id).having("min_credit_limit > 50").sum(:credit_limit)
+ assert_nil c[1]
+ assert_equal 60, c[2]
+ assert_equal 53, c[9]
+ end
+
+ def test_should_group_by_summed_association
+ c = Account.group(:firm).sum(:credit_limit)
+ assert_equal 50, c[companies(:first_firm)]
+ assert_equal 105, c[companies(:rails_core)]
+ assert_equal 60, c[companies(:first_client)]
+ end
+
+ def test_should_sum_field_with_conditions
+ assert_equal 105, Account.where("firm_id = 6").sum(:credit_limit)
+ end
+
+ def test_should_return_zero_if_sum_conditions_return_nothing
+ assert_equal 0, Account.where("1 = 2").sum(:credit_limit)
+ assert_equal 0, companies(:rails_core).companies.where("1 = 2").sum(:id)
+ end
+
+ def test_sum_should_return_valid_values_for_decimals
+ NumericData.create(bank_balance: 19.83)
+ assert_equal 19.83, NumericData.sum(:bank_balance)
+ end
+
+ def test_should_return_type_casted_values_with_group_and_expression
+ assert_equal 0.5, Account.group(:firm_name).sum("0.01 * credit_limit")["37signals"]
+ end
+
+ def test_should_group_by_summed_field_with_conditions
+ c = Account.where("firm_id > 1").group(:firm_id).sum(:credit_limit)
+ assert_nil c[1]
+ assert_equal 105, c[6]
+ assert_equal 60, c[2]
+ end
+
+ def test_should_group_by_summed_field_with_conditions_and_having
+ c = Account.where("firm_id > 1").group(:firm_id).
+ having("sum(credit_limit) > 60").sum(:credit_limit)
+ assert_nil c[1]
+ assert_equal 105, c[6]
+ assert_nil c[2]
+ end
+
+ def test_should_group_by_fields_with_table_alias
+ c = Account.group("accounts.firm_id").sum(:credit_limit)
+ assert_equal 50, c[1]
+ assert_equal 105, c[6]
+ assert_equal 60, c[2]
+ end
+
+ def test_should_calculate_with_invalid_field
+ assert_equal 6, Account.calculate(:count, "*")
+ assert_equal 6, Account.calculate(:count, :all)
+ end
+
+ def test_should_calculate_grouped_with_invalid_field
+ c = Account.group("accounts.firm_id").count(:all)
+ assert_equal 1, c[1]
+ assert_equal 2, c[6]
+ assert_equal 1, c[2]
+ end
+
+ def test_should_calculate_grouped_association_with_invalid_field
+ c = Account.group(:firm).count(:all)
+ assert_equal 1, c[companies(:first_firm)]
+ assert_equal 2, c[companies(:rails_core)]
+ assert_equal 1, c[companies(:first_client)]
+ end
+
+ def test_should_group_by_association_with_non_numeric_foreign_key
+ Speedometer.create! id: "ABC"
+ Minivan.create! id: "OMG", speedometer_id: "ABC"
+
+ c = Minivan.group(:speedometer).count(:all)
+ first_key = c.keys.first
+ assert_equal Speedometer, first_key.class
+ assert_equal 1, c[first_key]
+ end
+
+ def test_should_calculate_grouped_association_with_foreign_key_option
+ Account.belongs_to :another_firm, class_name: "Firm", foreign_key: "firm_id"
+ c = Account.group(:another_firm).count(:all)
+ assert_equal 1, c[companies(:first_firm)]
+ assert_equal 2, c[companies(:rails_core)]
+ assert_equal 1, c[companies(:first_client)]
+ end
+
+ def test_should_calculate_grouped_by_function
+ c = Company.group("UPPER(#{QUOTED_TYPE})").count(:all)
+ assert_equal 2, c[nil]
+ assert_equal 1, c["DEPENDENTFIRM"]
+ assert_equal 5, c["CLIENT"]
+ assert_equal 2, c["FIRM"]
+ end
+
+ def test_should_calculate_grouped_by_function_with_table_alias
+ c = Company.group("UPPER(companies.#{QUOTED_TYPE})").count(:all)
+ assert_equal 2, c[nil]
+ assert_equal 1, c["DEPENDENTFIRM"]
+ assert_equal 5, c["CLIENT"]
+ assert_equal 2, c["FIRM"]
+ end
+
+ def test_should_not_overshadow_enumerable_sum
+ assert_equal 6, [1, 2, 3].sum(&:abs)
+ end
+
+ def test_should_sum_scoped_field
+ assert_equal 15, companies(:rails_core).companies.sum(:id)
+ end
+
+ def test_should_sum_scoped_field_with_from
+ assert_equal Club.count, Organization.clubs.count
+ end
+
+ def test_should_sum_scoped_field_with_conditions
+ assert_equal 8, companies(:rails_core).companies.where("id > 7").sum(:id)
+ end
+
+ def test_should_group_by_scoped_field
+ c = companies(:rails_core).companies.group(:name).sum(:id)
+ assert_equal 7, c["Leetsoft"]
+ assert_equal 8, c["Jadedpixel"]
+ end
+
+ def test_should_group_by_summed_field_through_association_and_having
+ c = companies(:rails_core).companies.group(:name).having("sum(id) > 7").sum(:id)
+ assert_nil c["Leetsoft"]
+ assert_equal 8, c["Jadedpixel"]
+ end
+
+ def test_should_count_selected_field_with_include
+ assert_equal 6, Account.includes(:firm).distinct.count
+ assert_equal 4, Account.includes(:firm).distinct.select(:credit_limit).count
+ end
+
+ def test_should_not_perform_joined_include_by_default
+ assert_equal Account.count, Account.includes(:firm).count
+ queries = assert_sql { Account.includes(:firm).count }
+ assert_no_match(/join/i, queries.last)
+ end
+
+ def test_should_perform_joined_include_when_referencing_included_tables
+ joined_count = Account.includes(:firm).where(companies: { name: "37signals" }).count
+ assert_equal 1, joined_count
+ end
+
+ def test_should_count_scoped_select
+ Account.update_all("credit_limit = NULL")
+ assert_equal 0, Account.select("credit_limit").count
+ end
+
+ def test_should_count_scoped_select_with_options
+ Account.update_all("credit_limit = NULL")
+ Account.last.update_columns("credit_limit" => 49)
+ Account.first.update_columns("credit_limit" => 51)
+
+ assert_equal 1, Account.select("credit_limit").where("credit_limit >= 50").count
+ end
+
+ def test_should_count_manual_select_with_include
+ assert_equal 6, Account.select("DISTINCT accounts.id").includes(:firm).count
+ end
+
+ def test_count_selected_arel_attribute
+ assert_equal 5, Account.select(Account.arel_table[:firm_id]).count
+ assert_equal 4, Account.distinct.select(Account.arel_table[:firm_id]).count
+ end
+
+ def test_count_with_column_parameter
+ assert_equal 5, Account.count(:firm_id)
+ end
+
+ def test_count_with_arel_attribute
+ assert_equal 5, Account.count(Account.arel_table[:firm_id])
+ end
+
+ def test_count_with_arel_star
+ assert_equal 6, Account.count(Arel.star)
+ end
+
+ def test_count_with_distinct
+ assert_equal 4, Account.select(:credit_limit).distinct.count
+ end
+
+ def test_count_with_aliased_attribute
+ assert_equal 6, Account.count(:available_credit)
+ end
+
+ def test_count_with_column_and_options_parameter
+ assert_equal 2, Account.where("credit_limit = 50 AND firm_id IS NOT NULL").count(:firm_id)
+ end
+
+ def test_should_count_field_in_joined_table
+ assert_equal 5, Account.joins(:firm).count("companies.id")
+ assert_equal 4, Account.joins(:firm).distinct.count("companies.id")
+ end
+
+ def test_count_arel_attribute_in_joined_table_with
+ assert_equal 5, Account.joins(:firm).count(Company.arel_table[:id])
+ assert_equal 4, Account.joins(:firm).distinct.count(Company.arel_table[:id])
+ end
+
+ def test_count_selected_arel_attribute_in_joined_table
+ assert_equal 5, Account.joins(:firm).select(Company.arel_table[:id]).count
+ assert_equal 4, Account.joins(:firm).distinct.select(Company.arel_table[:id]).count
+ end
+
+ def test_should_count_field_in_joined_table_with_group_by
+ c = Account.group("accounts.firm_id").joins(:firm).count("companies.id")
+
+ [1, 6, 2, 9].each { |firm_id| assert_includes c.keys, firm_id }
+ end
+
+ def test_should_count_field_of_root_table_with_conflicting_group_by_column
+ assert_equal({ 1 => 1 }, Firm.joins(:accounts).group(:firm_id).count)
+ assert_equal({ 1 => 1 }, Firm.joins(:accounts).group("accounts.firm_id").count)
+ end
+
+ def test_count_with_no_parameters_isnt_deprecated
+ assert_not_deprecated { Account.count }
+ end
+
+ def test_count_with_too_many_parameters_raises
+ assert_raise(ArgumentError) { Account.count(1, 2, 3) }
+ end
+
+ def test_count_with_order
+ assert_equal 6, Account.order(:credit_limit).count
+ end
+
+ def test_count_with_reverse_order
+ assert_equal 6, Account.order(:credit_limit).reverse_order.count
+ end
+
+ def test_count_with_where_and_order
+ assert_equal 1, Account.where(firm_name: "37signals").count
+ assert_equal 1, Account.where(firm_name: "37signals").order(:firm_name).count
+ assert_equal 1, Account.where(firm_name: "37signals").order(:firm_name).reverse_order.count
+ end
+
+ def test_count_with_block
+ assert_equal 4, Account.count { |account| account.credit_limit.modulo(10).zero? }
+ end
+
+ def test_should_sum_expression
+ if current_adapter?(:SQLite3Adapter, :Mysql2Adapter, :PostgreSQLAdapter, :OracleAdapter)
+ assert_equal 636, Account.sum("2 * credit_limit")
+ else
+ assert_equal 636, Account.sum("2 * credit_limit").to_i
+ end
+ end
+
+ def test_sum_expression_returns_zero_when_no_records_to_sum
+ assert_equal 0, Account.where("1 = 2").sum("2 * credit_limit")
+ end
+
+ def test_count_with_from_option
+ assert_equal Company.count(:all), Company.from("companies").count(:all)
+ assert_equal Account.where("credit_limit = 50").count(:all),
+ Account.from("accounts").where("credit_limit = 50").count(:all)
+ assert_equal Company.where(type: "Firm").count(:type),
+ Company.where(type: "Firm").from("companies").count(:type)
+ end
+
+ def test_sum_with_from_option
+ assert_equal Account.sum(:credit_limit), Account.from("accounts").sum(:credit_limit)
+ assert_equal Account.where("credit_limit > 50").sum(:credit_limit),
+ Account.where("credit_limit > 50").from("accounts").sum(:credit_limit)
+ end
+
+ def test_average_with_from_option
+ assert_equal Account.average(:credit_limit), Account.from("accounts").average(:credit_limit)
+ assert_equal Account.where("credit_limit > 50").average(:credit_limit),
+ Account.where("credit_limit > 50").from("accounts").average(:credit_limit)
+ end
+
+ def test_minimum_with_from_option
+ assert_equal Account.minimum(:credit_limit), Account.from("accounts").minimum(:credit_limit)
+ assert_equal Account.where("credit_limit > 50").minimum(:credit_limit),
+ Account.where("credit_limit > 50").from("accounts").minimum(:credit_limit)
+ end
+
+ def test_maximum_with_from_option
+ assert_equal Account.maximum(:credit_limit), Account.from("accounts").maximum(:credit_limit)
+ assert_equal Account.where("credit_limit > 50").maximum(:credit_limit),
+ Account.where("credit_limit > 50").from("accounts").maximum(:credit_limit)
+ end
+
+ def test_maximum_with_not_auto_table_name_prefix_if_column_included
+ Company.create!(name: "test", contracts: [Contract.new(developer_id: 7)])
+
+ assert_equal 7, Company.includes(:contracts).maximum(:developer_id)
+ end
+
+ def test_minimum_with_not_auto_table_name_prefix_if_column_included
+ Company.create!(name: "test", contracts: [Contract.new(developer_id: 7)])
+
+ assert_equal 7, Company.includes(:contracts).minimum(:developer_id)
+ end
+
+ def test_sum_with_not_auto_table_name_prefix_if_column_included
+ Company.create!(name: "test", contracts: [Contract.new(developer_id: 7)])
+
+ assert_equal 7, Company.includes(:contracts).sum(:developer_id)
+ end
+
+ if current_adapter?(:Mysql2Adapter)
+ def test_from_option_with_specified_index
+ assert_equal Edge.count(:all), Edge.from("edges USE INDEX(unique_edge_index)").count(:all)
+ assert_equal Edge.where("sink_id < 5").count(:all),
+ Edge.from("edges USE INDEX(unique_edge_index)").where("sink_id < 5").count(:all)
+ end
+ end
+
+ def test_from_option_with_table_different_than_class
+ assert_equal Account.count(:all), Company.from("accounts").count(:all)
+ end
+
+ def test_distinct_is_honored_when_used_with_count_operation_after_group
+ # Count the number of authors for approved topics
+ approved_topics_count = Topic.group(:approved).count(:author_name)[true]
+ assert_equal approved_topics_count, 4
+ # Count the number of distinct authors for approved Topics
+ distinct_authors_for_approved_count = Topic.group(:approved).distinct.count(:author_name)[true]
+ assert_equal distinct_authors_for_approved_count, 3
+ end
+
+ def test_pluck
+ assert_equal [1, 2, 3, 4, 5], Topic.order(:id).pluck(:id)
+ end
+
+ def test_pluck_without_column_names
+ if current_adapter?(:OracleAdapter)
+ assert_equal [[1, "Firm", 1, nil, "37signals", nil, 1, nil, nil]], Company.order(:id).limit(1).pluck
+ else
+ assert_equal [[1, "Firm", 1, nil, "37signals", nil, 1, nil, ""]], Company.order(:id).limit(1).pluck
+ end
+ end
+
+ def test_pluck_type_cast
+ topic = topics(:first)
+ relation = Topic.where(id: topic.id)
+ assert_equal [ topic.approved ], relation.pluck(:approved)
+ assert_equal [ topic.last_read ], relation.pluck(:last_read)
+ assert_equal [ topic.written_on ], relation.pluck(:written_on)
+ end
+
+ def test_pluck_with_type_cast_does_not_corrupt_the_query_cache
+ topic = topics(:first)
+ relation = Topic.where(id: topic.id)
+ assert_queries 1 do
+ Topic.cache do
+ kind = relation.select(:written_on).load.first.read_attribute_before_type_cast(:written_on).class
+ relation.pluck(:written_on)
+ assert_kind_of kind, relation.select(:written_on).load.first.read_attribute_before_type_cast(:written_on)
+ end
+ end
+ end
+
+ def test_pluck_and_distinct
+ assert_equal [50, 53, 55, 60], Account.order(:credit_limit).distinct.pluck(:credit_limit)
+ end
+
+ def test_pluck_in_relation
+ company = Company.first
+ contract = company.contracts.create!
+ assert_equal [contract.id], company.contracts.pluck(:id)
+ end
+
+ def test_pluck_on_aliased_attribute
+ assert_equal "The First Topic", Topic.order(:id).pluck(:heading).first
+ end
+
+ def test_pluck_with_serialization
+ t = Topic.create!(content: { foo: :bar })
+ assert_equal [{ foo: :bar }], Topic.where(id: t.id).pluck(:content)
+ end
+
+ def test_pluck_with_qualified_column_name
+ assert_equal [1, 2, 3, 4, 5], Topic.order(:id).pluck("topics.id")
+ end
+
+ def test_pluck_auto_table_name_prefix
+ c = Company.create!(name: "test", contracts: [Contract.new])
+ assert_equal [c.id], Company.joins(:contracts).pluck(:id)
+ end
+
+ def test_pluck_if_table_included
+ c = Company.create!(name: "test", contracts: [Contract.new(developer_id: 7)])
+ assert_equal [c.id], Company.includes(:contracts).where("contracts.id" => c.contracts.first).pluck(:id)
+ end
+
+ def test_pluck_not_auto_table_name_prefix_if_column_joined
+ Company.create!(name: "test", contracts: [Contract.new(developer_id: 7)])
+ assert_equal [7], Company.joins(:contracts).pluck(:developer_id)
+ end
+
+ def test_pluck_with_selection_clause
+ assert_equal [50, 53, 55, 60], Account.pluck(Arel.sql("DISTINCT credit_limit")).sort
+ assert_equal [50, 53, 55, 60], Account.pluck(Arel.sql("DISTINCT accounts.credit_limit")).sort
+ assert_equal [50, 53, 55, 60], Account.pluck(Arel.sql("DISTINCT(credit_limit)")).sort
+
+ # MySQL returns "SUM(DISTINCT(credit_limit))" as the column name unless
+ # an alias is provided. Without the alias, the column cannot be found
+ # and properly typecast.
+ assert_equal [50 + 53 + 55 + 60], Account.pluck(Arel.sql("SUM(DISTINCT(credit_limit)) as credit_limit"))
+ end
+
+ def test_plucks_with_ids
+ assert_equal Company.all.map(&:id).sort, Company.ids.sort
+ end
+
+ def test_pluck_with_includes_limit_and_empty_result
+ assert_equal [], Topic.includes(:replies).limit(0).pluck(:id)
+ assert_equal [], Topic.includes(:replies).limit(1).where("0 = 1").pluck(:id)
+ end
+
+ def test_pluck_with_includes_offset
+ assert_equal [5], Topic.includes(:replies).order(:id).offset(4).pluck(:id)
+ assert_equal [], Topic.includes(:replies).order(:id).offset(5).pluck(:id)
+ end
+
+ def test_pluck_with_join
+ assert_equal [[2, 2], [4, 4]], Reply.includes(:topic).pluck(:id, :"topics.id")
+ end
+
+ def test_group_by_with_limit
+ expected = { "Post" => 8, "SpecialPost" => 1 }
+ actual = Post.includes(:comments).group(:type).order(:type).limit(2).count("comments.id")
+ assert_equal expected, actual
+ end
+
+ def test_group_by_with_offset
+ expected = { "SpecialPost" => 1, "StiPost" => 2 }
+ actual = Post.includes(:comments).group(:type).order(:type).offset(1).count("comments.id")
+ assert_equal expected, actual
+ end
+
+ def test_group_by_with_limit_and_offset
+ expected = { "SpecialPost" => 1 }
+ actual = Post.includes(:comments).group(:type).order(:type).offset(1).limit(1).count("comments.id")
+ assert_equal expected, actual
+ end
+
+ def test_pluck_not_auto_table_name_prefix_if_column_included
+ Company.create!(name: "test", contracts: [Contract.new(developer_id: 7)])
+ ids = Company.includes(:contracts).pluck(:developer_id)
+ assert_equal Company.count, ids.length
+ assert_equal [7], ids.compact
+ end
+
+ def test_pluck_multiple_columns
+ assert_equal [
+ [1, "The First Topic"], [2, "The Second Topic of the day"],
+ [3, "The Third Topic of the day"], [4, "The Fourth Topic of the day"],
+ [5, "The Fifth Topic of the day"]
+ ], Topic.order(:id).pluck(:id, :title)
+ assert_equal [
+ [1, "The First Topic", "David"], [2, "The Second Topic of the day", "Mary"],
+ [3, "The Third Topic of the day", "Carl"], [4, "The Fourth Topic of the day", "Carl"],
+ [5, "The Fifth Topic of the day", "Jason"]
+ ], Topic.order(:id).pluck(:id, :title, :author_name)
+ end
+
+ def test_pluck_with_multiple_columns_and_selection_clause
+ assert_equal [[1, 50], [2, 50], [3, 50], [4, 60], [5, 55], [6, 53]],
+ Account.pluck("id, credit_limit")
+ end
+
+ def test_pluck_with_multiple_columns_and_includes
+ Company.create!(name: "test", contracts: [Contract.new(developer_id: 7)])
+ companies_and_developers = Company.order("companies.id").includes(:contracts).pluck(:name, :developer_id)
+
+ assert_equal Company.count, companies_and_developers.length
+ assert_equal ["37signals", nil], companies_and_developers.first
+ assert_equal ["test", 7], companies_and_developers.last
+ end
+
+ def test_pluck_with_reserved_words
+ Possession.create!(where: "Over There")
+
+ assert_equal ["Over There"], Possession.pluck(:where)
+ end
+
+ def test_pluck_replaces_select_clause
+ taks_relation = Topic.select(:approved, :id).order(:id)
+ assert_equal [1, 2, 3, 4, 5], taks_relation.pluck(:id)
+ assert_equal [false, true, true, true, true], taks_relation.pluck(:approved)
+ end
+
+ def test_pluck_columns_with_same_name
+ expected = [["The First Topic", "The Second Topic of the day"], ["The Third Topic of the day", "The Fourth Topic of the day"]]
+ actual = Topic.joins(:replies)
+ .pluck("topics.title", "replies_topics.title")
+ assert_equal expected, actual
+ end
+
+ def test_calculation_with_polymorphic_relation
+ part = ShipPart.create!(name: "has trinket")
+ part.trinkets.create!
+
+ assert_equal part.id, ShipPart.joins(:trinkets).sum(:id)
+ end
+
+ def test_pluck_joined_with_polymorphic_relation
+ part = ShipPart.create!(name: "has trinket")
+ part.trinkets.create!
+
+ assert_equal [part.id], ShipPart.joins(:trinkets).pluck(:id)
+ end
+
+ def test_pluck_loaded_relation
+ Company.attribute_names # Load schema information so we don't query below
+ companies = Company.order(:id).limit(3).load
+
+ assert_no_queries do
+ assert_equal ["37signals", "Summit", "Microsoft"], companies.pluck(:name)
+ end
+ end
+
+ def test_pluck_loaded_relation_multiple_columns
+ Company.attribute_names # Load schema information so we don't query below
+ companies = Company.order(:id).limit(3).load
+
+ assert_no_queries do
+ assert_equal [[1, "37signals"], [2, "Summit"], [3, "Microsoft"]], companies.pluck(:id, :name)
+ end
+ end
+
+ def test_pluck_loaded_relation_sql_fragment
+ Company.attribute_names # Load schema information so we don't query below
+ companies = Company.order(:name).limit(3).load
+
+ assert_queries 1 do
+ assert_equal ["37signals", "Apex", "Ex Nihilo"], companies.pluck(Arel.sql("DISTINCT name"))
+ end
+ end
+
+ def test_pick_one
+ assert_equal "The First Topic", Topic.order(:id).pick(:heading)
+ assert_nil Topic.none.pick(:heading)
+ assert_nil Topic.where("1=0").pick(:heading)
+ end
+
+ def test_pick_two
+ assert_equal ["David", "david@loudthinking.com"], Topic.order(:id).pick(:author_name, :author_email_address)
+ assert_nil Topic.none.pick(:author_name, :author_email_address)
+ assert_nil Topic.where("1=0").pick(:author_name, :author_email_address)
+ end
+
+ def test_pick_delegate_to_all
+ cool_first = minivans(:cool_first)
+ assert_equal cool_first.color, Minivan.pick(:color)
+ end
+
+ def test_grouped_calculation_with_polymorphic_relation
+ part = ShipPart.create!(name: "has trinket")
+ part.trinkets.create!
+
+ assert_equal({ "has trinket" => part.id }, ShipPart.joins(:trinkets).group("ship_parts.name").sum(:id))
+ end
+
+ def test_calculation_grouped_by_association_doesnt_error_when_no_records_have_association
+ Client.update_all(client_of: nil)
+ assert_equal({ nil => Client.count }, Client.group(:firm).count)
+ end
+
+ def test_should_reference_correct_aliases_while_joining_tables_of_has_many_through_association
+ assert_nothing_raised do
+ developer = Developer.create!(name: "developer")
+ developer.ratings.includes(comment: :post).where(posts: { id: 1 }).count
+ end
+ end
+
+ def test_sum_uses_enumerable_version_when_block_is_given
+ block_called = false
+ relation = Client.all.load
+
+ assert_no_queries do
+ assert_equal 0, relation.sum { block_called = true; 0 }
+ end
+ assert block_called
+ end
+
+ def test_having_with_strong_parameters
+ protected_params = Class.new do
+ attr_reader :permitted
+ alias :permitted? :permitted
+
+ def initialize(parameters)
+ @parameters = parameters
+ @permitted = false
+ end
+
+ def to_h
+ @parameters
+ end
+
+ def permit!
+ @permitted = true
+ self
+ end
+ end
+
+ params = protected_params.new(credit_limit: "50")
+
+ assert_raises(ActiveModel::ForbiddenAttributesError) do
+ Account.group(:id).having(params)
+ end
+
+ result = Account.group(:id).having(params.permit!)
+ assert_equal 50, result[0].credit_limit
+ assert_equal 50, result[1].credit_limit
+ assert_equal 50, result[2].credit_limit
+ end
+
+ def test_group_by_attribute_with_custom_type
+ assert_equal({ "proposed" => 2, "published" => 2 }, Book.group(:status).count)
+ end
+
+ def test_deprecate_count_with_block_and_column_name
+ assert_deprecated do
+ assert_equal 6, Account.count(:firm_id) { true }
+ end
+ end
+
+ def test_deprecate_sum_with_block_and_column_name
+ assert_deprecated do
+ assert_equal 6, Account.sum(:firm_id) { 1 }
+ end
+ end
+
+ test "#skip_query_cache! for #pluck" do
+ Account.cache do
+ assert_queries(1) do
+ Account.pluck(:credit_limit)
+ Account.pluck(:credit_limit)
+ end
+
+ assert_queries(2) do
+ Account.all.skip_query_cache!.pluck(:credit_limit)
+ Account.all.skip_query_cache!.pluck(:credit_limit)
+ end
+ end
+ end
+
+ test "#skip_query_cache! for a simple calculation" do
+ Account.cache do
+ assert_queries(1) do
+ Account.calculate(:sum, :credit_limit)
+ Account.calculate(:sum, :credit_limit)
+ end
+
+ assert_queries(2) do
+ Account.all.skip_query_cache!.calculate(:sum, :credit_limit)
+ Account.all.skip_query_cache!.calculate(:sum, :credit_limit)
+ end
+ end
+ end
+
+ test "#skip_query_cache! for a grouped calculation" do
+ Account.cache do
+ assert_queries(1) do
+ Account.group(:firm_id).calculate(:sum, :credit_limit)
+ Account.group(:firm_id).calculate(:sum, :credit_limit)
+ end
+
+ assert_queries(2) do
+ Account.all.skip_query_cache!.group(:firm_id).calculate(:sum, :credit_limit)
+ Account.all.skip_query_cache!.group(:firm_id).calculate(:sum, :credit_limit)
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/callbacks_test.rb b/activerecord/test/cases/callbacks_test.rb
new file mode 100644
index 0000000000..4d6a112af5
--- /dev/null
+++ b/activerecord/test/cases/callbacks_test.rb
@@ -0,0 +1,506 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/developer"
+require "models/computer"
+
+class CallbackDeveloper < ActiveRecord::Base
+ self.table_name = "developers"
+
+ class << self
+ def callback_proc(callback_method)
+ Proc.new { |model| model.history << [callback_method, :proc] }
+ end
+
+ def define_callback_method(callback_method)
+ define_method(callback_method) do
+ history << [callback_method, :method]
+ end
+ send(callback_method, :"#{callback_method}")
+ end
+
+ def callback_object(callback_method)
+ klass = Class.new
+ klass.define_method(callback_method) do |model|
+ model.history << [callback_method, :object]
+ end
+ klass.new
+ end
+ end
+
+ ActiveRecord::Callbacks::CALLBACKS.each do |callback_method|
+ next if callback_method.to_s.start_with?("around_")
+ define_callback_method(callback_method)
+ send(callback_method, callback_proc(callback_method))
+ send(callback_method, callback_object(callback_method))
+ send(callback_method) { |model| model.history << [callback_method, :block] }
+ end
+
+ def history
+ @history ||= []
+ end
+end
+
+class CallbackDeveloperWithHaltedValidation < CallbackDeveloper
+ before_validation proc { |model| model.history << [:before_validation, :throwing_abort]; throw(:abort) }
+ before_validation proc { |model| model.history << [:before_validation, :should_never_get_here] }
+end
+
+class ParentDeveloper < ActiveRecord::Base
+ self.table_name = "developers"
+ attr_accessor :after_save_called
+ before_validation { |record| record.after_save_called = true }
+end
+
+class ChildDeveloper < ParentDeveloper
+end
+
+class ImmutableDeveloper < ActiveRecord::Base
+ self.table_name = "developers"
+
+ validates_inclusion_of :salary, in: 50000..200000
+
+ before_save :cancel
+ before_destroy :cancel
+
+ private
+ def cancel
+ false
+ end
+end
+
+class DeveloperWithCanceledCallbacks < ActiveRecord::Base
+ self.table_name = "developers"
+
+ validates_inclusion_of :salary, in: 50000..200000
+
+ before_save :cancel
+ before_destroy :cancel
+
+ private
+ def cancel
+ throw(:abort)
+ end
+end
+
+class OnCallbacksDeveloper < ActiveRecord::Base
+ self.table_name = "developers"
+
+ before_validation { history << :before_validation }
+ before_validation(on: :create) { history << :before_validation_on_create }
+ before_validation(on: :update) { history << :before_validation_on_update }
+
+ validate do
+ history << :validate
+ end
+
+ after_validation { history << :after_validation }
+ after_validation(on: :create) { history << :after_validation_on_create }
+ after_validation(on: :update) { history << :after_validation_on_update }
+
+ def history
+ @history ||= []
+ end
+end
+
+class ContextualCallbacksDeveloper < ActiveRecord::Base
+ self.table_name = "developers"
+
+ before_validation { history << :before_validation }
+ before_validation :before_validation_on_create_and_update, on: [ :create, :update ]
+
+ validate do
+ history << :validate
+ end
+
+ after_validation { history << :after_validation }
+ after_validation :after_validation_on_create_and_update, on: [ :create, :update ]
+
+ def before_validation_on_create_and_update
+ history << "before_validation_on_#{validation_context}".to_sym
+ end
+
+ def after_validation_on_create_and_update
+ history << "after_validation_on_#{validation_context}".to_sym
+ end
+
+ def history
+ @history ||= []
+ end
+end
+
+class CallbackHaltedDeveloper < ActiveRecord::Base
+ self.table_name = "developers"
+
+ attr_reader :after_save_called, :after_create_called, :after_update_called, :after_destroy_called
+ attr_accessor :cancel_before_save, :cancel_before_create, :cancel_before_update, :cancel_before_destroy
+
+ before_save { throw(:abort) if defined?(@cancel_before_save) }
+ before_create { throw(:abort) if @cancel_before_create }
+ before_update { throw(:abort) if @cancel_before_update }
+ before_destroy { throw(:abort) if @cancel_before_destroy }
+
+ after_save { @after_save_called = true }
+ after_update { @after_update_called = true }
+ after_create { @after_create_called = true }
+ after_destroy { @after_destroy_called = true }
+end
+
+class CallbacksTest < ActiveRecord::TestCase
+ fixtures :developers
+
+ def test_initialize
+ david = CallbackDeveloper.new
+ assert_equal [
+ [ :after_initialize, :method ],
+ [ :after_initialize, :proc ],
+ [ :after_initialize, :object ],
+ [ :after_initialize, :block ],
+ ], david.history
+ end
+
+ def test_find
+ david = CallbackDeveloper.find(1)
+ assert_equal [
+ [ :after_find, :method ],
+ [ :after_find, :proc ],
+ [ :after_find, :object ],
+ [ :after_find, :block ],
+ [ :after_initialize, :method ],
+ [ :after_initialize, :proc ],
+ [ :after_initialize, :object ],
+ [ :after_initialize, :block ],
+ ], david.history
+ end
+
+ def test_new_valid?
+ david = CallbackDeveloper.new
+ david.valid?
+ assert_equal [
+ [ :after_initialize, :method ],
+ [ :after_initialize, :proc ],
+ [ :after_initialize, :object ],
+ [ :after_initialize, :block ],
+ [ :before_validation, :method ],
+ [ :before_validation, :proc ],
+ [ :before_validation, :object ],
+ [ :before_validation, :block ],
+ [ :after_validation, :method ],
+ [ :after_validation, :proc ],
+ [ :after_validation, :object ],
+ [ :after_validation, :block ],
+ ], david.history
+ end
+
+ def test_existing_valid?
+ david = CallbackDeveloper.find(1)
+ david.valid?
+ assert_equal [
+ [ :after_find, :method ],
+ [ :after_find, :proc ],
+ [ :after_find, :object ],
+ [ :after_find, :block ],
+ [ :after_initialize, :method ],
+ [ :after_initialize, :proc ],
+ [ :after_initialize, :object ],
+ [ :after_initialize, :block ],
+ [ :before_validation, :method ],
+ [ :before_validation, :proc ],
+ [ :before_validation, :object ],
+ [ :before_validation, :block ],
+ [ :after_validation, :method ],
+ [ :after_validation, :proc ],
+ [ :after_validation, :object ],
+ [ :after_validation, :block ],
+ ], david.history
+ end
+
+ def test_create
+ david = CallbackDeveloper.create("name" => "David", "salary" => 1000000)
+ assert_equal [
+ [ :after_initialize, :method ],
+ [ :after_initialize, :proc ],
+ [ :after_initialize, :object ],
+ [ :after_initialize, :block ],
+ [ :before_validation, :method ],
+ [ :before_validation, :proc ],
+ [ :before_validation, :object ],
+ [ :before_validation, :block ],
+ [ :after_validation, :method ],
+ [ :after_validation, :proc ],
+ [ :after_validation, :object ],
+ [ :after_validation, :block ],
+ [ :before_save, :method ],
+ [ :before_save, :proc ],
+ [ :before_save, :object ],
+ [ :before_save, :block ],
+ [ :before_create, :method ],
+ [ :before_create, :proc ],
+ [ :before_create, :object ],
+ [ :before_create, :block ],
+ [ :after_create, :method ],
+ [ :after_create, :proc ],
+ [ :after_create, :object ],
+ [ :after_create, :block ],
+ [ :after_save, :method ],
+ [ :after_save, :proc ],
+ [ :after_save, :object ],
+ [ :after_save, :block ],
+ [ :after_commit, :block ],
+ [ :after_commit, :object ],
+ [ :after_commit, :proc ],
+ [ :after_commit, :method ]
+ ], david.history
+ end
+
+ def test_validate_on_create
+ david = OnCallbacksDeveloper.create("name" => "David", "salary" => 1000000)
+ assert_equal [
+ :before_validation,
+ :before_validation_on_create,
+ :validate,
+ :after_validation,
+ :after_validation_on_create
+ ], david.history
+ end
+
+ def test_validate_on_contextual_create
+ david = ContextualCallbacksDeveloper.create("name" => "David", "salary" => 1000000)
+ assert_equal [
+ :before_validation,
+ :before_validation_on_create,
+ :validate,
+ :after_validation,
+ :after_validation_on_create
+ ], david.history
+ end
+
+ def test_update
+ david = CallbackDeveloper.find(1)
+ david.save
+ assert_equal [
+ [ :after_find, :method ],
+ [ :after_find, :proc ],
+ [ :after_find, :object ],
+ [ :after_find, :block ],
+ [ :after_initialize, :method ],
+ [ :after_initialize, :proc ],
+ [ :after_initialize, :object ],
+ [ :after_initialize, :block ],
+ [ :before_validation, :method ],
+ [ :before_validation, :proc ],
+ [ :before_validation, :object ],
+ [ :before_validation, :block ],
+ [ :after_validation, :method ],
+ [ :after_validation, :proc ],
+ [ :after_validation, :object ],
+ [ :after_validation, :block ],
+ [ :before_save, :method ],
+ [ :before_save, :proc ],
+ [ :before_save, :object ],
+ [ :before_save, :block ],
+ [ :before_update, :method ],
+ [ :before_update, :proc ],
+ [ :before_update, :object ],
+ [ :before_update, :block ],
+ [ :after_update, :method ],
+ [ :after_update, :proc ],
+ [ :after_update, :object ],
+ [ :after_update, :block ],
+ [ :after_save, :method ],
+ [ :after_save, :proc ],
+ [ :after_save, :object ],
+ [ :after_save, :block ],
+ [ :after_commit, :block ],
+ [ :after_commit, :object ],
+ [ :after_commit, :proc ],
+ [ :after_commit, :method ]
+ ], david.history
+ end
+
+ def test_validate_on_update
+ david = OnCallbacksDeveloper.find(1)
+ david.save
+ assert_equal [
+ :before_validation,
+ :before_validation_on_update,
+ :validate,
+ :after_validation,
+ :after_validation_on_update
+ ], david.history
+ end
+
+ def test_validate_on_contextual_update
+ david = ContextualCallbacksDeveloper.find(1)
+ david.save
+ assert_equal [
+ :before_validation,
+ :before_validation_on_update,
+ :validate,
+ :after_validation,
+ :after_validation_on_update
+ ], david.history
+ end
+
+ def test_destroy
+ david = CallbackDeveloper.find(1)
+ david.destroy
+ assert_equal [
+ [ :after_find, :method ],
+ [ :after_find, :proc ],
+ [ :after_find, :object ],
+ [ :after_find, :block ],
+ [ :after_initialize, :method ],
+ [ :after_initialize, :proc ],
+ [ :after_initialize, :object ],
+ [ :after_initialize, :block ],
+ [ :before_destroy, :method ],
+ [ :before_destroy, :proc ],
+ [ :before_destroy, :object ],
+ [ :before_destroy, :block ],
+ [ :after_destroy, :method ],
+ [ :after_destroy, :proc ],
+ [ :after_destroy, :object ],
+ [ :after_destroy, :block ],
+ [ :after_commit, :block ],
+ [ :after_commit, :object ],
+ [ :after_commit, :proc ],
+ [ :after_commit, :method ]
+ ], david.history
+ end
+
+ def test_delete
+ david = CallbackDeveloper.find(1)
+ CallbackDeveloper.delete(david.id)
+ assert_equal [
+ [ :after_find, :method ],
+ [ :after_find, :proc ],
+ [ :after_find, :object ],
+ [ :after_find, :block ],
+ [ :after_initialize, :method ],
+ [ :after_initialize, :proc ],
+ [ :after_initialize, :object ],
+ [ :after_initialize, :block ],
+ ], david.history
+ end
+
+ def assert_save_callbacks_not_called(someone)
+ assert_not someone.after_save_called
+ assert_not someone.after_create_called
+ assert_not someone.after_update_called
+ end
+ private :assert_save_callbacks_not_called
+
+ def test_before_create_throwing_abort
+ someone = CallbackHaltedDeveloper.new
+ someone.cancel_before_create = true
+ assert_predicate someone, :valid?
+ assert_not someone.save
+ assert_save_callbacks_not_called(someone)
+ end
+
+ def test_before_save_throwing_abort
+ david = DeveloperWithCanceledCallbacks.find(1)
+ assert_predicate david, :valid?
+ assert_not david.save
+ exc = assert_raise(ActiveRecord::RecordNotSaved) { david.save! }
+ assert_equal david, exc.record
+
+ david = DeveloperWithCanceledCallbacks.find(1)
+ david.salary = 10_000_000
+ assert_not_predicate david, :valid?
+ assert_not david.save
+ assert_raise(ActiveRecord::RecordInvalid) { david.save! }
+
+ someone = CallbackHaltedDeveloper.find(1)
+ someone.cancel_before_save = true
+ assert_predicate someone, :valid?
+ assert_not someone.save
+ assert_save_callbacks_not_called(someone)
+ end
+
+ def test_before_update_throwing_abort
+ someone = CallbackHaltedDeveloper.find(1)
+ someone.cancel_before_update = true
+ assert_predicate someone, :valid?
+ assert_not someone.save
+ assert_save_callbacks_not_called(someone)
+ end
+
+ def test_before_destroy_throwing_abort
+ david = DeveloperWithCanceledCallbacks.find(1)
+ assert_not david.destroy
+ exc = assert_raise(ActiveRecord::RecordNotDestroyed) { david.destroy! }
+ assert_equal david, exc.record
+ assert_not_nil ImmutableDeveloper.find_by_id(1)
+
+ someone = CallbackHaltedDeveloper.find(1)
+ someone.cancel_before_destroy = true
+ assert_not someone.destroy
+ assert_raise(ActiveRecord::RecordNotDestroyed) { someone.destroy! }
+ assert_not someone.after_destroy_called
+ end
+
+ def test_callback_throwing_abort
+ david = CallbackDeveloperWithHaltedValidation.find(1)
+ david.save
+ assert_equal [
+ [ :after_find, :method ],
+ [ :after_find, :proc ],
+ [ :after_find, :object ],
+ [ :after_find, :block ],
+ [ :after_initialize, :method ],
+ [ :after_initialize, :proc ],
+ [ :after_initialize, :object ],
+ [ :after_initialize, :block ],
+ [ :before_validation, :method ],
+ [ :before_validation, :proc ],
+ [ :before_validation, :object ],
+ [ :before_validation, :block ],
+ [ :before_validation, :throwing_abort ],
+ [ :after_rollback, :block ],
+ [ :after_rollback, :object ],
+ [ :after_rollback, :proc ],
+ [ :after_rollback, :method ],
+ ], david.history
+ end
+
+ def test_inheritance_of_callbacks
+ parent = ParentDeveloper.new
+ assert_not parent.after_save_called
+ parent.save
+ assert parent.after_save_called
+
+ child = ChildDeveloper.new
+ assert_not child.after_save_called
+ child.save
+ assert child.after_save_called
+ end
+
+ def test_before_save_doesnt_allow_on_option
+ exception = assert_raises ArgumentError do
+ Class.new(ActiveRecord::Base) do
+ before_save(on: :create) { }
+ end
+ end
+ assert_equal "Unknown key: :on. Valid keys are: :if, :unless, :prepend", exception.message
+ end
+
+ def test_around_save_doesnt_allow_on_option
+ exception = assert_raises ArgumentError do
+ Class.new(ActiveRecord::Base) do
+ around_save(on: :create) { }
+ end
+ end
+ assert_equal "Unknown key: :on. Valid keys are: :if, :unless, :prepend", exception.message
+ end
+
+ def test_after_save_doesnt_allow_on_option
+ exception = assert_raises ArgumentError do
+ Class.new(ActiveRecord::Base) do
+ after_save(on: :create) { }
+ end
+ end
+ assert_equal "Unknown key: :on. Valid keys are: :if, :unless, :prepend", exception.message
+ end
+end
diff --git a/activerecord/test/cases/clone_test.rb b/activerecord/test/cases/clone_test.rb
new file mode 100644
index 0000000000..eea36ee736
--- /dev/null
+++ b/activerecord/test/cases/clone_test.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+
+module ActiveRecord
+ class CloneTest < ActiveRecord::TestCase
+ fixtures :topics
+
+ def test_persisted
+ topic = Topic.first
+ cloned = topic.clone
+ assert topic.persisted?, "topic persisted"
+ assert cloned.persisted?, "topic persisted"
+ assert_not cloned.new_record?, "topic is not new"
+ end
+
+ def test_stays_frozen
+ topic = Topic.first
+ topic.freeze
+
+ cloned = topic.clone
+ assert cloned.persisted?, "topic persisted"
+ assert_not cloned.new_record?, "topic is not new"
+ assert cloned.frozen?, "topic should be frozen"
+ end
+
+ def test_shallow
+ topic = Topic.first
+ cloned = topic.clone
+ topic.author_name = "Aaron"
+ assert_equal "Aaron", cloned.author_name
+ end
+
+ def test_freezing_a_cloned_model_does_not_freeze_clone
+ cloned = Topic.new
+ clone = cloned.clone
+ cloned.freeze
+ assert_not_predicate clone, :frozen?
+ end
+ end
+end
diff --git a/activerecord/test/cases/coders/json_test.rb b/activerecord/test/cases/coders/json_test.rb
new file mode 100644
index 0000000000..e40d576b39
--- /dev/null
+++ b/activerecord/test/cases/coders/json_test.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ module Coders
+ class JSONTest < ActiveRecord::TestCase
+ def test_returns_nil_if_empty_string_given
+ assert_nil JSON.load("")
+ end
+
+ def test_returns_nil_if_nil_given
+ assert_nil JSON.load(nil)
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/coders/yaml_column_test.rb b/activerecord/test/cases/coders/yaml_column_test.rb
new file mode 100644
index 0000000000..4a5559c62f
--- /dev/null
+++ b/activerecord/test/cases/coders/yaml_column_test.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ module Coders
+ class YAMLColumnTest < ActiveRecord::TestCase
+ def test_initialize_takes_class
+ coder = YAMLColumn.new("attr_name", Object)
+ assert_equal Object, coder.object_class
+ end
+
+ def test_type_mismatch_on_different_classes_on_dump
+ coder = YAMLColumn.new("tags", Array)
+ error = assert_raises(SerializationTypeMismatch) do
+ coder.dump("a")
+ end
+ assert_equal %{can't dump `tags`: was supposed to be a Array, but was a String. -- "a"}, error.to_s
+ end
+
+ def test_type_mismatch_on_different_classes
+ coder = YAMLColumn.new("tags", Array)
+ error = assert_raises(SerializationTypeMismatch) do
+ coder.load "--- foo"
+ end
+ assert_equal %{can't load `tags`: was supposed to be a Array, but was a String. -- "foo"}, error.to_s
+ end
+
+ def test_nil_is_ok
+ coder = YAMLColumn.new("attr_name")
+ assert_nil coder.load "--- "
+ end
+
+ def test_returns_new_with_different_class
+ coder = YAMLColumn.new("attr_name", SerializationTypeMismatch)
+ assert_equal SerializationTypeMismatch, coder.load("--- ").class
+ end
+
+ def test_returns_string_unless_starts_with_dash
+ coder = YAMLColumn.new("attr_name")
+ assert_equal "foo", coder.load("foo")
+ end
+
+ def test_load_handles_other_classes
+ coder = YAMLColumn.new("attr_name")
+ assert_equal [], coder.load([])
+ end
+
+ def test_load_doesnt_swallow_yaml_exceptions
+ coder = YAMLColumn.new("attr_name")
+ bad_yaml = "--- {"
+ assert_raises(Psych::SyntaxError) do
+ coder.load(bad_yaml)
+ end
+ end
+
+ def test_load_doesnt_handle_undefined_class_or_module
+ coder = YAMLColumn.new("attr_name")
+ missing_class_yaml = '--- !ruby/object:DoesNotExistAndShouldntEver {}\n'
+ assert_raises(ArgumentError) do
+ coder.load(missing_class_yaml)
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/collection_cache_key_test.rb b/activerecord/test/cases/collection_cache_key_test.rb
new file mode 100644
index 0000000000..483383257b
--- /dev/null
+++ b/activerecord/test/cases/collection_cache_key_test.rb
@@ -0,0 +1,175 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/computer"
+require "models/developer"
+require "models/project"
+require "models/topic"
+require "models/post"
+require "models/comment"
+
+module ActiveRecord
+ class CollectionCacheKeyTest < ActiveRecord::TestCase
+ fixtures :developers, :projects, :developers_projects, :topics, :comments, :posts
+
+ test "collection_cache_key on model" do
+ assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, Developer.collection_cache_key)
+ end
+
+ test "cache_key for relation" do
+ developers = Developer.where(salary: 100000).order(updated_at: :desc)
+ last_developer_timestamp = developers.first.updated_at
+
+ assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key)
+
+ /\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/ =~ developers.cache_key
+
+ assert_equal ActiveSupport::Digest.hexdigest(developers.to_sql), $1
+ assert_equal developers.count.to_s, $2
+ assert_equal last_developer_timestamp.to_s(ActiveRecord::Base.cache_timestamp_format), $3
+ end
+
+ test "cache_key for relation with limit" do
+ developers = Developer.where(salary: 100000).order(updated_at: :desc).limit(5)
+ last_developer_timestamp = developers.first.updated_at
+
+ assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key)
+
+ /\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/ =~ developers.cache_key
+
+ assert_equal ActiveSupport::Digest.hexdigest(developers.to_sql), $1
+ assert_equal developers.count.to_s, $2
+ assert_equal last_developer_timestamp.to_s(ActiveRecord::Base.cache_timestamp_format), $3
+ end
+
+ test "cache_key for relation with custom select and limit" do
+ developers = Developer.where(salary: 100000).order(updated_at: :desc).limit(5)
+ developers_with_select = developers.select("developers.*")
+ last_developer_timestamp = developers.first.updated_at
+
+ assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers_with_select.cache_key)
+
+ /\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/ =~ developers_with_select.cache_key
+
+ assert_equal ActiveSupport::Digest.hexdigest(developers_with_select.to_sql), $1
+ assert_equal developers.count.to_s, $2
+ assert_equal last_developer_timestamp.to_s(ActiveRecord::Base.cache_timestamp_format), $3
+ end
+
+ test "cache_key for loaded relation" do
+ developers = Developer.where(salary: 100000).order(updated_at: :desc).limit(5).load
+ last_developer_timestamp = developers.first.updated_at
+
+ assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key)
+
+ /\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/ =~ developers.cache_key
+
+ assert_equal ActiveSupport::Digest.hexdigest(developers.to_sql), $1
+ assert_equal developers.count.to_s, $2
+ assert_equal last_developer_timestamp.to_s(ActiveRecord::Base.cache_timestamp_format), $3
+ end
+
+ test "cache_key for relation with table alias" do
+ table_alias = Developer.arel_table.alias("omg_developers")
+ table_metadata = ActiveRecord::TableMetadata.new(Developer, table_alias)
+ predicate_builder = ActiveRecord::PredicateBuilder.new(table_metadata)
+
+ developers = ActiveRecord::Relation.create(
+ Developer,
+ table: table_alias,
+ predicate_builder: predicate_builder
+ )
+ developers = developers.where(salary: 100000).order(updated_at: :desc)
+ last_developer_timestamp = developers.first.updated_at
+
+ assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key)
+
+ /\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/ =~ developers.cache_key
+
+ assert_equal ActiveSupport::Digest.hexdigest(developers.to_sql), $1
+ assert_equal developers.count.to_s, $2
+ assert_equal last_developer_timestamp.to_s(ActiveRecord::Base.cache_timestamp_format), $3
+ end
+
+ test "cache_key for relation with includes" do
+ comments = Comment.includes(:post).where("posts.type": "Post")
+ assert_match(/\Acomments\/query-(\h+)-(\d+)-(\d+)\z/, comments.cache_key)
+ end
+
+ test "cache_key for loaded relation with includes" do
+ comments = Comment.includes(:post).where("posts.type": "Post").load
+ assert_match(/\Acomments\/query-(\h+)-(\d+)-(\d+)\z/, comments.cache_key)
+ end
+
+ test "it triggers at most one query" do
+ developers = Developer.where(name: "David")
+
+ assert_queries(1) { developers.cache_key }
+ assert_no_queries { developers.cache_key }
+ end
+
+ test "it doesn't trigger any query if the relation is already loaded" do
+ developers = Developer.where(name: "David").load
+ assert_no_queries { developers.cache_key }
+ end
+
+ test "relation cache_key changes when the sql query changes" do
+ developers = Developer.where(name: "David")
+ other_relation = Developer.where(name: "David").where("1 = 1")
+
+ assert_not_equal developers.cache_key, other_relation.cache_key
+ end
+
+ test "cache_key for empty relation" do
+ developers = Developer.where(name: "Non Existent Developer")
+ assert_match(/\Adevelopers\/query-(\h+)-0\z/, developers.cache_key)
+ end
+
+ test "cache_key with custom timestamp column" do
+ topics = Topic.where("title like ?", "%Topic%")
+ last_topic_timestamp = topics(:fifth).written_on.utc.to_s(:usec)
+ assert_match(last_topic_timestamp, topics.cache_key(:written_on))
+ end
+
+ test "cache_key with unknown timestamp column" do
+ topics = Topic.where("title like ?", "%Topic%")
+ assert_raises(ActiveRecord::StatementInvalid) { topics.cache_key(:published_at) }
+ end
+
+ test "collection proxy provides a cache_key" do
+ developers = projects(:active_record).developers
+ assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key)
+ end
+
+ test "cache_key for loaded collection with zero size" do
+ Comment.delete_all
+ posts = Post.includes(:comments)
+ empty_loaded_collection = posts.first.comments
+
+ assert_match(/\Acomments\/query-(\h+)-0\z/, empty_loaded_collection.cache_key)
+ end
+
+ test "cache_key for queries with offset which return 0 rows" do
+ developers = Developer.offset(20)
+ assert_match(/\Adevelopers\/query-(\h+)-0\z/, developers.cache_key)
+ end
+
+ test "cache_key with a relation having selected columns" do
+ developers = Developer.select(:salary)
+
+ assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key)
+ end
+
+ test "cache_key with a relation having distinct and order" do
+ developers = Developer.distinct.order(:salary).limit(5)
+
+ assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key)
+ end
+
+ test "cache_key with a relation having custom select and order" do
+ developers = Developer.select("name AS dev_name").order("dev_name DESC").limit(5)
+
+ assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key)
+ end
+ end
+end
diff --git a/activerecord/test/cases/column_alias_test.rb b/activerecord/test/cases/column_alias_test.rb
new file mode 100644
index 0000000000..a883d21fb8
--- /dev/null
+++ b/activerecord/test/cases/column_alias_test.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+
+class TestColumnAlias < ActiveRecord::TestCase
+ fixtures :topics
+
+ QUERY = if "Oracle" == ActiveRecord::Base.connection.adapter_name
+ "SELECT id AS pk FROM topics WHERE ROWNUM < 2"
+ else
+ "SELECT id AS pk FROM topics"
+ end
+
+ def test_column_alias
+ records = Topic.connection.select_all(QUERY)
+ assert_equal "pk", records[0].keys[0]
+ end
+end
diff --git a/activerecord/test/cases/column_definition_test.rb b/activerecord/test/cases/column_definition_test.rb
new file mode 100644
index 0000000000..cbd2b44589
--- /dev/null
+++ b/activerecord/test/cases/column_definition_test.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class ColumnDefinitionTest < ActiveRecord::TestCase
+ def setup
+ @adapter = AbstractAdapter.new(nil)
+ def @adapter.native_database_types
+ { string: "varchar" }
+ end
+ @viz = @adapter.send(:schema_creation)
+ end
+
+ # Avoid column definitions in create table statements like:
+ # `title` varchar(255) DEFAULT NULL
+ def test_should_not_include_default_clause_when_default_is_null
+ column_def = ColumnDefinition.new("title", "string", limit: 20)
+ assert_equal "title varchar(20)", @viz.accept(column_def)
+ end
+
+ def test_should_include_default_clause_when_default_is_present
+ column_def = ColumnDefinition.new("title", "string", limit: 20, default: "Hello")
+ assert_equal "title varchar(20) DEFAULT 'Hello'", @viz.accept(column_def)
+ end
+
+ def test_should_specify_not_null_if_null_option_is_false
+ column_def = ColumnDefinition.new("title", "string", limit: 20, default: "Hello", null: false)
+ assert_equal "title varchar(20) DEFAULT 'Hello' NOT NULL", @viz.accept(column_def)
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/comment_test.rb b/activerecord/test/cases/comment_test.rb
new file mode 100644
index 0000000000..584e03d196
--- /dev/null
+++ b/activerecord/test/cases/comment_test.rb
@@ -0,0 +1,168 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+if ActiveRecord::Base.connection.supports_comments?
+ class CommentTest < ActiveRecord::TestCase
+ include SchemaDumpingHelper
+
+ class Commented < ActiveRecord::Base
+ self.table_name = "commenteds"
+ end
+
+ class BlankComment < ActiveRecord::Base
+ end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+
+ @connection.create_table("commenteds", comment: "A table with comment", force: true) do |t|
+ t.string "name", comment: "Comment should help clarify the column purpose"
+ t.boolean "obvious", comment: "Question is: should you comment obviously named objects?"
+ t.string "content"
+ t.index "name", comment: %Q["Very important" index that powers all the performance.\nAnd it's fun!]
+ end
+
+ @connection.create_table("blank_comments", comment: " ", force: true) do |t|
+ t.string :space_comment, comment: " "
+ t.string :empty_comment, comment: ""
+ t.string :nil_comment, comment: nil
+ t.string :absent_comment
+ t.index :space_comment, comment: " "
+ t.index :empty_comment, comment: ""
+ t.index :nil_comment, comment: nil
+ t.index :absent_comment
+ end
+
+ Commented.reset_column_information
+ BlankComment.reset_column_information
+ end
+
+ teardown do
+ @connection.drop_table "commenteds", if_exists: true
+ @connection.drop_table "blank_comments", if_exists: true
+ end
+
+ def test_column_created_in_block
+ column = Commented.columns_hash["name"]
+ assert_equal :string, column.type
+ assert_equal "Comment should help clarify the column purpose", column.comment
+ end
+
+ def test_blank_columns_created_in_block
+ %w[ space_comment empty_comment nil_comment absent_comment ].each do |field|
+ column = BlankComment.columns_hash[field]
+ assert_equal :string, column.type
+ assert_nil column.comment
+ end
+ end
+
+ def test_blank_indexes_created_in_block
+ @connection.indexes("blank_comments").each do |index|
+ assert_nil index.comment
+ end
+ end
+
+ def test_add_column_with_comment_later
+ @connection.add_column :commenteds, :rating, :integer, comment: "I am running out of imagination"
+ Commented.reset_column_information
+ column = Commented.columns_hash["rating"]
+
+ assert_equal :integer, column.type
+ assert_equal "I am running out of imagination", column.comment
+ end
+
+ def test_add_index_with_comment_later
+ unless current_adapter?(:OracleAdapter)
+ @connection.add_index :commenteds, :obvious, name: "idx_obvious", comment: "We need to see obvious comments"
+ index = @connection.indexes("commenteds").find { |idef| idef.name == "idx_obvious" }
+ assert_equal "We need to see obvious comments", index.comment
+ end
+ end
+
+ def test_add_comment_to_column
+ @connection.change_column :commenteds, :content, :string, comment: "Whoa, content describes itself!"
+
+ Commented.reset_column_information
+ column = Commented.columns_hash["content"]
+
+ assert_equal :string, column.type
+ assert_equal "Whoa, content describes itself!", column.comment
+ end
+
+ def test_remove_comment_from_column
+ @connection.change_column :commenteds, :obvious, :string, comment: nil
+
+ Commented.reset_column_information
+ column = Commented.columns_hash["obvious"]
+
+ assert_equal :string, column.type
+ assert_nil column.comment
+ end
+
+ def test_schema_dump_with_comments
+ # Do all the stuff from other tests
+ @connection.add_column :commenteds, :rating, :integer, comment: "I am running out of imagination"
+ @connection.change_column :commenteds, :content, :string, comment: "Whoa, content describes itself!"
+ @connection.change_column :commenteds, :content, :string
+ @connection.change_column :commenteds, :obvious, :string, comment: nil
+ @connection.add_index :commenteds, :obvious, name: "idx_obvious", comment: "We need to see obvious comments"
+
+ # And check that these changes are reflected in dump
+ output = dump_table_schema "commenteds"
+ assert_match %r[create_table "commenteds",.*\s+comment: "A table with comment"], output
+ assert_match %r[t\.string\s+"name",\s+comment: "Comment should help clarify the column purpose"], output
+ assert_match %r[t\.string\s+"obvious"\n], output
+ assert_match %r[t\.string\s+"content",\s+comment: "Whoa, content describes itself!"], output
+ if current_adapter?(:OracleAdapter)
+ assert_match %r[t\.integer\s+"rating",\s+precision: 38,\s+comment: "I am running out of imagination"], output
+ else
+ assert_match %r[t\.integer\s+"rating",\s+comment: "I am running out of imagination"], output
+ assert_match %r[t\.index\s+.+\s+comment: "\\\"Very important\\\" index that powers all the performance.\\nAnd it's fun!"], output
+ assert_match %r[t\.index\s+.+\s+name: "idx_obvious",\s+comment: "We need to see obvious comments"], output
+ end
+ end
+
+ def test_schema_dump_omits_blank_comments
+ output = dump_table_schema "blank_comments"
+
+ assert_match %r[create_table "blank_comments"], output
+ assert_no_match %r[create_table "blank_comments",.+comment:], output
+
+ assert_match %r[t\.string\s+"space_comment"\n], output
+ assert_no_match %r[t\.string\s+"space_comment", comment:\n], output
+
+ assert_match %r[t\.string\s+"empty_comment"\n], output
+ assert_no_match %r[t\.string\s+"empty_comment", comment:\n], output
+
+ assert_match %r[t\.string\s+"nil_comment"\n], output
+ assert_no_match %r[t\.string\s+"nil_comment", comment:\n], output
+
+ assert_match %r[t\.string\s+"absent_comment"\n], output
+ assert_no_match %r[t\.string\s+"absent_comment", comment:\n], output
+ end
+
+ def test_change_table_comment
+ @connection.change_table_comment :commenteds, "Edited table comment"
+ assert_equal "Edited table comment", @connection.table_comment("commenteds")
+ end
+
+ def test_change_table_comment_to_nil
+ @connection.change_table_comment :commenteds, nil
+ assert_nil @connection.table_comment("commenteds")
+ end
+
+ def test_change_column_comment
+ @connection.change_column_comment :commenteds, :name, "Edited column comment"
+ column = Commented.columns_hash["name"]
+ assert_equal "Edited column comment", column.comment
+ end
+
+ def test_change_column_comment_to_nil
+ @connection.change_column_comment :commenteds, :name, nil
+ column = Commented.columns_hash["name"]
+ assert_nil column.comment
+ end
+ end
+end
diff --git a/activerecord/test/cases/connection_adapters/adapter_leasing_test.rb b/activerecord/test/cases/connection_adapters/adapter_leasing_test.rb
new file mode 100644
index 0000000000..72838ff56b
--- /dev/null
+++ b/activerecord/test/cases/connection_adapters/adapter_leasing_test.rb
@@ -0,0 +1,58 @@
+# 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
+ pool = Pool.new(ConnectionSpecification.new("primary", {}, nil))
+ 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
diff --git a/activerecord/test/cases/connection_adapters/connection_handler_test.rb b/activerecord/test/cases/connection_adapters/connection_handler_test.rb
new file mode 100644
index 0000000000..51d0cc3d12
--- /dev/null
+++ b/activerecord/test/cases/connection_adapters/connection_handler_test.rb
@@ -0,0 +1,388 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/person"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class ConnectionHandlerTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ fixtures :people
+
+ def setup
+ @handler = ConnectionHandler.new
+ @spec_name = "primary"
+ @pool = @handler.establish_connection(ActiveRecord::Base.configurations["arunit"])
+ end
+
+ def test_default_env_fall_back_to_default_env_when_rails_env_or_rack_env_is_empty_string
+ original_rails_env = ENV["RAILS_ENV"]
+ original_rack_env = ENV["RACK_ENV"]
+ ENV["RAILS_ENV"] = ENV["RACK_ENV"] = ""
+
+ assert_equal "default_env", ActiveRecord::ConnectionHandling::DEFAULT_ENV.call
+ ensure
+ ENV["RAILS_ENV"] = original_rails_env
+ ENV["RACK_ENV"] = original_rack_env
+ end
+
+ def test_establish_connection_uses_spec_name
+ old_config = ActiveRecord::Base.configurations
+ config = { "readonly" => { "adapter" => "sqlite3" } }
+ ActiveRecord::Base.configurations = config
+ resolver = ConnectionAdapters::ConnectionSpecification::Resolver.new(ActiveRecord::Base.configurations)
+ spec = resolver.spec(:readonly)
+ @handler.establish_connection(spec.to_hash)
+
+ assert_not_nil @handler.retrieve_connection_pool("readonly")
+ ensure
+ ActiveRecord::Base.configurations = old_config
+ @handler.remove_connection("readonly")
+ end
+
+ def test_establish_connection_using_3_levels_config
+ previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env"
+
+ config = {
+ "default_env" => {
+ "readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" },
+ "primary" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" }
+ },
+ "another_env" => {
+ "readonly" => { "adapter" => "sqlite3", "database" => "db/bad-readonly.sqlite3" },
+ "primary" => { "adapter" => "sqlite3", "database" => "db/bad-primary.sqlite3" }
+ },
+ "common" => { "adapter" => "sqlite3", "database" => "db/common.sqlite3" }
+ }
+ @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config
+
+ @handler.establish_connection(:common)
+ @handler.establish_connection(:primary)
+ @handler.establish_connection(:readonly)
+
+ assert_not_nil pool = @handler.retrieve_connection_pool("readonly")
+ assert_equal "db/readonly.sqlite3", pool.spec.config[:database]
+
+ assert_not_nil pool = @handler.retrieve_connection_pool("primary")
+ assert_equal "db/primary.sqlite3", pool.spec.config[:database]
+
+ assert_not_nil pool = @handler.retrieve_connection_pool("common")
+ assert_equal "db/common.sqlite3", pool.spec.config[:database]
+ ensure
+ ActiveRecord::Base.configurations = @prev_configs
+ ENV["RAILS_ENV"] = previous_env
+ end
+
+ unless in_memory_db?
+ def test_establish_connection_using_3_level_config_defaults_to_default_env_primary_db
+ previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env"
+
+ config = {
+ "default_env" => {
+ "primary" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" },
+ "readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" }
+ },
+ "another_env" => {
+ "primary" => { "adapter" => "sqlite3", "database" => "db/another-primary.sqlite3" },
+ "readonly" => { "adapter" => "sqlite3", "database" => "db/another-readonly.sqlite3" }
+ }
+ }
+ @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config
+
+ ActiveRecord::Base.establish_connection
+
+ assert_match "db/primary.sqlite3", ActiveRecord::Base.connection.pool.spec.config[:database]
+ ensure
+ ActiveRecord::Base.configurations = @prev_configs
+ ENV["RAILS_ENV"] = previous_env
+ ActiveRecord::Base.establish_connection(:arunit)
+ FileUtils.rm_rf "db"
+ end
+
+ def test_establish_connection_using_2_level_config_defaults_to_default_env_primary_db
+ previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env"
+
+ config = {
+ "default_env" => {
+ "adapter" => "sqlite3", "database" => "db/primary.sqlite3"
+ },
+ "another_env" => {
+ "adapter" => "sqlite3", "database" => "db/bad-primary.sqlite3"
+ }
+ }
+ @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config
+
+ ActiveRecord::Base.establish_connection
+
+ assert_match "db/primary.sqlite3", ActiveRecord::Base.connection.pool.spec.config[:database]
+ ensure
+ ActiveRecord::Base.configurations = @prev_configs
+ ENV["RAILS_ENV"] = previous_env
+ ActiveRecord::Base.establish_connection(:arunit)
+ FileUtils.rm_rf "db"
+ end
+ end
+
+ def test_establish_connection_using_two_level_configurations
+ config = { "development" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" } }
+ @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config
+
+ @handler.establish_connection(:development)
+
+ assert_not_nil pool = @handler.retrieve_connection_pool("development")
+ assert_equal "db/primary.sqlite3", pool.spec.config[:database]
+ ensure
+ ActiveRecord::Base.configurations = @prev_configs
+ end
+
+ def test_establish_connection_using_top_level_key_in_two_level_config
+ config = {
+ "development" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" },
+ "development_readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" }
+ }
+ @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config
+
+ @handler.establish_connection(:development_readonly)
+
+ assert_not_nil pool = @handler.retrieve_connection_pool("development_readonly")
+ assert_equal "db/readonly.sqlite3", pool.spec.config[:database]
+ ensure
+ ActiveRecord::Base.configurations = @prev_configs
+ end
+
+ def test_symbolized_configurations_assignment
+ @prev_configs = ActiveRecord::Base.configurations
+ config = {
+ development: {
+ primary: {
+ adapter: "sqlite3",
+ database: "db/development.sqlite3",
+ },
+ },
+ test: {
+ primary: {
+ adapter: "sqlite3",
+ database: "db/test.sqlite3",
+ },
+ },
+ }
+ ActiveRecord::Base.configurations = config
+ ActiveRecord::Base.configurations.configs_for.each do |db_config|
+ assert_instance_of ActiveRecord::DatabaseConfigurations::HashConfig, db_config
+ assert_instance_of String, db_config.env_name
+ assert_instance_of String, db_config.spec_name
+ db_config.config.keys.each do |key|
+ assert_instance_of String, key
+ end
+ end
+ ensure
+ ActiveRecord::Base.configurations = @prev_configs
+ end
+
+ def test_retrieve_connection
+ assert @handler.retrieve_connection(@spec_name)
+ end
+
+ def test_active_connections?
+ assert_not_predicate @handler, :active_connections?
+ assert @handler.retrieve_connection(@spec_name)
+ assert_predicate @handler, :active_connections?
+ @handler.clear_active_connections!
+ assert_not_predicate @handler, :active_connections?
+ end
+
+ def test_retrieve_connection_pool
+ assert_not_nil @handler.retrieve_connection_pool(@spec_name)
+ end
+
+ def test_retrieve_connection_pool_with_invalid_id
+ assert_nil @handler.retrieve_connection_pool("foo")
+ end
+
+ def test_connection_pools
+ assert_equal([@pool], @handler.connection_pools)
+ end
+
+ if Process.respond_to?(:fork)
+ def test_connection_pool_per_pid
+ object_id = ActiveRecord::Base.connection.object_id
+
+ rd, wr = IO.pipe
+ rd.binmode
+ wr.binmode
+
+ pid = fork {
+ rd.close
+ wr.write Marshal.dump ActiveRecord::Base.connection.object_id
+ wr.close
+ exit!
+ }
+
+ wr.close
+
+ Process.waitpid pid
+ assert_not_equal object_id, Marshal.load(rd.read)
+ rd.close
+ end
+
+ def test_forked_child_doesnt_mangle_parent_connection
+ object_id = ActiveRecord::Base.connection.object_id
+ assert_predicate ActiveRecord::Base.connection, :active?
+
+ rd, wr = IO.pipe
+ rd.binmode
+ wr.binmode
+
+ pid = fork {
+ rd.close
+ if ActiveRecord::Base.connection.active?
+ wr.write Marshal.dump ActiveRecord::Base.connection.object_id
+ end
+ wr.close
+
+ exit # allow finalizers to run
+ }
+
+ wr.close
+
+ Process.waitpid pid
+ assert_not_equal object_id, Marshal.load(rd.read)
+ rd.close
+
+ assert_equal 3, ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM people")
+ end
+
+ unless in_memory_db?
+ def test_forked_child_recovers_from_disconnected_parent
+ object_id = ActiveRecord::Base.connection.object_id
+ assert_predicate ActiveRecord::Base.connection, :active?
+
+ rd, wr = IO.pipe
+ rd.binmode
+ wr.binmode
+
+ outer_pid = fork {
+ ActiveRecord::Base.connection.disconnect!
+
+ pid = fork {
+ rd.close
+ if ActiveRecord::Base.connection.active?
+ pair = [ActiveRecord::Base.connection.object_id,
+ ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM people")]
+ wr.write Marshal.dump pair
+ end
+ wr.close
+
+ exit # allow finalizers to run
+ }
+
+ Process.waitpid pid
+ }
+
+ wr.close
+
+ Process.waitpid outer_pid
+ child_id, child_count = Marshal.load(rd.read)
+
+ assert_not_equal object_id, child_id
+ rd.close
+
+ assert_equal 3, child_count
+
+ # Outer connection is unaffected
+ assert_equal 6, ActiveRecord::Base.connection.select_value("SELECT 2 * COUNT(*) FROM people")
+ end
+ end
+
+ def test_retrieve_connection_pool_copies_schema_cache_from_ancestor_pool
+ @pool.schema_cache = @pool.connection.schema_cache
+ @pool.schema_cache.add("posts")
+
+ rd, wr = IO.pipe
+ rd.binmode
+ wr.binmode
+
+ pid = fork {
+ rd.close
+ pool = @handler.retrieve_connection_pool(@spec_name)
+ wr.write Marshal.dump pool.schema_cache.size
+ wr.close
+ exit!
+ }
+
+ wr.close
+
+ Process.waitpid pid
+ assert_equal @pool.schema_cache.size, Marshal.load(rd.read)
+ rd.close
+ end
+
+ def test_pool_from_any_process_for_uses_most_recent_spec
+ skip unless current_adapter?(:SQLite3Adapter)
+
+ file = Tempfile.new "lol.sqlite3"
+
+ rd, wr = IO.pipe
+ rd.binmode
+ wr.binmode
+
+ pid = fork do
+ ActiveRecord::Base.configurations["arunit"]["database"] = file.path
+ ActiveRecord::Base.establish_connection(:arunit)
+
+ pid2 = fork do
+ wr.write ActiveRecord::Base.connection_config[:database]
+ wr.close
+ end
+
+ Process.waitpid pid2
+ end
+
+ Process.waitpid pid
+
+ wr.close
+
+ assert_equal file.path, rd.read
+
+ rd.close
+ ensure
+ if file
+ file.close
+ file.unlink
+ end
+ end
+
+ def test_a_class_using_custom_pool_and_switching_back_to_primary
+ klass2 = Class.new(Base) { def self.name; "klass2"; end }
+
+ assert_same klass2.connection, ActiveRecord::Base.connection
+
+ pool = klass2.establish_connection(ActiveRecord::Base.connection_pool.spec.config)
+ assert_same klass2.connection, pool.connection
+ assert_not_same klass2.connection, ActiveRecord::Base.connection
+
+ klass2.remove_connection
+
+ assert_same klass2.connection, ActiveRecord::Base.connection
+ end
+
+ def test_connection_specification_name_should_fallback_to_parent
+ klassA = Class.new(Base)
+ klassB = Class.new(klassA)
+
+ assert_equal klassB.connection_specification_name, klassA.connection_specification_name
+ klassA.connection_specification_name = "readonly"
+ assert_equal "readonly", klassB.connection_specification_name
+ end
+
+ def test_remove_connection_should_not_remove_parent
+ klass2 = Class.new(Base) { def self.name; "klass2"; end }
+ klass2.remove_connection
+ assert_not_nil ActiveRecord::Base.connection
+ assert_same klass2.connection, ActiveRecord::Base.connection
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/connection_adapters/connection_handlers_multi_db_test.rb b/activerecord/test/cases/connection_adapters/connection_handlers_multi_db_test.rb
new file mode 100644
index 0000000000..0b3fb82e12
--- /dev/null
+++ b/activerecord/test/cases/connection_adapters/connection_handlers_multi_db_test.rb
@@ -0,0 +1,343 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/person"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class ConnectionHandlersMultiDbTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ fixtures :people
+
+ def setup
+ @handlers = { writing: ConnectionHandler.new, reading: ConnectionHandler.new }
+ @rw_handler = @handlers[:writing]
+ @ro_handler = @handlers[:reading]
+ @spec_name = "primary"
+ @rw_pool = @handlers[:writing].establish_connection(ActiveRecord::Base.configurations["arunit"])
+ @ro_pool = @handlers[:reading].establish_connection(ActiveRecord::Base.configurations["arunit"])
+ end
+
+ def teardown
+ ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.default_connection_handler }
+ end
+
+ class MultiConnectionTestModel < ActiveRecord::Base
+ end
+
+ def test_multiple_connection_handlers_works_in_a_threaded_environment
+ tf_writing = Tempfile.open "test_writing"
+ tf_reading = Tempfile.open "test_reading"
+
+ MultiConnectionTestModel.connects_to database: { writing: { database: tf_writing.path, adapter: "sqlite3" }, reading: { database: tf_reading.path, adapter: "sqlite3" } }
+
+ MultiConnectionTestModel.connection.execute("CREATE TABLE `test_1` (connection_role VARCHAR (255))")
+ MultiConnectionTestModel.connection.execute("INSERT INTO test_1 VALUES ('writing')")
+
+ ActiveRecord::Base.connected_to(role: :reading) do
+ MultiConnectionTestModel.connection.execute("CREATE TABLE `test_1` (connection_role VARCHAR (255))")
+ MultiConnectionTestModel.connection.execute("INSERT INTO test_1 VALUES ('reading')")
+ end
+
+ read_latch = Concurrent::CountDownLatch.new
+ write_latch = Concurrent::CountDownLatch.new
+
+ MultiConnectionTestModel.connection
+
+ thread = Thread.new do
+ MultiConnectionTestModel.connection
+
+ write_latch.wait
+ assert_equal "writing", MultiConnectionTestModel.connection.select_value("SELECT connection_role from test_1")
+ read_latch.count_down
+ end
+
+ ActiveRecord::Base.connected_to(role: :reading) do
+ write_latch.count_down
+ assert_equal "reading", MultiConnectionTestModel.connection.select_value("SELECT connection_role from test_1")
+ read_latch.wait
+ end
+
+ thread.join
+ ensure
+ tf_reading.close
+ tf_reading.unlink
+ tf_writing.close
+ tf_writing.unlink
+ end
+
+ unless in_memory_db?
+ def test_establish_connection_using_3_levels_config
+ previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env"
+
+ config = {
+ "default_env" => {
+ "readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" },
+ "primary" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" }
+ }
+ }
+ @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config
+
+ ActiveRecord::Base.connects_to(database: { writing: :primary, reading: :readonly })
+
+ assert_not_nil pool = ActiveRecord::Base.connection_handlers[:writing].retrieve_connection_pool("primary")
+ assert_equal "db/primary.sqlite3", pool.spec.config[:database]
+
+ assert_not_nil pool = ActiveRecord::Base.connection_handlers[:reading].retrieve_connection_pool("primary")
+ assert_equal "db/readonly.sqlite3", pool.spec.config[:database]
+ ensure
+ ActiveRecord::Base.configurations = @prev_configs
+ ActiveRecord::Base.establish_connection(:arunit)
+ ENV["RAILS_ENV"] = previous_env
+ end
+
+ def test_switching_connections_via_handler
+ previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env"
+
+ config = {
+ "default_env" => {
+ "readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" },
+ "primary" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" }
+ }
+ }
+ @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config
+
+ ActiveRecord::Base.connects_to(database: { writing: :primary, reading: :readonly })
+
+ ActiveRecord::Base.connected_to(role: :reading) do
+ @ro_handler = ActiveRecord::Base.connection_handler
+ assert_equal ActiveRecord::Base.connection_handler, ActiveRecord::Base.connection_handlers[:reading]
+ assert ActiveRecord::Base.connected_to?(role: :reading)
+ assert_not ActiveRecord::Base.connected_to?(role: :writing)
+ end
+
+ ActiveRecord::Base.connected_to(role: :writing) do
+ assert_equal ActiveRecord::Base.connection_handler, ActiveRecord::Base.connection_handlers[:writing]
+ assert_not_equal @ro_handler, ActiveRecord::Base.connection_handler
+ assert ActiveRecord::Base.connected_to?(role: :writing)
+ assert_not ActiveRecord::Base.connected_to?(role: :reading)
+ end
+ ensure
+ ActiveRecord::Base.configurations = @prev_configs
+ ActiveRecord::Base.establish_connection(:arunit)
+ ENV["RAILS_ENV"] = previous_env
+ end
+
+ def test_switching_connections_with_database_url
+ previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env"
+ previous_url, ENV["DATABASE_URL"] = ENV["DATABASE_URL"], "postgres://localhost/foo"
+
+ ActiveRecord::Base.connected_to(database: { writing: "postgres://localhost/bar" }) do
+ assert ActiveRecord::Base.connected_to?(role: :writing)
+
+ handler = ActiveRecord::Base.connection_handler
+ assert_equal handler, ActiveRecord::Base.connection_handlers[:writing]
+
+ assert_not_nil pool = handler.retrieve_connection_pool("primary")
+ assert_equal({ adapter: "postgresql", database: "bar", host: "localhost" }, pool.spec.config)
+ end
+ ensure
+ ActiveRecord::Base.establish_connection(:arunit)
+ ENV["RAILS_ENV"] = previous_env
+ ENV["DATABASE_URL"] = previous_url
+ end
+
+ def test_switching_connections_with_database_config_hash
+ previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env"
+ config = { adapter: "sqlite3", database: "db/readonly.sqlite3" }
+
+ ActiveRecord::Base.connected_to(database: { writing: config }) do
+ assert ActiveRecord::Base.connected_to?(role: :writing)
+
+ handler = ActiveRecord::Base.connection_handler
+ assert_equal handler, ActiveRecord::Base.connection_handlers[:writing]
+
+ assert_not_nil pool = handler.retrieve_connection_pool("primary")
+ assert_equal(config, pool.spec.config)
+ end
+ ensure
+ ActiveRecord::Base.establish_connection(:arunit)
+ ENV["RAILS_ENV"] = previous_env
+ end
+
+ def test_switching_connections_with_database_and_role_raises
+ error = assert_raises(ArgumentError) do
+ ActiveRecord::Base.connected_to(database: :readonly, role: :writing) { }
+ end
+ assert_equal "connected_to can only accept a `database` or a `role` argument, but not both arguments.", error.message
+ end
+
+ def test_switching_connections_without_database_and_role_raises
+ error = assert_raises(ArgumentError) do
+ ActiveRecord::Base.connected_to { }
+ end
+ assert_equal "must provide a `database` or a `role`.", error.message
+ end
+
+ def test_switching_connections_with_database_symbol
+ previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env"
+
+ config = {
+ "default_env" => {
+ "readonly" => { adapter: "sqlite3", database: "db/readonly.sqlite3" },
+ "primary" => { adapter: "sqlite3", database: "db/primary.sqlite3" }
+ }
+ }
+ @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config
+
+ ActiveRecord::Base.connected_to(database: :readonly) do
+ assert ActiveRecord::Base.connected_to?(role: :readonly)
+
+ handler = ActiveRecord::Base.connection_handler
+ assert_equal handler, ActiveRecord::Base.connection_handlers[:readonly]
+
+ assert_not_nil pool = handler.retrieve_connection_pool("primary")
+ assert_equal(config["default_env"]["readonly"], pool.spec.config)
+ end
+ ensure
+ ActiveRecord::Base.configurations = @prev_configs
+ ActiveRecord::Base.establish_connection(:arunit)
+ ENV["RAILS_ENV"] = previous_env
+ end
+
+ def test_connects_to_with_single_configuration
+ config = {
+ "development" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" },
+ }
+ @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config
+
+ ActiveRecord::Base.connects_to database: { writing: :development }
+
+ assert_equal 1, ActiveRecord::Base.connection_handlers.size
+ assert_equal ActiveRecord::Base.connection_handler, ActiveRecord::Base.connection_handlers[:writing]
+ assert ActiveRecord::Base.connected_to?(role: :writing)
+ ensure
+ ActiveRecord::Base.configurations = @prev_configs
+ ActiveRecord::Base.establish_connection(:arunit)
+ end
+
+ def test_connects_to_using_top_level_key_in_two_level_config
+ config = {
+ "development" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" },
+ "development_readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" }
+ }
+ @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config
+
+ ActiveRecord::Base.connects_to database: { writing: :development, reading: :development_readonly }
+
+ assert_not_nil pool = ActiveRecord::Base.connection_handlers[:reading].retrieve_connection_pool("primary")
+ assert_equal "db/readonly.sqlite3", pool.spec.config[:database]
+ ensure
+ ActiveRecord::Base.configurations = @prev_configs
+ ActiveRecord::Base.establish_connection(:arunit)
+ end
+
+ def test_connects_to_returns_array_of_established_connections
+ config = {
+ "development" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" },
+ "development_readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" }
+ }
+ @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config
+
+ result = ActiveRecord::Base.connects_to database: { writing: :development, reading: :development_readonly }
+
+ assert_equal(
+ [
+ ActiveRecord::Base.connection_handlers[:writing].retrieve_connection_pool("primary"),
+ ActiveRecord::Base.connection_handlers[:reading].retrieve_connection_pool("primary")
+ ],
+ result
+ )
+ ensure
+ ActiveRecord::Base.configurations = @prev_configs
+ ActiveRecord::Base.establish_connection(:arunit)
+ end
+ end
+
+ def test_connection_pools
+ assert_equal([@rw_pool], @handlers[:writing].connection_pools)
+ assert_equal([@ro_pool], @handlers[:reading].connection_pools)
+ end
+
+ def test_retrieve_connection
+ assert @rw_handler.retrieve_connection(@spec_name)
+ assert @ro_handler.retrieve_connection(@spec_name)
+ end
+
+ def test_active_connections?
+ assert_not_predicate @rw_handler, :active_connections?
+ assert_not_predicate @ro_handler, :active_connections?
+
+ assert @rw_handler.retrieve_connection(@spec_name)
+ assert @ro_handler.retrieve_connection(@spec_name)
+
+ assert_predicate @rw_handler, :active_connections?
+ assert_predicate @ro_handler, :active_connections?
+
+ @rw_handler.clear_active_connections!
+ assert_not_predicate @rw_handler, :active_connections?
+
+ @ro_handler.clear_active_connections!
+ assert_not_predicate @ro_handler, :active_connections?
+ end
+
+ def test_retrieve_connection_pool
+ assert_not_nil @rw_handler.retrieve_connection_pool(@spec_name)
+ assert_not_nil @ro_handler.retrieve_connection_pool(@spec_name)
+ end
+
+ def test_retrieve_connection_pool_with_invalid_id
+ assert_nil @rw_handler.retrieve_connection_pool("foo")
+ assert_nil @ro_handler.retrieve_connection_pool("foo")
+ end
+
+ def test_connection_handlers_are_per_thread_and_not_per_fiber
+ original_handlers = ActiveRecord::Base.connection_handlers
+
+ ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.default_connection_handler, reading: ActiveRecord::ConnectionAdapters::ConnectionHandler.new }
+
+ reading_handler = ActiveRecord::Base.connection_handlers[:reading]
+
+ reading = ActiveRecord::Base.with_handler(:reading) do
+ Person.connection_handler
+ end
+
+ assert_not_equal reading, ActiveRecord::Base.connection_handler
+ assert_equal reading, reading_handler
+ ensure
+ ActiveRecord::Base.connection_handlers = original_handlers
+ end
+
+ def test_connection_handlers_swapping_connections_in_fiber
+ original_handlers = ActiveRecord::Base.connection_handlers
+
+ ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.default_connection_handler, reading: ActiveRecord::ConnectionAdapters::ConnectionHandler.new }
+
+ reading_handler = ActiveRecord::Base.connection_handlers[:reading]
+
+ enum = Enumerator.new do |r|
+ r << ActiveRecord::Base.connection_handler
+ end
+
+ reading = ActiveRecord::Base.with_handler(:reading) do
+ enum.next
+ end
+
+ assert_equal reading, reading_handler
+ ensure
+ ActiveRecord::Base.connection_handlers = original_handlers
+ end
+
+ def test_calling_connected_to_on_a_non_existent_handler_raises
+ error = assert_raises ArgumentError do
+ ActiveRecord::Base.connected_to(role: :reading) do
+ yield
+ end
+ end
+
+ assert_equal "The reading role does not exist. Add it by establishing a connection with `connects_to` or use an existing role (writing).", error.message
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/connection_adapters/connection_specification_test.rb b/activerecord/test/cases/connection_adapters/connection_specification_test.rb
new file mode 100644
index 0000000000..f81b73c344
--- /dev/null
+++ b/activerecord/test/cases/connection_adapters/connection_specification_test.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class ConnectionSpecificationTest < ActiveRecord::TestCase
+ def test_dup_deep_copy_config
+ spec = ConnectionSpecification.new("primary", { a: :b }, "bar")
+ assert_not_equal(spec.config.object_id, spec.dup.config.object_id)
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb b/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb
new file mode 100644
index 0000000000..06c1c51724
--- /dev/null
+++ b/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb
@@ -0,0 +1,260 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class MergeAndResolveDefaultUrlConfigTest < ActiveRecord::TestCase
+ def setup
+ @previous_database_url = ENV.delete("DATABASE_URL")
+ @previous_rack_env = ENV.delete("RACK_ENV")
+ @previous_rails_env = ENV.delete("RAILS_ENV")
+ end
+
+ teardown do
+ ENV["DATABASE_URL"] = @previous_database_url
+ ENV["RACK_ENV"] = @previous_rack_env
+ ENV["RAILS_ENV"] = @previous_rails_env
+ end
+
+ def resolve_config(config)
+ configs = ActiveRecord::DatabaseConfigurations.new(config)
+ configs.to_h
+ end
+
+ def resolve_spec(spec, config)
+ configs = ActiveRecord::DatabaseConfigurations.new(config)
+ resolver = ConnectionAdapters::ConnectionSpecification::Resolver.new(configs)
+ resolver.resolve(spec, spec)
+ end
+
+ def test_resolver_with_database_uri_and_current_env_symbol_key
+ ENV["DATABASE_URL"] = "postgres://localhost/foo"
+ config = { "not_production" => { "adapter" => "not_postgres", "database" => "not_foo" } }
+ actual = resolve_spec(:default_env, config)
+ expected = { "adapter" => "postgresql", "database" => "foo", "host" => "localhost", "name" => "default_env" }
+ assert_equal expected, actual
+ end
+
+ def test_resolver_with_database_uri_and_current_env_symbol_key_and_rails_env
+ ENV["DATABASE_URL"] = "postgres://localhost/foo"
+ ENV["RAILS_ENV"] = "foo"
+
+ config = { "not_production" => { "adapter" => "not_postgres", "database" => "not_foo" } }
+ actual = resolve_spec(:foo, config)
+ expected = { "adapter" => "postgresql", "database" => "foo", "host" => "localhost", "name" => "foo" }
+ assert_equal expected, actual
+ end
+
+ def test_resolver_with_database_uri_and_current_env_symbol_key_and_rack_env
+ ENV["DATABASE_URL"] = "postgres://localhost/foo"
+ ENV["RACK_ENV"] = "foo"
+
+ config = { "not_production" => { "adapter" => "not_postgres", "database" => "not_foo" } }
+ actual = resolve_spec(:foo, config)
+ expected = { "adapter" => "postgresql", "database" => "foo", "host" => "localhost", "name" => "foo" }
+ assert_equal expected, actual
+ end
+
+ def test_resolver_with_database_uri_and_known_key
+ ENV["DATABASE_URL"] = "postgres://localhost/foo"
+ config = { "production" => { "adapter" => "not_postgres", "database" => "not_foo", "host" => "localhost" } }
+ actual = resolve_spec(:production, config)
+ expected = { "adapter" => "not_postgres", "database" => "not_foo", "host" => "localhost", "name" => "production" }
+ assert_equal expected, actual
+ end
+
+ def test_resolver_with_database_uri_and_unknown_symbol_key
+ ENV["DATABASE_URL"] = "postgres://localhost/foo"
+ config = { "not_production" => { "adapter" => "not_postgres", "database" => "not_foo" } }
+ assert_raises AdapterNotSpecified do
+ resolve_spec(:production, config)
+ end
+ end
+
+ def test_resolver_with_database_uri_and_supplied_url
+ ENV["DATABASE_URL"] = "not-postgres://not-localhost/not_foo"
+ config = { "production" => { "adapter" => "also_not_postgres", "database" => "also_not_foo" } }
+ actual = resolve_spec("postgres://localhost/foo", config)
+ expected = { "adapter" => "postgresql", "database" => "foo", "host" => "localhost" }
+ assert_equal expected, actual
+ end
+
+ def test_jdbc_url
+ config = { "production" => { "url" => "jdbc:postgres://localhost/foo" } }
+ actual = resolve_config(config)
+ assert_equal config, actual
+ end
+
+ def test_environment_does_not_exist_in_config_url_does_exist
+ ENV["DATABASE_URL"] = "postgres://localhost/foo"
+ config = { "not_default_env" => { "adapter" => "not_postgres", "database" => "not_foo" } }
+ actual = resolve_config(config)
+ expect_prod = { "adapter" => "postgresql", "database" => "foo", "host" => "localhost" }
+ assert_equal expect_prod, actual["default_env"]
+ end
+
+ def test_url_with_hyphenated_scheme
+ ENV["DATABASE_URL"] = "ibm-db://localhost/foo"
+ config = { "default_env" => { "adapter" => "not_postgres", "database" => "not_foo", "host" => "localhost" } }
+ actual = resolve_spec(:default_env, config)
+ expected = { "adapter" => "ibm_db", "database" => "foo", "host" => "localhost", "name" => "default_env" }
+ assert_equal expected, actual
+ end
+
+ def test_string_connection
+ config = { "default_env" => "postgres://localhost/foo" }
+ actual = resolve_config(config)
+ expected = { "default_env" =>
+ { "adapter" => "postgresql",
+ "database" => "foo",
+ "host" => "localhost"
+ }
+ }
+ assert_equal expected, actual
+ end
+
+ def test_url_sub_key
+ config = { "default_env" => { "url" => "postgres://localhost/foo" } }
+ actual = resolve_config(config)
+ expected = { "default_env" =>
+ { "adapter" => "postgresql",
+ "database" => "foo",
+ "host" => "localhost"
+ }
+ }
+ assert_equal expected, actual
+ end
+
+ def test_hash
+ config = { "production" => { "adapter" => "postgres", "database" => "foo" } }
+ actual = resolve_config(config)
+ assert_equal config, actual
+ end
+
+ def test_blank
+ config = {}
+ actual = resolve_config(config)
+ assert_equal config, actual
+ end
+
+ def test_blank_with_database_url
+ ENV["DATABASE_URL"] = "postgres://localhost/foo"
+
+ config = {}
+ actual = resolve_config(config)
+ expected = { "adapter" => "postgresql",
+ "database" => "foo",
+ "host" => "localhost" }
+ assert_equal expected, actual["default_env"]
+ assert_nil actual["production"]
+ assert_nil actual["development"]
+ assert_nil actual["test"]
+ assert_nil actual[:default_env]
+ assert_nil actual[:production]
+ assert_nil actual[:development]
+ assert_nil actual[:test]
+ end
+
+ def test_blank_with_database_url_with_rails_env
+ ENV["RAILS_ENV"] = "not_production"
+ ENV["DATABASE_URL"] = "postgres://localhost/foo"
+
+ config = {}
+ actual = resolve_config(config)
+ expected = { "adapter" => "postgresql",
+ "database" => "foo",
+ "host" => "localhost" }
+
+ assert_equal expected, actual["not_production"]
+ assert_nil actual["production"]
+ assert_nil actual["default_env"]
+ assert_nil actual["development"]
+ assert_nil actual["test"]
+ assert_nil actual[:default_env]
+ assert_nil actual[:not_production]
+ assert_nil actual[:production]
+ assert_nil actual[:development]
+ assert_nil actual[:test]
+ end
+
+ def test_blank_with_database_url_with_rack_env
+ ENV["RACK_ENV"] = "not_production"
+ ENV["DATABASE_URL"] = "postgres://localhost/foo"
+
+ config = {}
+ actual = resolve_config(config)
+ expected = { "adapter" => "postgresql",
+ "database" => "foo",
+ "host" => "localhost" }
+
+ assert_equal expected, actual["not_production"]
+ assert_nil actual["production"]
+ assert_nil actual["default_env"]
+ assert_nil actual["development"]
+ assert_nil actual["test"]
+ assert_nil actual[:default_env]
+ assert_nil actual[:not_production]
+ assert_nil actual[:production]
+ assert_nil actual[:development]
+ assert_nil actual[:test]
+ end
+
+ def test_database_url_with_ipv6_host_and_port
+ ENV["DATABASE_URL"] = "postgres://[::1]:5454/foo"
+
+ config = {}
+ actual = resolve_config(config)
+ expected = { "adapter" => "postgresql",
+ "database" => "foo",
+ "host" => "::1",
+ "port" => 5454 }
+ assert_equal expected, actual["default_env"]
+ end
+
+ def test_url_sub_key_with_database_url
+ ENV["DATABASE_URL"] = "NOT-POSTGRES://localhost/NOT_FOO"
+
+ config = { "default_env" => { "url" => "postgres://localhost/foo" } }
+ actual = resolve_config(config)
+ expected = { "default_env" =>
+ { "adapter" => "postgresql",
+ "database" => "foo",
+ "host" => "localhost"
+ }
+ }
+ assert_equal expected, actual
+ end
+
+ def test_merge_no_conflicts_with_database_url
+ ENV["DATABASE_URL"] = "postgres://localhost/foo"
+
+ config = { "default_env" => { "pool" => "5" } }
+ actual = resolve_config(config)
+ expected = { "default_env" =>
+ { "adapter" => "postgresql",
+ "database" => "foo",
+ "host" => "localhost",
+ "pool" => "5"
+ }
+ }
+ assert_equal expected, actual
+ end
+
+ def test_merge_conflicts_with_database_url
+ ENV["DATABASE_URL"] = "postgres://localhost/foo"
+
+ config = { "default_env" => { "adapter" => "NOT-POSTGRES", "database" => "NOT-FOO", "pool" => "5" } }
+ actual = resolve_config(config)
+ expected = { "default_env" =>
+ { "adapter" => "postgresql",
+ "database" => "foo",
+ "host" => "localhost",
+ "pool" => "5"
+ }
+ }
+ assert_equal expected, actual
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/connection_adapters/mysql_type_lookup_test.rb b/activerecord/test/cases/connection_adapters/mysql_type_lookup_test.rb
new file mode 100644
index 0000000000..02e76ce146
--- /dev/null
+++ b/activerecord/test/cases/connection_adapters/mysql_type_lookup_test.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/connection_helper"
+
+if current_adapter?(:Mysql2Adapter)
+ module ActiveRecord
+ module ConnectionAdapters
+ class MysqlTypeLookupTest < ActiveRecord::TestCase
+ include ConnectionHelper
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ end
+
+ def teardown
+ reset_connection
+ end
+
+ def test_boolean_types
+ emulate_booleans(true) do
+ assert_lookup_type :boolean, "tinyint(1)"
+ assert_lookup_type :boolean, "TINYINT(1)"
+ end
+ end
+
+ def test_string_types
+ assert_lookup_type :string, "enum('one', 'two', 'three')"
+ assert_lookup_type :string, "ENUM('one', 'two', 'three')"
+ assert_lookup_type :string, "set('one', 'two', 'three')"
+ assert_lookup_type :string, "SET('one', 'two', 'three')"
+ end
+
+ def test_set_type_with_value_matching_other_type
+ assert_lookup_type :string, "SET('unicode', '8bit', 'none', 'time')"
+ end
+
+ def test_enum_type_with_value_matching_other_type
+ assert_lookup_type :string, "ENUM('unicode', '8bit', 'none')"
+ end
+
+ def test_binary_types
+ assert_lookup_type :binary, "bit"
+ assert_lookup_type :binary, "BIT"
+ end
+
+ def test_integer_types
+ emulate_booleans(false) do
+ assert_lookup_type :integer, "tinyint(1)"
+ assert_lookup_type :integer, "TINYINT(1)"
+ assert_lookup_type :integer, "year"
+ assert_lookup_type :integer, "YEAR"
+ end
+ end
+
+ private
+
+ def assert_lookup_type(type, lookup)
+ cast_type = @connection.send(:type_map).lookup(lookup)
+ assert_equal type, cast_type.type
+ end
+
+ def emulate_booleans(value)
+ old_emulate_booleans = @connection.emulate_booleans
+ change_emulate_booleans(value)
+ yield
+ ensure
+ change_emulate_booleans(old_emulate_booleans)
+ end
+
+ def change_emulate_booleans(value)
+ @connection.emulate_booleans = value
+ @connection.clear_cache!
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/connection_adapters/schema_cache_test.rb b/activerecord/test/cases/connection_adapters/schema_cache_test.rb
new file mode 100644
index 0000000000..67496381d1
--- /dev/null
+++ b/activerecord/test/cases/connection_adapters/schema_cache_test.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class SchemaCacheTest < ActiveRecord::TestCase
+ def setup
+ connection = ActiveRecord::Base.connection
+ @cache = SchemaCache.new connection
+ end
+
+ def test_primary_key
+ assert_equal "id", @cache.primary_keys("posts")
+ end
+
+ def test_yaml_dump_and_load
+ @cache.columns("posts")
+ @cache.columns_hash("posts")
+ @cache.data_sources("posts")
+ @cache.primary_keys("posts")
+
+ new_cache = YAML.load(YAML.dump(@cache))
+ assert_no_queries do
+ assert_equal 12, new_cache.columns("posts").size
+ assert_equal 12, new_cache.columns_hash("posts").size
+ assert new_cache.data_sources("posts")
+ assert_equal "id", new_cache.primary_keys("posts")
+ end
+ end
+
+ def test_yaml_loads_5_1_dump
+ body = File.open(schema_dump_path).read
+ cache = YAML.load(body)
+
+ assert_no_queries do
+ assert_equal 11, cache.columns("posts").size
+ assert_equal 11, cache.columns_hash("posts").size
+ assert cache.data_sources("posts")
+ assert_equal "id", cache.primary_keys("posts")
+ end
+ end
+
+ def test_primary_key_for_non_existent_table
+ assert_nil @cache.primary_keys("omgponies")
+ end
+
+ def test_caches_columns
+ columns = @cache.columns("posts")
+ assert_equal columns, @cache.columns("posts")
+ end
+
+ def test_caches_columns_hash
+ columns_hash = @cache.columns_hash("posts")
+ assert_equal columns_hash, @cache.columns_hash("posts")
+ end
+
+ def test_clearing
+ @cache.columns("posts")
+ @cache.columns_hash("posts")
+ @cache.data_sources("posts")
+ @cache.primary_keys("posts")
+
+ @cache.clear!
+
+ assert_equal 0, @cache.size
+ end
+
+ def test_dump_and_load
+ @cache.columns("posts")
+ @cache.columns_hash("posts")
+ @cache.data_sources("posts")
+ @cache.primary_keys("posts")
+
+ @cache = Marshal.load(Marshal.dump(@cache))
+
+ assert_no_queries do
+ assert_equal 12, @cache.columns("posts").size
+ assert_equal 12, @cache.columns_hash("posts").size
+ assert @cache.data_sources("posts")
+ assert_equal "id", @cache.primary_keys("posts")
+ end
+ end
+
+ def test_data_source_exist
+ assert @cache.data_source_exists?("posts")
+ assert_not @cache.data_source_exists?("foo")
+ end
+
+ def test_clear_data_source_cache
+ @cache.clear_data_source_cache!("posts")
+ end
+
+ private
+
+ def schema_dump_path
+ "test/assets/schema_dump_5_1.yml"
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/connection_adapters/type_lookup_test.rb b/activerecord/test/cases/connection_adapters/type_lookup_test.rb
new file mode 100644
index 0000000000..1c79d776f0
--- /dev/null
+++ b/activerecord/test/cases/connection_adapters/type_lookup_test.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+unless current_adapter?(:PostgreSQLAdapter) # PostgreSQL does not use type strings for lookup
+ module ActiveRecord
+ module ConnectionAdapters
+ class TypeLookupTest < ActiveRecord::TestCase
+ setup do
+ @connection = ActiveRecord::Base.connection
+ end
+
+ def test_boolean_types
+ assert_lookup_type :boolean, "boolean"
+ assert_lookup_type :boolean, "BOOLEAN"
+ end
+
+ def test_string_types
+ assert_lookup_type :string, "char"
+ assert_lookup_type :string, "varchar"
+ assert_lookup_type :string, "VARCHAR"
+ assert_lookup_type :string, "varchar(255)"
+ assert_lookup_type :string, "character varying"
+ end
+
+ def test_binary_types
+ assert_lookup_type :binary, "binary"
+ assert_lookup_type :binary, "BINARY"
+ assert_lookup_type :binary, "blob"
+ assert_lookup_type :binary, "BLOB"
+ end
+
+ def test_text_types
+ assert_lookup_type :text, "text"
+ assert_lookup_type :text, "TEXT"
+ assert_lookup_type :text, "clob"
+ assert_lookup_type :text, "CLOB"
+ end
+
+ def test_date_types
+ assert_lookup_type :date, "date"
+ assert_lookup_type :date, "DATE"
+ end
+
+ def test_time_types
+ assert_lookup_type :time, "time"
+ assert_lookup_type :time, "TIME"
+ end
+
+ def test_datetime_types
+ assert_lookup_type :datetime, "datetime"
+ assert_lookup_type :datetime, "DATETIME"
+ assert_lookup_type :datetime, "timestamp"
+ assert_lookup_type :datetime, "TIMESTAMP"
+ end
+
+ def test_decimal_types
+ assert_lookup_type :decimal, "decimal"
+ assert_lookup_type :decimal, "decimal(2,8)"
+ assert_lookup_type :decimal, "DECIMAL"
+ assert_lookup_type :decimal, "numeric"
+ assert_lookup_type :decimal, "numeric(2,8)"
+ assert_lookup_type :decimal, "NUMERIC"
+ assert_lookup_type :decimal, "number"
+ assert_lookup_type :decimal, "number(2,8)"
+ assert_lookup_type :decimal, "NUMBER"
+ end
+
+ def test_float_types
+ assert_lookup_type :float, "float"
+ assert_lookup_type :float, "FLOAT"
+ assert_lookup_type :float, "double"
+ assert_lookup_type :float, "DOUBLE"
+ end
+
+ def test_integer_types
+ assert_lookup_type :integer, "integer"
+ assert_lookup_type :integer, "INTEGER"
+ assert_lookup_type :integer, "tinyint"
+ assert_lookup_type :integer, "smallint"
+ assert_lookup_type :integer, "bigint"
+ end
+
+ def test_bigint_limit
+ limit = @connection.send(:type_map).lookup("bigint").send(:_limit)
+ if current_adapter?(:OracleAdapter)
+ assert_equal 19, limit
+ else
+ assert_equal 8, limit
+ end
+ end
+
+ def test_decimal_without_scale
+ if current_adapter?(:OracleAdapter)
+ {
+ decimal: %w{decimal(2) decimal(2,0) numeric(2) numeric(2,0)},
+ integer: %w{number(2) number(2,0)}
+ }
+ else
+ { decimal: %w{decimal(2) decimal(2,0) numeric(2) numeric(2,0) number(2) number(2,0)} }
+ end.each do |expected_type, types|
+ types.each do |type|
+ cast_type = @connection.send(:type_map).lookup(type)
+
+ assert_equal expected_type, cast_type.type
+ assert_equal 2, cast_type.cast(2.1)
+ end
+ end
+ end
+
+ private
+
+ def assert_lookup_type(type, lookup)
+ cast_type = @connection.send(:type_map).lookup(lookup)
+ assert_equal type, cast_type.type
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/connection_management_test.rb b/activerecord/test/cases/connection_management_test.rb
new file mode 100644
index 0000000000..b9b5cc0e28
--- /dev/null
+++ b/activerecord/test/cases/connection_management_test.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "rack"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class ConnectionManagementTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ class App
+ attr_reader :calls
+ def initialize
+ @calls = []
+ end
+
+ def call(env)
+ @calls << env
+ [200, {}, ["hi mom"]]
+ end
+ end
+
+ def setup
+ @env = {}
+ @app = App.new
+ @management = middleware(@app)
+
+ # make sure we have an active connection
+ assert ActiveRecord::Base.connection
+ assert_predicate ActiveRecord::Base.connection_handler, :active_connections?
+ end
+
+ def test_app_delegation
+ manager = middleware(@app)
+
+ manager.call @env
+ assert_equal [@env], @app.calls
+ end
+
+ def test_body_responds_to_each
+ _, _, body = @management.call(@env)
+ bits = []
+ body.each { |bit| bits << bit }
+ assert_equal ["hi mom"], bits
+ end
+
+ def test_connections_are_cleared_after_body_close
+ _, _, body = @management.call(@env)
+ body.close
+ assert_not_predicate ActiveRecord::Base.connection_handler, :active_connections?
+ end
+
+ def test_active_connections_are_not_cleared_on_body_close_during_transaction
+ ActiveRecord::Base.transaction do
+ _, _, body = @management.call(@env)
+ body.close
+ assert_predicate ActiveRecord::Base.connection_handler, :active_connections?
+ end
+ end
+
+ def test_connections_closed_if_exception
+ app = Class.new(App) { def call(env); raise NotImplementedError; end }.new
+ explosive = middleware(app)
+ assert_raises(NotImplementedError) { explosive.call(@env) }
+ assert_not_predicate ActiveRecord::Base.connection_handler, :active_connections?
+ end
+
+ def test_connections_not_closed_if_exception_inside_transaction
+ ActiveRecord::Base.transaction do
+ app = Class.new(App) { def call(env); raise RuntimeError; end }.new
+ explosive = middleware(app)
+ assert_raises(RuntimeError) { explosive.call(@env) }
+ assert_predicate ActiveRecord::Base.connection_handler, :active_connections?
+ end
+ end
+
+ test "doesn't clear active connections when running in a test case" do
+ executor.wrap do
+ @management.call(@env)
+ assert_predicate ActiveRecord::Base.connection_handler, :active_connections?
+ end
+ end
+
+ test "proxy is polite to its body and responds to it" do
+ body = Class.new(String) { def to_path; "/path"; end }.new
+ app = lambda { |_| [200, {}, body] }
+ response_body = middleware(app).call(@env)[2]
+ assert_respond_to response_body, :to_path
+ assert_equal "/path", response_body.to_path
+ end
+
+ test "doesn't mutate the original response" do
+ original_response = [200, {}, "hi"]
+ app = lambda { |_| original_response }
+ middleware(app).call(@env)[2]
+ assert_equal "hi", original_response.last
+ end
+
+ private
+ def executor
+ @executor ||= Class.new(ActiveSupport::Executor).tap do |exe|
+ ActiveRecord::QueryCache.install_executor_hooks(exe)
+ end
+ end
+
+ def middleware(app)
+ lambda do |env|
+ a, b, c = executor.wrap { app.call(env) }
+ [a, b, Rack::BodyProxy.new(c) { }]
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb
new file mode 100644
index 0000000000..a15ad9a45b
--- /dev/null
+++ b/activerecord/test/cases/connection_pool_test.rb
@@ -0,0 +1,708 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "concurrent/atomic/count_down_latch"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class ConnectionPoolTest < ActiveRecord::TestCase
+ attr_reader :pool
+
+ def setup
+ super
+
+ # Keep a duplicate pool so we do not bother others
+ @pool = ConnectionPool.new ActiveRecord::Base.connection_pool.spec
+
+ if in_memory_db?
+ # Separate connections to an in-memory database create an entirely new database,
+ # with an empty schema etc, so we just stub out this schema on the fly.
+ @pool.with_connection do |connection|
+ connection.create_table :posts do |t|
+ t.integer :cololumn
+ end
+ end
+ end
+ end
+
+ teardown do
+ @pool.disconnect!
+ end
+
+ def active_connections(pool)
+ pool.connections.find_all(&:in_use?)
+ end
+
+ def test_checkout_after_close
+ connection = pool.connection
+ assert_predicate connection, :in_use?
+
+ connection.close
+ assert_not_predicate connection, :in_use?
+
+ assert_predicate pool.connection, :in_use?
+ end
+
+ def test_released_connection_moves_between_threads
+ thread_conn = nil
+
+ Thread.new {
+ pool.with_connection do |conn|
+ thread_conn = conn
+ end
+ }.join
+
+ assert thread_conn
+
+ Thread.new {
+ pool.with_connection do |conn|
+ assert_equal thread_conn, conn
+ end
+ }.join
+ end
+
+ def test_with_connection
+ assert_equal 0, active_connections(pool).size
+
+ main_thread = pool.connection
+ assert_equal 1, active_connections(pool).size
+
+ Thread.new {
+ pool.with_connection do |conn|
+ assert conn
+ assert_equal 2, active_connections(pool).size
+ end
+ assert_equal 1, active_connections(pool).size
+ }.join
+
+ main_thread.close
+ assert_equal 0, active_connections(pool).size
+ end
+
+ def test_active_connection_in_use
+ assert_not_predicate pool, :active_connection?
+ main_thread = pool.connection
+
+ assert_predicate pool, :active_connection?
+
+ main_thread.close
+
+ assert_not_predicate pool, :active_connection?
+ end
+
+ def test_full_pool_exception
+ @pool.checkout_timeout = 0.001 # no need to delay test suite by waiting the whole full default timeout
+ @pool.size.times { assert @pool.checkout }
+
+ assert_raises(ConnectionTimeoutError) do
+ @pool.checkout
+ end
+ end
+
+ def test_full_pool_blocks
+ cs = @pool.size.times.map { @pool.checkout }
+ t = Thread.new { @pool.checkout }
+
+ # make sure our thread is in the timeout section
+ Thread.pass until @pool.num_waiting_in_queue == 1
+
+ connection = cs.first
+ connection.close
+ assert_equal connection, t.join.value
+ end
+
+ def test_full_pool_blocking_shares_load_interlock
+ @pool.instance_variable_set(:@size, 1)
+
+ load_interlock_latch = Concurrent::CountDownLatch.new
+ connection_latch = Concurrent::CountDownLatch.new
+
+ able_to_get_connection = false
+ able_to_load = false
+
+ thread_with_load_interlock = Thread.new do
+ ActiveSupport::Dependencies.interlock.running do
+ load_interlock_latch.count_down
+ connection_latch.wait
+
+ @pool.with_connection do
+ able_to_get_connection = true
+ end
+ end
+ end
+
+ thread_with_last_connection = Thread.new do
+ @pool.with_connection do
+ connection_latch.count_down
+ load_interlock_latch.wait
+
+ ActiveSupport::Dependencies.interlock.loading do
+ able_to_load = true
+ end
+ end
+ end
+
+ thread_with_load_interlock.join
+ thread_with_last_connection.join
+
+ assert able_to_get_connection
+ assert able_to_load
+ end
+
+ def test_removing_releases_latch
+ cs = @pool.size.times.map { @pool.checkout }
+ t = Thread.new { @pool.checkout }
+
+ # make sure our thread is in the timeout section
+ Thread.pass until @pool.num_waiting_in_queue == 1
+
+ connection = cs.first
+ @pool.remove connection
+ assert_respond_to t.join.value, :execute
+ connection.close
+ end
+
+ def test_reap_and_active
+ @pool.checkout
+ @pool.checkout
+ @pool.checkout
+
+ connections = @pool.connections.dup
+
+ @pool.reap
+
+ assert_equal connections.length, @pool.connections.length
+ end
+
+ def test_reap_inactive
+ ready = Concurrent::CountDownLatch.new
+ @pool.checkout
+ child = Thread.new do
+ @pool.checkout
+ @pool.checkout
+ ready.count_down
+ Thread.stop
+ end
+ ready.wait
+
+ assert_equal 3, active_connections(@pool).size
+
+ child.terminate
+ child.join
+ @pool.reap
+
+ assert_equal 1, active_connections(@pool).size
+ ensure
+ @pool.connections.each { |conn| conn.close if conn.in_use? }
+ end
+
+ def test_idle_timeout_configuration
+ @pool.disconnect!
+ spec = ActiveRecord::Base.connection_pool.spec
+ spec.config.merge!(idle_timeout: "0.02")
+ @pool = ConnectionPool.new(spec)
+ idle_conn = @pool.checkout
+ @pool.checkin(idle_conn)
+
+ idle_conn.instance_variable_set(
+ :@idle_since,
+ Concurrent.monotonic_time - 0.01
+ )
+
+ @pool.flush
+ assert_equal 1, @pool.connections.length
+
+ idle_conn.instance_variable_set(
+ :@idle_since,
+ Concurrent.monotonic_time - 0.02
+ )
+
+ @pool.flush
+ assert_equal 0, @pool.connections.length
+ end
+
+ def test_disable_flush
+ @pool.disconnect!
+ spec = ActiveRecord::Base.connection_pool.spec
+ spec.config.merge!(idle_timeout: -5)
+ @pool = ConnectionPool.new(spec)
+ idle_conn = @pool.checkout
+ @pool.checkin(idle_conn)
+
+ idle_conn.instance_variable_set(
+ :@idle_since,
+ Concurrent.monotonic_time - 1
+ )
+
+ @pool.flush
+ assert_equal 1, @pool.connections.length
+ end
+
+ def test_flush
+ idle_conn = @pool.checkout
+ recent_conn = @pool.checkout
+ active_conn = @pool.checkout
+
+ @pool.checkin idle_conn
+ @pool.checkin recent_conn
+
+ assert_equal 3, @pool.connections.length
+
+ idle_conn.instance_variable_set(
+ :@idle_since,
+ Concurrent.monotonic_time - 1000
+ )
+
+ @pool.flush(30)
+
+ assert_equal 2, @pool.connections.length
+
+ assert_equal [recent_conn, active_conn].sort_by(&:__id__), @pool.connections.sort_by(&:__id__)
+ ensure
+ @pool.checkin active_conn
+ end
+
+ def test_flush_bang
+ idle_conn = @pool.checkout
+ recent_conn = @pool.checkout
+ active_conn = @pool.checkout
+ _dead_conn = Thread.new { @pool.checkout }.join
+
+ @pool.checkin idle_conn
+ @pool.checkin recent_conn
+
+ assert_equal 4, @pool.connections.length
+
+ def idle_conn.seconds_idle
+ 1000
+ end
+
+ @pool.flush!
+
+ assert_equal 1, @pool.connections.length
+
+ assert_equal [active_conn].sort_by(&:__id__), @pool.connections.sort_by(&:__id__)
+ ensure
+ @pool.checkin active_conn
+ end
+
+ def test_remove_connection
+ conn = @pool.checkout
+ assert_predicate conn, :in_use?
+
+ length = @pool.connections.length
+ @pool.remove conn
+ assert_predicate conn, :in_use?
+ assert_equal(length - 1, @pool.connections.length)
+ ensure
+ conn.close
+ end
+
+ def test_remove_connection_for_thread
+ conn = @pool.connection
+ @pool.remove conn
+ assert_not_equal(conn, @pool.connection)
+ ensure
+ conn.close if conn
+ end
+
+ def test_active_connection?
+ assert_not_predicate @pool, :active_connection?
+ assert @pool.connection
+ assert_predicate @pool, :active_connection?
+ @pool.release_connection
+ assert_not_predicate @pool, :active_connection?
+ end
+
+ def test_checkout_behaviour
+ pool = ConnectionPool.new ActiveRecord::Base.connection_pool.spec
+ main_connection = pool.connection
+ assert_not_nil main_connection
+ threads = []
+ 4.times do |i|
+ threads << Thread.new(i) do
+ thread_connection = pool.connection
+ assert_not_nil thread_connection
+ thread_connection.close
+ end
+ end
+
+ threads.each(&:join)
+
+ Thread.new do
+ assert pool.connection
+ pool.connection.close
+ end.join
+ end
+
+ def test_checkout_order_is_lifo
+ conn1 = @pool.checkout
+ conn2 = @pool.checkout
+ @pool.checkin conn1
+ @pool.checkin conn2
+ assert_equal [conn2, conn1], 2.times.map { @pool.checkout }
+ end
+
+ # The connection pool is "fair" if threads waiting for
+ # connections receive them in the order in which they began
+ # waiting. This ensures that we don't timeout one HTTP request
+ # even while well under capacity in a multi-threaded environment
+ # such as a Java servlet container.
+ #
+ # We don't need strict fairness: if two connections become
+ # available at the same time, it's fine if two threads that were
+ # waiting acquire the connections out of order.
+ #
+ # Thus this test prepares waiting threads and then trickles in
+ # available connections slowly, ensuring the wakeup order is
+ # correct in this case.
+ def test_checkout_fairness
+ @pool.instance_variable_set(:@size, 10)
+ expected = (1..@pool.size).to_a.freeze
+ # check out all connections so our threads start out waiting
+ conns = expected.map { @pool.checkout }
+ mutex = Mutex.new
+ order = []
+ errors = []
+
+ threads = expected.map do |i|
+ t = Thread.new {
+ begin
+ @pool.checkout # never checked back in
+ mutex.synchronize { order << i }
+ rescue => e
+ mutex.synchronize { errors << e }
+ end
+ }
+ Thread.pass until @pool.num_waiting_in_queue == i
+ t
+ end
+
+ # this should wake up the waiting threads one by one in order
+ conns.each { |conn| @pool.checkin(conn); sleep 0.1 }
+
+ threads.each(&:join)
+
+ raise errors.first if errors.any?
+
+ assert_equal(expected, order)
+ end
+
+ # As mentioned in #test_checkout_fairness, we don't care about
+ # strict fairness. This test creates two groups of threads:
+ # group1 whose members all start waiting before any thread in
+ # group2. Enough connections are checked in to wakeup all
+ # group1 threads, and the fact that only group1 and no group2
+ # threads acquired a connection is enforced.
+ def test_checkout_fairness_by_group
+ @pool.instance_variable_set(:@size, 10)
+ # take all the connections
+ conns = (1..10).map { @pool.checkout }
+ mutex = Mutex.new
+ successes = [] # threads that successfully got a connection
+ errors = []
+
+ make_thread = proc do |i|
+ t = Thread.new {
+ begin
+ @pool.checkout # never checked back in
+ mutex.synchronize { successes << i }
+ rescue => e
+ mutex.synchronize { errors << e }
+ end
+ }
+ Thread.pass until @pool.num_waiting_in_queue == i
+ t
+ end
+
+ # all group1 threads start waiting before any in group2
+ group1 = (1..5).map(&make_thread)
+ group2 = (6..10).map(&make_thread)
+
+ # checkin n connections back to the pool
+ checkin = proc do |n|
+ n.times do
+ c = conns.pop
+ @pool.checkin(c)
+ end
+ end
+
+ checkin.call(group1.size) # should wake up all group1
+
+ loop do
+ sleep 0.1
+ break if mutex.synchronize { (successes.size + errors.size) == group1.size }
+ end
+
+ winners = mutex.synchronize { successes.dup }
+ checkin.call(group2.size) # should wake up everyone remaining
+
+ group1.each(&:join)
+ group2.each(&:join)
+
+ assert_equal((1..group1.size).to_a, winners.sort)
+
+ if errors.any?
+ raise errors.first
+ end
+ end
+
+ def test_automatic_reconnect_restores_after_disconnect
+ pool = ConnectionPool.new ActiveRecord::Base.connection_pool.spec
+ assert pool.automatic_reconnect
+ assert pool.connection
+
+ pool.disconnect!
+ assert pool.connection
+ end
+
+ def test_automatic_reconnect_can_be_disabled
+ pool = ConnectionPool.new ActiveRecord::Base.connection_pool.spec
+ pool.disconnect!
+ pool.automatic_reconnect = false
+
+ assert_raises(ConnectionNotEstablished) do
+ pool.connection
+ end
+
+ assert_raises(ConnectionNotEstablished) do
+ pool.with_connection
+ end
+ end
+
+ def test_pool_sets_connection_visitor
+ assert @pool.connection.visitor.is_a?(Arel::Visitors::ToSql)
+ end
+
+ # make sure exceptions are thrown when establish_connection
+ # is called with an anonymous class
+ def test_anonymous_class_exception
+ anonymous = Class.new(ActiveRecord::Base)
+
+ assert_raises(RuntimeError) do
+ anonymous.establish_connection
+ end
+ end
+
+ class ConnectionTestModel < ActiveRecord::Base
+ end
+
+ def test_connection_notification_is_called
+ payloads = []
+ subscription = ActiveSupport::Notifications.subscribe("!connection.active_record") do |name, started, finished, unique_id, payload|
+ payloads << payload
+ end
+ ConnectionTestModel.establish_connection :arunit
+
+ assert_equal [:config, :connection_id, :spec_name], payloads[0].keys.sort
+ assert_equal "ActiveRecord::ConnectionAdapters::ConnectionPoolTest::ConnectionTestModel", payloads[0][:spec_name]
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscription) if subscription
+ end
+
+ def test_pool_sets_connection_schema_cache
+ connection = pool.checkout
+ schema_cache = SchemaCache.new connection
+ schema_cache.add(:posts)
+ pool.schema_cache = schema_cache
+
+ pool.with_connection do |conn|
+ assert_not_same pool.schema_cache, conn.schema_cache
+ assert_equal pool.schema_cache.size, conn.schema_cache.size
+ assert_same pool.schema_cache.columns(:posts), conn.schema_cache.columns(:posts)
+ end
+
+ pool.checkin connection
+ end
+
+ def test_concurrent_connection_establishment
+ assert_operator @pool.connections.size, :<=, 1
+
+ all_threads_in_new_connection = Concurrent::CountDownLatch.new(@pool.size - @pool.connections.size)
+ all_go = Concurrent::CountDownLatch.new
+
+ @pool.singleton_class.class_eval do
+ define_method(:new_connection) do
+ all_threads_in_new_connection.count_down
+ all_go.wait
+ super()
+ end
+ end
+
+ connecting_threads = []
+ @pool.size.times do
+ connecting_threads << Thread.new { @pool.checkout }
+ end
+
+ begin
+ Timeout.timeout(5) do
+ # the kernel of the whole test is here, everything else is just scaffolding,
+ # this latch will not be released unless conn. pool allows for concurrent
+ # connection creation
+ all_threads_in_new_connection.wait
+ end
+ rescue Timeout::Error
+ flunk "pool unable to establish connections concurrently or implementation has " \
+ "changed, this test then needs to patch a different :new_connection method"
+ ensure
+ # clean up the threads
+ all_go.count_down
+ connecting_threads.map(&:join)
+ end
+ end
+
+ def test_non_bang_disconnect_and_clear_reloadable_connections_throw_exception_if_threads_dont_return_their_conns
+ Thread.report_on_exception, original_report_on_exception = false, Thread.report_on_exception
+ @pool.checkout_timeout = 0.001 # no need to delay test suite by waiting the whole full default timeout
+ [:disconnect, :clear_reloadable_connections].each do |group_action_method|
+ @pool.with_connection do |connection|
+ assert_raises(ExclusiveConnectionTimeoutError) do
+ Thread.new { @pool.send(group_action_method) }.join
+ end
+ end
+ end
+ ensure
+ Thread.report_on_exception = original_report_on_exception
+ end
+
+ def test_disconnect_and_clear_reloadable_connections_attempt_to_wait_for_threads_to_return_their_conns
+ [:disconnect, :disconnect!, :clear_reloadable_connections, :clear_reloadable_connections!].each do |group_action_method|
+ thread = timed_join_result = nil
+ @pool.with_connection do |connection|
+ thread = Thread.new { @pool.send(group_action_method) }
+
+ # give the other `thread` some time to get stuck in `group_action_method`
+ timed_join_result = thread.join(0.3)
+ # thread.join # => `nil` means the other thread hasn't finished running and is still waiting for us to
+ # release our connection
+ assert_nil timed_join_result
+
+ # assert that since this is within default timeout our connection hasn't been forcefully taken away from us
+ assert_predicate @pool, :active_connection?
+ end
+ ensure
+ thread.join if thread && !timed_join_result # clean up the other thread
+ end
+ end
+
+ def test_bang_versions_of_disconnect_and_clear_reloadable_connections_if_unable_to_acquire_all_connections_proceed_anyway
+ @pool.checkout_timeout = 0.001 # no need to delay test suite by waiting the whole full default timeout
+ [:disconnect!, :clear_reloadable_connections!].each do |group_action_method|
+ @pool.with_connection do |connection|
+ Thread.new { @pool.send(group_action_method) }.join
+ # assert connection has been forcefully taken away from us
+ assert_not_predicate @pool, :active_connection?
+
+ # make a new connection for with_connection to clean up
+ @pool.connection
+ end
+ end
+ end
+
+ def test_disconnect_and_clear_reloadable_connections_are_able_to_preempt_other_waiting_threads
+ with_single_connection_pool do |pool|
+ [:disconnect, :disconnect!, :clear_reloadable_connections, :clear_reloadable_connections!].each do |group_action_method|
+ conn = pool.connection # drain the only available connection
+ second_thread_done = Concurrent::Event.new
+
+ begin
+ # create a first_thread and let it get into the FIFO queue first
+ first_thread = Thread.new do
+ pool.with_connection { second_thread_done.wait }
+ end
+
+ # wait for first_thread to get in queue
+ Thread.pass until pool.num_waiting_in_queue == 1
+
+ # create a different, later thread, that will attempt to do a "group action",
+ # but because of the group action semantics it should be able to preempt the
+ # first_thread when a connection is made available
+ second_thread = Thread.new do
+ pool.send(group_action_method)
+ second_thread_done.set
+ end
+
+ # wait for second_thread to get in queue
+ Thread.pass until pool.num_waiting_in_queue == 2
+
+ # return the only available connection
+ pool.checkin(conn)
+
+ # if the second_thread is not able to preempt the first_thread,
+ # they will temporarily (until either of them timeouts with ConnectionTimeoutError)
+ # deadlock and a join(2) timeout will be reached
+ assert second_thread.join(2), "#{group_action_method} is not able to preempt other waiting threads"
+
+ ensure
+ # post test clean up
+ failed = !second_thread_done.set?
+
+ if failed
+ second_thread_done.set
+
+ first_thread.join(2)
+ second_thread.join(2)
+ end
+
+ first_thread.join(10) || raise("first_thread got stuck")
+ second_thread.join(10) || raise("second_thread got stuck")
+ end
+ end
+ end
+ end
+
+ def test_clear_reloadable_connections_creates_new_connections_for_waiting_threads_if_necessary
+ with_single_connection_pool do |pool|
+ conn = pool.connection # drain the only available connection
+ def conn.requires_reloading? # make sure it gets removed from the pool by clear_reloadable_connections
+ true
+ end
+
+ stuck_thread = Thread.new do
+ pool.with_connection { }
+ end
+
+ # wait for stuck_thread to get in queue
+ Thread.pass until pool.num_waiting_in_queue == 1
+
+ pool.clear_reloadable_connections
+
+ unless stuck_thread.join(2)
+ flunk "clear_reloadable_connections must not let other connection waiting threads get stuck in queue"
+ end
+
+ assert_equal 0, pool.num_waiting_in_queue
+ end
+ end
+
+ def test_connection_pool_stat
+ with_single_connection_pool do |pool|
+ pool.with_connection do |connection|
+ stats = pool.stat
+ assert_equal({ size: 1, connections: 1, busy: 1, dead: 0, idle: 0, waiting: 0, checkout_timeout: 5 }, stats)
+ end
+
+ stats = pool.stat
+ assert_equal({ size: 1, connections: 1, busy: 0, dead: 0, idle: 1, waiting: 0, checkout_timeout: 5 }, stats)
+
+ Thread.new do
+ pool.checkout
+ Thread.current.kill
+ end.join
+
+ stats = pool.stat
+ assert_equal({ size: 1, connections: 1, busy: 0, dead: 1, idle: 0, waiting: 0, checkout_timeout: 5 }, stats)
+ end
+ end
+
+ private
+ def with_single_connection_pool
+ one_conn_spec = ActiveRecord::Base.connection_pool.spec.dup
+ one_conn_spec.config[:pool] = 1 # this is safe to do, because .dupped ConnectionSpecification also auto-dups its config
+ yield(pool = ConnectionPool.new(one_conn_spec))
+ ensure
+ pool.disconnect! if pool
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/connection_specification/resolver_test.rb b/activerecord/test/cases/connection_specification/resolver_test.rb
new file mode 100644
index 0000000000..72be14f507
--- /dev/null
+++ b/activerecord/test/cases/connection_specification/resolver_test.rb
@@ -0,0 +1,141 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class ConnectionSpecification
+ class ResolverTest < ActiveRecord::TestCase
+ def resolve(spec, config = {})
+ configs = ActiveRecord::DatabaseConfigurations.new(config)
+ resolver = ConnectionAdapters::ConnectionSpecification::Resolver.new(configs)
+ resolver.resolve(spec, spec)
+ end
+
+ def spec(spec, config = {})
+ configs = ActiveRecord::DatabaseConfigurations.new(config)
+ resolver = ConnectionAdapters::ConnectionSpecification::Resolver.new(configs)
+ resolver.spec(spec)
+ end
+
+ def test_url_invalid_adapter
+ error = assert_raises(LoadError) do
+ spec "ridiculous://foo?encoding=utf8"
+ end
+
+ assert_match "Could not load the 'ridiculous' Active Record adapter. Ensure that the adapter is spelled correctly in config/database.yml and that you've added the necessary adapter gem to your Gemfile.", error.message
+ end
+
+ # The abstract adapter is used simply to bypass the bit of code that
+ # checks that the adapter file can be required in.
+
+ def test_url_from_environment
+ spec = resolve :production, "production" => "abstract://foo?encoding=utf8"
+ assert_equal({
+ "adapter" => "abstract",
+ "host" => "foo",
+ "encoding" => "utf8",
+ "name" => "production" }, spec)
+ end
+
+ def test_url_sub_key
+ spec = resolve :production, "production" => { "url" => "abstract://foo?encoding=utf8" }
+ assert_equal({
+ "adapter" => "abstract",
+ "host" => "foo",
+ "encoding" => "utf8",
+ "name" => "production" }, spec)
+ end
+
+ def test_url_sub_key_merges_correctly
+ hash = { "url" => "abstract://foo?encoding=utf8&", "adapter" => "sqlite3", "host" => "bar", "pool" => "3" }
+ spec = resolve :production, "production" => hash
+ assert_equal({
+ "adapter" => "abstract",
+ "host" => "foo",
+ "encoding" => "utf8",
+ "pool" => "3",
+ "name" => "production" }, spec)
+ end
+
+ def test_url_host_no_db
+ spec = resolve "abstract://foo?encoding=utf8"
+ assert_equal({
+ "adapter" => "abstract",
+ "host" => "foo",
+ "encoding" => "utf8" }, spec)
+ end
+
+ def test_url_missing_scheme
+ spec = resolve "foo"
+ assert_equal({
+ "database" => "foo" }, spec)
+ end
+
+ def test_url_host_db
+ spec = resolve "abstract://foo/bar?encoding=utf8"
+ assert_equal({
+ "adapter" => "abstract",
+ "database" => "bar",
+ "host" => "foo",
+ "encoding" => "utf8" }, spec)
+ end
+
+ def test_url_port
+ spec = resolve "abstract://foo:123?encoding=utf8"
+ assert_equal({
+ "adapter" => "abstract",
+ "port" => 123,
+ "host" => "foo",
+ "encoding" => "utf8" }, spec)
+ end
+
+ def test_encoded_password
+ password = "am@z1ng_p@ssw0rd#!"
+ encoded_password = URI.encode_www_form_component(password)
+ spec = resolve "abstract://foo:#{encoded_password}@localhost/bar"
+ assert_equal password, spec["password"]
+ end
+
+ def test_url_with_authority_for_sqlite3
+ spec = resolve "sqlite3:///foo_test"
+ assert_equal("/foo_test", spec["database"])
+ end
+
+ def test_url_absolute_path_for_sqlite3
+ spec = resolve "sqlite3:/foo_test"
+ assert_equal("/foo_test", spec["database"])
+ end
+
+ def test_url_relative_path_for_sqlite3
+ spec = resolve "sqlite3:foo_test"
+ assert_equal("foo_test", spec["database"])
+ end
+
+ def test_url_memory_db_for_sqlite3
+ spec = resolve "sqlite3::memory:"
+ assert_equal(":memory:", spec["database"])
+ end
+
+ def test_url_sub_key_for_sqlite3
+ spec = resolve :production, "production" => { "url" => "sqlite3:foo?encoding=utf8" }
+ assert_equal({
+ "adapter" => "sqlite3",
+ "database" => "foo",
+ "encoding" => "utf8",
+ "name" => "production" }, spec)
+ end
+
+ def test_spec_name_on_key_lookup
+ spec = spec(:readonly, "readonly" => { "adapter" => "sqlite3" })
+ assert_equal "readonly", spec.name
+ end
+
+ def test_spec_name_with_inline_config
+ spec = spec("adapter" => "sqlite3")
+ assert_equal "primary", spec.name, "should default to primary id"
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/core_test.rb b/activerecord/test/cases/core_test.rb
new file mode 100644
index 0000000000..36e3d543cd
--- /dev/null
+++ b/activerecord/test/cases/core_test.rb
@@ -0,0 +1,125 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/person"
+require "models/topic"
+require "pp"
+
+class NonExistentTable < ActiveRecord::Base; end
+
+class CoreTest < ActiveRecord::TestCase
+ fixtures :topics
+
+ def test_inspect_class
+ assert_equal "ActiveRecord::Base", ActiveRecord::Base.inspect
+ assert_equal "LoosePerson(abstract)", LoosePerson.inspect
+ assert_match(/^Topic\(id: integer, title: string/, Topic.inspect)
+ end
+
+ def test_inspect_instance
+ topic = topics(:first)
+ assert_equal %(#<Topic id: 1, title: "The First Topic", author_name: "David", author_email_address: "david@loudthinking.com", written_on: "#{topic.written_on.to_s(:db)}", bonus_time: "#{topic.bonus_time.to_s(:db)}", last_read: "#{topic.last_read.to_s(:db)}", content: "Have a nice day", important: nil, approved: false, replies_count: 1, unique_replies_count: 0, parent_id: nil, parent_title: nil, type: nil, group: nil, created_at: "#{topic.created_at.to_s(:db)}", updated_at: "#{topic.updated_at.to_s(:db)}">), topic.inspect
+ end
+
+ def test_inspect_new_instance
+ assert_match(/Topic id: nil/, Topic.new.inspect)
+ end
+
+ def test_inspect_limited_select_instance
+ assert_equal %(#<Topic id: 1>), Topic.all.merge!(select: "id", where: "id = 1").first.inspect
+ assert_equal %(#<Topic id: 1, title: "The First Topic">), Topic.all.merge!(select: "id, title", where: "id = 1").first.inspect
+ end
+
+ def test_inspect_instance_with_non_primary_key_id_attribute
+ topic = topics(:first).becomes(TitlePrimaryKeyTopic)
+ assert_match(/id: 1/, topic.inspect)
+ end
+
+ def test_inspect_class_without_table
+ assert_equal "NonExistentTable(Table doesn't exist)", NonExistentTable.inspect
+ end
+
+ def test_pretty_print_new
+ topic = Topic.new
+ actual = +""
+ PP.pp(topic, StringIO.new(actual))
+ expected = <<~PRETTY
+ #<Topic:0xXXXXXX
+ id: nil,
+ title: nil,
+ author_name: nil,
+ author_email_address: "test@test.com",
+ written_on: nil,
+ bonus_time: nil,
+ last_read: nil,
+ content: nil,
+ important: nil,
+ approved: true,
+ replies_count: 0,
+ unique_replies_count: 0,
+ parent_id: nil,
+ parent_title: nil,
+ type: nil,
+ group: nil,
+ created_at: nil,
+ updated_at: nil>
+ PRETTY
+ assert actual.start_with?(expected.split("XXXXXX").first)
+ assert actual.end_with?(expected.split("XXXXXX").last)
+ end
+
+ def test_pretty_print_persisted
+ topic = topics(:first)
+ actual = +""
+ PP.pp(topic, StringIO.new(actual))
+ expected = <<~PRETTY
+ #<Topic:0x\\w+
+ id: 1,
+ title: "The First Topic",
+ author_name: "David",
+ author_email_address: "david@loudthinking.com",
+ written_on: 2003-07-16 14:28:11 UTC,
+ bonus_time: 2000-01-01 14:28:00 UTC,
+ last_read: Thu, 15 Apr 2004,
+ content: "Have a nice day",
+ important: nil,
+ approved: false,
+ replies_count: 1,
+ unique_replies_count: 0,
+ parent_id: nil,
+ parent_title: nil,
+ type: nil,
+ group: nil,
+ created_at: [^,]+,
+ updated_at: [^,>]+>
+ PRETTY
+ assert_match(/\A#{expected}\z/, actual)
+ end
+
+ def test_pretty_print_uninitialized
+ topic = Topic.allocate
+ actual = +""
+ PP.pp(topic, StringIO.new(actual))
+ expected = "#<Topic:XXXXXX not initialized>\n"
+ assert actual.start_with?(expected.split("XXXXXX").first)
+ assert actual.end_with?(expected.split("XXXXXX").last)
+ end
+
+ def test_pretty_print_overridden_by_inspect
+ subtopic = Class.new(Topic) do
+ def inspect
+ "inspecting topic"
+ end
+ end
+ actual = +""
+ PP.pp(subtopic.new, StringIO.new(actual))
+ assert_equal "inspecting topic\n", actual
+ end
+
+ def test_pretty_print_with_non_primary_key_id_attribute
+ topic = topics(:first).becomes(TitlePrimaryKeyTopic)
+ actual = +""
+ PP.pp(topic, StringIO.new(actual))
+ assert_match(/id: 1/, actual)
+ end
+end
diff --git a/activerecord/test/cases/counter_cache_test.rb b/activerecord/test/cases/counter_cache_test.rb
new file mode 100644
index 0000000000..99d286dc52
--- /dev/null
+++ b/activerecord/test/cases/counter_cache_test.rb
@@ -0,0 +1,367 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+require "models/car"
+require "models/aircraft"
+require "models/wheel"
+require "models/engine"
+require "models/reply"
+require "models/category"
+require "models/categorization"
+require "models/dog"
+require "models/dog_lover"
+require "models/person"
+require "models/friendship"
+require "models/subscriber"
+require "models/subscription"
+require "models/book"
+
+class CounterCacheTest < ActiveRecord::TestCase
+ fixtures :topics, :categories, :categorizations, :cars, :dogs, :dog_lovers, :people, :friendships, :subscribers, :subscriptions, :books
+
+ class ::SpecialTopic < ::Topic
+ has_many :special_replies, foreign_key: "parent_id"
+ has_many :lightweight_special_replies, -> { select("topics.id, topics.title") }, foreign_key: "parent_id", class_name: "SpecialReply"
+ end
+
+ class ::SpecialReply < ::Reply
+ belongs_to :special_topic, foreign_key: "parent_id", counter_cache: "replies_count"
+ end
+
+ setup do
+ @topic = Topic.find(1)
+ end
+
+ test "increment counter" do
+ assert_difference "@topic.reload.replies_count" do
+ Topic.increment_counter(:replies_count, @topic.id)
+ end
+ end
+
+ test "decrement counter" do
+ assert_difference "@topic.reload.replies_count", -1 do
+ Topic.decrement_counter(:replies_count, @topic.id)
+ end
+ end
+
+ test "reset counters" do
+ # throw the count off by 1
+ Topic.increment_counter(:replies_count, @topic.id)
+
+ # check that it gets reset
+ assert_difference "@topic.reload.replies_count", -1 do
+ Topic.reset_counters(@topic.id, :replies)
+ end
+ end
+
+ test "reset counters by counter name" do
+ # throw the count off by 1
+ Topic.increment_counter(:replies_count, @topic.id)
+
+ # check that it gets reset
+ assert_difference "@topic.reload.replies_count", -1 do
+ Topic.reset_counters(@topic.id, :replies_count)
+ end
+ end
+
+ test "reset multiple counters" do
+ Topic.update_counters @topic.id, replies_count: 1, unique_replies_count: 1
+ assert_difference ["@topic.reload.replies_count", "@topic.reload.unique_replies_count"], -1 do
+ Topic.reset_counters(@topic.id, :replies, :unique_replies)
+ end
+ end
+
+ test "reset counters with string argument" do
+ Topic.increment_counter("replies_count", @topic.id)
+
+ assert_difference "@topic.reload.replies_count", -1 do
+ Topic.reset_counters(@topic.id, "replies")
+ end
+ end
+
+ test "reset counters with modularized and camelized classnames" do
+ special = SpecialTopic.create!(title: "Special")
+ SpecialTopic.increment_counter(:replies_count, special.id)
+
+ assert_difference "special.reload.replies_count", -1 do
+ SpecialTopic.reset_counters(special.id, :special_replies)
+ end
+ end
+
+ test "reset counter with belongs_to which has class_name" do
+ car = cars(:honda)
+ assert_nothing_raised do
+ Car.reset_counters(car.id, :engines)
+ end
+ assert_nothing_raised do
+ Car.reset_counters(car.id, :wheels)
+ end
+ end
+
+ test "reset the right counter if two have the same class_name" do
+ david = dog_lovers(:david)
+
+ DogLover.increment_counter(:bred_dogs_count, david.id)
+ DogLover.increment_counter(:trained_dogs_count, david.id)
+
+ assert_difference "david.reload.bred_dogs_count", -1 do
+ DogLover.reset_counters(david.id, :bred_dogs)
+ end
+ assert_difference "david.reload.trained_dogs_count", -1 do
+ DogLover.reset_counters(david.id, :trained_dogs)
+ end
+ end
+
+ test "update counter with initial null value" do
+ category = categories(:general)
+ assert_equal 2, category.categorizations.count
+ assert_nil category.categorizations_count
+
+ Category.update_counters(category.id, categorizations_count: category.categorizations.count)
+ assert_equal 2, category.reload.categorizations_count
+ end
+
+ test "update counter for decrement" do
+ assert_difference "@topic.reload.replies_count", -3 do
+ Topic.update_counters(@topic.id, replies_count: -3)
+ end
+ end
+
+ test "update counters of multiple records" do
+ t1, t2 = topics(:first, :second)
+
+ assert_difference ["t1.reload.replies_count", "t2.reload.replies_count"], 2 do
+ Topic.update_counters([t1.id, t2.id], replies_count: 2)
+ end
+ end
+
+ test "update multiple counters" do
+ assert_difference ["@topic.reload.replies_count", "@topic.reload.unique_replies_count"], 2 do
+ Topic.update_counters @topic.id, replies_count: 2, unique_replies_count: 2
+ end
+ end
+
+ test "update other counters on parent destroy" do
+ david, joanna = dog_lovers(:david, :joanna)
+ joanna = joanna # squelch a warning
+
+ assert_difference "joanna.reload.dogs_count", -1 do
+ david.destroy
+ end
+ end
+
+ test "reset the right counter if two have the same foreign key" do
+ michael = people(:michael)
+ assert_nothing_raised do
+ Person.reset_counters(michael.id, :friends_too)
+ end
+ end
+
+ test "reset counter of has_many :through association" do
+ subscriber = subscribers("second")
+ Subscriber.reset_counters(subscriber.id, "books")
+ Subscriber.increment_counter("books_count", subscriber.id)
+
+ assert_difference "subscriber.reload.books_count", -1 do
+ Subscriber.reset_counters(subscriber.id, "books")
+ end
+ end
+
+ test "the passed symbol needs to be an association name or counter name" do
+ e = assert_raises(ArgumentError) do
+ Topic.reset_counters(@topic.id, :undefined_count)
+ end
+ assert_equal "'Topic' has no association called 'undefined_count'", e.message
+ end
+
+ test "reset counter works with select declared on association" do
+ special = SpecialTopic.create!(title: "Special")
+ SpecialTopic.increment_counter(:replies_count, special.id)
+
+ assert_difference "special.reload.replies_count", -1 do
+ SpecialTopic.reset_counters(special.id, :lightweight_special_replies)
+ end
+ end
+
+ test "counters are updated both in memory and in the database on create" do
+ car = Car.new(engines_count: 0)
+ car.engines = [Engine.new, Engine.new]
+ car.save!
+
+ assert_equal 2, car.engines_count
+ assert_equal 2, car.reload.engines_count
+ end
+
+ test "counter caches are updated in memory when the default value is nil" do
+ car = Car.new(engines_count: nil)
+ car.engines = [Engine.new, Engine.new]
+ car.save!
+
+ assert_equal 2, car.engines_count
+ assert_equal 2, car.reload.engines_count
+ end
+
+ test "update counters in a polymorphic relationship" do
+ aircraft = Aircraft.create!
+
+ assert_difference "aircraft.reload.wheels_count" do
+ aircraft.wheels << Wheel.create!
+ end
+
+ assert_difference "aircraft.reload.wheels_count", -1 do
+ aircraft.wheels.first.destroy
+ end
+ end
+
+ test "update counters doesn't touch timestamps by default" do
+ @topic.update_column :updated_at, 5.minutes.ago
+ previously_updated_at = @topic.updated_at
+
+ Topic.update_counters(@topic.id, replies_count: -1)
+
+ assert_equal previously_updated_at, @topic.updated_at
+ end
+
+ test "update counters doesn't touch timestamps with touch: []" do
+ @topic.update_column :updated_at, 5.minutes.ago
+ previously_updated_at = @topic.updated_at
+
+ Topic.update_counters(@topic.id, replies_count: -1, touch: [])
+
+ assert_equal previously_updated_at, @topic.updated_at
+ end
+
+ test "update counters with touch: true" do
+ assert_touching @topic, :updated_at do
+ Topic.update_counters(@topic.id, replies_count: -1, touch: true)
+ end
+ end
+
+ test "update counters of multiple records with touch: true" do
+ t1, t2 = topics(:first, :second)
+
+ assert_touching t1, :updated_at do
+ assert_difference ["t1.reload.replies_count", "t2.reload.replies_count"], 2 do
+ Topic.update_counters([t1.id, t2.id], replies_count: 2, touch: true)
+ end
+ end
+ end
+
+ test "update multiple counters with touch: true" do
+ assert_touching @topic, :updated_at do
+ Topic.update_counters(@topic.id, replies_count: 2, unique_replies_count: 2, touch: true)
+ end
+ end
+
+ test "reset counters with touch: true" do
+ assert_touching @topic, :updated_at do
+ Topic.reset_counters(@topic.id, :replies, touch: true)
+ end
+ end
+
+ test "reset multiple counters with touch: true" do
+ assert_touching @topic, :updated_at do
+ Topic.update_counters(@topic.id, replies_count: 1, unique_replies_count: 1)
+ Topic.reset_counters(@topic.id, :replies, :unique_replies, touch: true)
+ end
+ end
+
+ test "increment counters with touch: true" do
+ assert_touching @topic, :updated_at do
+ Topic.increment_counter(:replies_count, @topic.id, touch: true)
+ end
+ end
+
+ test "decrement counters with touch: true" do
+ assert_touching @topic, :updated_at do
+ Topic.decrement_counter(:replies_count, @topic.id, touch: true)
+ end
+ end
+
+ test "update counters with touch: :written_on" do
+ assert_touching @topic, :updated_at, :written_on do
+ Topic.update_counters(@topic.id, replies_count: -1, touch: :written_on)
+ end
+ end
+
+ test "update multiple counters with touch: :written_on" do
+ assert_touching @topic, :updated_at, :written_on do
+ Topic.update_counters(@topic.id, replies_count: 2, unique_replies_count: 2, touch: :written_on)
+ end
+ end
+
+ test "reset counters with touch: :written_on" do
+ assert_touching @topic, :updated_at, :written_on do
+ Topic.reset_counters(@topic.id, :replies, touch: :written_on)
+ end
+ end
+
+ test "reset multiple counters with touch: :written_on" do
+ assert_touching @topic, :updated_at, :written_on do
+ Topic.update_counters(@topic.id, replies_count: 1, unique_replies_count: 1)
+ Topic.reset_counters(@topic.id, :replies, :unique_replies, touch: :written_on)
+ end
+ end
+
+ test "increment counters with touch: :written_on" do
+ assert_touching @topic, :updated_at, :written_on do
+ Topic.increment_counter(:replies_count, @topic.id, touch: :written_on)
+ end
+ end
+
+ test "decrement counters with touch: :written_on" do
+ assert_touching @topic, :updated_at, :written_on do
+ Topic.decrement_counter(:replies_count, @topic.id, touch: :written_on)
+ end
+ end
+
+ test "update counters with touch: %i( updated_at written_on )" do
+ assert_touching @topic, :updated_at, :written_on do
+ Topic.update_counters(@topic.id, replies_count: -1, touch: %i( updated_at written_on ))
+ end
+ end
+
+ test "update multiple counters with touch: %i( updated_at written_on )" do
+ assert_touching @topic, :updated_at, :written_on do
+ Topic.update_counters(@topic.id, replies_count: 2, unique_replies_count: 2, touch: %i( updated_at written_on ))
+ end
+ end
+
+ test "reset counters with touch: %i( updated_at written_on )" do
+ assert_touching @topic, :updated_at, :written_on do
+ Topic.reset_counters(@topic.id, :replies, touch: %i( updated_at written_on ))
+ end
+ end
+
+ test "reset multiple counters with touch: %i( updated_at written_on )" do
+ assert_touching @topic, :updated_at, :written_on do
+ Topic.update_counters(@topic.id, replies_count: 1, unique_replies_count: 1)
+ Topic.reset_counters(@topic.id, :replies, :unique_replies, touch: %i( updated_at written_on ))
+ end
+ end
+
+ test "increment counters with touch: %i( updated_at written_on )" do
+ assert_touching @topic, :updated_at, :written_on do
+ Topic.increment_counter(:replies_count, @topic.id, touch: %i( updated_at written_on ))
+ end
+ end
+
+ test "decrement counters with touch: %i( updated_at written_on )" do
+ assert_touching @topic, :updated_at, :written_on do
+ Topic.decrement_counter(:replies_count, @topic.id, touch: %i( updated_at written_on ))
+ end
+ end
+
+ private
+ def assert_touching(record, *attributes)
+ record.update_columns attributes.map { |attr| [ attr, 5.minutes.ago ] }.to_h
+ touch_times = attributes.map { |attr| [ attr, record.public_send(attr) ] }.to_h
+
+ yield
+
+ touch_times.each do |attr, previous_touch_time|
+ assert_operator previous_touch_time, :<, record.reload.public_send(attr)
+ end
+ end
+end
diff --git a/activerecord/test/cases/custom_locking_test.rb b/activerecord/test/cases/custom_locking_test.rb
new file mode 100644
index 0000000000..f52b26e9ec
--- /dev/null
+++ b/activerecord/test/cases/custom_locking_test.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/person"
+
+module ActiveRecord
+ class CustomLockingTest < ActiveRecord::TestCase
+ fixtures :people
+
+ def test_custom_lock
+ if current_adapter?(:Mysql2Adapter)
+ assert_match "SHARE MODE", Person.lock("LOCK IN SHARE MODE").to_sql
+ assert_sql(/LOCK IN SHARE MODE/) do
+ Person.all.merge!(lock: "LOCK IN SHARE MODE").find(1)
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/database_statements_test.rb b/activerecord/test/cases/database_statements_test.rb
new file mode 100644
index 0000000000..1c934602ec
--- /dev/null
+++ b/activerecord/test/cases/database_statements_test.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class DatabaseStatementsTest < ActiveRecord::TestCase
+ def setup
+ @connection = ActiveRecord::Base.connection
+ end
+
+ unless current_adapter?(:OracleAdapter)
+ def test_exec_insert
+ result = @connection.exec_insert("INSERT INTO accounts (firm_id,credit_limit) VALUES (42,5000)", nil, [])
+ assert_not_nil @connection.send(:last_inserted_id, result)
+ end
+ end
+
+ def test_insert_should_return_the_inserted_id
+ assert_not_nil return_the_inserted_id(method: :insert)
+ end
+
+ def test_create_should_return_the_inserted_id
+ assert_not_nil return_the_inserted_id(method: :create)
+ end
+
+ private
+
+ def return_the_inserted_id(method:)
+ # Oracle adapter uses prefetched primary key values from sequence and passes them to connection adapter insert method
+ if current_adapter?(:OracleAdapter)
+ sequence_name = "accounts_seq"
+ id_value = @connection.next_sequence_value(sequence_name)
+ @connection.send(method, "INSERT INTO accounts (id, firm_id,credit_limit) VALUES (accounts_seq.nextval,42,5000)", nil, :id, id_value, sequence_name)
+ else
+ @connection.send(method, "INSERT INTO accounts (firm_id,credit_limit) VALUES (42,5000)")
+ end
+ end
+end
diff --git a/activerecord/test/cases/date_test.rb b/activerecord/test/cases/date_test.rb
new file mode 100644
index 0000000000..9f412cdb63
--- /dev/null
+++ b/activerecord/test/cases/date_test.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+
+class DateTest < ActiveRecord::TestCase
+ def test_date_with_time_value
+ time_value = Time.new(2016, 05, 11, 19, 0, 0)
+ topic = Topic.create(last_read: time_value)
+ assert_equal topic, Topic.find_by(last_read: time_value)
+ end
+
+ def test_date_with_string_value
+ string_value = "2016-05-11 19:00:00"
+ topic = Topic.create(last_read: string_value)
+ assert_equal topic, Topic.find_by(last_read: string_value)
+ end
+
+ def test_assign_valid_dates
+ valid_dates = [[2007, 11, 30], [1993, 2, 28], [2008, 2, 29]]
+
+ invalid_dates = [[2007, 11, 31], [1993, 2, 29], [2007, 2, 29]]
+
+ valid_dates.each do |date_src|
+ topic = Topic.new("last_read(1i)" => date_src[0].to_s, "last_read(2i)" => date_src[1].to_s, "last_read(3i)" => date_src[2].to_s)
+ # Oracle DATE columns are datetime columns and Oracle adapter returns Time value
+ if current_adapter?(:OracleAdapter)
+ assert_equal(topic.last_read.to_date, Date.new(*date_src))
+ else
+ assert_equal(topic.last_read, Date.new(*date_src))
+ end
+ end
+
+ invalid_dates.each do |date_src|
+ assert_nothing_raised do
+ topic = Topic.new("last_read(1i)" => date_src[0].to_s, "last_read(2i)" => date_src[1].to_s, "last_read(3i)" => date_src[2].to_s)
+ # Oracle DATE columns are datetime columns and Oracle adapter returns Time value
+ if current_adapter?(:OracleAdapter)
+ assert_equal(topic.last_read.to_date, Time.local(*date_src).to_date, "The date should be modified according to the behavior of the Time object")
+ else
+ assert_equal(topic.last_read, Time.local(*date_src).to_date, "The date should be modified according to the behavior of the Time object")
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/date_time_precision_test.rb b/activerecord/test/cases/date_time_precision_test.rb
new file mode 100644
index 0000000000..e64a8372d0
--- /dev/null
+++ b/activerecord/test/cases/date_time_precision_test.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+if subsecond_precision_supported?
+ class DateTimePrecisionTest < ActiveRecord::TestCase
+ include SchemaDumpingHelper
+ self.use_transactional_tests = false
+
+ class Foo < ActiveRecord::Base; end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ Foo.reset_column_information
+ end
+
+ teardown do
+ @connection.drop_table :foos, if_exists: true
+ end
+
+ def test_datetime_data_type_with_precision
+ @connection.create_table(:foos, force: true)
+ @connection.add_column :foos, :created_at, :datetime, precision: 0
+ @connection.add_column :foos, :updated_at, :datetime, precision: 5
+ assert_equal 0, Foo.columns_hash["created_at"].precision
+ assert_equal 5, Foo.columns_hash["updated_at"].precision
+ end
+
+ def test_datetime_precision_is_truncated_on_assignment
+ @connection.create_table(:foos, force: true)
+ @connection.add_column :foos, :created_at, :datetime, precision: 0
+ @connection.add_column :foos, :updated_at, :datetime, precision: 6
+
+ time = ::Time.now.change(nsec: 123456789)
+ foo = Foo.new(created_at: time, updated_at: time)
+
+ assert_equal 0, foo.created_at.nsec
+ assert_equal 123456000, foo.updated_at.nsec
+
+ foo.save!
+ foo.reload
+
+ assert_equal 0, foo.created_at.nsec
+ assert_equal 123456000, foo.updated_at.nsec
+ end
+
+ def test_timestamps_helper_with_custom_precision
+ @connection.create_table(:foos, force: true) do |t|
+ t.timestamps precision: 4
+ end
+ assert_equal 4, Foo.columns_hash["created_at"].precision
+ assert_equal 4, Foo.columns_hash["updated_at"].precision
+ end
+
+ def test_passing_precision_to_datetime_does_not_set_limit
+ @connection.create_table(:foos, force: true) do |t|
+ t.timestamps precision: 4
+ end
+ assert_nil Foo.columns_hash["created_at"].limit
+ assert_nil Foo.columns_hash["updated_at"].limit
+ end
+
+ def test_invalid_datetime_precision_raises_error
+ assert_raises ActiveRecord::ActiveRecordError do
+ @connection.create_table(:foos, force: true) do |t|
+ t.timestamps precision: 7
+ end
+ end
+ end
+
+ def test_formatting_datetime_according_to_precision
+ @connection.create_table(:foos, force: true) do |t|
+ t.datetime :created_at, precision: 0
+ t.datetime :updated_at, precision: 4
+ end
+ date = ::Time.utc(2014, 8, 17, 12, 30, 0, 999999)
+ Foo.create!(created_at: date, updated_at: date)
+ assert foo = Foo.find_by(created_at: date)
+ assert_equal 1, Foo.where(updated_at: date).count
+ assert_equal date.to_s, foo.created_at.to_s
+ assert_equal date.to_s, foo.updated_at.to_s
+ assert_equal 000000, foo.created_at.usec
+ assert_equal 999900, foo.updated_at.usec
+ end
+
+ def test_schema_dump_includes_datetime_precision
+ @connection.create_table(:foos, force: true) do |t|
+ t.timestamps precision: 6
+ end
+ output = dump_table_schema("foos")
+ assert_match %r{t\.datetime\s+"created_at",\s+precision: 6,\s+null: false$}, output
+ assert_match %r{t\.datetime\s+"updated_at",\s+precision: 6,\s+null: false$}, output
+ end
+
+ if current_adapter?(:PostgreSQLAdapter, :SQLServerAdapter)
+ def test_datetime_precision_with_zero_should_be_dumped
+ @connection.create_table(:foos, force: true) do |t|
+ t.timestamps precision: 0
+ end
+ output = dump_table_schema("foos")
+ assert_match %r{t\.datetime\s+"created_at",\s+precision: 0,\s+null: false$}, output
+ assert_match %r{t\.datetime\s+"updated_at",\s+precision: 0,\s+null: false$}, output
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/date_time_test.rb b/activerecord/test/cases/date_time_test.rb
new file mode 100644
index 0000000000..b5f35aff0e
--- /dev/null
+++ b/activerecord/test/cases/date_time_test.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+require "models/task"
+
+class DateTimeTest < ActiveRecord::TestCase
+ include InTimeZone
+
+ def test_saves_both_date_and_time
+ with_env_tz "America/New_York" do
+ with_timezone_config default: :utc do
+ time_values = [1807, 2, 10, 15, 30, 45]
+ # create DateTime value with local time zone offset
+ local_offset = Rational(Time.local(*time_values).utc_offset, 86400)
+ now = DateTime.civil(*(time_values + [local_offset]))
+
+ task = Task.new
+ task.starting = now
+ task.save!
+
+ # check against Time.local, since some platforms will return a Time instead of a DateTime
+ assert_equal Time.local(*time_values), Task.find(task.id).starting
+ end
+ end
+ end
+
+ def test_assign_empty_date_time
+ task = Task.new
+ task.starting = ""
+ task.ending = nil
+ assert_nil task.starting
+ assert_nil task.ending
+ end
+
+ def test_assign_bad_date_time_with_timezone
+ in_time_zone "Pacific Time (US & Canada)" do
+ task = Task.new
+ task.starting = "2014-07-01T24:59:59GMT"
+ assert_nil task.starting
+ end
+ end
+
+ def test_assign_empty_date
+ topic = Topic.new
+ topic.last_read = ""
+ assert_nil topic.last_read
+ end
+
+ def test_assign_empty_time
+ topic = Topic.new
+ topic.bonus_time = ""
+ assert_nil topic.bonus_time
+ end
+
+ def test_assign_in_local_timezone
+ now = DateTime.civil(2017, 3, 1, 12, 0, 0)
+ with_timezone_config default: :local do
+ task = Task.new starting: now
+ assert_equal now, task.starting
+ end
+ end
+
+ def test_date_time_with_string_value_with_subsecond_precision
+ skip unless subsecond_precision_supported?
+ string_value = "2017-07-04 14:19:00.5"
+ topic = Topic.create(written_on: string_value)
+ assert_equal topic, Topic.find_by(written_on: string_value)
+ end
+
+ def test_date_time_with_string_value_with_non_iso_format
+ string_value = "04/07/2017 2:19pm"
+ topic = Topic.create(written_on: string_value)
+ assert_equal topic, Topic.find_by(written_on: string_value)
+ end
+end
diff --git a/activerecord/test/cases/defaults_test.rb b/activerecord/test/cases/defaults_test.rb
new file mode 100644
index 0000000000..5d02e59ef6
--- /dev/null
+++ b/activerecord/test/cases/defaults_test.rb
@@ -0,0 +1,226 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+require "models/default"
+require "models/entrant"
+
+class DefaultTest < ActiveRecord::TestCase
+ def test_nil_defaults_for_not_null_columns
+ %w(id name course_id).each do |name|
+ column = Entrant.columns_hash[name]
+ assert_not column.null, "#{name} column should be NOT NULL"
+ assert_not column.default, "#{name} column should be DEFAULT 'nil'"
+ end
+ end
+
+ if current_adapter?(:PostgreSQLAdapter)
+ def test_multiline_default_text
+ record = Default.new
+ # older postgres versions represent the default with escapes ("\\012" for a newline)
+ assert("--- []\n\n" == record.multiline_default || "--- []\\012\\012" == record.multiline_default)
+ end
+ end
+end
+
+class DefaultNumbersTest < ActiveRecord::TestCase
+ class DefaultNumber < ActiveRecord::Base; end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table :default_numbers do |t|
+ t.integer :positive_integer, default: 7
+ t.integer :negative_integer, default: -5
+ t.decimal :decimal_number, default: "2.78", precision: 5, scale: 2
+ end
+ end
+
+ teardown do
+ @connection.drop_table :default_numbers, if_exists: true
+ end
+
+ def test_default_positive_integer
+ record = DefaultNumber.new
+ assert_equal 7, record.positive_integer
+ assert_equal "7", record.positive_integer_before_type_cast
+ end
+
+ def test_default_negative_integer
+ record = DefaultNumber.new
+ assert_equal (-5), record.negative_integer
+ assert_equal "-5", record.negative_integer_before_type_cast
+ end
+
+ def test_default_decimal_number
+ record = DefaultNumber.new
+ assert_equal BigDecimal("2.78"), record.decimal_number
+ assert_equal "2.78", record.decimal_number_before_type_cast
+ end
+end
+
+class DefaultStringsTest < ActiveRecord::TestCase
+ class DefaultString < ActiveRecord::Base; end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table :default_strings do |t|
+ t.string :string_col, default: "Smith"
+ t.string :string_col_with_quotes, default: "O'Connor"
+ end
+ DefaultString.reset_column_information
+ end
+
+ def test_default_strings
+ assert_equal "Smith", DefaultString.new.string_col
+ end
+
+ def test_default_strings_containing_single_quotes
+ assert_equal "O'Connor", DefaultString.new.string_col_with_quotes
+ end
+
+ teardown do
+ @connection.drop_table :default_strings
+ end
+end
+
+if current_adapter?(:PostgreSQLAdapter)
+ class PostgresqlDefaultExpressionTest < ActiveRecord::TestCase
+ include SchemaDumpingHelper
+
+ test "schema dump includes default expression" do
+ output = dump_table_schema("defaults")
+ if ActiveRecord::Base.connection.postgresql_version >= 100000
+ assert_match %r/t\.date\s+"modified_date",\s+default: -> { "CURRENT_DATE" }/, output
+ assert_match %r/t\.datetime\s+"modified_time",\s+default: -> { "CURRENT_TIMESTAMP" }/, output
+ else
+ assert_match %r/t\.date\s+"modified_date",\s+default: -> { "\('now'::text\)::date" }/, output
+ assert_match %r/t\.datetime\s+"modified_time",\s+default: -> { "now\(\)" }/, output
+ end
+ assert_match %r/t\.date\s+"modified_date_function",\s+default: -> { "now\(\)" }/, output
+ assert_match %r/t\.datetime\s+"modified_time_function",\s+default: -> { "now\(\)" }/, output
+ end
+ end
+end
+
+if current_adapter?(:Mysql2Adapter)
+ class MysqlDefaultExpressionTest < ActiveRecord::TestCase
+ include SchemaDumpingHelper
+
+ if supports_default_expression?
+ test "schema dump includes default expression" do
+ output = dump_table_schema("defaults")
+ assert_match %r/t\.binary\s+"uuid",\s+limit: 36,\s+default: -> { "\(uuid\(\)\)" }/i, output
+ end
+ end
+
+ if subsecond_precision_supported?
+ test "schema dump datetime includes default expression" do
+ output = dump_table_schema("datetime_defaults")
+ assert_match %r/t\.datetime\s+"modified_datetime",\s+default: -> { "CURRENT_TIMESTAMP(?:\(\))?" }/i, output
+ end
+
+ test "schema dump datetime includes precise default expression" do
+ output = dump_table_schema("datetime_defaults")
+ assert_match %r/t\.datetime\s+"precise_datetime",.+default: -> { "CURRENT_TIMESTAMP\(6\)" }/i, output
+ end
+
+ test "schema dump timestamp includes default expression" do
+ output = dump_table_schema("timestamp_defaults")
+ assert_match %r/t\.timestamp\s+"modified_timestamp",\s+default: -> { "CURRENT_TIMESTAMP(?:\(\))?" }/i, output
+ end
+
+ test "schema dump timestamp includes precise default expression" do
+ output = dump_table_schema("timestamp_defaults")
+ assert_match %r/t\.timestamp\s+"precise_timestamp",.+default: -> { "CURRENT_TIMESTAMP\(6\)" }/i, output
+ end
+
+ test "schema dump timestamp without default expression" do
+ output = dump_table_schema("timestamp_defaults")
+ assert_match %r/t\.timestamp\s+"nullable_timestamp"$/, output
+ end
+ end
+ end
+
+ class DefaultsTestWithoutTransactionalFixtures < ActiveRecord::TestCase
+ # ActiveRecord::Base#create! (and #save and other related methods) will
+ # open a new transaction. When in transactional tests mode, this will
+ # cause Active Record to create a new savepoint. However, since MySQL doesn't
+ # support DDL transactions, creating a table will result in any created
+ # savepoints to be automatically released. This in turn causes the savepoint
+ # release code in AbstractAdapter#transaction to fail.
+ #
+ # We don't want that to happen, so we disable transactional tests here.
+ self.use_transactional_tests = false
+
+ def using_strict(strict)
+ connection = ActiveRecord::Base.remove_connection
+ ActiveRecord::Base.establish_connection connection.merge(strict: strict)
+ yield
+ ensure
+ ActiveRecord::Base.remove_connection
+ ActiveRecord::Base.establish_connection connection
+ end
+
+ # Strict mode controls how MySQL handles invalid or missing values
+ # in data-change statements such as INSERT or UPDATE. A value can be
+ # invalid for several reasons. For example, it might have the wrong
+ # data type for the column, or it might be out of range. A value is
+ # missing when a new row to be inserted does not contain a value for
+ # a non-NULL column that has no explicit DEFAULT clause in its definition.
+ # (For a NULL column, NULL is inserted if the value is missing.)
+ #
+ # If strict mode is not in effect, MySQL inserts adjusted values for
+ # invalid or missing values and produces warnings. In strict mode,
+ # you can produce this behavior by using INSERT IGNORE or UPDATE IGNORE.
+ #
+ # https://dev.mysql.com/doc/refman/5.7/en/sql-mode.html#sql-mode-strict
+ def test_mysql_not_null_defaults_non_strict
+ using_strict(false) do
+ with_mysql_not_null_table do |klass|
+ record = klass.new
+ assert_nil record.non_null_integer
+ assert_nil record.non_null_string
+ assert_nil record.non_null_text
+ assert_nil record.non_null_blob
+
+ record.save!
+ record.reload
+
+ assert_equal 0, record.non_null_integer
+ assert_equal "", record.non_null_string
+ assert_equal "", record.non_null_text
+ assert_equal "", record.non_null_blob
+ end
+ end
+ end
+
+ def test_mysql_not_null_defaults_strict
+ using_strict(true) do
+ with_mysql_not_null_table do |klass|
+ record = klass.new
+ assert_nil record.non_null_integer
+ assert_nil record.non_null_string
+ assert_nil record.non_null_text
+ assert_nil record.non_null_blob
+
+ assert_raises(ActiveRecord::NotNullViolation) { klass.create }
+ end
+ end
+ end
+
+ def with_mysql_not_null_table
+ klass = Class.new(ActiveRecord::Base)
+ klass.table_name = "test_mysql_not_null_defaults"
+ klass.connection.create_table klass.table_name do |t|
+ t.integer :non_null_integer, null: false
+ t.string :non_null_string, null: false
+ t.text :non_null_text, null: false
+ t.blob :non_null_blob, null: false
+ end
+
+ yield klass
+ ensure
+ klass.connection.drop_table(klass.table_name) rescue nil
+ end
+ end
+end
diff --git a/activerecord/test/cases/dirty_test.rb b/activerecord/test/cases/dirty_test.rb
new file mode 100644
index 0000000000..dfd74bfcb4
--- /dev/null
+++ b/activerecord/test/cases/dirty_test.rb
@@ -0,0 +1,922 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic" # For booleans
+require "models/pirate" # For timestamps
+require "models/parrot"
+require "models/person" # For optimistic locking
+require "models/aircraft"
+require "models/numeric_data"
+
+class DirtyTest < ActiveRecord::TestCase
+ include InTimeZone
+
+ # Dummy to force column loads so query counts are clean.
+ def setup
+ Person.create first_name: "foo"
+ end
+
+ def test_attribute_changes
+ # New record - no changes.
+ pirate = Pirate.new
+ assert_equal false, pirate.catchphrase_changed?
+ assert_equal false, pirate.non_validated_parrot_id_changed?
+
+ # Change catchphrase.
+ pirate.catchphrase = "arrr"
+ assert_predicate pirate, :catchphrase_changed?
+ assert_nil pirate.catchphrase_was
+ assert_equal [nil, "arrr"], pirate.catchphrase_change
+
+ # Saved - no changes.
+ pirate.save!
+ assert_not_predicate pirate, :catchphrase_changed?
+ assert_nil pirate.catchphrase_change
+
+ # Same value - no changes.
+ pirate.catchphrase = "arrr"
+ assert_not_predicate pirate, :catchphrase_changed?
+ assert_nil pirate.catchphrase_change
+ end
+
+ def test_time_attributes_changes_with_time_zone
+ in_time_zone "Paris" do
+ target = Class.new(ActiveRecord::Base)
+ target.table_name = "pirates"
+
+ # New record - no changes.
+ pirate = target.new
+ assert_not_predicate pirate, :created_on_changed?
+ assert_nil pirate.created_on_change
+
+ # Saved - no changes.
+ pirate.catchphrase = "arrrr, time zone!!"
+ pirate.save!
+ assert_not_predicate pirate, :created_on_changed?
+ assert_nil pirate.created_on_change
+
+ # Change created_on.
+ old_created_on = pirate.created_on
+ pirate.created_on = Time.now - 1.day
+ assert_predicate pirate, :created_on_changed?
+ assert_kind_of ActiveSupport::TimeWithZone, pirate.created_on_was
+ assert_equal old_created_on, pirate.created_on_was
+ pirate.created_on = old_created_on
+ assert_not_predicate pirate, :created_on_changed?
+ end
+ end
+
+ def test_setting_time_attributes_with_time_zone_field_to_itself_should_not_be_marked_as_a_change
+ in_time_zone "Paris" do
+ target = Class.new(ActiveRecord::Base)
+ target.table_name = "pirates"
+
+ pirate = target.create!
+ pirate.created_on = pirate.created_on
+ assert_not_predicate pirate, :created_on_changed?
+ end
+ end
+
+ def test_time_attributes_changes_without_time_zone_by_skip
+ in_time_zone "Paris" do
+ target = Class.new(ActiveRecord::Base)
+ target.table_name = "pirates"
+
+ target.skip_time_zone_conversion_for_attributes = [:created_on]
+
+ # New record - no changes.
+ pirate = target.new
+ assert_not_predicate pirate, :created_on_changed?
+ assert_nil pirate.created_on_change
+
+ # Saved - no changes.
+ pirate.catchphrase = "arrrr, time zone!!"
+ pirate.save!
+ assert_not_predicate pirate, :created_on_changed?
+ assert_nil pirate.created_on_change
+
+ # Change created_on.
+ old_created_on = pirate.created_on
+ pirate.created_on = Time.now + 1.day
+ assert_predicate pirate, :created_on_changed?
+ # kind_of does not work because
+ # ActiveSupport::TimeWithZone.name == 'Time'
+ assert_instance_of Time, pirate.created_on_was
+ assert_equal old_created_on, pirate.created_on_was
+ end
+ end
+
+ def test_time_attributes_changes_without_time_zone
+ with_timezone_config aware_attributes: false do
+ target = Class.new(ActiveRecord::Base)
+ target.table_name = "pirates"
+
+ # New record - no changes.
+ pirate = target.new
+ assert_not_predicate pirate, :created_on_changed?
+ assert_nil pirate.created_on_change
+
+ # Saved - no changes.
+ pirate.catchphrase = "arrrr, time zone!!"
+ pirate.save!
+ assert_not_predicate pirate, :created_on_changed?
+ assert_nil pirate.created_on_change
+
+ # Change created_on.
+ old_created_on = pirate.created_on
+ pirate.created_on = Time.now + 1.day
+ assert_predicate pirate, :created_on_changed?
+ # kind_of does not work because
+ # ActiveSupport::TimeWithZone.name == 'Time'
+ assert_instance_of Time, pirate.created_on_was
+ assert_equal old_created_on, pirate.created_on_was
+ end
+ end
+
+ def test_aliased_attribute_changes
+ # the actual attribute here is name, title is an
+ # alias setup via alias_attribute
+ parrot = Parrot.new
+ assert_not_predicate parrot, :title_changed?
+ assert_nil parrot.title_change
+
+ parrot.name = "Sam"
+ assert_predicate parrot, :title_changed?
+ assert_nil parrot.title_was
+ assert_equal parrot.name_change, parrot.title_change
+ end
+
+ def test_restore_attribute!
+ pirate = Pirate.create!(catchphrase: "Yar!")
+ pirate.catchphrase = "Ahoy!"
+
+ pirate.restore_catchphrase!
+ assert_equal "Yar!", pirate.catchphrase
+ assert_equal Hash.new, pirate.changes
+ assert_not_predicate pirate, :catchphrase_changed?
+ end
+
+ def test_nullable_number_not_marked_as_changed_if_new_value_is_blank
+ pirate = Pirate.new
+
+ ["", nil].each do |value|
+ pirate.parrot_id = value
+ assert_not_predicate pirate, :parrot_id_changed?
+ assert_nil pirate.parrot_id_change
+ end
+ end
+
+ def test_nullable_decimal_not_marked_as_changed_if_new_value_is_blank
+ numeric_data = NumericData.new
+
+ ["", nil].each do |value|
+ numeric_data.bank_balance = value
+ assert_not_predicate numeric_data, :bank_balance_changed?
+ assert_nil numeric_data.bank_balance_change
+ end
+ end
+
+ def test_nullable_float_not_marked_as_changed_if_new_value_is_blank
+ numeric_data = NumericData.new
+
+ ["", nil].each do |value|
+ numeric_data.temperature = value
+ assert_not_predicate numeric_data, :temperature_changed?
+ assert_nil numeric_data.temperature_change
+ end
+ end
+
+ def test_nullable_datetime_not_marked_as_changed_if_new_value_is_blank
+ in_time_zone "Edinburgh" do
+ target = Class.new(ActiveRecord::Base)
+ target.table_name = "topics"
+
+ topic = target.create
+ assert_nil topic.written_on
+
+ ["", nil].each do |value|
+ topic.written_on = value
+ assert_nil topic.written_on
+ assert_not_predicate topic, :written_on_changed?
+ end
+ end
+ end
+
+ def test_integer_zero_to_string_zero_not_marked_as_changed
+ pirate = Pirate.new
+ pirate.parrot_id = 0
+ pirate.catchphrase = "arrr"
+ assert pirate.save!
+
+ assert_not_predicate pirate, :changed?
+
+ pirate.parrot_id = "0"
+ assert_not_predicate pirate, :changed?
+ end
+
+ def test_integer_zero_to_integer_zero_not_marked_as_changed
+ pirate = Pirate.new
+ pirate.parrot_id = 0
+ pirate.catchphrase = "arrr"
+ assert pirate.save!
+
+ assert_not_predicate pirate, :changed?
+
+ pirate.parrot_id = 0
+ assert_not_predicate pirate, :changed?
+ end
+
+ def test_float_zero_to_string_zero_not_marked_as_changed
+ data = NumericData.new temperature: 0.0
+ data.save!
+
+ assert_not_predicate data, :changed?
+
+ data.temperature = "0"
+ assert_empty data.changes
+
+ data.temperature = "0.0"
+ assert_empty data.changes
+
+ data.temperature = "0.00"
+ assert_empty data.changes
+ end
+
+ def test_zero_to_blank_marked_as_changed
+ pirate = Pirate.new
+ pirate.catchphrase = "Yarrrr, me hearties"
+ pirate.parrot_id = 1
+ pirate.save
+
+ # check the change from 1 to ''
+ pirate = Pirate.find_by_catchphrase("Yarrrr, me hearties")
+ pirate.parrot_id = ""
+ assert_predicate pirate, :parrot_id_changed?
+ assert_equal([1, nil], pirate.parrot_id_change)
+ pirate.save
+
+ # check the change from nil to 0
+ pirate = Pirate.find_by_catchphrase("Yarrrr, me hearties")
+ pirate.parrot_id = 0
+ assert_predicate pirate, :parrot_id_changed?
+ assert_equal([nil, 0], pirate.parrot_id_change)
+ pirate.save
+
+ # check the change from 0 to ''
+ pirate = Pirate.find_by_catchphrase("Yarrrr, me hearties")
+ pirate.parrot_id = ""
+ assert_predicate pirate, :parrot_id_changed?
+ assert_equal([0, nil], pirate.parrot_id_change)
+ end
+
+ def test_object_should_be_changed_if_any_attribute_is_changed
+ pirate = Pirate.new
+ assert_not_predicate pirate, :changed?
+ assert_equal [], pirate.changed
+ assert_equal Hash.new, pirate.changes
+
+ pirate.catchphrase = "arrr"
+ assert_predicate pirate, :changed?
+ assert_nil pirate.catchphrase_was
+ assert_equal %w(catchphrase), pirate.changed
+ assert_equal({ "catchphrase" => [nil, "arrr"] }, pirate.changes)
+
+ pirate.save
+ assert_not_predicate pirate, :changed?
+ assert_equal [], pirate.changed
+ assert_equal Hash.new, pirate.changes
+ end
+
+ def test_attribute_will_change!
+ pirate = Pirate.create!(catchphrase: "arr")
+
+ assert_not_predicate pirate, :catchphrase_changed?
+ assert pirate.catchphrase_will_change!
+ assert_predicate pirate, :catchphrase_changed?
+ assert_equal ["arr", "arr"], pirate.catchphrase_change
+
+ pirate.catchphrase << " matey!"
+ assert_predicate pirate, :catchphrase_changed?
+ assert_equal ["arr", "arr matey!"], pirate.catchphrase_change
+ end
+
+ def test_virtual_attribute_will_change
+ parrot = Parrot.create!(name: "Ruby")
+ parrot.send(:attribute_will_change!, :cancel_save_from_callback)
+ assert_predicate parrot, :has_changes_to_save?
+ end
+
+ def test_association_assignment_changes_foreign_key
+ pirate = Pirate.create!(catchphrase: "jarl")
+ pirate.parrot = Parrot.create!(name: "Lorre")
+ assert_predicate pirate, :changed?
+ assert_equal %w(parrot_id), pirate.changed
+ end
+
+ def test_attribute_should_be_compared_with_type_cast
+ topic = Topic.new
+ assert_predicate topic, :approved?
+ assert_not_predicate topic, :approved_changed?
+
+ # Coming from web form.
+ params = { topic: { approved: 1 } }
+ # In the controller.
+ topic.attributes = params[:topic]
+ assert_predicate topic, :approved?
+ assert_not_predicate topic, :approved_changed?
+ end
+
+ def test_partial_update
+ pirate = Pirate.new(catchphrase: "foo")
+ old_updated_on = 1.hour.ago.beginning_of_day
+
+ with_partial_writes Pirate, false do
+ assert_queries(2) { 2.times { pirate.save! } }
+ Pirate.where(id: pirate.id).update_all(updated_on: old_updated_on)
+ end
+
+ with_partial_writes Pirate, true do
+ assert_no_queries { 2.times { pirate.save! } }
+ assert_equal old_updated_on, pirate.reload.updated_on
+
+ assert_queries(1) { pirate.catchphrase = "bar"; pirate.save! }
+ assert_not_equal old_updated_on, pirate.reload.updated_on
+ end
+ end
+
+ def test_partial_update_with_optimistic_locking
+ person = Person.new(first_name: "foo")
+
+ with_partial_writes Person, false do
+ assert_queries(2) { 2.times { person.save! } }
+ Person.where(id: person.id).update_all(first_name: "baz")
+ end
+
+ old_lock_version = person.lock_version
+
+ with_partial_writes Person, true do
+ assert_no_queries { 2.times { person.save! } }
+ assert_equal old_lock_version, person.reload.lock_version
+
+ assert_queries(1) { person.first_name = "bar"; person.save! }
+ assert_not_equal old_lock_version, person.reload.lock_version
+ end
+ end
+
+ def test_changed_attributes_should_be_preserved_if_save_failure
+ pirate = Pirate.new
+ pirate.parrot_id = 1
+ assert_not pirate.save
+ check_pirate_after_save_failure(pirate)
+
+ pirate = Pirate.new
+ pirate.parrot_id = 1
+ assert_raise(ActiveRecord::RecordInvalid) { pirate.save! }
+ check_pirate_after_save_failure(pirate)
+ end
+
+ def test_reload_should_clear_changed_attributes
+ pirate = Pirate.create!(catchphrase: "shiver me timbers")
+ pirate.catchphrase = "*hic*"
+ assert_predicate pirate, :changed?
+ pirate.reload
+ assert_not_predicate pirate, :changed?
+ end
+
+ def test_dup_objects_should_not_copy_dirty_flag_from_creator
+ pirate = Pirate.create!(catchphrase: "shiver me timbers")
+ pirate_dup = pirate.dup
+ pirate_dup.restore_catchphrase!
+ pirate.catchphrase = "I love Rum"
+ assert_predicate pirate, :catchphrase_changed?
+ assert_not_predicate pirate_dup, :catchphrase_changed?
+ end
+
+ def test_reverted_changes_are_not_dirty
+ phrase = "shiver me timbers"
+ pirate = Pirate.create!(catchphrase: phrase)
+ pirate.catchphrase = "*hic*"
+ assert_predicate pirate, :changed?
+ pirate.catchphrase = phrase
+ assert_not_predicate pirate, :changed?
+ end
+
+ def test_reverted_changes_are_not_dirty_after_multiple_changes
+ phrase = "shiver me timbers"
+ pirate = Pirate.create!(catchphrase: phrase)
+ 10.times do |i|
+ pirate.catchphrase = "*hic*" * i
+ assert_predicate pirate, :changed?
+ end
+ assert_predicate pirate, :changed?
+ pirate.catchphrase = phrase
+ assert_not_predicate pirate, :changed?
+ end
+
+ def test_reverted_changes_are_not_dirty_going_from_nil_to_value_and_back
+ pirate = Pirate.create!(catchphrase: "Yar!")
+
+ pirate.parrot_id = 1
+ assert_predicate pirate, :changed?
+ assert_predicate pirate, :parrot_id_changed?
+ assert_not_predicate pirate, :catchphrase_changed?
+
+ pirate.parrot_id = nil
+ assert_not_predicate pirate, :changed?
+ assert_not_predicate pirate, :parrot_id_changed?
+ assert_not_predicate pirate, :catchphrase_changed?
+ end
+
+ def test_save_should_store_serialized_attributes_even_with_partial_writes
+ with_partial_writes(Topic) do
+ topic = Topic.create!(content: { a: "a" })
+
+ assert_not_predicate topic, :changed?
+
+ topic.content[:b] = "b"
+
+ assert_predicate topic, :changed?
+
+ topic.save!
+
+ assert_not_predicate topic, :changed?
+ assert_equal "b", topic.content[:b]
+
+ topic.reload
+
+ assert_equal "b", topic.content[:b]
+ end
+ end
+
+ def test_save_always_should_update_timestamps_when_serialized_attributes_are_present
+ with_partial_writes(Topic) do
+ topic = Topic.create!(content: { a: "a" })
+ topic.save!
+
+ updated_at = topic.updated_at
+ travel(1.second) do
+ topic.content[:hello] = "world"
+ topic.save!
+ end
+
+ assert_not_equal updated_at, topic.updated_at
+ assert_equal "world", topic.content[:hello]
+ end
+ end
+
+ def test_save_should_not_save_serialized_attribute_with_partial_writes_if_not_present
+ with_partial_writes(Topic) do
+ topic = Topic.create!(author_name: "Bill", content: { a: "a" })
+ topic = Topic.select("id, author_name").find(topic.id)
+ topic.update_columns author_name: "John"
+ assert_not_nil topic.reload.content
+ end
+ end
+
+ def test_changes_to_save_should_not_mutate_array_of_hashes
+ topic = Topic.new(author_name: "Bill", content: [{ a: "a" }])
+
+ topic.changes_to_save
+
+ assert_equal [{ a: "a" }], topic.content
+ end
+
+ def test_previous_changes
+ # original values should be in previous_changes
+ pirate = Pirate.new
+
+ assert_equal Hash.new, pirate.previous_changes
+ pirate.catchphrase = "arrr"
+ pirate.save!
+
+ assert_equal 4, pirate.previous_changes.size
+ assert_equal [nil, "arrr"], pirate.previous_changes["catchphrase"]
+ assert_equal [nil, pirate.id], pirate.previous_changes["id"]
+ assert_nil pirate.previous_changes["updated_on"][0]
+ assert_not_nil pirate.previous_changes["updated_on"][1]
+ assert_nil pirate.previous_changes["created_on"][0]
+ assert_not_nil pirate.previous_changes["created_on"][1]
+ assert_not pirate.previous_changes.key?("parrot_id")
+
+ # original values should be in previous_changes
+ pirate = Pirate.new
+
+ assert_equal Hash.new, pirate.previous_changes
+ pirate.catchphrase = "arrr"
+ pirate.save
+
+ assert_equal 4, pirate.previous_changes.size
+ assert_equal [nil, "arrr"], pirate.previous_changes["catchphrase"]
+ assert_equal [nil, pirate.id], pirate.previous_changes["id"]
+ assert_includes pirate.previous_changes, "updated_on"
+ assert_includes pirate.previous_changes, "created_on"
+ assert_not pirate.previous_changes.key?("parrot_id")
+
+ pirate.catchphrase = "Yar!!"
+ pirate.reload
+ assert_equal Hash.new, pirate.previous_changes
+
+ pirate = Pirate.find_by_catchphrase("arrr")
+
+ travel(1.second)
+
+ pirate.catchphrase = "Me Maties!"
+ pirate.save!
+
+ assert_equal 2, pirate.previous_changes.size
+ assert_equal ["arrr", "Me Maties!"], pirate.previous_changes["catchphrase"]
+ assert_not_nil pirate.previous_changes["updated_on"][0]
+ assert_not_nil pirate.previous_changes["updated_on"][1]
+ assert_not pirate.previous_changes.key?("parrot_id")
+ assert_not pirate.previous_changes.key?("created_on")
+
+ pirate = Pirate.find_by_catchphrase("Me Maties!")
+
+ travel(1.second)
+
+ pirate.catchphrase = "Thar She Blows!"
+ pirate.save
+
+ assert_equal 2, pirate.previous_changes.size
+ assert_equal ["Me Maties!", "Thar She Blows!"], pirate.previous_changes["catchphrase"]
+ assert_not_nil pirate.previous_changes["updated_on"][0]
+ assert_not_nil pirate.previous_changes["updated_on"][1]
+ assert_not pirate.previous_changes.key?("parrot_id")
+ assert_not pirate.previous_changes.key?("created_on")
+
+ travel(1.second)
+
+ pirate = Pirate.find_by_catchphrase("Thar She Blows!")
+ pirate.update(catchphrase: "Ahoy!")
+
+ assert_equal 2, pirate.previous_changes.size
+ assert_equal ["Thar She Blows!", "Ahoy!"], pirate.previous_changes["catchphrase"]
+ assert_not_nil pirate.previous_changes["updated_on"][0]
+ assert_not_nil pirate.previous_changes["updated_on"][1]
+ assert_not pirate.previous_changes.key?("parrot_id")
+ assert_not pirate.previous_changes.key?("created_on")
+
+ travel(1.second)
+
+ pirate = Pirate.find_by_catchphrase("Ahoy!")
+ pirate.update_attribute(:catchphrase, "Ninjas suck!")
+
+ assert_equal 2, pirate.previous_changes.size
+ assert_equal ["Ahoy!", "Ninjas suck!"], pirate.previous_changes["catchphrase"]
+ assert_not_nil pirate.previous_changes["updated_on"][0]
+ assert_not_nil pirate.previous_changes["updated_on"][1]
+ assert_not pirate.previous_changes.key?("parrot_id")
+ assert_not pirate.previous_changes.key?("created_on")
+ end
+
+ class Testings < ActiveRecord::Base; end
+ def test_field_named_field
+ ActiveRecord::Base.connection.create_table :testings do |t|
+ t.string :field
+ end
+ assert_nothing_raised do
+ Testings.new.attributes
+ end
+ ensure
+ ActiveRecord::Base.connection.drop_table :testings rescue nil
+ ActiveRecord::Base.clear_cache!
+ end
+
+ def test_datetime_attribute_can_be_updated_with_fractional_seconds
+ skip "Fractional seconds are not supported" unless subsecond_precision_supported?
+ in_time_zone "Paris" do
+ target = Class.new(ActiveRecord::Base)
+ target.table_name = "topics"
+
+ written_on = Time.utc(2012, 12, 1, 12, 0, 0).in_time_zone("Paris")
+
+ topic = target.create(written_on: written_on)
+ topic.written_on += 0.3
+
+ assert topic.written_on_changed?, "Fractional second update not detected"
+ end
+ end
+
+ def test_datetime_attribute_doesnt_change_if_zone_is_modified_in_string
+ time_in_paris = Time.utc(2014, 1, 1, 12, 0, 0).in_time_zone("Paris")
+ pirate = Pirate.create!(catchphrase: "rrrr", created_on: time_in_paris)
+
+ pirate.created_on = pirate.created_on.in_time_zone("Tokyo").to_s
+ assert_not_predicate pirate, :created_on_changed?
+ end
+
+ test "partial insert" do
+ with_partial_writes Person do
+ jon = nil
+ assert_sql(/first_name/i) do
+ jon = Person.create! first_name: "Jon"
+ end
+
+ assert ActiveRecord::SQLCounter.log_all.none? { |sql| sql.include?("followers_count") }
+
+ jon.reload
+ assert_equal "Jon", jon.first_name
+ assert_equal 0, jon.followers_count
+ assert_not_nil jon.id
+ end
+ end
+
+ test "partial insert with empty values" do
+ with_partial_writes Aircraft do
+ a = Aircraft.create!
+ a.reload
+ assert_not_nil a.id
+ end
+ end
+
+ test "in place mutation detection" do
+ pirate = Pirate.create!(catchphrase: "arrrr")
+ pirate.catchphrase << " matey!"
+
+ assert_predicate pirate, :catchphrase_changed?
+ expected_changes = {
+ "catchphrase" => ["arrrr", "arrrr matey!"]
+ }
+ assert_equal(expected_changes, pirate.changes)
+ assert_equal("arrrr", pirate.catchphrase_was)
+ assert pirate.catchphrase_changed?(from: "arrrr")
+ assert_not pirate.catchphrase_changed?(from: "anything else")
+ assert_includes pirate.changed_attributes, :catchphrase
+
+ pirate.save!
+ pirate.reload
+
+ assert_equal "arrrr matey!", pirate.catchphrase
+ assert_not_predicate pirate, :changed?
+ end
+
+ test "in place mutation for binary" do
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = :binaries
+ serialize :data
+ end
+
+ binary = klass.create!(data: "\\\\foo")
+
+ assert_not_predicate binary, :changed?
+
+ binary.data = binary.data.dup
+
+ assert_not_predicate binary, :changed?
+
+ binary = klass.last
+
+ assert_not_predicate binary, :changed?
+
+ binary.data << "bar"
+
+ assert_predicate binary, :changed?
+ end
+
+ test "changes is correct for subclass" do
+ foo = Class.new(Pirate) do
+ def catchphrase
+ super.upcase
+ end
+ end
+
+ pirate = foo.create!(catchphrase: "arrrr")
+
+ new_catchphrase = "arrrr matey!"
+
+ pirate.catchphrase = new_catchphrase
+ assert_predicate pirate, :catchphrase_changed?
+
+ expected_changes = {
+ "catchphrase" => ["arrrr", new_catchphrase]
+ }
+
+ assert_equal new_catchphrase.upcase, pirate.catchphrase
+ assert_equal expected_changes, pirate.changes
+ end
+
+ test "changes is correct if override attribute reader" do
+ pirate = Pirate.create!(catchphrase: "arrrr")
+ def pirate.catchphrase
+ super.upcase
+ end
+
+ new_catchphrase = "arrrr matey!"
+
+ pirate.catchphrase = new_catchphrase
+ assert_predicate pirate, :catchphrase_changed?
+
+ expected_changes = {
+ "catchphrase" => ["arrrr", new_catchphrase]
+ }
+
+ assert_equal new_catchphrase.upcase, pirate.catchphrase
+ assert_equal expected_changes, pirate.changes
+ end
+
+ test "attribute_changed? doesn't compute in-place changes for unrelated attributes" do
+ test_type_class = Class.new(ActiveRecord::Type::Value) do
+ define_method(:changed_in_place?) do |*|
+ raise
+ end
+ end
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "people"
+ attribute :foo, test_type_class.new
+ end
+
+ model = klass.new(first_name: "Jim")
+ assert_predicate model, :first_name_changed?
+ end
+
+ test "attribute_will_change! doesn't try to save non-persistable attributes" do
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "people"
+ attribute :non_persisted_attribute, :string
+ end
+
+ record = klass.new(first_name: "Sean")
+ record.non_persisted_attribute_will_change!
+
+ assert_predicate record, :non_persisted_attribute_changed?
+ assert record.save
+ end
+
+ test "virtual attributes are not written with partial_writes off" do
+ with_partial_writes(ActiveRecord::Base, false) do
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "people"
+ attribute :non_persisted_attribute, :string
+ end
+
+ record = klass.new(first_name: "Sean")
+ record.non_persisted_attribute_will_change!
+
+ assert record.save
+
+ record.non_persisted_attribute_will_change!
+
+ assert record.save
+ end
+ end
+
+ test "mutating and then assigning doesn't remove the change" do
+ pirate = Pirate.create!(catchphrase: "arrrr")
+ pirate.catchphrase << " matey!"
+ pirate.catchphrase = "arrrr matey!"
+
+ assert pirate.catchphrase_changed?(from: "arrrr", to: "arrrr matey!")
+ end
+
+ test "getters with side effects are allowed" do
+ klass = Class.new(Pirate) do
+ def catchphrase
+ if super.blank?
+ update_attribute(:catchphrase, "arr") # what could possibly go wrong?
+ end
+ super
+ end
+ end
+
+ pirate = klass.create!(catchphrase: "lol")
+ pirate.update_attribute(:catchphrase, nil)
+
+ assert_equal "arr", pirate.catchphrase
+ end
+
+ test "attributes assigned but not selected are dirty" do
+ person = Person.select(:id).first
+ assert_not_predicate person, :changed?
+
+ person.first_name = "Sean"
+ assert_predicate person, :changed?
+
+ person.first_name = nil
+ assert_predicate person, :changed?
+ end
+
+ test "attributes not selected are still missing after save" do
+ person = Person.select(:id).first
+ assert_raises(ActiveModel::MissingAttributeError) { person.first_name }
+ assert person.save # calls forget_attribute_assignments
+ assert_raises(ActiveModel::MissingAttributeError) { person.first_name }
+ end
+
+ test "saved_change_to_attribute? returns whether a change occurred in the last save" do
+ person = Person.create!(first_name: "Sean")
+
+ assert_predicate person, :saved_change_to_first_name?
+ assert_not_predicate person, :saved_change_to_gender?
+ assert person.saved_change_to_first_name?(from: nil, to: "Sean")
+ assert person.saved_change_to_first_name?(from: nil)
+ assert person.saved_change_to_first_name?(to: "Sean")
+ assert_not person.saved_change_to_first_name?(from: "Jim", to: "Sean")
+ assert_not person.saved_change_to_first_name?(from: "Jim")
+ assert_not person.saved_change_to_first_name?(to: "Jim")
+ end
+
+ test "saved_change_to_attribute returns the change that occurred in the last save" do
+ person = Person.create!(first_name: "Sean", gender: "M")
+
+ assert_equal [nil, "Sean"], person.saved_change_to_first_name
+ assert_equal [nil, "M"], person.saved_change_to_gender
+
+ person.update(first_name: "Jim")
+
+ assert_equal ["Sean", "Jim"], person.saved_change_to_first_name
+ assert_nil person.saved_change_to_gender
+ end
+
+ test "attribute_before_last_save returns the original value before saving" do
+ person = Person.create!(first_name: "Sean", gender: "M")
+
+ assert_nil person.first_name_before_last_save
+ assert_nil person.gender_before_last_save
+
+ person.first_name = "Jim"
+
+ assert_nil person.first_name_before_last_save
+ assert_nil person.gender_before_last_save
+
+ person.save
+
+ assert_equal "Sean", person.first_name_before_last_save
+ assert_equal "M", person.gender_before_last_save
+ end
+
+ test "saved_changes? returns whether the last call to save changed anything" do
+ person = Person.create!(first_name: "Sean")
+
+ assert_predicate person, :saved_changes?
+
+ person.save
+
+ assert_not_predicate person, :saved_changes?
+ end
+
+ test "saved_changes returns a hash of all the changes that occurred" do
+ person = Person.create!(first_name: "Sean", gender: "M")
+
+ assert_equal [nil, "Sean"], person.saved_changes[:first_name]
+ assert_equal [nil, "M"], person.saved_changes[:gender]
+ assert_equal %w(id first_name gender created_at updated_at).sort, person.saved_changes.keys.sort
+
+ travel(1.second) do
+ person.update(first_name: "Jim")
+ end
+
+ assert_equal ["Sean", "Jim"], person.saved_changes[:first_name]
+ assert_equal %w(first_name lock_version updated_at).sort, person.saved_changes.keys.sort
+ end
+
+ test "changed? in after callbacks returns false" do
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "people"
+
+ after_save do
+ raise "changed? should be false" if changed?
+ raise "has_changes_to_save? should be false" if has_changes_to_save?
+ raise "saved_changes? should be true" unless saved_changes?
+ raise "id_in_database should not be nil" if id_in_database.nil?
+ end
+ end
+
+ person = klass.create!(first_name: "Sean")
+ assert_not_predicate person, :changed?
+ end
+
+ test "changed? in around callbacks after yield returns false" do
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "people"
+
+ around_create :check_around
+
+ def check_around
+ yield
+ raise "changed? should be false" if changed?
+ raise "has_changes_to_save? should be false" if has_changes_to_save?
+ raise "saved_changes? should be true" unless saved_changes?
+ raise "id_in_database should not be nil" if id_in_database.nil?
+ end
+ end
+
+ person = klass.create!(first_name: "Sean")
+ assert_not_predicate person, :changed?
+ end
+
+ private
+ def with_partial_writes(klass, on = true)
+ old = klass.partial_writes?
+ klass.partial_writes = on
+ yield
+ ensure
+ klass.partial_writes = old
+ end
+
+ def check_pirate_after_save_failure(pirate)
+ assert_predicate pirate, :changed?
+ assert_predicate pirate, :parrot_id_changed?
+ assert_equal %w(parrot_id), pirate.changed
+ assert_nil pirate.parrot_id_was
+ end
+end
diff --git a/activerecord/test/cases/disconnected_test.rb b/activerecord/test/cases/disconnected_test.rb
new file mode 100644
index 0000000000..533665d0f4
--- /dev/null
+++ b/activerecord/test/cases/disconnected_test.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class TestRecord < ActiveRecord::Base
+end
+
+class TestDisconnectedAdapter < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ end
+
+ teardown do
+ return if in_memory_db?
+ spec = ActiveRecord::Base.connection_config
+ ActiveRecord::Base.establish_connection(spec)
+ end
+
+ unless in_memory_db?
+ test "can't execute statements while disconnected" do
+ @connection.execute "SELECT count(*) from products"
+ @connection.disconnect!
+ assert_raises(ActiveRecord::StatementInvalid) do
+ silence_warnings do
+ @connection.execute "SELECT count(*) from products"
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/dup_test.rb b/activerecord/test/cases/dup_test.rb
new file mode 100644
index 0000000000..a2efbf89f9
--- /dev/null
+++ b/activerecord/test/cases/dup_test.rb
@@ -0,0 +1,177 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/reply"
+require "models/topic"
+require "models/movie"
+
+module ActiveRecord
+ class DupTest < ActiveRecord::TestCase
+ fixtures :topics
+
+ def test_dup
+ assert_not_predicate Topic.new.freeze.dup, :frozen?
+ end
+
+ def test_not_readonly
+ topic = Topic.first
+
+ duped = topic.dup
+ assert_not duped.readonly?, "should not be readonly"
+ end
+
+ def test_is_readonly
+ topic = Topic.first
+ topic.readonly!
+
+ duped = topic.dup
+ assert duped.readonly?, "should be readonly"
+ end
+
+ def test_dup_not_persisted
+ topic = Topic.first
+ duped = topic.dup
+
+ assert_not duped.persisted?, "topic not persisted"
+ assert duped.new_record?, "topic is new"
+ end
+
+ def test_dup_not_destroyed
+ topic = Topic.first
+ topic.destroy
+
+ duped = topic.dup
+ assert_not_predicate duped, :destroyed?
+ end
+
+ def test_dup_has_no_id
+ topic = Topic.first
+ duped = topic.dup
+ assert_nil duped.id
+ end
+
+ def test_dup_with_modified_attributes
+ topic = Topic.first
+ topic.author_name = "Aaron"
+ duped = topic.dup
+ assert_equal "Aaron", duped.author_name
+ end
+
+ def test_dup_with_changes
+ dbtopic = Topic.first
+ topic = Topic.new
+
+ topic.attributes = dbtopic.attributes.except("id")
+
+ # duped has no timestamp values
+ duped = dbtopic.dup
+
+ # clear topic timestamp values
+ topic.send(:clear_timestamp_attributes)
+
+ assert_equal topic.changes, duped.changes
+ end
+
+ def test_dup_topics_are_independent
+ topic = Topic.first
+ topic.author_name = "Aaron"
+ duped = topic.dup
+
+ duped.author_name = "meow"
+
+ assert_not_equal topic.changes, duped.changes
+ end
+
+ def test_dup_attributes_are_independent
+ topic = Topic.first
+ duped = topic.dup
+
+ duped.author_name = "meow"
+ topic.author_name = "Aaron"
+
+ assert_equal "Aaron", topic.author_name
+ assert_equal "meow", duped.author_name
+ end
+
+ def test_dup_timestamps_are_cleared
+ topic = Topic.first
+ assert_not_nil topic.updated_at
+ assert_not_nil topic.created_at
+
+ # temporary change to the topic object
+ topic.updated_at -= 3.days
+
+ # dup should not preserve the timestamps if present
+ new_topic = topic.dup
+ assert_nil new_topic.updated_at
+ assert_nil new_topic.created_at
+
+ new_topic.save
+ assert_not_nil new_topic.updated_at
+ assert_not_nil new_topic.created_at
+ end
+
+ def test_dup_after_initialize_callbacks
+ topic = Topic.new
+ assert Topic.after_initialize_called
+ Topic.after_initialize_called = false
+ topic.dup
+ assert Topic.after_initialize_called
+ end
+
+ def test_dup_validity_is_independent
+ repair_validations(Topic) do
+ Topic.validates_presence_of :title
+ topic = Topic.new("title" => "Literature")
+ topic.valid?
+
+ duped = topic.dup
+ duped.title = nil
+ assert_predicate duped, :invalid?
+
+ topic.title = nil
+ duped.title = "Mathematics"
+ assert_predicate topic, :invalid?
+ assert_predicate duped, :valid?
+ end
+ end
+
+ def test_dup_with_default_scope
+ prev_default_scopes = Topic.default_scopes
+ Topic.default_scopes = [proc { Topic.where(approved: true) }]
+ topic = Topic.new(approved: false)
+ assert_not topic.dup.approved?, "should not be overridden by default scopes"
+ ensure
+ Topic.default_scopes = prev_default_scopes
+ end
+
+ def test_dup_without_primary_key
+ skip if current_adapter?(:OracleAdapter)
+
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "parrots_pirates"
+ end
+
+ record = klass.create!
+
+ assert_nothing_raised do
+ record.dup
+ end
+ end
+
+ def test_dup_record_not_persisted_after_rollback_transaction
+ movie = Movie.new(name: "test")
+
+ assert_raises(ActiveRecord::RecordInvalid) do
+ Movie.transaction do
+ movie.save!
+ duped = movie.dup
+ duped.name = nil
+ duped.save!
+ end
+ end
+
+ assert_not movie.persisted?
+ end
+ end
+end
diff --git a/activerecord/test/cases/enum_test.rb b/activerecord/test/cases/enum_test.rb
new file mode 100644
index 0000000000..8a0f6f6df1
--- /dev/null
+++ b/activerecord/test/cases/enum_test.rb
@@ -0,0 +1,563 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/author"
+require "models/book"
+
+class EnumTest < ActiveRecord::TestCase
+ fixtures :books, :authors, :author_addresses
+
+ setup do
+ @book = books(:awdr)
+ end
+
+ test "query state by predicate" do
+ assert_predicate @book, :published?
+ assert_not_predicate @book, :written?
+ assert_not_predicate @book, :proposed?
+
+ assert_predicate @book, :read?
+ assert_predicate @book, :in_english?
+ assert_predicate @book, :author_visibility_visible?
+ assert_predicate @book, :illustrator_visibility_visible?
+ assert_predicate @book, :with_medium_font_size?
+ assert_predicate @book, :medium_to_read?
+ end
+
+ test "query state with strings" do
+ assert_equal "published", @book.status
+ assert_equal "read", @book.read_status
+ assert_equal "english", @book.language
+ assert_equal "visible", @book.author_visibility
+ assert_equal "visible", @book.illustrator_visibility
+ assert_equal "medium", @book.difficulty
+ end
+
+ test "find via scope" do
+ assert_equal @book, Book.published.first
+ assert_equal @book, Book.read.first
+ assert_equal @book, Book.in_english.first
+ assert_equal @book, Book.author_visibility_visible.first
+ assert_equal @book, Book.illustrator_visibility_visible.first
+ assert_equal @book, Book.medium_to_read.first
+ assert_equal books(:ddd), Book.forgotten.first
+ assert_equal books(:rfr), authors(:david).unpublished_books.first
+ end
+
+ test "find via where with values" do
+ published, written = Book.statuses[:published], Book.statuses[:written]
+
+ assert_equal @book, Book.where(status: published).first
+ assert_not_equal @book, Book.where(status: written).first
+ assert_equal @book, Book.where(status: [published]).first
+ assert_not_equal @book, Book.where(status: [written]).first
+ assert_not_equal @book, Book.where("status <> ?", published).first
+ assert_equal @book, Book.where("status <> ?", written).first
+ end
+
+ test "find via where with symbols" do
+ assert_equal @book, Book.where(status: :published).first
+ assert_not_equal @book, Book.where(status: :written).first
+ assert_equal @book, Book.where(status: [:published]).first
+ assert_not_equal @book, Book.where(status: [:written]).first
+ assert_not_equal @book, Book.where.not(status: :published).first
+ assert_equal @book, Book.where.not(status: :written).first
+ assert_equal books(:ddd), Book.where(read_status: :forgotten).first
+ end
+
+ test "find via where with strings" do
+ assert_equal @book, Book.where(status: "published").first
+ assert_not_equal @book, Book.where(status: "written").first
+ assert_equal @book, Book.where(status: ["published"]).first
+ assert_not_equal @book, Book.where(status: ["written"]).first
+ assert_not_equal @book, Book.where.not(status: "published").first
+ assert_equal @book, Book.where.not(status: "written").first
+ assert_equal books(:ddd), Book.where(read_status: "forgotten").first
+ end
+
+ test "build from scope" do
+ assert_predicate Book.written.build, :written?
+ assert_not_predicate Book.written.build, :proposed?
+ end
+
+ test "build from where" do
+ assert_predicate Book.where(status: Book.statuses[:written]).build, :written?
+ assert_not_predicate Book.where(status: Book.statuses[:written]).build, :proposed?
+ assert_predicate Book.where(status: :written).build, :written?
+ assert_not_predicate Book.where(status: :written).build, :proposed?
+ assert_predicate Book.where(status: "written").build, :written?
+ assert_not_predicate Book.where(status: "written").build, :proposed?
+ end
+
+ test "update by declaration" do
+ @book.written!
+ assert_predicate @book, :written?
+ @book.in_english!
+ assert_predicate @book, :in_english?
+ @book.author_visibility_visible!
+ assert_predicate @book, :author_visibility_visible?
+ end
+
+ test "update by setter" do
+ @book.update! status: :written
+ assert_predicate @book, :written?
+ end
+
+ test "enum methods are overwritable" do
+ assert_equal "do publish work...", @book.published!
+ assert_predicate @book, :published?
+ end
+
+ test "direct assignment" do
+ @book.status = :written
+ assert_predicate @book, :written?
+ end
+
+ test "assign string value" do
+ @book.status = "written"
+ assert_predicate @book, :written?
+ end
+
+ test "enum changed attributes" do
+ old_status = @book.status
+ old_language = @book.language
+ @book.status = :proposed
+ @book.language = :spanish
+ assert_equal old_status, @book.changed_attributes[:status]
+ assert_equal old_language, @book.changed_attributes[:language]
+ end
+
+ test "enum changes" do
+ old_status = @book.status
+ old_language = @book.language
+ @book.status = :proposed
+ @book.language = :spanish
+ assert_equal [old_status, "proposed"], @book.changes[:status]
+ assert_equal [old_language, "spanish"], @book.changes[:language]
+ end
+
+ test "enum attribute was" do
+ old_status = @book.status
+ old_language = @book.language
+ @book.status = :published
+ @book.language = :spanish
+ assert_equal old_status, @book.attribute_was(:status)
+ assert_equal old_language, @book.attribute_was(:language)
+ end
+
+ test "enum attribute changed" do
+ @book.status = :proposed
+ @book.language = :french
+ assert @book.attribute_changed?(:status)
+ assert @book.attribute_changed?(:language)
+ end
+
+ test "enum attribute changed to" do
+ @book.status = :proposed
+ @book.language = :french
+ assert @book.attribute_changed?(:status, to: "proposed")
+ assert @book.attribute_changed?(:language, to: "french")
+ end
+
+ test "enum attribute changed from" do
+ old_status = @book.status
+ old_language = @book.language
+ @book.status = :proposed
+ @book.language = :french
+ assert @book.attribute_changed?(:status, from: old_status)
+ assert @book.attribute_changed?(:language, from: old_language)
+ end
+
+ test "enum attribute changed from old status to new status" do
+ old_status = @book.status
+ old_language = @book.language
+ @book.status = :proposed
+ @book.language = :french
+ assert @book.attribute_changed?(:status, from: old_status, to: "proposed")
+ assert @book.attribute_changed?(:language, from: old_language, to: "french")
+ end
+
+ test "enum didn't change" do
+ old_status = @book.status
+ @book.status = old_status
+ assert_not @book.attribute_changed?(:status)
+ end
+
+ test "persist changes that are dirty" do
+ @book.status = :proposed
+ assert @book.attribute_changed?(:status)
+ @book.status = :written
+ assert @book.attribute_changed?(:status)
+ end
+
+ test "reverted changes that are not dirty" do
+ old_status = @book.status
+ @book.status = :proposed
+ assert @book.attribute_changed?(:status)
+ @book.status = old_status
+ assert_not @book.attribute_changed?(:status)
+ end
+
+ test "reverted changes are not dirty going from nil to value and back" do
+ book = Book.create!(nullable_status: nil)
+
+ book.nullable_status = :married
+ assert book.attribute_changed?(:nullable_status)
+
+ book.nullable_status = nil
+ assert_not book.attribute_changed?(:nullable_status)
+ end
+
+ test "assign non existing value raises an error" do
+ e = assert_raises(ArgumentError) do
+ @book.status = :unknown
+ end
+ assert_equal "'unknown' is not a valid status", e.message
+ end
+
+ test "NULL values from database should be casted to nil" do
+ Book.where(id: @book.id).update_all("status = NULL")
+ assert_nil @book.reload.status
+ end
+
+ test "assign nil value" do
+ @book.status = nil
+ assert_nil @book.status
+ end
+
+ test "assign empty string value" do
+ @book.status = ""
+ assert_nil @book.status
+ end
+
+ test "assign long empty string value" do
+ @book.status = " "
+ assert_nil @book.status
+ end
+
+ test "constant to access the mapping" do
+ assert_equal 0, Book.statuses[:proposed]
+ assert_equal 1, Book.statuses["written"]
+ assert_equal 2, Book.statuses[:published]
+ end
+
+ test "building new objects with enum scopes" do
+ assert_predicate Book.written.build, :written?
+ assert_predicate Book.read.build, :read?
+ assert_predicate Book.in_spanish.build, :in_spanish?
+ assert_predicate Book.illustrator_visibility_invisible.build, :illustrator_visibility_invisible?
+ end
+
+ test "creating new objects with enum scopes" do
+ assert_predicate Book.written.create, :written?
+ assert_predicate Book.read.create, :read?
+ assert_predicate Book.in_spanish.create, :in_spanish?
+ assert_predicate Book.illustrator_visibility_invisible.create, :illustrator_visibility_invisible?
+ end
+
+ test "_before_type_cast" do
+ assert_equal 2, @book.status_before_type_cast
+ assert_equal "published", @book.status
+
+ @book.status = "published"
+
+ assert_equal "published", @book.status_before_type_cast
+ assert_equal "published", @book.status
+ end
+
+ test "invalid definition values raise an ArgumentError" do
+ e = assert_raises(ArgumentError) do
+ Class.new(ActiveRecord::Base) do
+ self.table_name = "books"
+ enum status: [proposed: 1, written: 2, published: 3]
+ end
+ end
+
+ assert_match(/must be either a hash, an array of symbols, or an array of strings./, e.message)
+
+ e = assert_raises(ArgumentError) do
+ Class.new(ActiveRecord::Base) do
+ self.table_name = "books"
+ enum status: { "" => 1, "active" => 2 }
+ end
+ end
+
+ assert_match(/Enum label name must not be blank/, e.message)
+
+ e = assert_raises(ArgumentError) do
+ Class.new(ActiveRecord::Base) do
+ self.table_name = "books"
+ enum status: ["active", ""]
+ end
+ end
+
+ assert_match(/Enum label name must not be blank/, e.message)
+ end
+
+ test "reserved enum names" do
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "books"
+ enum status: [:proposed, :written, :published]
+ end
+
+ conflicts = [
+ :column, # generates class method .columns, which conflicts with an AR method
+ :logger, # generates #logger, which conflicts with an AR method
+ :attributes, # generates #attributes=, which conflicts with an AR method
+ ]
+
+ conflicts.each_with_index do |name, i|
+ e = assert_raises(ArgumentError) do
+ klass.class_eval { enum name => ["value_#{i}"] }
+ end
+ assert_match(/You tried to define an enum named \"#{name}\" on the model/, e.message)
+ end
+ end
+
+ test "reserved enum values" do
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "books"
+ enum status: [:proposed, :written, :published]
+ end
+
+ conflicts = [
+ :new, # generates a scope that conflicts with an AR class method
+ :valid, # generates #valid?, which conflicts with an AR method
+ :save, # generates #save!, which conflicts with an AR method
+ :proposed, # same value as an existing enum
+ :public, :private, :protected, # some important methods on Module and Class
+ :name, :parent, :superclass
+ ]
+
+ conflicts.each_with_index do |value, i|
+ e = assert_raises(ArgumentError, "enum value `#{value}` should not be allowed") do
+ klass.class_eval { enum "status_#{i}" => [value] }
+ end
+ assert_match(/You tried to define an enum named .* on the model/, e.message)
+ end
+ end
+
+ test "reserved enum values for relation" do
+ relation_method_samples = [
+ :records,
+ :to_ary,
+ :scope_for_create
+ ]
+
+ relation_method_samples.each do |value|
+ e = assert_raises(ArgumentError, "enum value `#{value}` should not be allowed") do
+ Class.new(ActiveRecord::Base) do
+ self.table_name = "books"
+ enum category: [:other, value]
+ end
+ end
+ assert_match(/You tried to define an enum named .* on the model/, e.message)
+ end
+ end
+
+ test "overriding enum method should not raise" do
+ assert_nothing_raised do
+ Class.new(ActiveRecord::Base) do
+ self.table_name = "books"
+
+ def published!
+ super
+ "do publish work..."
+ end
+
+ enum status: [:proposed, :written, :published]
+
+ def written!
+ super
+ "do written work..."
+ end
+ end
+ end
+ end
+
+ test "validate uniqueness" do
+ klass = Class.new(ActiveRecord::Base) do
+ def self.name; "Book"; end
+ enum status: [:proposed, :written]
+ validates_uniqueness_of :status
+ end
+ klass.delete_all
+ klass.create!(status: "proposed")
+ book = klass.new(status: "written")
+ assert_predicate book, :valid?
+ book.status = "proposed"
+ assert_not_predicate book, :valid?
+ end
+
+ test "validate inclusion of value in array" do
+ klass = Class.new(ActiveRecord::Base) do
+ def self.name; "Book"; end
+ enum status: [:proposed, :written]
+ validates_inclusion_of :status, in: ["written"]
+ end
+ klass.delete_all
+ invalid_book = klass.new(status: "proposed")
+ assert_not_predicate invalid_book, :valid?
+ valid_book = klass.new(status: "written")
+ assert_predicate valid_book, :valid?
+ end
+
+ test "enums are distinct per class" do
+ klass1 = Class.new(ActiveRecord::Base) do
+ self.table_name = "books"
+ enum status: [:proposed, :written]
+ end
+
+ klass2 = Class.new(ActiveRecord::Base) do
+ self.table_name = "books"
+ enum status: [:drafted, :uploaded]
+ end
+
+ book1 = klass1.proposed.create!
+ book1.status = :written
+ assert_equal ["proposed", "written"], book1.status_change
+
+ book2 = klass2.drafted.create!
+ book2.status = :uploaded
+ assert_equal ["drafted", "uploaded"], book2.status_change
+ end
+
+ test "enums are inheritable" do
+ subklass1 = Class.new(Book)
+
+ subklass2 = Class.new(Book) do
+ enum status: [:drafted, :uploaded]
+ end
+
+ book1 = subklass1.proposed.create!
+ book1.status = :written
+ assert_equal ["proposed", "written"], book1.status_change
+
+ book2 = subklass2.drafted.create!
+ book2.status = :uploaded
+ assert_equal ["drafted", "uploaded"], book2.status_change
+ end
+
+ test "attempting to modify enum raises error" do
+ e = assert_raises(RuntimeError) do
+ Book.statuses["bad_enum"] = 40
+ end
+
+ assert_match(/can't modify frozen/, e.message)
+
+ e = assert_raises(RuntimeError) do
+ Book.statuses.delete("published")
+ end
+
+ assert_match(/can't modify frozen/, e.message)
+ end
+
+ test "declare multiple enums at a time" do
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "books"
+ enum status: [:proposed, :written, :published],
+ nullable_status: [:single, :married]
+ end
+
+ book1 = klass.proposed.create!
+ assert_predicate book1, :proposed?
+
+ book2 = klass.single.create!
+ assert_predicate book2, :single?
+ end
+
+ test "enum with alias_attribute" do
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "books"
+ alias_attribute :aliased_status, :status
+ enum aliased_status: [:proposed, :written, :published]
+ end
+
+ book = klass.proposed.create!
+ assert_predicate book, :proposed?
+ assert_equal "proposed", book.aliased_status
+
+ book = klass.find(book.id)
+ assert_predicate book, :proposed?
+ assert_equal "proposed", book.aliased_status
+ end
+
+ test "query state by predicate with prefix" do
+ assert_predicate @book, :author_visibility_visible?
+ assert_not_predicate @book, :author_visibility_invisible?
+ assert_predicate @book, :illustrator_visibility_visible?
+ assert_not_predicate @book, :illustrator_visibility_invisible?
+ end
+
+ test "query state by predicate with custom prefix" do
+ assert_predicate @book, :in_english?
+ assert_not_predicate @book, :in_spanish?
+ assert_not_predicate @book, :in_french?
+ end
+
+ test "query state by predicate with custom suffix" do
+ assert_predicate @book, :medium_to_read?
+ assert_not_predicate @book, :easy_to_read?
+ assert_not_predicate @book, :hard_to_read?
+ end
+
+ test "enum methods with custom suffix defined" do
+ assert_respond_to @book.class, :easy_to_read
+ assert_respond_to @book.class, :medium_to_read
+ assert_respond_to @book.class, :hard_to_read
+
+ assert_respond_to @book, :easy_to_read?
+ assert_respond_to @book, :medium_to_read?
+ assert_respond_to @book, :hard_to_read?
+
+ assert_respond_to @book, :easy_to_read!
+ assert_respond_to @book, :medium_to_read!
+ assert_respond_to @book, :hard_to_read!
+ end
+
+ test "update enum attributes with custom suffix" do
+ @book.medium_to_read!
+ assert_not_predicate @book, :easy_to_read?
+ assert_predicate @book, :medium_to_read?
+ assert_not_predicate @book, :hard_to_read?
+
+ @book.easy_to_read!
+ assert_predicate @book, :easy_to_read?
+ assert_not_predicate @book, :medium_to_read?
+ assert_not_predicate @book, :hard_to_read?
+
+ @book.hard_to_read!
+ assert_not_predicate @book, :easy_to_read?
+ assert_not_predicate @book, :medium_to_read?
+ assert_predicate @book, :hard_to_read?
+ end
+
+ test "uses default status when no status is provided in fixtures" do
+ book = books(:tlg)
+ assert book.proposed?, "expected fixture to default to proposed status"
+ assert book.in_english?, "expected fixture to default to english language"
+ end
+
+ test "uses default value from database on initialization" do
+ book = Book.new
+ assert_predicate book, :proposed?
+ end
+
+ test "uses default value from database on initialization when using custom mapping" do
+ book = Book.new
+ assert_predicate book, :hard?
+ end
+
+ test "data type of Enum type" do
+ assert_equal :integer, Book.type_for_attribute("status").type
+ end
+
+ test "scopes can be disabled" do
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "books"
+ enum status: [:proposed, :written], _scopes: false
+ end
+
+ assert_raises(NoMethodError) { klass.proposed }
+ end
+end
diff --git a/activerecord/test/cases/errors_test.rb b/activerecord/test/cases/errors_test.rb
new file mode 100644
index 0000000000..0d2be944b5
--- /dev/null
+++ b/activerecord/test/cases/errors_test.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class ErrorsTest < ActiveRecord::TestCase
+ def test_can_be_instantiated_with_no_args
+ base = ActiveRecord::ActiveRecordError
+ error_klasses = ObjectSpace.each_object(Class).select { |klass| klass < base }
+
+ (error_klasses - [ActiveRecord::AmbiguousSourceReflectionForThroughAssociation]).each do |error_klass|
+ error_klass.new.inspect
+ rescue ArgumentError
+ raise "Instance of #{error_klass} can't be initialized with no arguments"
+ end
+ end
+end
diff --git a/activerecord/test/cases/explain_subscriber_test.rb b/activerecord/test/cases/explain_subscriber_test.rb
new file mode 100644
index 0000000000..79a0630193
--- /dev/null
+++ b/activerecord/test/cases/explain_subscriber_test.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "active_record/explain_subscriber"
+require "active_record/explain_registry"
+
+if ActiveRecord::Base.connection.supports_explain?
+ class ExplainSubscriberTest < ActiveRecord::TestCase
+ SUBSCRIBER = ActiveRecord::ExplainSubscriber.new
+
+ def setup
+ ActiveRecord::ExplainRegistry.reset
+ ActiveRecord::ExplainRegistry.collect = true
+ end
+
+ def test_collects_nothing_if_the_payload_has_an_exception
+ SUBSCRIBER.finish(nil, nil, exception: Exception.new)
+ assert_empty queries
+ end
+
+ def test_collects_nothing_for_ignored_payloads
+ ActiveRecord::ExplainSubscriber::IGNORED_PAYLOADS.each do |ip|
+ SUBSCRIBER.finish(nil, nil, name: ip)
+ end
+ assert_empty queries
+ end
+
+ def test_collects_nothing_if_collect_is_false
+ ActiveRecord::ExplainRegistry.collect = false
+ SUBSCRIBER.finish(nil, nil, name: "SQL", sql: "select 1 from users", binds: [1, 2])
+ assert_empty queries
+ end
+
+ def test_collects_pairs_of_queries_and_binds
+ sql = "select 1 from users"
+ binds = [1, 2]
+ SUBSCRIBER.finish(nil, nil, name: "SQL", sql: sql, binds: binds)
+ assert_equal 1, queries.size
+ assert_equal sql, queries[0][0]
+ assert_equal binds, queries[0][1]
+ end
+
+ def test_collects_nothing_if_the_statement_is_not_explainable
+ SUBSCRIBER.finish(nil, nil, name: "SQL", sql: "SHOW max_identifier_length")
+ assert_empty queries
+ end
+
+ def test_collects_nothing_if_the_statement_is_only_partially_matched
+ SUBSCRIBER.finish(nil, nil, name: "SQL", sql: "select_db yo_mama")
+ assert_empty queries
+ end
+
+ def test_collects_cte_queries
+ SUBSCRIBER.finish(nil, nil, name: "SQL", sql: "with s as (values(3)) select 1 from s")
+ assert_equal 1, queries.size
+ end
+
+ teardown do
+ ActiveRecord::ExplainRegistry.reset
+ end
+
+ def queries
+ ActiveRecord::ExplainRegistry.queries
+ end
+ end
+end
diff --git a/activerecord/test/cases/explain_test.rb b/activerecord/test/cases/explain_test.rb
new file mode 100644
index 0000000000..a0e75f4e89
--- /dev/null
+++ b/activerecord/test/cases/explain_test.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/car"
+
+if ActiveRecord::Base.connection.supports_explain?
+ class ExplainTest < ActiveRecord::TestCase
+ fixtures :cars
+
+ def base
+ ActiveRecord::Base
+ end
+
+ def connection
+ base.connection
+ end
+
+ def test_relation_explain
+ message = Car.where(name: "honda").explain
+ assert_match(/^EXPLAIN for:/, message)
+ end
+
+ def test_collecting_queries_for_explain
+ queries = ActiveRecord::Base.collecting_queries_for_explain do
+ Car.where(name: "honda").to_a
+ end
+
+ sql, binds = queries[0]
+ assert_match "SELECT", sql
+ if binds.any?
+ assert_equal 1, binds.length
+ assert_equal "honda", binds.last.value
+ else
+ assert_match "honda", sql
+ end
+ end
+
+ def test_exec_explain_with_no_binds
+ sqls = %w(foo bar)
+ binds = [[], []]
+ queries = sqls.zip(binds)
+
+ stub_explain_for_query_plans do
+ expected = sqls.map { |sql| "EXPLAIN for: #{sql}\nquery plan #{sql}" }.join("\n")
+ assert_equal expected, base.exec_explain(queries)
+ end
+ end
+
+ def test_exec_explain_with_binds
+ sqls = %w(foo bar)
+ binds = [[bind_param("wadus", 1)], [bind_param("chaflan", 2)]]
+ queries = sqls.zip(binds)
+
+ stub_explain_for_query_plans(["query plan foo\n", "query plan bar\n"]) do
+ expected = <<~SQL
+ EXPLAIN for: #{sqls[0]} [["wadus", 1]]
+ query plan foo
+
+ EXPLAIN for: #{sqls[1]} [["chaflan", 2]]
+ query plan bar
+ SQL
+ assert_equal expected, base.exec_explain(queries)
+ end
+ end
+
+ def test_unsupported_connection_adapter
+ connection.stub(:supports_explain?, false) do
+ assert_not_called(base.logger, :warn) do
+ Car.where(name: "honda").to_a
+ end
+ end
+ end
+
+ private
+
+ def stub_explain_for_query_plans(query_plans = ["query plan foo", "query plan bar"])
+ explain_called = 0
+
+ connection.stub(:explain, proc { explain_called += 1; query_plans[explain_called - 1] }) do
+ yield
+ end
+ end
+
+ def bind_param(name, value)
+ ActiveRecord::Relation::QueryAttribute.new(name, value, ActiveRecord::Type::Value.new)
+ end
+ end
+end
diff --git a/activerecord/test/cases/filter_attributes_test.rb b/activerecord/test/cases/filter_attributes_test.rb
new file mode 100644
index 0000000000..2f4c9b0ef7
--- /dev/null
+++ b/activerecord/test/cases/filter_attributes_test.rb
@@ -0,0 +1,132 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/admin"
+require "models/admin/user"
+require "models/admin/account"
+require "models/user"
+require "pp"
+
+class FilterAttributesTest < ActiveRecord::TestCase
+ fixtures :"admin/users", :"admin/accounts"
+
+ setup do
+ @previous_filter_attributes = ActiveRecord::Base.filter_attributes
+ ActiveRecord::Base.filter_attributes = [:name]
+ end
+
+ teardown do
+ ActiveRecord::Base.filter_attributes = @previous_filter_attributes
+ end
+
+ test "filter_attributes" do
+ Admin::User.all.each do |user|
+ assert_includes user.inspect, "name: [FILTERED]"
+ assert_equal 1, user.inspect.scan("[FILTERED]").length
+ end
+
+ Admin::Account.all.each do |account|
+ assert_includes account.inspect, "name: [FILTERED]"
+ assert_equal 1, account.inspect.scan("[FILTERED]").length
+ end
+ end
+
+ test "string filter_attributes perform pertial match" do
+ ActiveRecord::Base.filter_attributes = ["n"]
+ Admin::Account.all.each do |account|
+ assert_includes account.inspect, "name: [FILTERED]"
+ assert_equal 1, account.inspect.scan("[FILTERED]").length
+ end
+ end
+
+ test "regex filter_attributes are accepted" do
+ ActiveRecord::Base.filter_attributes = [/\An\z/]
+ account = Admin::Account.find_by(name: "37signals")
+ assert_includes account.inspect, 'name: "37signals"'
+ assert_equal 0, account.inspect.scan("[FILTERED]").length
+
+ ActiveRecord::Base.filter_attributes = [/\An/]
+ account = Admin::Account.find_by(name: "37signals")
+ assert_includes account.reload.inspect, "name: [FILTERED]"
+ assert_equal 1, account.inspect.scan("[FILTERED]").length
+ end
+
+ test "proc filter_attributes are accepted" do
+ ActiveRecord::Base.filter_attributes = [ lambda { |key, value| value.reverse! if key == "name" } ]
+ account = Admin::Account.find_by(name: "37signals")
+ assert_includes account.inspect, 'name: "slangis73"'
+ end
+
+ test "filter_attributes could be overwritten by models" do
+ Admin::Account.all.each do |account|
+ assert_includes account.inspect, "name: [FILTERED]"
+ assert_equal 1, account.inspect.scan("[FILTERED]").length
+ end
+
+ begin
+ Admin::Account.filter_attributes = []
+
+ # Above changes should not impact other models
+ Admin::User.all.each do |user|
+ assert_includes user.inspect, "name: [FILTERED]"
+ assert_equal 1, user.inspect.scan("[FILTERED]").length
+ end
+
+ Admin::Account.all.each do |account|
+ assert_not_includes account.inspect, "name: [FILTERED]"
+ assert_equal 0, account.inspect.scan("[FILTERED]").length
+ end
+ ensure
+ Admin::Account.remove_instance_variable(:@filter_attributes)
+ end
+ end
+
+ test "filter_attributes should not filter nil value" do
+ account = Admin::Account.new
+
+ assert_includes account.inspect, "name: nil"
+ assert_not_includes account.inspect, "name: [FILTERED]"
+ assert_equal 0, account.inspect.scan("[FILTERED]").length
+ end
+
+ test "filter_attributes should handle [FILTERED] value properly" do
+ User.filter_attributes = ["auth"]
+ user = User.new(token: "[FILTERED]", auth_token: "[FILTERED]")
+
+ assert_includes user.inspect, "auth_token: [FILTERED]"
+ assert_includes user.inspect, 'token: "[FILTERED]"'
+ ensure
+ User.remove_instance_variable(:@filter_attributes)
+ end
+
+ test "filter_attributes on pretty_print" do
+ user = admin_users(:david)
+ actual = "".dup
+ PP.pp(user, StringIO.new(actual))
+
+ assert_includes actual, "name: [FILTERED]"
+ assert_equal 1, actual.scan("[FILTERED]").length
+ end
+
+ test "filter_attributes on pretty_print should not filter nil value" do
+ user = Admin::User.new
+ actual = "".dup
+ PP.pp(user, StringIO.new(actual))
+
+ assert_includes actual, "name: nil"
+ assert_not_includes actual, "name: [FILTERED]"
+ assert_equal 0, actual.scan("[FILTERED]").length
+ end
+
+ test "filter_attributes on pretty_print should handle [FILTERED] value properly" do
+ User.filter_attributes = ["auth"]
+ user = User.new(token: "[FILTERED]", auth_token: "[FILTERED]")
+ actual = "".dup
+ PP.pp(user, StringIO.new(actual))
+
+ assert_includes actual, "auth_token: [FILTERED]"
+ assert_includes actual, 'token: "[FILTERED]"'
+ ensure
+ User.remove_instance_variable(:@filter_attributes)
+ end
+end
diff --git a/activerecord/test/cases/finder_respond_to_test.rb b/activerecord/test/cases/finder_respond_to_test.rb
new file mode 100644
index 0000000000..66413a98e4
--- /dev/null
+++ b/activerecord/test/cases/finder_respond_to_test.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+
+class FinderRespondToTest < ActiveRecord::TestCase
+ fixtures :topics
+
+ def test_should_preserve_normal_respond_to_behaviour_on_base
+ assert_respond_to ActiveRecord::Base, :new
+ assert_not_respond_to ActiveRecord::Base, :find_by_something
+ end
+
+ def test_should_preserve_normal_respond_to_behaviour_and_respond_to_newly_added_method
+ Topic.singleton_class.define_method(:method_added_for_finder_respond_to_test) { }
+ assert_respond_to Topic, :method_added_for_finder_respond_to_test
+ ensure
+ Topic.singleton_class.remove_method :method_added_for_finder_respond_to_test
+ end
+
+ def test_should_preserve_normal_respond_to_behaviour_and_respond_to_standard_object_method
+ assert_respond_to Topic, :to_s
+ end
+
+ def test_should_respond_to_find_by_one_attribute_before_caching
+ ensure_topic_method_is_not_cached(:find_by_title)
+ assert_respond_to Topic, :find_by_title
+ end
+
+ def test_should_respond_to_find_by_with_bang
+ ensure_topic_method_is_not_cached(:find_by_title!)
+ assert_respond_to Topic, :find_by_title!
+ end
+
+ def test_should_respond_to_find_by_two_attributes
+ ensure_topic_method_is_not_cached(:find_by_title_and_author_name)
+ assert_respond_to Topic, :find_by_title_and_author_name
+ end
+
+ def test_should_respond_to_find_all_by_an_aliased_attribute
+ ensure_topic_method_is_not_cached(:find_by_heading)
+ assert_respond_to Topic, :find_by_heading
+ end
+
+ def test_should_not_respond_to_find_by_one_missing_attribute
+ assert_not_respond_to Topic, :find_by_undertitle
+ end
+
+ def test_should_not_respond_to_find_by_invalid_method_syntax
+ assert_not_respond_to Topic, :fail_to_find_by_title
+ assert_not_respond_to Topic, :find_by_title?
+ assert_not_respond_to Topic, :fail_to_find_or_create_by_title
+ assert_not_respond_to Topic, :find_or_create_by_title?
+ end
+
+ private
+
+ def ensure_topic_method_is_not_cached(method_id)
+ Topic.singleton_class.remove_method method_id if Topic.public_methods.include? method_id
+ end
+end
diff --git a/activerecord/test/cases/finder_test.rb b/activerecord/test/cases/finder_test.rb
new file mode 100644
index 0000000000..961ae03a4c
--- /dev/null
+++ b/activerecord/test/cases/finder_test.rb
@@ -0,0 +1,1422 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+require "models/author"
+require "models/categorization"
+require "models/comment"
+require "models/company"
+require "models/tagging"
+require "models/topic"
+require "models/reply"
+require "models/rating"
+require "models/entrant"
+require "models/project"
+require "models/developer"
+require "models/computer"
+require "models/customer"
+require "models/toy"
+require "models/matey"
+require "models/dog"
+require "models/car"
+require "models/tyre"
+require "models/subscriber"
+
+class FinderTest < ActiveRecord::TestCase
+ fixtures :companies, :topics, :entrants, :developers, :developers_projects, :posts, :comments, :accounts, :authors, :author_addresses, :customers, :categories, :categorizations, :cars
+
+ def test_find_by_id_with_hash
+ assert_nothing_raised do
+ Post.find_by_id(limit: 1)
+ end
+ end
+
+ def test_find_by_title_and_id_with_hash
+ assert_nothing_raised do
+ Post.find_by_title_and_id("foo", limit: 1)
+ end
+ end
+
+ def test_find
+ assert_equal(topics(:first).title, Topic.find(1).title)
+ end
+
+ def test_find_with_proc_parameter_and_block
+ exception = assert_raises(RuntimeError) do
+ Topic.all.find(-> { raise "should happen" }) { |e| e.title == "non-existing-title" }
+ end
+ assert_equal "should happen", exception.message
+
+ assert_nothing_raised do
+ Topic.all.find(-> { raise "should not happen" }) { |e| e.title == topics(:first).title }
+ end
+ end
+
+ def test_find_with_ids_returning_ordered
+ records = Topic.find([4, 2, 5])
+ assert_equal "The Fourth Topic of the day", records[0].title
+ assert_equal "The Second Topic of the day", records[1].title
+ assert_equal "The Fifth Topic of the day", records[2].title
+
+ records = Topic.find(4, 2, 5)
+ assert_equal "The Fourth Topic of the day", records[0].title
+ assert_equal "The Second Topic of the day", records[1].title
+ assert_equal "The Fifth Topic of the day", records[2].title
+
+ records = Topic.find(["4", "2", "5"])
+ assert_equal "The Fourth Topic of the day", records[0].title
+ assert_equal "The Second Topic of the day", records[1].title
+ assert_equal "The Fifth Topic of the day", records[2].title
+
+ records = Topic.find("4", "2", "5")
+ assert_equal "The Fourth Topic of the day", records[0].title
+ assert_equal "The Second Topic of the day", records[1].title
+ assert_equal "The Fifth Topic of the day", records[2].title
+ end
+
+ def test_find_with_ids_and_order_clause
+ # The order clause takes precedence over the informed ids
+ records = Topic.order(:author_name).find([5, 3, 1])
+ assert_equal "The Third Topic of the day", records[0].title
+ assert_equal "The First Topic", records[1].title
+ assert_equal "The Fifth Topic of the day", records[2].title
+
+ records = Topic.order(:id).find([5, 3, 1])
+ assert_equal "The First Topic", records[0].title
+ assert_equal "The Third Topic of the day", records[1].title
+ assert_equal "The Fifth Topic of the day", records[2].title
+ end
+
+ def test_find_with_ids_with_limit_and_order_clause
+ # The order clause takes precedence over the informed ids
+ records = Topic.limit(2).order(:id).find([5, 3, 1])
+ assert_equal 2, records.size
+ assert_equal "The First Topic", records[0].title
+ assert_equal "The Third Topic of the day", records[1].title
+ end
+
+ def test_find_with_ids_and_limit
+ records = Topic.limit(3).find([3, 2, 5, 1, 4])
+ assert_equal 3, records.size
+ assert_equal "The Third Topic of the day", records[0].title
+ assert_equal "The Second Topic of the day", records[1].title
+ assert_equal "The Fifth Topic of the day", records[2].title
+ end
+
+ def test_find_with_ids_where_and_limit
+ # Please note that Topic 1 is the only not approved so
+ # if it were among the first 3 it would raise an ActiveRecord::RecordNotFound
+ records = Topic.where(approved: true).limit(3).find([3, 2, 5, 1, 4])
+ assert_equal 3, records.size
+ assert_equal "The Third Topic of the day", records[0].title
+ assert_equal "The Second Topic of the day", records[1].title
+ assert_equal "The Fifth Topic of the day", records[2].title
+ end
+
+ def test_find_with_ids_and_offset
+ records = Topic.offset(2).find([3, 2, 5, 1, 4])
+ assert_equal 3, records.size
+ assert_equal "The Fifth Topic of the day", records[0].title
+ assert_equal "The First Topic", records[1].title
+ assert_equal "The Fourth Topic of the day", records[2].title
+ end
+
+ def test_find_with_ids_with_no_id_passed
+ exception = assert_raises(ActiveRecord::RecordNotFound) { Topic.find }
+ assert_equal exception.model, "Topic"
+ assert_equal exception.primary_key, "id"
+ end
+
+ def test_find_with_ids_with_id_out_of_range
+ exception = assert_raises(ActiveRecord::RecordNotFound) do
+ Topic.find("9999999999999999999999999999999")
+ end
+
+ assert_equal exception.model, "Topic"
+ assert_equal exception.primary_key, "id"
+ end
+
+ def test_find_passing_active_record_object_is_not_permitted
+ assert_raises(ArgumentError) do
+ Topic.find(Topic.last)
+ end
+ end
+
+ def test_symbols_table_ref
+ gc_disabled = GC.disable
+ Post.where("author_id" => nil) # warm up
+ x = Symbol.all_symbols.count
+ Post.where("title" => { "xxxqqqq" => "bar" })
+ assert_equal x, Symbol.all_symbols.count
+ ensure
+ GC.enable if gc_disabled == false
+ end
+
+ # find should handle strings that come from URLs
+ # (example: Category.find(params[:id]))
+ def test_find_with_string
+ assert_equal(Topic.find(1).title, Topic.find("1").title)
+ end
+
+ def test_exists
+ assert_equal true, Topic.exists?(1)
+ assert_equal true, Topic.exists?("1")
+ assert_equal true, Topic.exists?(title: "The First Topic")
+ assert_equal true, Topic.exists?(heading: "The First Topic")
+ assert_equal true, Topic.exists?(author_name: "Mary", approved: true)
+ assert_equal true, Topic.exists?(["parent_id = ?", 1])
+ assert_equal true, Topic.exists?(id: [1, 9999])
+
+ assert_equal false, Topic.exists?(45)
+ assert_equal false, Topic.exists?(9999999999999999999999999999999)
+ assert_equal false, Topic.exists?(Topic.new.id)
+
+ assert_raise(NoMethodError) { Topic.exists?([1, 2]) }
+ end
+
+ def test_exists_with_scope
+ davids = Author.where(name: "David")
+ assert_equal true, davids.exists?
+ assert_equal true, davids.exists?(authors(:david).id)
+ assert_equal false, davids.exists?(authors(:mary).id)
+ assert_equal false, davids.exists?("42")
+ assert_equal false, davids.exists?(42)
+ assert_equal false, davids.exists?(davids.new.id)
+
+ fake = Author.where(name: "fake author")
+ assert_equal false, fake.exists?
+ assert_equal false, fake.exists?(authors(:david).id)
+ end
+
+ def test_exists_uses_existing_scope
+ post = authors(:david).posts.first
+ authors = Author.includes(:posts).where(name: "David", posts: { id: post.id })
+ assert_equal true, authors.exists?(authors(:david).id)
+ end
+
+ def test_any_with_scope_on_hash_includes
+ post = authors(:david).posts.first
+ categories = Categorization.includes(author: :posts).where(posts: { id: post.id })
+ assert_equal true, categories.exists?
+ end
+
+ def test_exists_with_polymorphic_relation
+ post = Post.create!(title: "Post", body: "default", taggings: [Tagging.new(comment: "tagging comment")])
+ relation = Post.tagged_with_comment("tagging comment")
+
+ assert_equal true, relation.exists?(title: ["Post"])
+ assert_equal true, relation.exists?(["title LIKE ?", "Post%"])
+ assert_equal true, relation.exists?
+ assert_equal true, relation.exists?(post.id)
+ assert_equal true, relation.exists?(post.id.to_s)
+
+ assert_equal false, relation.exists?(false)
+ end
+
+ def test_exists_with_string
+ assert_equal false, Subscriber.exists?("foo")
+ assert_equal false, Subscriber.exists?(" ")
+
+ Subscriber.create!(id: "foo")
+ Subscriber.create!(id: " ")
+
+ assert_equal true, Subscriber.exists?("foo")
+ assert_equal true, Subscriber.exists?(" ")
+ end
+
+ def test_exists_passing_active_record_object_is_not_permitted
+ assert_raises(ArgumentError) do
+ Topic.exists?(Topic.new)
+ end
+ end
+
+ def test_exists_does_not_select_columns_without_alias
+ assert_sql(/SELECT\W+1 AS one FROM ["`]topics["`]/i) do
+ Topic.exists?
+ end
+ end
+
+ def test_exists_returns_true_with_one_record_and_no_args
+ assert_equal true, Topic.exists?
+ end
+
+ def test_exists_returns_false_with_false_arg
+ assert_equal false, Topic.exists?(false)
+ end
+
+ # exists? should handle nil for id's that come from URLs and always return false
+ # (example: Topic.exists?(params[:id])) where params[:id] is nil
+ def test_exists_with_nil_arg
+ assert_equal false, Topic.exists?(nil)
+ assert_equal true, Topic.exists?
+
+ assert_equal false, Topic.first.replies.exists?(nil)
+ assert_equal true, Topic.first.replies.exists?
+ end
+
+ def test_exists_with_empty_hash_arg
+ assert_equal true, Topic.exists?({})
+ end
+
+ # Ensure +exists?+ runs without an error by excluding distinct value.
+ # See https://github.com/rails/rails/pull/26981.
+ def test_exists_with_order_and_distinct
+ assert_equal true, Topic.order(:id).distinct.exists?
+ end
+
+ # Ensure +exists?+ runs without an error by excluding order value.
+ def test_exists_with_order
+ assert_equal true, Topic.order(Arel.sql("invalid sql here")).exists?
+ end
+
+ def test_exists_with_joins
+ assert_equal true, Topic.joins(:replies).where(replies_topics: { approved: true }).order("replies_topics.created_at DESC").exists?
+ end
+
+ def test_exists_with_left_joins
+ assert_equal true, Topic.left_joins(:replies).where(replies_topics: { approved: true }).order("replies_topics.created_at DESC").exists?
+ end
+
+ def test_exists_with_eager_load
+ assert_equal true, Topic.eager_load(:replies).where(replies_topics: { approved: true }).order("replies_topics.created_at DESC").exists?
+ end
+
+ def test_exists_with_includes_limit_and_empty_result
+ assert_no_queries { assert_equal false, Topic.includes(:replies).limit(0).exists? }
+ assert_queries(1) { assert_equal false, Topic.includes(:replies).limit(1).where("0 = 1").exists? }
+ end
+
+ def test_exists_with_distinct_association_includes_and_limit
+ author = Author.first
+ unique_categorized_posts = author.unique_categorized_posts.includes(:special_comments)
+ assert_no_queries { assert_equal false, unique_categorized_posts.limit(0).exists? }
+ assert_queries(1) { assert_equal true, unique_categorized_posts.limit(1).exists? }
+ end
+
+ def test_exists_with_distinct_association_includes_limit_and_order
+ author = Author.first
+ unique_categorized_posts = author.unique_categorized_posts.includes(:special_comments).order("comments.tags_count DESC")
+ assert_no_queries { assert_equal false, unique_categorized_posts.limit(0).exists? }
+ assert_queries(1) { assert_equal true, unique_categorized_posts.limit(1).exists? }
+ end
+
+ def test_exists_should_reference_correct_aliases_while_joining_tables_of_has_many_through_association
+ ratings = developers(:david).ratings.includes(comment: :post).where(posts: { id: 1 })
+ assert_queries(1) { assert_not_predicate ratings.limit(1), :exists? }
+ end
+
+ def test_exists_with_empty_table_and_no_args_given
+ Topic.delete_all
+ assert_equal false, Topic.exists?
+ end
+
+ def test_exists_with_aggregate_having_three_mappings
+ existing_address = customers(:david).address
+ assert_equal true, Customer.exists?(address: existing_address)
+ end
+
+ def test_exists_with_aggregate_having_three_mappings_with_one_difference
+ existing_address = customers(:david).address
+ assert_equal false, Customer.exists?(address: Address.new(existing_address.street, existing_address.city, existing_address.country + "1"))
+ assert_equal false, Customer.exists?(address: Address.new(existing_address.street, existing_address.city + "1", existing_address.country))
+ assert_equal false, Customer.exists?(address: Address.new(existing_address.street + "1", existing_address.city, existing_address.country))
+ end
+
+ def test_exists_does_not_instantiate_records
+ assert_not_called(Developer, :instantiate) do
+ Developer.exists?
+ end
+ end
+
+ def test_find_by_array_of_one_id
+ assert_kind_of(Array, Topic.find([ 1 ]))
+ assert_equal(1, Topic.find([ 1 ]).length)
+ end
+
+ def test_find_by_ids
+ assert_equal 2, Topic.find(1, 2).size
+ assert_equal topics(:second).title, Topic.find([2]).first.title
+ end
+
+ def test_find_by_ids_with_limit_and_offset
+ assert_equal 2, Entrant.limit(2).find([1, 3, 2]).size
+ entrants = Entrant.limit(3).offset(2).find([1, 3, 2])
+ assert_equal 1, entrants.size
+ assert_equal "Ruby Guru", entrants.first.name
+
+ # Also test an edge case: If you have 11 results, and you set a
+ # limit of 3 and offset of 9, then you should find that there
+ # will be only 2 results, regardless of the limit.
+ devs = Developer.all
+ last_devs = Developer.limit(3).offset(9).find devs.map(&:id)
+ assert_equal 2, last_devs.size
+ assert_equal "fixture_10", last_devs[0].name
+ assert_equal "Jamis", last_devs[1].name
+ end
+
+ def test_find_with_large_number
+ assert_raises(ActiveRecord::RecordNotFound) { Topic.find("9999999999999999999999999999999") }
+ end
+
+ def test_find_by_with_large_number
+ assert_nil Topic.find_by(id: "9999999999999999999999999999999")
+ end
+
+ def test_find_by_id_with_large_number
+ assert_nil Topic.find_by_id("9999999999999999999999999999999")
+ end
+
+ def test_find_on_relation_with_large_number
+ assert_raises(ActiveRecord::RecordNotFound) do
+ Topic.where("1=1").find(9999999999999999999999999999999)
+ end
+ end
+
+ def test_find_by_on_relation_with_large_number
+ assert_nil Topic.where("1=1").find_by(id: 9999999999999999999999999999999)
+ end
+
+ def test_find_by_bang_on_relation_with_large_number
+ assert_raises(ActiveRecord::RecordNotFound) do
+ Topic.where("1=1").find_by!(id: 9999999999999999999999999999999)
+ end
+ end
+
+ def test_find_an_empty_array
+ empty_array = []
+ result = Topic.find(empty_array)
+ assert_equal [], result
+ assert_not_same empty_array, result
+ end
+
+ def test_find_doesnt_have_implicit_ordering
+ assert_sql(/^((?!ORDER).)*$/) { Topic.find(1) }
+ end
+
+ def test_find_by_ids_missing_one
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.find(1, 2, 45) }
+ end
+
+ def test_find_with_group_and_sanitized_having_method
+ developers = Developer.group(:salary).having("sum(salary) > ?", 10000).select("salary").to_a
+ assert_equal 3, developers.size
+ assert_equal 3, developers.map(&:salary).uniq.size
+ assert developers.all? { |developer| developer.salary > 10000 }
+ end
+
+ def test_find_with_entire_select_statement
+ topics = Topic.find_by_sql "SELECT * FROM topics WHERE author_name = 'Mary'"
+
+ assert_equal(1, topics.size)
+ assert_equal(topics(:second).title, topics.first.title)
+ end
+
+ def test_find_with_prepared_select_statement
+ topics = Topic.find_by_sql ["SELECT * FROM topics WHERE author_name = ?", "Mary"]
+
+ assert_equal(1, topics.size)
+ assert_equal(topics(:second).title, topics.first.title)
+ end
+
+ def test_find_by_sql_with_sti_on_joined_table
+ accounts = Account.find_by_sql("SELECT * FROM accounts INNER JOIN companies ON companies.id = accounts.firm_id")
+ assert_equal [Account], accounts.collect(&:class).uniq
+ end
+
+ def test_find_by_association_subquery
+ author = authors(:david)
+ assert_equal author.post, Post.find_by(author: Author.where(id: author))
+ assert_equal author.post, Post.find_by(author_id: Author.where(id: author))
+ end
+
+ def test_find_by_and_where_consistency_with_active_record_instance
+ author = authors(:david)
+ assert_equal Post.where(author_id: author).take, Post.find_by(author_id: author)
+ end
+
+ def test_take
+ assert_equal topics(:first), Topic.where("title = 'The First Topic'").take
+ end
+
+ def test_take_failing
+ assert_nil Topic.where("title = 'This title does not exist'").take
+ end
+
+ def test_take_bang_present
+ assert_nothing_raised do
+ assert_equal topics(:second), Topic.where("title = 'The Second Topic of the day'").take!
+ end
+ end
+
+ def test_take_bang_missing
+ assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" do
+ Topic.where("title = 'This title does not exist'").take!
+ end
+ end
+
+ def test_first
+ assert_equal topics(:second).title, Topic.where("title = 'The Second Topic of the day'").first.title
+ end
+
+ def test_first_failing
+ assert_nil Topic.where("title = 'The Second Topic of the day!'").first
+ end
+
+ def test_first_bang_present
+ assert_nothing_raised do
+ assert_equal topics(:second), Topic.where("title = 'The Second Topic of the day'").first!
+ end
+ end
+
+ def test_first_bang_missing
+ assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" do
+ Topic.where("title = 'This title does not exist'").first!
+ end
+ end
+
+ def test_first_have_primary_key_order_by_default
+ expected = topics(:first)
+ expected.touch # PostgreSQL changes the default order if no order clause is used
+ assert_equal expected, Topic.first
+ assert_equal expected, Topic.limit(5).first
+ end
+
+ def test_model_class_responds_to_first_bang
+ assert Topic.first!
+ Topic.delete_all
+ assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" do
+ Topic.first!
+ end
+ end
+
+ def test_second
+ assert_equal topics(:second).title, Topic.second.title
+ end
+
+ def test_second_with_offset
+ assert_equal topics(:fifth), Topic.offset(3).second
+ end
+
+ def test_second_have_primary_key_order_by_default
+ expected = topics(:second)
+ expected.touch # PostgreSQL changes the default order if no order clause is used
+ assert_equal expected, Topic.second
+ assert_equal expected, Topic.limit(5).second
+ end
+
+ def test_model_class_responds_to_second_bang
+ assert Topic.second!
+ Topic.delete_all
+ assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" do
+ Topic.second!
+ end
+ end
+
+ def test_third
+ assert_equal topics(:third).title, Topic.third.title
+ end
+
+ def test_third_with_offset
+ assert_equal topics(:fifth), Topic.offset(2).third
+ end
+
+ def test_third_have_primary_key_order_by_default
+ expected = topics(:third)
+ expected.touch # PostgreSQL changes the default order if no order clause is used
+ assert_equal expected, Topic.third
+ assert_equal expected, Topic.limit(5).third
+ end
+
+ def test_model_class_responds_to_third_bang
+ assert Topic.third!
+ Topic.delete_all
+ assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" do
+ Topic.third!
+ end
+ end
+
+ def test_fourth
+ assert_equal topics(:fourth).title, Topic.fourth.title
+ end
+
+ def test_fourth_with_offset
+ assert_equal topics(:fifth), Topic.offset(1).fourth
+ end
+
+ def test_fourth_have_primary_key_order_by_default
+ expected = topics(:fourth)
+ expected.touch # PostgreSQL changes the default order if no order clause is used
+ assert_equal expected, Topic.fourth
+ assert_equal expected, Topic.limit(5).fourth
+ end
+
+ def test_model_class_responds_to_fourth_bang
+ assert Topic.fourth!
+ Topic.delete_all
+ assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" do
+ Topic.fourth!
+ end
+ end
+
+ def test_fifth
+ assert_equal topics(:fifth).title, Topic.fifth.title
+ end
+
+ def test_fifth_with_offset
+ assert_equal topics(:fifth), Topic.offset(0).fifth
+ end
+
+ def test_fifth_have_primary_key_order_by_default
+ expected = topics(:fifth)
+ expected.touch # PostgreSQL changes the default order if no order clause is used
+ assert_equal expected, Topic.fifth
+ assert_equal expected, Topic.limit(5).fifth
+ end
+
+ def test_model_class_responds_to_fifth_bang
+ assert Topic.fifth!
+ Topic.delete_all
+ assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" do
+ Topic.fifth!
+ end
+ end
+
+ def test_second_to_last
+ assert_equal topics(:fourth).title, Topic.second_to_last.title
+
+ # test with offset
+ assert_equal topics(:fourth), Topic.offset(1).second_to_last
+ assert_equal topics(:fourth), Topic.offset(2).second_to_last
+ assert_equal topics(:fourth), Topic.offset(3).second_to_last
+ assert_nil Topic.offset(4).second_to_last
+ assert_nil Topic.offset(5).second_to_last
+
+ # test with limit
+ assert_nil Topic.limit(1).second
+ assert_nil Topic.limit(1).second_to_last
+ end
+
+ def test_second_to_last_have_primary_key_order_by_default
+ expected = topics(:fourth)
+ expected.touch # PostgreSQL changes the default order if no order clause is used
+ assert_equal expected, Topic.second_to_last
+ end
+
+ def test_model_class_responds_to_second_to_last_bang
+ assert Topic.second_to_last!
+ Topic.delete_all
+ assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" do
+ Topic.second_to_last!
+ end
+ end
+
+ def test_third_to_last
+ assert_equal topics(:third).title, Topic.third_to_last.title
+
+ # test with offset
+ assert_equal topics(:third), Topic.offset(1).third_to_last
+ assert_equal topics(:third), Topic.offset(2).third_to_last
+ assert_nil Topic.offset(3).third_to_last
+ assert_nil Topic.offset(4).third_to_last
+ assert_nil Topic.offset(5).third_to_last
+
+ # test with limit
+ assert_nil Topic.limit(1).third
+ assert_nil Topic.limit(1).third_to_last
+ assert_nil Topic.limit(2).third
+ assert_nil Topic.limit(2).third_to_last
+ end
+
+ def test_third_to_last_have_primary_key_order_by_default
+ expected = topics(:third)
+ expected.touch # PostgreSQL changes the default order if no order clause is used
+ assert_equal expected, Topic.third_to_last
+ end
+
+ def test_model_class_responds_to_third_to_last_bang
+ assert Topic.third_to_last!
+ Topic.delete_all
+ assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" do
+ Topic.third_to_last!
+ end
+ end
+
+ def test_last_bang_present
+ assert_nothing_raised do
+ assert_equal topics(:second), Topic.where("title = 'The Second Topic of the day'").last!
+ end
+ end
+
+ def test_last_bang_missing
+ assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" do
+ Topic.where("title = 'This title does not exist'").last!
+ end
+ end
+
+ def test_model_class_responds_to_last_bang
+ assert_equal topics(:fifth), Topic.last!
+ assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" do
+ Topic.delete_all
+ Topic.last!
+ end
+ end
+
+ def test_take_and_first_and_last_with_integer_should_use_sql_limit
+ assert_sql(/LIMIT|ROWNUM <=|FETCH FIRST/) { Topic.take(3).entries }
+ assert_sql(/LIMIT|ROWNUM <=|FETCH FIRST/) { Topic.first(2).entries }
+ assert_sql(/LIMIT|ROWNUM <=|FETCH FIRST/) { Topic.last(5).entries }
+ end
+
+ def test_last_with_integer_and_order_should_keep_the_order
+ assert_equal Topic.order("title").to_a.last(2), Topic.order("title").last(2)
+ end
+
+ def test_last_with_integer_and_order_should_use_sql_limit
+ relation = Topic.order("title")
+ assert_queries(1) { relation.last(5) }
+ assert_not_predicate relation, :loaded?
+ end
+
+ def test_last_with_integer_and_reorder_should_use_sql_limit
+ relation = Topic.reorder("title")
+ assert_queries(1) { relation.last(5) }
+ assert_not_predicate relation, :loaded?
+ end
+
+ def test_last_on_loaded_relation_should_not_use_sql
+ relation = Topic.limit(10).load
+ assert_no_queries do
+ relation.last
+ relation.last(2)
+ end
+ end
+
+ def test_last_with_irreversible_order
+ assert_raises(ActiveRecord::IrreversibleOrderError) do
+ Topic.order(Arel.sql("coalesce(author_name, title)")).last
+ end
+ end
+
+ def test_last_on_relation_with_limit_and_offset
+ post = posts("sti_comments")
+
+ comments = post.comments.order(id: :asc)
+ assert_equal comments.limit(2).to_a.last, comments.limit(2).last
+ assert_equal comments.limit(2).to_a.last(2), comments.limit(2).last(2)
+ assert_equal comments.limit(2).to_a.last(3), comments.limit(2).last(3)
+
+ assert_equal comments.offset(2).to_a.last, comments.offset(2).last
+ assert_equal comments.offset(2).to_a.last(2), comments.offset(2).last(2)
+ assert_equal comments.offset(2).to_a.last(3), comments.offset(2).last(3)
+
+ comments = comments.offset(1)
+ assert_equal comments.limit(2).to_a.last, comments.limit(2).last
+ assert_equal comments.limit(2).to_a.last(2), comments.limit(2).last(2)
+ assert_equal comments.limit(2).to_a.last(3), comments.limit(2).last(3)
+ end
+
+ def test_first_on_relation_with_limit_and_offset
+ post = posts("sti_comments")
+
+ comments = post.comments.order(id: :asc)
+ assert_equal comments.limit(2).to_a.first, comments.limit(2).first
+ assert_equal comments.limit(2).to_a.first(2), comments.limit(2).first(2)
+ assert_equal comments.limit(2).to_a.first(3), comments.limit(2).first(3)
+
+ assert_equal comments.offset(2).to_a.first, comments.offset(2).first
+ assert_equal comments.offset(2).to_a.first(2), comments.offset(2).first(2)
+ assert_equal comments.offset(2).to_a.first(3), comments.offset(2).first(3)
+
+ comments = comments.offset(1)
+ assert_equal comments.limit(2).to_a.first, comments.limit(2).first
+ assert_equal comments.limit(2).to_a.first(2), comments.limit(2).first(2)
+ assert_equal comments.limit(2).to_a.first(3), comments.limit(2).first(3)
+ end
+
+ def test_first_have_determined_order_by_default
+ expected = [companies(:second_client), companies(:another_client)]
+ clients = Client.where(name: expected.map(&:name))
+
+ assert_equal expected, clients.first(2)
+ assert_equal expected, clients.limit(5).first(2)
+ end
+
+ def test_implicit_order_column_is_configurable
+ old_implicit_order_column = Topic.implicit_order_column
+ Topic.implicit_order_column = "title"
+
+ assert_equal topics(:fifth), Topic.first
+ assert_equal topics(:third), Topic.last
+ ensure
+ Topic.implicit_order_column = old_implicit_order_column
+ end
+
+ def test_take_and_first_and_last_with_integer_should_return_an_array
+ assert_kind_of Array, Topic.take(5)
+ assert_kind_of Array, Topic.first(5)
+ assert_kind_of Array, Topic.last(5)
+ end
+
+ def test_unexisting_record_exception_handling
+ assert_raise(ActiveRecord::RecordNotFound) {
+ Topic.find(1).parent
+ }
+
+ Topic.find(2).topic
+ end
+
+ def test_find_only_some_columns
+ topic = Topic.select("author_name").find(1)
+ assert_raise(ActiveModel::MissingAttributeError) { topic.title }
+ assert_raise(ActiveModel::MissingAttributeError) { topic.title? }
+ assert_nil topic.read_attribute("title")
+ assert_equal "David", topic.author_name
+ assert_not topic.attribute_present?("title")
+ assert_not topic.attribute_present?(:title)
+ assert topic.attribute_present?("author_name")
+ assert_respond_to topic, "author_name"
+ end
+
+ def test_find_on_array_conditions
+ assert Topic.where(["approved = ?", false]).find(1)
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.where(["approved = ?", true]).find(1) }
+ end
+
+ def test_find_on_hash_conditions
+ assert Topic.where(approved: false).find(1)
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.where(approved: true).find(1) }
+ end
+
+ def test_find_on_hash_conditions_with_qualified_attribute_dot_notation_string
+ assert Topic.where("topics.approved" => false).find(1)
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.where("topics.approved" => true).find(1) }
+ end
+
+ def test_find_on_hash_conditions_with_qualified_attribute_dot_notation_symbol
+ assert Topic.where('topics.approved': false).find(1)
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.where('topics.approved': true).find(1) }
+ end
+
+ def test_find_on_hash_conditions_with_hashed_table_name
+ assert Topic.where(topics: { approved: false }).find(1)
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.where(topics: { approved: true }).find(1) }
+ end
+
+ def test_find_on_combined_explicit_and_hashed_table_names
+ assert Topic.where("topics.approved" => false, topics: { author_name: "David" }).find(1)
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.where("topics.approved" => true, topics: { author_name: "David" }).find(1) }
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.where("topics.approved" => false, topics: { author_name: "Melanie" }).find(1) }
+ end
+
+ def test_find_with_hash_conditions_on_joined_table
+ firms = Firm.joins(:account).where(accounts: { credit_limit: 50 })
+ assert_equal 1, firms.size
+ assert_equal companies(:first_firm), firms.first
+ end
+
+ def test_find_with_hash_conditions_on_joined_table_and_with_range
+ firms = DependentFirm.joins(:account).where(name: "RailsCore", accounts: { credit_limit: 55..60 })
+ assert_equal 1, firms.size
+ assert_equal companies(:rails_core), firms.first
+ end
+
+ def test_find_on_hash_conditions_with_explicit_table_name_and_aggregate
+ david = customers(:david)
+ assert Customer.where("customers.name" => david.name, :address => david.address).find(david.id)
+ assert_raise(ActiveRecord::RecordNotFound) {
+ Customer.where("customers.name" => david.name + "1", :address => david.address).find(david.id)
+ }
+ end
+
+ def test_find_on_association_proxy_conditions
+ assert_equal [1, 2, 3, 5, 6, 7, 8, 9, 10, 12], Comment.where(post_id: authors(:david).posts).map(&:id).sort
+ end
+
+ def test_find_on_hash_conditions_with_range
+ assert_equal [1, 2], Topic.where(id: 1..2).to_a.map(&:id).sort
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.where(id: 2..3).find(1) }
+ end
+
+ def test_find_on_hash_conditions_with_end_exclusive_range
+ assert_equal [1, 2, 3], Topic.where(id: 1..3).to_a.map(&:id).sort
+ assert_equal [1, 2], Topic.where(id: 1...3).to_a.map(&:id).sort
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.where(id: 2...3).find(3) }
+ end
+
+ def test_find_on_hash_conditions_with_multiple_ranges
+ assert_equal [1, 2, 3], Comment.where(id: 1..3, post_id: 1..2).to_a.map(&:id).sort
+ assert_equal [1], Comment.where(id: 1..1, post_id: 1..10).to_a.map(&:id).sort
+ end
+
+ def test_find_on_hash_conditions_with_array_of_integers_and_ranges
+ assert_equal [1, 2, 3, 5, 6, 7, 8, 9], Comment.where(id: [1..2, 3, 5, 6..8, 9]).to_a.map(&:id).sort
+ end
+
+ def test_find_on_hash_conditions_with_array_of_ranges
+ assert_equal [1, 2, 6, 7, 8], Comment.where(id: [1..2, 6..8]).to_a.map(&:id).sort
+ end
+
+ def test_find_on_hash_conditions_with_open_ended_range
+ assert_equal [1, 2, 3], Comment.where(id: Float::INFINITY..3).to_a.map(&:id).sort
+ end
+
+ def test_find_on_hash_conditions_with_numeric_range_for_string
+ topic = Topic.create!(title: "12 Factor App")
+ assert_equal [topic], Topic.where(title: 10..2).to_a
+ end
+
+ def test_find_on_multiple_hash_conditions
+ assert Topic.where(author_name: "David", title: "The First Topic", replies_count: 1, approved: false).find(1)
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.where(author_name: "David", title: "The First Topic", replies_count: 1, approved: true).find(1) }
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.where(author_name: "David", title: "HHC", replies_count: 1, approved: false).find(1) }
+ end
+
+ def test_condition_interpolation
+ assert_kind_of Firm, Company.where("name = '%s'", "37signals").first
+ assert_nil Company.where(["name = '%s'", "37signals!"]).first
+ assert_nil Company.where(["name = '%s'", "37signals!' OR 1=1"]).first
+ assert_kind_of Time, Topic.where(["id = %d", 1]).first.written_on
+ end
+
+ def test_condition_array_interpolation
+ assert_kind_of Firm, Company.where(["name = '%s'", "37signals"]).first
+ assert_nil Company.where(["name = '%s'", "37signals!"]).first
+ assert_nil Company.where(["name = '%s'", "37signals!' OR 1=1"]).first
+ assert_kind_of Time, Topic.where(["id = %d", 1]).first.written_on
+ end
+
+ def test_condition_hash_interpolation
+ assert_kind_of Firm, Company.where(name: "37signals").first
+ assert_nil Company.where(name: "37signals!").first
+ assert_kind_of Time, Topic.where(id: 1).first.written_on
+ end
+
+ def test_hash_condition_find_malformed
+ assert_raise(ActiveRecord::StatementInvalid) {
+ Company.where(id: 2, dhh: true).first
+ }
+ end
+
+ def test_hash_condition_find_with_escaped_characters
+ Company.create("name" => "Ain't noth'n like' \#stuff")
+ assert Company.where(name: "Ain't noth'n like' \#stuff").first
+ end
+
+ def test_hash_condition_find_with_array
+ p1, p2 = Post.limit(2).order("id asc").to_a
+ assert_equal [p1, p2], Post.where(id: [p1, p2]).order("id asc").to_a
+ assert_equal [p1, p2], Post.where(id: [p1, p2.id]).order("id asc").to_a
+ end
+
+ def test_hash_condition_find_with_nil
+ topic = Topic.where(last_read: nil).first
+ assert_not_nil topic
+ assert_nil topic.last_read
+ end
+
+ def test_hash_condition_find_with_aggregate_having_one_mapping
+ balance = customers(:david).balance
+ assert_kind_of Money, balance
+ found_customer = Customer.where(balance: balance).first
+ assert_equal customers(:david), found_customer
+ end
+
+ def test_hash_condition_find_with_aggregate_having_three_mappings_array
+ david_address = customers(:david).address
+ zaphod_address = customers(:zaphod).address
+ barney_address = customers(:barney).address
+ assert_kind_of Address, david_address
+ assert_kind_of Address, zaphod_address
+ found_customers = Customer.where(address: [david_address, zaphod_address, barney_address])
+ assert_equal [customers(:david), customers(:zaphod), customers(:barney)], found_customers.sort_by(&:id)
+ end
+
+ def test_hash_condition_find_with_aggregate_having_one_mapping_array
+ david_balance = customers(:david).balance
+ zaphod_balance = customers(:zaphod).balance
+ assert_kind_of Money, david_balance
+ assert_kind_of Money, zaphod_balance
+ found_customers = Customer.where(balance: [david_balance, zaphod_balance])
+ assert_equal [customers(:david), customers(:zaphod)], found_customers.sort_by(&:id)
+ end
+
+ def test_hash_condition_find_with_aggregate_attribute_having_same_name_as_field_and_key_value_being_aggregate
+ gps_location = customers(:david).gps_location
+ assert_kind_of GpsLocation, gps_location
+ found_customer = Customer.where(gps_location: gps_location).first
+ assert_equal customers(:david), found_customer
+ end
+
+ def test_hash_condition_find_with_aggregate_having_one_mapping_and_key_value_being_attribute_value
+ balance = customers(:david).balance
+ assert_kind_of Money, balance
+ found_customer = Customer.where(balance: balance.amount).first
+ assert_equal customers(:david), found_customer
+ end
+
+ def test_hash_condition_find_with_aggregate_attribute_having_same_name_as_field_and_key_value_being_attribute_value
+ gps_location = customers(:david).gps_location
+ assert_kind_of GpsLocation, gps_location
+ found_customer = Customer.where(gps_location: gps_location.gps_location).first
+ assert_equal customers(:david), found_customer
+ end
+
+ def test_hash_condition_find_with_aggregate_having_three_mappings
+ address = customers(:david).address
+ assert_kind_of Address, address
+ found_customer = Customer.where(address: address).first
+ assert_equal customers(:david), found_customer
+ end
+
+ def test_hash_condition_find_with_one_condition_being_aggregate_and_another_not
+ address = customers(:david).address
+ assert_kind_of Address, address
+ found_customer = Customer.where(address: address, name: customers(:david).name).first
+ assert_equal customers(:david), found_customer
+ end
+
+ def test_condition_utc_time_interpolation_with_default_timezone_local
+ with_env_tz "America/New_York" do
+ with_timezone_config default: :local do
+ topic = Topic.first
+ assert_equal topic, Topic.where(["written_on = ?", topic.written_on.getutc]).first
+ end
+ end
+ end
+
+ def test_hash_condition_utc_time_interpolation_with_default_timezone_local
+ with_env_tz "America/New_York" do
+ with_timezone_config default: :local do
+ topic = Topic.first
+ assert_equal topic, Topic.where(written_on: topic.written_on.getutc).first
+ end
+ end
+ end
+
+ def test_condition_local_time_interpolation_with_default_timezone_utc
+ with_env_tz "America/New_York" do
+ with_timezone_config default: :utc do
+ topic = Topic.first
+ assert_equal topic, Topic.where(["written_on = ?", topic.written_on.getlocal]).first
+ end
+ end
+ end
+
+ def test_hash_condition_local_time_interpolation_with_default_timezone_utc
+ with_env_tz "America/New_York" do
+ with_timezone_config default: :utc do
+ topic = Topic.first
+ assert_equal topic, Topic.where(written_on: topic.written_on.getlocal).first
+ end
+ end
+ end
+
+ def test_bind_variables
+ assert_kind_of Firm, Company.where(["name = ?", "37signals"]).first
+ assert_nil Company.where(["name = ?", "37signals!"]).first
+ assert_nil Company.where(["name = ?", "37signals!' OR 1=1"]).first
+ assert_kind_of Time, Topic.where(["id = ?", 1]).first.written_on
+ assert_raise(ActiveRecord::PreparedStatementInvalid) {
+ Company.where(["id=? AND name = ?", 2]).first
+ }
+ assert_raise(ActiveRecord::PreparedStatementInvalid) {
+ Company.where(["id=?", 2, 3, 4]).first
+ }
+ end
+
+ def test_bind_variables_with_quotes
+ Company.create("name" => "37signals' go'es against")
+ assert Company.where(["name = ?", "37signals' go'es against"]).first
+ end
+
+ def test_named_bind_variables_with_quotes
+ Company.create("name" => "37signals' go'es against")
+ assert Company.where(["name = :name", { name: "37signals' go'es against" }]).first
+ end
+
+ def test_named_bind_variables
+ assert_kind_of Firm, Company.where(["name = :name", { name: "37signals" }]).first
+ assert_nil Company.where(["name = :name", { name: "37signals!" }]).first
+ assert_nil Company.where(["name = :name", { name: "37signals!' OR 1=1" }]).first
+ assert_kind_of Time, Topic.where(["id = :id", { id: 1 }]).first.written_on
+ end
+
+ def test_count_by_sql
+ assert_equal(0, Entrant.count_by_sql("SELECT COUNT(*) FROM entrants WHERE id > 3"))
+ assert_equal(1, Entrant.count_by_sql(["SELECT COUNT(*) FROM entrants WHERE id > ?", 2]))
+ assert_equal(2, Entrant.count_by_sql(["SELECT COUNT(*) FROM entrants WHERE id > ?", 1]))
+ end
+
+ def test_find_by_one_attribute
+ assert_equal topics(:first), Topic.find_by_title("The First Topic")
+ assert_nil Topic.find_by_title("The First Topic!")
+ end
+
+ def test_find_by_one_attribute_bang
+ assert_equal topics(:first), Topic.find_by_title!("The First Topic")
+ assert_raises_with_message(ActiveRecord::RecordNotFound, "Couldn't find Topic") do
+ Topic.find_by_title!("The First Topic!")
+ end
+ end
+
+ def test_find_by_on_attribute_that_is_a_reserved_word
+ dog_alias = "Dog"
+ dog = Dog.create(alias: dog_alias)
+
+ assert_equal dog, Dog.find_by_alias(dog_alias)
+ end
+
+ def test_find_by_one_attribute_that_is_an_alias
+ assert_equal topics(:first), Topic.find_by_heading("The First Topic")
+ assert_nil Topic.find_by_heading("The First Topic!")
+ end
+
+ def test_find_by_one_attribute_bang_with_blank_defined
+ blank_topic = BlankTopic.create(title: "The Blank One")
+ assert_equal blank_topic, BlankTopic.find_by_title!("The Blank One")
+ end
+
+ def test_find_by_one_attribute_with_conditions
+ assert_equal accounts(:rails_core_account), Account.where("firm_id = ?", 6).find_by_credit_limit(50)
+ end
+
+ def test_find_by_one_attribute_that_is_an_aggregate
+ address = customers(:david).address
+ assert_kind_of Address, address
+ found_customer = Customer.find_by_address(address)
+ assert_equal customers(:david), found_customer
+ end
+
+ def test_find_by_one_attribute_that_is_an_aggregate_with_one_attribute_difference
+ address = customers(:david).address
+ assert_kind_of Address, address
+ missing_address = Address.new(address.street, address.city, address.country + "1")
+ assert_nil Customer.find_by_address(missing_address)
+ missing_address = Address.new(address.street, address.city + "1", address.country)
+ assert_nil Customer.find_by_address(missing_address)
+ missing_address = Address.new(address.street + "1", address.city, address.country)
+ assert_nil Customer.find_by_address(missing_address)
+ end
+
+ def test_find_by_two_attributes_that_are_both_aggregates
+ balance = customers(:david).balance
+ address = customers(:david).address
+ assert_kind_of Money, balance
+ assert_kind_of Address, address
+ found_customer = Customer.find_by_balance_and_address(balance, address)
+ assert_equal customers(:david), found_customer
+ end
+
+ def test_find_by_two_attributes_with_one_being_an_aggregate
+ balance = customers(:david).balance
+ assert_kind_of Money, balance
+ found_customer = Customer.find_by_balance_and_name(balance, customers(:david).name)
+ assert_equal customers(:david), found_customer
+ end
+
+ def test_dynamic_finder_on_one_attribute_with_conditions_returns_same_results_after_caching
+ # ensure this test can run independently of order
+ Account.singleton_class.remove_method :find_by_credit_limit if Account.public_methods.include?(:find_by_credit_limit)
+ a = Account.where("firm_id = ?", 6).find_by_credit_limit(50)
+ assert_equal a, Account.where("firm_id = ?", 6).find_by_credit_limit(50) # find_by_credit_limit has been cached
+ end
+
+ def test_find_by_one_attribute_with_several_options
+ assert_equal accounts(:unknown), Account.order("id DESC").where("id != ?", 3).find_by_credit_limit(50)
+ end
+
+ def test_find_by_one_missing_attribute
+ assert_raise(NoMethodError) { Topic.find_by_undertitle("The First Topic!") }
+ end
+
+ def test_find_by_invalid_method_syntax
+ assert_raise(NoMethodError) { Topic.fail_to_find_by_title("The First Topic") }
+ assert_raise(NoMethodError) { Topic.find_by_title?("The First Topic") }
+ assert_raise(NoMethodError) { Topic.fail_to_find_or_create_by_title("Nonexistent Title") }
+ assert_raise(NoMethodError) { Topic.find_or_create_by_title?("Nonexistent Title") }
+ end
+
+ def test_find_by_two_attributes
+ assert_equal topics(:first), Topic.find_by_title_and_author_name("The First Topic", "David")
+ assert_nil Topic.find_by_title_and_author_name("The First Topic", "Mary")
+ end
+
+ def test_find_by_two_attributes_but_passing_only_one
+ assert_raise(ArgumentError) { Topic.find_by_title_and_author_name("The First Topic") }
+ end
+
+ def test_find_by_nil_attribute
+ topic = Topic.find_by_last_read nil
+ assert_not_nil topic
+ assert_nil topic.last_read
+ end
+
+ def test_find_by_nil_and_not_nil_attributes
+ topic = Topic.find_by_last_read_and_author_name nil, "Mary"
+ assert_equal "Mary", topic.author_name
+ end
+
+ def test_find_with_bad_sql
+ assert_raise(ActiveRecord::StatementInvalid) { Topic.find_by_sql "select 1 from badtable" }
+ end
+
+ def test_joins_dont_clobber_id
+ first = Firm.
+ joins("INNER JOIN companies clients ON clients.firm_id = companies.id").
+ where("companies.id = 1").first
+ assert_equal 1, first.id
+ end
+
+ def test_joins_with_string_array
+ person_with_reader_and_post = Post.
+ joins(["INNER JOIN categorizations ON categorizations.post_id = posts.id",
+ "INNER JOIN categories ON categories.id = categorizations.category_id AND categories.type = 'SpecialCategory'"
+ ])
+ assert_equal 1, person_with_reader_and_post.size
+ end
+
+ def test_find_by_id_with_conditions_with_or
+ assert_nothing_raised do
+ Post.where("posts.id <= 3 OR posts.#{QUOTED_TYPE} = 'Post'").find([1, 2, 3])
+ end
+ end
+
+ def test_find_ignores_previously_inserted_record
+ Post.create!(title: "test", body: "it out")
+ assert_equal [], Post.where(id: nil)
+ end
+
+ def test_find_by_empty_ids
+ assert_equal [], Post.find([])
+ end
+
+ def test_find_by_empty_in_condition
+ assert_equal [], Post.where("id in (?)", [])
+ end
+
+ def test_find_by_records
+ p1, p2 = Post.limit(2).order("id asc").to_a
+ assert_equal [p1, p2], Post.where(["id in (?)", [p1, p2]]).order("id asc")
+ assert_equal [p1, p2], Post.where(["id in (?)", [p1, p2.id]]).order("id asc")
+ end
+
+ def test_select_value
+ assert_equal "37signals", Company.connection.select_value("SELECT name FROM companies WHERE id = 1")
+ assert_nil Company.connection.select_value("SELECT name FROM companies WHERE id = -1")
+ # make sure we didn't break count...
+ assert_equal 0, Company.count_by_sql("SELECT COUNT(*) FROM companies WHERE name = 'Halliburton'")
+ assert_equal 1, Company.count_by_sql("SELECT COUNT(*) FROM companies WHERE name = '37signals'")
+ end
+
+ def test_select_values
+ assert_equal ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"], Company.connection.select_values("SELECT id FROM companies ORDER BY id").map!(&:to_s)
+ assert_equal ["37signals", "Summit", "Microsoft", "Flamboyant Software", "Ex Nihilo", "RailsCore", "Leetsoft", "Jadedpixel", "Odegy", "Ex Nihilo Part Deux", "Apex"], Company.connection.select_values("SELECT name FROM companies ORDER BY id")
+ end
+
+ def test_select_rows
+ assert_equal(
+ [["1", "1", nil, "37signals"],
+ ["2", "1", "2", "Summit"],
+ ["3", "1", "1", "Microsoft"]],
+ Company.connection.select_rows("SELECT id, firm_id, client_of, name FROM companies WHERE id IN (1,2,3) ORDER BY id").map! { |i| i.map! { |j| j.to_s unless j.nil? } })
+ assert_equal [["1", "37signals"], ["2", "Summit"], ["3", "Microsoft"]],
+ Company.connection.select_rows("SELECT id, name FROM companies WHERE id IN (1,2,3) ORDER BY id").map! { |i| i.map! { |j| j.to_s unless j.nil? } }
+ end
+
+ def test_find_with_order_on_included_associations_with_construct_finder_sql_for_association_limiting_and_is_distinct
+ assert_equal 2, Post.includes(authors: :author_address).
+ where.not(author_addresses: { id: nil }).
+ order("author_addresses.id DESC").limit(2).to_a.size
+
+ assert_equal 3, Post.includes(author: :author_address, authors: :author_address).
+ where.not(author_addresses_authors: { id: nil }).
+ order("author_addresses_authors.id DESC").limit(3).to_a.size
+ end
+
+ def test_find_with_eager_loading_collection_and_ordering_by_collection_primary_key
+ assert_equal Post.first, Post.eager_load(comments: :ratings).
+ order("posts.id, ratings.id, comments.id").first
+ end
+
+ def test_find_with_nil_inside_set_passed_for_one_attribute
+ client_of = Company.
+ where(client_of: [2, 1, nil],
+ name: ["37signals", "Summit", "Microsoft"]).
+ order("client_of DESC").
+ map(&:client_of)
+
+ assert_includes client_of, nil
+ assert_equal [2, 1].sort, client_of.compact.sort
+ end
+
+ def test_find_with_nil_inside_set_passed_for_attribute
+ client_of = Company.
+ where(client_of: [nil]).
+ order("client_of DESC").
+ map(&:client_of)
+
+ assert_equal [], client_of.compact
+ end
+
+ def test_with_limiting_with_custom_select
+ posts = Post.references(:authors).merge(
+ includes: :author, select: 'posts.*, authors.id as "author_id"',
+ limit: 3, order: "posts.id"
+ ).to_a
+ assert_equal 3, posts.size
+ assert_equal [0, 1, 1], posts.map(&:author_id).sort
+ end
+
+ def test_find_one_message_on_primary_key
+ e = assert_raises(ActiveRecord::RecordNotFound) do
+ Car.find(0)
+ end
+ assert_equal 0, e.id
+ assert_equal "id", e.primary_key
+ assert_equal "Car", e.model
+ assert_equal "Couldn't find Car with 'id'=0", e.message
+ end
+
+ def test_find_one_message_with_custom_primary_key
+ table_with_custom_primary_key do |model|
+ model.primary_key = :name
+ e = assert_raises(ActiveRecord::RecordNotFound) do
+ model.find "Hello World!"
+ end
+ assert_equal "Couldn't find MercedesCar with 'name'=Hello World!", e.message
+ end
+ end
+
+ def test_find_some_message_with_custom_primary_key
+ table_with_custom_primary_key do |model|
+ model.primary_key = :name
+ e = assert_raises(ActiveRecord::RecordNotFound) do
+ model.find "Hello", "World!"
+ end
+ assert_equal "Couldn't find all MercedesCars with 'name': (Hello, World!) (found 0 results, but was looking for 2).", e.message
+ end
+ end
+
+ def test_find_without_primary_key
+ assert_raises(ActiveRecord::UnknownPrimaryKey) do
+ Matey.find(1)
+ end
+ end
+
+ def test_finder_with_offset_string
+ assert_nothing_raised { Topic.offset("3").to_a }
+ end
+
+ test "find_by with hash conditions returns the first matching record" do
+ assert_equal posts(:eager_other), Post.find_by(id: posts(:eager_other).id)
+ end
+
+ test "find_by with non-hash conditions returns the first matching record" do
+ assert_equal posts(:eager_other), Post.find_by("id = #{posts(:eager_other).id}")
+ end
+
+ test "find_by with multi-arg conditions returns the first matching record" do
+ assert_equal posts(:eager_other), Post.find_by("id = ?", posts(:eager_other).id)
+ end
+
+ test "find_by with range conditions returns the first matching record" do
+ assert_equal posts(:eager_other), Post.find_by(id: posts(:eager_other).id...posts(:misc_by_bob).id)
+ end
+
+ test "find_by returns nil if the record is missing" do
+ assert_nil Post.find_by("1 = 0")
+ end
+
+ test "find_by with associations" do
+ assert_equal authors(:david), Post.find_by(author: authors(:david)).author
+ assert_equal authors(:mary), Post.find_by(author: authors(:mary)).author
+ end
+
+ test "find_by doesn't have implicit ordering" do
+ assert_sql(/^((?!ORDER).)*$/) { Post.find_by(id: posts(:eager_other).id) }
+ end
+
+ test "find_by! with hash conditions returns the first matching record" do
+ assert_equal posts(:eager_other), Post.find_by!(id: posts(:eager_other).id)
+ end
+
+ test "find_by! with non-hash conditions returns the first matching record" do
+ assert_equal posts(:eager_other), Post.find_by!("id = #{posts(:eager_other).id}")
+ end
+
+ test "find_by! with multi-arg conditions returns the first matching record" do
+ assert_equal posts(:eager_other), Post.find_by!("id = ?", posts(:eager_other).id)
+ end
+
+ test "find_by! doesn't have implicit ordering" do
+ assert_sql(/^((?!ORDER).)*$/) { Post.find_by!(id: posts(:eager_other).id) }
+ end
+
+ test "find_by! raises RecordNotFound if the record is missing" do
+ assert_raises(ActiveRecord::RecordNotFound) do
+ Post.find_by!("1 = 0")
+ end
+ end
+
+ test "find on a scope does not perform statement caching" do
+ honda = cars(:honda)
+ zyke = cars(:zyke)
+ tyre = honda.tyres.create!
+ tyre2 = zyke.tyres.create!
+
+ assert_equal tyre, honda.tyres.custom_find(tyre.id)
+ assert_equal tyre2, zyke.tyres.custom_find(tyre2.id)
+ end
+
+ test "find_by on a scope does not perform statement caching" do
+ honda = cars(:honda)
+ zyke = cars(:zyke)
+ tyre = honda.tyres.create!
+ tyre2 = zyke.tyres.create!
+
+ assert_equal tyre, honda.tyres.custom_find_by(id: tyre.id)
+ assert_equal tyre2, zyke.tyres.custom_find_by(id: tyre2.id)
+ end
+
+ test "#skip_query_cache! for #exists?" do
+ Topic.cache do
+ assert_queries(1) do
+ Topic.exists?
+ Topic.exists?
+ end
+
+ assert_queries(2) do
+ Topic.all.skip_query_cache!.exists?
+ Topic.all.skip_query_cache!.exists?
+ end
+ end
+ end
+
+ test "#skip_query_cache! for #exists? with a limited eager load" do
+ Topic.cache do
+ assert_queries(1) do
+ Topic.eager_load(:replies).limit(1).exists?
+ Topic.eager_load(:replies).limit(1).exists?
+ end
+
+ assert_queries(2) do
+ Topic.eager_load(:replies).limit(1).skip_query_cache!.exists?
+ Topic.eager_load(:replies).limit(1).skip_query_cache!.exists?
+ end
+ end
+ end
+
+ private
+ def table_with_custom_primary_key
+ yield(Class.new(Toy) do
+ def self.name
+ "MercedesCar"
+ end
+ end)
+ end
+
+ def assert_raises_with_message(exception_class, message, &block)
+ err = assert_raises(exception_class) { block.call }
+ assert_match message, err.message
+ end
+end
diff --git a/activerecord/test/cases/fixture_set/file_test.rb b/activerecord/test/cases/fixture_set/file_test.rb
new file mode 100644
index 0000000000..ff99988cb5
--- /dev/null
+++ b/activerecord/test/cases/fixture_set/file_test.rb
@@ -0,0 +1,158 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "tempfile"
+
+module ActiveRecord
+ class FixtureSet
+ class FileTest < ActiveRecord::TestCase
+ def test_open
+ fh = File.open(::File.join(FIXTURES_ROOT, "accounts.yml"))
+ assert_equal 6, fh.to_a.length
+ end
+
+ def test_open_with_block
+ called = false
+ File.open(::File.join(FIXTURES_ROOT, "accounts.yml")) do |fh|
+ called = true
+ assert_equal 6, fh.to_a.length
+ end
+ assert called, "block called"
+ end
+
+ def test_names
+ File.open(::File.join(FIXTURES_ROOT, "accounts.yml")) do |fh|
+ assert_equal ["signals37",
+ "unknown",
+ "rails_core_account",
+ "last_account",
+ "rails_core_account_2",
+ "odegy_account"].sort, fh.to_a.map(&:first).sort
+ end
+ end
+
+ def test_values
+ File.open(::File.join(FIXTURES_ROOT, "accounts.yml")) do |fh|
+ assert_equal [1, 2, 3, 4, 5, 6].sort, fh.to_a.map(&:last).map { |x|
+ x["id"]
+ }.sort
+ end
+ end
+
+ def test_erb_processing
+ File.open(::File.join(FIXTURES_ROOT, "developers.yml")) do |fh|
+ devs = Array.new(8) { |i| "dev_#{i + 3}" }
+ assert_equal [], devs - fh.to_a.map(&:first)
+ end
+ end
+
+ def test_empty_file
+ tmp_yaml ["empty", "yml"], "" do |t|
+ assert_equal [], File.open(t.path) { |fh| fh.to_a }
+ end
+ end
+
+ # A valid YAML file is not necessarily a value Fixture file. Make sure
+ # an exception is raised if the format is not valid Fixture format.
+ def test_wrong_fixture_format_string
+ tmp_yaml ["empty", "yml"], "qwerty" do |t|
+ assert_raises(ActiveRecord::Fixture::FormatError) do
+ File.open(t.path) { |fh| fh.to_a }
+ end
+ end
+ end
+
+ def test_wrong_fixture_format_nested
+ tmp_yaml ["empty", "yml"], "one: two" do |t|
+ assert_raises(ActiveRecord::Fixture::FormatError) do
+ File.open(t.path) { |fh| fh.to_a }
+ end
+ end
+ end
+
+ def test_render_context_helper
+ ActiveRecord::FixtureSet.context_class.class_eval do
+ def fixture_helper
+ "Fixture helper"
+ end
+ end
+ yaml = "one:\n name: <%= fixture_helper %>\n"
+ tmp_yaml ["curious", "yml"], yaml do |t|
+ golden =
+ [["one", { "name" => "Fixture helper" }]]
+ assert_equal golden, File.open(t.path) { |fh| fh.to_a }
+ end
+ ActiveRecord::FixtureSet.context_class.class_eval do
+ remove_method :fixture_helper
+ end
+ end
+
+ def test_render_context_lookup_scope
+ yaml = <<END
+one:
+ ActiveRecord: <%= defined? ActiveRecord %>
+ ActiveRecord_FixtureSet: <%= defined? ActiveRecord::FixtureSet %>
+ FixtureSet: <%= defined? FixtureSet %>
+ ActiveRecord_FixtureSet_File: <%= defined? ActiveRecord::FixtureSet::File %>
+ File: <%= File.name %>
+END
+
+ golden = [["one", {
+ "ActiveRecord" => "constant",
+ "ActiveRecord_FixtureSet" => "constant",
+ "FixtureSet" => nil,
+ "ActiveRecord_FixtureSet_File" => "constant",
+ "File" => "File"
+ }]]
+
+ tmp_yaml ["curious", "yml"], yaml do |t|
+ assert_equal golden, File.open(t.path) { |fh| fh.to_a }
+ end
+ end
+
+ # Make sure that each fixture gets its own rendering context so that
+ # fixtures are independent.
+ def test_independent_render_contexts
+ yaml1 = "<% def leaked_method; 'leak'; end %>\n"
+ yaml2 = "one:\n name: <%= leaked_method %>\n"
+ tmp_yaml ["leaky", "yml"], yaml1 do |t1|
+ tmp_yaml ["curious", "yml"], yaml2 do |t2|
+ File.open(t1.path) { |fh| fh.to_a }
+ assert_raises(NameError) do
+ File.open(t2.path) { |fh| fh.to_a }
+ end
+ end
+ end
+ end
+
+ def test_removes_fixture_config_row
+ File.open(::File.join(FIXTURES_ROOT, "other_posts.yml")) do |fh|
+ assert_equal(["second_welcome"], fh.each.map { |name, _| name })
+ end
+ end
+
+ def test_extracts_model_class_from_config_row
+ File.open(::File.join(FIXTURES_ROOT, "other_posts.yml")) do |fh|
+ assert_equal "Post", fh.model_class
+ end
+ end
+
+ def test_erb_filename
+ filename = "filename.yaml"
+ erb = File.new(filename).send(:prepare_erb, "<% Rails.env %>\n")
+ assert_equal erb.filename, filename
+ end
+
+ private
+ def tmp_yaml(name, contents)
+ t = Tempfile.new name
+ t.binmode
+ t.write contents
+ t.close
+ yield t
+ ensure
+ t.close true
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/fixtures_test.rb b/activerecord/test/cases/fixtures_test.rb
new file mode 100644
index 0000000000..fe2f417a04
--- /dev/null
+++ b/activerecord/test/cases/fixtures_test.rb
@@ -0,0 +1,1364 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/connection_helper"
+require "models/admin"
+require "models/admin/account"
+require "models/admin/randomly_named_c1"
+require "models/admin/user"
+require "models/binary"
+require "models/book"
+require "models/bulb"
+require "models/category"
+require "models/post"
+require "models/comment"
+require "models/company"
+require "models/computer"
+require "models/course"
+require "models/developer"
+require "models/dog"
+require "models/doubloon"
+require "models/joke"
+require "models/matey"
+require "models/other_dog"
+require "models/parrot"
+require "models/pirate"
+require "models/randomly_named_c1"
+require "models/reply"
+require "models/ship"
+require "models/task"
+require "models/topic"
+require "models/traffic_light"
+require "models/treasure"
+require "tempfile"
+
+class FixturesTest < ActiveRecord::TestCase
+ include ConnectionHelper
+
+ self.use_instantiated_fixtures = true
+ self.use_transactional_tests = false
+
+ # other_topics fixture should not be included here
+ fixtures :topics, :developers, :accounts, :tasks, :categories, :funny_jokes, :binaries, :traffic_lights
+
+ FIXTURES = %w( accounts binaries companies customers
+ developers developers_projects entrants
+ movies projects subscribers topics tasks )
+ MATCH_ATTRIBUTE_NAME = /[a-zA-Z][-\w]*/
+
+ def test_clean_fixtures
+ FIXTURES.each do |name|
+ fixtures = nil
+ assert_nothing_raised { fixtures = create_fixtures(name).first }
+ assert_kind_of(ActiveRecord::FixtureSet, fixtures)
+ fixtures.each { |_name, fixture|
+ fixture.each { |key, value|
+ assert_match(MATCH_ATTRIBUTE_NAME, key)
+ }
+ }
+ end
+ end
+
+ class InsertQuerySubscriber
+ attr_reader :events
+
+ def initialize
+ @events = []
+ end
+
+ def call(_, _, _, _, values)
+ @events << values[:sql] if values[:sql] =~ /INSERT/
+ end
+ end
+
+ if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter)
+ def test_bulk_insert
+ subscriber = InsertQuerySubscriber.new
+ subscription = ActiveSupport::Notifications.subscribe("sql.active_record", subscriber)
+ create_fixtures("bulbs")
+ assert_equal 1, subscriber.events.size, "It takes one INSERT query to insert two fixtures"
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscription)
+ end
+
+ def test_bulk_insert_multiple_table_with_a_multi_statement_query
+ subscriber = InsertQuerySubscriber.new
+ subscription = ActiveSupport::Notifications.subscribe("sql.active_record", subscriber)
+
+ create_fixtures("bulbs", "authors", "computers")
+
+ expected_sql = <<~EOS.chop
+ INSERT INTO #{ActiveRecord::Base.connection.quote_table_name("bulbs")} .*
+ INSERT INTO #{ActiveRecord::Base.connection.quote_table_name("authors")} .*
+ INSERT INTO #{ActiveRecord::Base.connection.quote_table_name("computers")} .*
+ EOS
+ assert_equal 1, subscriber.events.size
+ assert_match(/#{expected_sql}/, subscriber.events.first)
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscription)
+ end
+
+ def test_bulk_insert_with_a_multi_statement_query_raises_an_exception_when_any_insert_fails
+ require "models/aircraft"
+
+ assert_equal false, Aircraft.columns_hash["wheels_count"].null
+ fixtures = {
+ "aircraft" => [
+ { "name" => "working_aircrafts", "wheels_count" => 2 },
+ { "name" => "broken_aircrafts", "wheels_count" => nil },
+ ]
+ }
+
+ assert_no_difference "Aircraft.count" do
+ assert_raises(ActiveRecord::NotNullViolation) do
+ ActiveRecord::Base.connection.insert_fixtures_set(fixtures)
+ end
+ end
+ end
+
+ def test_bulk_insert_with_a_multi_statement_query_in_a_nested_transaction
+ fixtures = {
+ "traffic_lights" => [
+ { "location" => "US", "state" => ["NY"], "long_state" => ["a"] },
+ ]
+ }
+
+ assert_difference "TrafficLight.count" do
+ ActiveRecord::Base.transaction do
+ conn = ActiveRecord::Base.connection
+ assert_equal 1, conn.open_transactions
+ conn.insert_fixtures_set(fixtures)
+ assert_equal 1, conn.open_transactions
+ end
+ end
+ end
+ end
+
+ if current_adapter?(:Mysql2Adapter)
+ def test_bulk_insert_with_multi_statements_enabled
+ run_without_connection do |orig_connection|
+ ActiveRecord::Base.establish_connection(
+ orig_connection.merge(flags: %w[MULTI_STATEMENTS])
+ )
+
+ fixtures = {
+ "traffic_lights" => [
+ { "location" => "US", "state" => ["NY"], "long_state" => ["a"] },
+ ]
+ }
+
+ ActiveRecord::Base.connection.stub(:supports_set_server_option?, false) do
+ assert_nothing_raised do
+ conn = ActiveRecord::Base.connection
+ conn.execute("SELECT 1; SELECT 2;")
+ conn.raw_connection.abandon_results!
+ end
+
+ assert_difference "TrafficLight.count" do
+ ActiveRecord::Base.transaction do
+ conn = ActiveRecord::Base.connection
+ assert_equal 1, conn.open_transactions
+ conn.insert_fixtures_set(fixtures)
+ assert_equal 1, conn.open_transactions
+ end
+ end
+
+ assert_nothing_raised do
+ conn = ActiveRecord::Base.connection
+ conn.execute("SELECT 1; SELECT 2;")
+ conn.raw_connection.abandon_results!
+ end
+ end
+ end
+ end
+
+ def test_bulk_insert_with_multi_statements_disabled
+ run_without_connection do |orig_connection|
+ ActiveRecord::Base.establish_connection(
+ orig_connection.merge(flags: [])
+ )
+
+ fixtures = {
+ "traffic_lights" => [
+ { "location" => "US", "state" => ["NY"], "long_state" => ["a"] },
+ ]
+ }
+
+ ActiveRecord::Base.connection.stub(:supports_set_server_option?, false) do
+ assert_raises(ActiveRecord::StatementInvalid) do
+ conn = ActiveRecord::Base.connection
+ conn.execute("SELECT 1; SELECT 2;")
+ conn.raw_connection.abandon_results!
+ end
+
+ assert_difference "TrafficLight.count" do
+ conn = ActiveRecord::Base.connection
+ conn.insert_fixtures_set(fixtures)
+ end
+
+ assert_raises(ActiveRecord::StatementInvalid) do
+ conn = ActiveRecord::Base.connection
+ conn.execute("SELECT 1; SELECT 2;")
+ conn.raw_connection.abandon_results!
+ end
+ end
+ end
+ end
+
+ def test_insert_fixtures_set_raises_an_error_when_max_allowed_packet_is_smaller_than_fixtures_set_size
+ conn = ActiveRecord::Base.connection
+ mysql_margin = 2
+ packet_size = 1024
+ bytes_needed_to_have_a_1024_bytes_fixture = 858
+ fixtures = {
+ "traffic_lights" => [
+ { "location" => "US", "state" => ["NY"], "long_state" => ["a" * bytes_needed_to_have_a_1024_bytes_fixture] },
+ ]
+ }
+
+ conn.stub(:max_allowed_packet, packet_size - mysql_margin) do
+ error = assert_raises(ActiveRecord::ActiveRecordError) { conn.insert_fixtures_set(fixtures) }
+ assert_match(/Fixtures set is too large #{packet_size}\./, error.message)
+ end
+ end
+
+ def test_insert_fixture_set_when_max_allowed_packet_is_bigger_than_fixtures_set_size
+ conn = ActiveRecord::Base.connection
+ packet_size = 1024
+ fixtures = {
+ "traffic_lights" => [
+ { "location" => "US", "state" => ["NY"], "long_state" => ["a" * 51] },
+ ]
+ }
+
+ conn.stub(:max_allowed_packet, packet_size) do
+ assert_difference "TrafficLight.count" do
+ conn.insert_fixtures_set(fixtures)
+ end
+ end
+ end
+
+ def test_insert_fixtures_set_split_the_total_sql_into_two_chunks_smaller_than_max_allowed_packet
+ subscriber = InsertQuerySubscriber.new
+ subscription = ActiveSupport::Notifications.subscribe("sql.active_record", subscriber)
+ conn = ActiveRecord::Base.connection
+ packet_size = 1024
+ fixtures = {
+ "traffic_lights" => [
+ { "location" => "US", "state" => ["NY"], "long_state" => ["a" * 450] },
+ ],
+ "comments" => [
+ { "post_id" => 1, "body" => "a" * 450 },
+ ]
+ }
+
+ conn.stub(:max_allowed_packet, packet_size) do
+ conn.insert_fixtures_set(fixtures)
+
+ assert_equal 2, subscriber.events.size
+ assert_operator subscriber.events.first.bytesize, :<, packet_size
+ assert_operator subscriber.events.second.bytesize, :<, packet_size
+ end
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscription)
+ end
+
+ def test_insert_fixtures_set_concat_total_sql_into_a_single_packet_smaller_than_max_allowed_packet
+ subscriber = InsertQuerySubscriber.new
+ subscription = ActiveSupport::Notifications.subscribe("sql.active_record", subscriber)
+ conn = ActiveRecord::Base.connection
+ packet_size = 1024
+ fixtures = {
+ "traffic_lights" => [
+ { "location" => "US", "state" => ["NY"], "long_state" => ["a" * 200] },
+ ],
+ "comments" => [
+ { "post_id" => 1, "body" => "a" * 200 },
+ ]
+ }
+
+ conn.stub(:max_allowed_packet, packet_size) do
+ assert_difference ["TrafficLight.count", "Comment.count"], +1 do
+ conn.insert_fixtures_set(fixtures)
+ end
+ end
+ assert_equal 1, subscriber.events.size
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscription)
+ end
+ end
+
+ def test_auto_value_on_primary_key
+ fixtures = [
+ { "name" => "first", "wheels_count" => 2 },
+ { "name" => "second", "wheels_count" => 3 }
+ ]
+ conn = ActiveRecord::Base.connection
+ assert_nothing_raised do
+ conn.insert_fixtures_set({ "aircraft" => fixtures }, ["aircraft"])
+ end
+ result = conn.select_all("SELECT name, wheels_count FROM aircraft ORDER BY id")
+ assert_equal fixtures, result.to_a
+ end
+
+ def test_deprecated_insert_fixtures
+ fixtures = [
+ { "name" => "first", "wheels_count" => 2 },
+ { "name" => "second", "wheels_count" => 3 }
+ ]
+ conn = ActiveRecord::Base.connection
+ conn.delete("DELETE FROM aircraft")
+ assert_deprecated do
+ conn.insert_fixtures(fixtures, "aircraft")
+ end
+ result = conn.select_all("SELECT name, wheels_count FROM aircraft ORDER BY id")
+ assert_equal fixtures, result.to_a
+ end
+
+ def test_broken_yaml_exception
+ badyaml = Tempfile.new ["foo", ".yml"]
+ badyaml.write "a: : "
+ badyaml.flush
+
+ dir = File.dirname badyaml.path
+ name = File.basename badyaml.path, ".yml"
+ assert_raises(ActiveRecord::Fixture::FormatError) do
+ ActiveRecord::FixtureSet.create_fixtures(dir, name)
+ end
+ ensure
+ badyaml.close
+ badyaml.unlink
+ end
+
+ def test_create_fixtures
+ fixtures = ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT, "parrots")
+ assert Parrot.find_by_name("Curious George"), "George is not in the database"
+ assert fixtures.detect { |f| f.name == "parrots" }, "no fixtures named 'parrots' in #{fixtures.map(&:name).inspect}"
+ end
+
+ def test_multiple_clean_fixtures
+ fixtures_array = nil
+ assert_nothing_raised { fixtures_array = create_fixtures(*FIXTURES) }
+ assert_kind_of(Array, fixtures_array)
+ fixtures_array.each { |fixtures| assert_kind_of(ActiveRecord::FixtureSet, fixtures) }
+ end
+
+ def test_create_symbol_fixtures
+ fixtures = ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT, :collections, collections: Course) { Course.connection }
+
+ assert Course.find_by_name("Collection"), "course is not in the database"
+ assert fixtures.detect { |f| f.name == "collections" }, "no fixtures named 'collections' in #{fixtures.map(&:name).inspect}"
+ end
+
+ def test_attributes
+ topics = create_fixtures("topics").first
+ assert_equal("The First Topic", topics["first"]["title"])
+ assert_nil(topics["second"]["author_email_address"])
+ end
+
+ def test_no_args_returns_all
+ all_topics = topics
+ assert_equal 5, all_topics.length
+ assert_equal "The First Topic", all_topics.first["title"]
+ assert_equal 5, all_topics.last.id
+ end
+
+ def test_no_args_record_returns_all_without_array
+ all_binaries = binaries
+ assert_kind_of(Array, all_binaries)
+ assert_equal 2, binaries.length
+ end
+
+ def test_nil_raises
+ assert_raise(StandardError) { topics(nil) }
+ assert_raise(StandardError) { topics([nil]) }
+ end
+
+ def test_inserts
+ create_fixtures("topics")
+ first_row = ActiveRecord::Base.connection.select_one("SELECT * FROM topics WHERE author_name = 'David'")
+ assert_equal("The First Topic", first_row["title"])
+
+ second_row = ActiveRecord::Base.connection.select_one("SELECT * FROM topics WHERE author_name = 'Mary'")
+ assert_nil(second_row["author_email_address"])
+ end
+
+ def test_inserts_with_pre_and_suffix
+ # Reset cache to make finds on the new table work
+ ActiveRecord::FixtureSet.reset_cache
+
+ ActiveRecord::Base.connection.create_table :prefix_other_topics_suffix do |t|
+ t.column :title, :string
+ t.column :author_name, :string
+ t.column :author_email_address, :string
+ t.column :written_on, :datetime
+ t.column :bonus_time, :time
+ t.column :last_read, :date
+ t.column :content, :string
+ t.column :approved, :boolean, default: true
+ t.column :replies_count, :integer, default: 0
+ t.column :parent_id, :integer
+ t.column :type, :string, limit: 50
+ end
+
+ # Store existing prefix/suffix
+ old_prefix = ActiveRecord::Base.table_name_prefix
+ old_suffix = ActiveRecord::Base.table_name_suffix
+
+ # Set a prefix/suffix we can test against
+ ActiveRecord::Base.table_name_prefix = "prefix_"
+ ActiveRecord::Base.table_name_suffix = "_suffix"
+
+ other_topic_klass = Class.new(ActiveRecord::Base) do
+ def self.name
+ "OtherTopic"
+ end
+ end
+
+ topics = [create_fixtures("other_topics")].flatten.first
+
+ # This checks for a caching problem which causes a bug in the fixtures
+ # class-level configuration helper.
+ assert_not_nil topics, "Fixture data inserted, but fixture objects not returned from create"
+
+ first_row = ActiveRecord::Base.connection.select_one("SELECT * FROM prefix_other_topics_suffix WHERE author_name = 'David'")
+ assert_not_nil first_row, "The prefix_other_topics_suffix table appears to be empty despite create_fixtures: the row with author_name = 'David' was not found"
+ assert_equal("The First Topic", first_row["title"])
+
+ second_row = ActiveRecord::Base.connection.select_one("SELECT * FROM prefix_other_topics_suffix WHERE author_name = 'Mary'")
+ assert_nil(second_row["author_email_address"])
+
+ assert_equal :prefix_other_topics_suffix, topics.table_name.to_sym
+ # This assertion should preferably be the last in the list, because calling
+ # other_topic_klass.table_name sets a class-level instance variable
+ assert_equal :prefix_other_topics_suffix, other_topic_klass.table_name.to_sym
+
+ ensure
+ # Restore prefix/suffix to its previous values
+ ActiveRecord::Base.table_name_prefix = old_prefix
+ ActiveRecord::Base.table_name_suffix = old_suffix
+
+ ActiveRecord::Base.connection.drop_table :prefix_other_topics_suffix rescue nil
+ end
+
+ def test_insert_with_datetime
+ create_fixtures("tasks")
+ first = Task.find(1)
+ assert first
+ end
+
+ def test_logger_level_invariant
+ level = ActiveRecord::Base.logger.level
+ create_fixtures("topics")
+ assert_equal level, ActiveRecord::Base.logger.level
+ end
+
+ def test_instantiation
+ topics = create_fixtures("topics").first
+ assert_kind_of Topic, topics["first"].find
+ end
+
+ def test_complete_instantiation
+ assert_equal "The First Topic", @first.title
+ end
+
+ def test_fixtures_from_root_yml_with_instantiation
+ assert_equal 50, @unknown.credit_limit
+ end
+
+ def test_erb_in_fixtures
+ assert_equal "fixture_5", @dev_5.name
+ end
+
+ def test_empty_yaml_fixture
+ assert_not_nil ActiveRecord::FixtureSet.new(nil, "accounts", Account, FIXTURES_ROOT + "/naked/yml/accounts")
+ end
+
+ def test_empty_yaml_fixture_with_a_comment_in_it
+ assert_not_nil ActiveRecord::FixtureSet.new(nil, "companies", Company, FIXTURES_ROOT + "/naked/yml/companies")
+ end
+
+ def test_nonexistent_fixture_file
+ nonexistent_fixture_path = FIXTURES_ROOT + "/imnothere"
+
+ # sanity check to make sure that this file never exists
+ assert_empty Dir[nonexistent_fixture_path + "*"]
+
+ assert_raise(Errno::ENOENT) do
+ ActiveRecord::FixtureSet.new(nil, "companies", Company, nonexistent_fixture_path)
+ end
+ end
+
+ def test_dirty_dirty_yaml_file
+ fixture_path = FIXTURES_ROOT + "/naked/yml/courses"
+ error = assert_raise(ActiveRecord::Fixture::FormatError) do
+ ActiveRecord::FixtureSet.new(nil, "courses", Course, fixture_path)
+ end
+ assert_equal "fixture is not a hash: #{fixture_path}.yml", error.to_s
+ end
+
+ def test_yaml_file_with_one_invalid_fixture
+ fixture_path = FIXTURES_ROOT + "/naked/yml/courses_with_invalid_key"
+ error = assert_raise(ActiveRecord::Fixture::FormatError) do
+ ActiveRecord::FixtureSet.new(nil, "courses", Course, fixture_path)
+ end
+ assert_equal "fixture key is not a hash: #{fixture_path}.yml, keys: [\"two\"]", error.to_s
+ end
+
+ def test_yaml_file_with_invalid_column
+ e = assert_raise(ActiveRecord::Fixture::FixtureError) do
+ ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT + "/naked/yml", "parrots")
+ end
+
+ if current_adapter?(:SQLite3Adapter)
+ assert_equal(%(table "parrots" has no column named "arrr".), e.message)
+ else
+ assert_equal(%(table "parrots" has no columns named "arrr", "foobar".), e.message)
+ end
+ end
+
+ def test_yaml_file_with_symbol_columns
+ ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT + "/naked/yml", "trees")
+ end
+
+ def test_omap_fixtures
+ assert_nothing_raised do
+ fixtures = ActiveRecord::FixtureSet.new(nil, "categories", Category, FIXTURES_ROOT + "/categories_ordered")
+
+ fixtures.each.with_index do |(name, fixture), i|
+ assert_equal "fixture_no_#{i}", name
+ assert_equal "Category #{i}", fixture["name"]
+ end
+ end
+ end
+
+ def test_yml_file_in_subdirectory
+ assert_equal(categories(:sub_special_1).name, "A special category in a subdir file")
+ assert_equal(categories(:sub_special_1).class, SpecialCategory)
+ end
+
+ def test_subsubdir_file_with_arbitrary_name
+ assert_equal(categories(:sub_special_3).name, "A special category in an arbitrarily named subsubdir file")
+ assert_equal(categories(:sub_special_3).class, SpecialCategory)
+ end
+
+ def test_binary_in_fixtures
+ data = File.open(ASSETS_ROOT + "/flowers.jpg", "rb") { |f| f.read }
+ data.force_encoding("ASCII-8BIT")
+ data.freeze
+ assert_equal data, @flowers.data
+ assert_equal data, @binary_helper.data
+ end
+
+ def test_serialized_fixtures
+ assert_equal ["Green", "Red", "Orange"], traffic_lights(:uk).state
+ end
+
+ def test_fixtures_are_set_up_with_database_env_variable
+ db_url_tmp = ENV["DATABASE_URL"]
+ ENV["DATABASE_URL"] = "sqlite3::memory:"
+ ActiveRecord::Base.stub(:configurations, {}) do
+ test_case = Class.new(ActiveRecord::TestCase) do
+ fixtures :accounts
+
+ def test_fixtures
+ assert accounts(:signals37)
+ end
+ end
+
+ result = test_case.new(:test_fixtures).run
+
+ assert result.passed?, "Expected #{result.name} to pass:\n#{result}"
+ end
+ ensure
+ ENV["DATABASE_URL"] = db_url_tmp
+ end
+end
+
+class HasManyThroughFixture < ActiveRecord::TestCase
+ def make_model(name)
+ Class.new(ActiveRecord::Base) { define_singleton_method(:name) { name } }
+ end
+
+ def test_has_many_through_with_default_table_name
+ pt = make_model "ParrotTreasure"
+ parrot = make_model "Parrot"
+ treasure = make_model "Treasure"
+
+ pt.table_name = "parrots_treasures"
+ pt.belongs_to :parrot, anonymous_class: parrot
+ pt.belongs_to :treasure, anonymous_class: treasure
+
+ parrot.has_many :parrot_treasures, anonymous_class: pt
+ parrot.has_many :treasures, through: :parrot_treasures
+
+ parrots = File.join FIXTURES_ROOT, "parrots"
+
+ fs = ActiveRecord::FixtureSet.new(nil, "parrots", parrot, parrots)
+ rows = fs.table_rows
+ assert_equal load_has_and_belongs_to_many["parrots_treasures"], rows["parrots_treasures"]
+ end
+
+ def test_has_many_through_with_renamed_table
+ pt = make_model "ParrotTreasure"
+ parrot = make_model "Parrot"
+ treasure = make_model "Treasure"
+
+ pt.belongs_to :parrot, anonymous_class: parrot
+ pt.belongs_to :treasure, anonymous_class: treasure
+
+ parrot.has_many :parrot_treasures, anonymous_class: pt
+ parrot.has_many :treasures, through: :parrot_treasures
+
+ parrots = File.join FIXTURES_ROOT, "parrots"
+
+ fs = ActiveRecord::FixtureSet.new(nil, "parrots", parrot, parrots)
+ rows = fs.table_rows
+ assert_equal load_has_and_belongs_to_many["parrots_treasures"], rows["parrot_treasures"]
+ end
+
+ def test_has_and_belongs_to_many_order
+ assert_equal ["parrots", "parrots_treasures"], load_has_and_belongs_to_many.keys
+ end
+
+ def load_has_and_belongs_to_many
+ parrot = make_model "Parrot"
+ parrot.has_and_belongs_to_many :treasures
+
+ parrots = File.join FIXTURES_ROOT, "parrots"
+
+ fs = ActiveRecord::FixtureSet.new(nil, "parrots", parrot, parrots)
+ fs.table_rows
+ end
+end
+
+if Account.connection.respond_to?(:reset_pk_sequence!)
+ class FixturesResetPkSequenceTest < ActiveRecord::TestCase
+ fixtures :accounts
+ fixtures :companies
+ self.use_transactional_tests = false
+
+ def setup
+ @instances = [Account.new(credit_limit: 50), Company.new(name: "RoR Consulting"), Course.new(name: "Test")]
+ ActiveRecord::FixtureSet.reset_cache # make sure tables get reinitialized
+ end
+
+ def test_resets_to_min_pk_with_specified_pk_and_sequence
+ @instances.each do |instance|
+ model = instance.class
+ model.delete_all
+ model.connection.reset_pk_sequence!(model.table_name, model.primary_key, model.sequence_name)
+
+ instance.save!
+ assert_equal 1, instance.id, "Sequence reset for #{model.table_name} failed."
+ end
+ end
+
+ def test_resets_to_min_pk_with_default_pk_and_sequence
+ @instances.each do |instance|
+ model = instance.class
+ model.delete_all
+ model.connection.reset_pk_sequence!(model.table_name)
+
+ instance.save!
+ assert_equal 1, instance.id, "Sequence reset for #{model.table_name} failed."
+ end
+ end
+
+ def test_create_fixtures_resets_sequences_when_not_cached
+ @instances.each do |instance|
+ max_id = create_fixtures(instance.class.table_name).first.fixtures.inject(0) do |_max_id, (_, fixture)|
+ fixture_id = fixture["id"].to_i
+ fixture_id > _max_id ? fixture_id : _max_id
+ end
+
+ # Clone the last fixture to check that it gets the next greatest id.
+ instance.save!
+ assert_equal max_id + 1, instance.id, "Sequence reset for #{instance.class.table_name} failed."
+ end
+ end
+ end
+end
+
+class FixturesWithoutInstantiationTest < ActiveRecord::TestCase
+ self.use_instantiated_fixtures = false
+ fixtures :topics, :developers, :accounts
+
+ def test_without_complete_instantiation
+ assert_not defined?(@first)
+ assert_not defined?(@topics)
+ assert_not defined?(@developers)
+ assert_not defined?(@accounts)
+ end
+
+ def test_fixtures_from_root_yml_without_instantiation
+ assert_not defined?(@unknown), "@unknown is not defined"
+ end
+
+ def test_visibility_of_accessor_method
+ assert_equal false, respond_to?(:topics, false), "should be private method"
+ assert_equal true, respond_to?(:topics, true), "confirm to respond surely"
+ end
+
+ def test_accessor_methods
+ assert_equal "The First Topic", topics(:first).title
+ assert_equal "Jamis", developers(:jamis).name
+ assert_equal 50, accounts(:signals37).credit_limit
+ end
+
+ def test_accessor_methods_with_multiple_args
+ assert_equal 2, topics(:first, :second).size
+ assert_raise(StandardError) { topics([:first, :second]) }
+ end
+
+ def test_reloading_fixtures_through_accessor_methods
+ topic = Struct.new(:title)
+ assert_equal "The First Topic", topics(:first).title
+ assert_called(@loaded_fixtures["topics"]["first"], :find, returns: topic.new("Fresh Topic!")) do
+ assert_equal "Fresh Topic!", topics(:first, true).title
+ end
+ end
+end
+
+class FixturesWithoutInstanceInstantiationTest < ActiveRecord::TestCase
+ self.use_instantiated_fixtures = true
+ self.use_instantiated_fixtures = :no_instances
+
+ fixtures :topics, :developers, :accounts
+
+ def test_without_instance_instantiation
+ assert_not defined?(@first), "@first is not defined"
+ end
+end
+
+class TransactionalFixturesTest < ActiveRecord::TestCase
+ self.use_instantiated_fixtures = true
+ self.use_transactional_tests = true
+
+ fixtures :topics
+
+ def test_destroy
+ assert_not_nil @first
+ @first.destroy
+ end
+
+ def test_destroy_just_kidding
+ assert_not_nil @first
+ end
+end
+
+class MultipleFixturesTest < ActiveRecord::TestCase
+ fixtures :topics
+ fixtures :developers, :accounts
+
+ def test_fixture_table_names
+ assert_equal %w(topics developers accounts), fixture_table_names
+ end
+end
+
+class SetupTest < ActiveRecord::TestCase
+ # fixtures :topics
+
+ def setup
+ @first = true
+ end
+
+ def test_nothing
+ end
+end
+
+class SetupSubclassTest < SetupTest
+ def setup
+ super
+ @second = true
+ end
+
+ def test_subclassing_should_preserve_setups
+ assert @first
+ assert @second
+ end
+end
+
+class OverlappingFixturesTest < ActiveRecord::TestCase
+ fixtures :topics, :developers
+ fixtures :developers, :accounts
+
+ def test_fixture_table_names
+ assert_equal %w(topics developers accounts), fixture_table_names
+ end
+end
+
+class ForeignKeyFixturesTest < ActiveRecord::TestCase
+ fixtures :fk_test_has_pk, :fk_test_has_fk
+
+ # if foreign keys are implemented and fixtures
+ # are not deleted in reverse order then this test
+ # case will raise StatementInvalid
+
+ def test_number1
+ assert true
+ end
+
+ def test_number2
+ assert true
+ end
+end
+
+class OverRideFixtureMethodTest < ActiveRecord::TestCase
+ fixtures :topics
+
+ def topics(name)
+ topic = super
+ topic.title = "omg"
+ topic
+ end
+
+ def test_fixture_methods_can_be_overridden
+ x = topics :first
+ assert_equal "omg", x.title
+ end
+end
+
+class FixtureWithSetModelClassTest < ActiveRecord::TestCase
+ fixtures :other_posts, :other_comments
+
+ # Set to false to blow away fixtures cache and ensure our fixtures are loaded
+ # and thus takes into account the +set_model_class+.
+ self.use_transactional_tests = false
+
+ def test_uses_fixture_class_defined_in_yaml
+ assert_kind_of Post, other_posts(:second_welcome)
+ end
+
+ def test_loads_the_associations_to_fixtures_with_set_model_class
+ post = other_posts(:second_welcome)
+ comment = other_comments(:second_greetings)
+ assert_equal [comment], post.comments
+ assert_equal post, comment.post
+ end
+end
+
+class SetFixtureClassPrevailsTest < ActiveRecord::TestCase
+ set_fixture_class bad_posts: Post
+ fixtures :bad_posts
+
+ # Set to false to blow away fixtures cache and ensure our fixtures are loaded
+ # and thus takes into account the +set_model_class+.
+ self.use_transactional_tests = false
+
+ def test_uses_set_fixture_class
+ assert_kind_of Post, bad_posts(:bad_welcome)
+ end
+end
+
+class CheckSetTableNameFixturesTest < ActiveRecord::TestCase
+ set_fixture_class funny_jokes: Joke
+ fixtures :funny_jokes
+ # Set to false to blow away fixtures cache and ensure our fixtures are loaded
+ # and thus takes into account our set_fixture_class
+ self.use_transactional_tests = false
+
+ def test_table_method
+ assert_kind_of Joke, funny_jokes(:a_joke)
+ end
+end
+
+class FixtureNameIsNotTableNameFixturesTest < ActiveRecord::TestCase
+ set_fixture_class items: Book
+ fixtures :items
+ # Set to false to blow away fixtures cache and ensure our fixtures are loaded
+ # and thus takes into account our set_fixture_class
+ self.use_transactional_tests = false
+
+ def test_named_accessor
+ assert_kind_of Book, items(:dvd)
+ end
+end
+
+class FixtureNameIsNotTableNameMultipleFixturesTest < ActiveRecord::TestCase
+ set_fixture_class items: Book, funny_jokes: Joke
+ fixtures :items, :funny_jokes
+ # Set to false to blow away fixtures cache and ensure our fixtures are loaded
+ # and thus takes into account our set_fixture_class
+ self.use_transactional_tests = false
+
+ def test_named_accessor_of_differently_named_fixture
+ assert_kind_of Book, items(:dvd)
+ end
+
+ def test_named_accessor_of_same_named_fixture
+ assert_kind_of Joke, funny_jokes(:a_joke)
+ end
+end
+
+class CustomConnectionFixturesTest < ActiveRecord::TestCase
+ set_fixture_class courses: Course
+ fixtures :courses
+ self.use_transactional_tests = false
+
+ def test_leaky_destroy
+ assert_nothing_raised { courses(:ruby) }
+ courses(:ruby).destroy
+ end
+
+ def test_it_twice_in_whatever_order_to_check_for_fixture_leakage
+ test_leaky_destroy
+ end
+end
+
+class TransactionalFixturesOnCustomConnectionTest < ActiveRecord::TestCase
+ set_fixture_class courses: Course
+ fixtures :courses
+ self.use_transactional_tests = true
+
+ def test_leaky_destroy
+ assert_nothing_raised { courses(:ruby) }
+ courses(:ruby).destroy
+ end
+
+ def test_it_twice_in_whatever_order_to_check_for_fixture_leakage
+ test_leaky_destroy
+ end
+end
+
+class TransactionalFixturesOnConnectionNotification < ActiveRecord::TestCase
+ self.use_transactional_tests = true
+ self.use_instantiated_fixtures = false
+
+ def test_transaction_created_on_connection_notification
+ connection = Class.new do
+ attr_accessor :pool
+
+ def transaction_open?; end
+ def begin_transaction(*args); end
+ def rollback_transaction(*args); end
+ end.new
+
+ connection.pool = Class.new do
+ def lock_thread=(lock_thread); end
+ end.new
+
+ assert_called_with(connection, :begin_transaction, [joinable: false]) do
+ fire_connection_notification(connection)
+ end
+ end
+
+ def test_notification_established_transactions_are_rolled_back
+ connection = Class.new do
+ attr_accessor :rollback_transaction_called
+ attr_accessor :pool
+
+ def transaction_open?; true; end
+ def begin_transaction(*args); end
+ def rollback_transaction(*args)
+ @rollback_transaction_called = true
+ end
+ end.new
+
+ connection.pool = Class.new do
+ def lock_thread=(lock_thread); end
+ end.new
+
+ fire_connection_notification(connection)
+ teardown_fixtures
+
+ assert(connection.rollback_transaction_called, "Expected <mock connection>#rollback_transaction to be called but was not")
+ end
+
+ private
+
+ def fire_connection_notification(connection)
+ assert_called_with(ActiveRecord::Base.connection_handler, :retrieve_connection, ["book"], returns: connection) do
+ message_bus = ActiveSupport::Notifications.instrumenter
+ payload = {
+ spec_name: "book",
+ config: nil,
+ connection_id: connection.object_id
+ }
+
+ message_bus.instrument("!connection.active_record", payload) { }
+ end
+ end
+end
+
+class InvalidTableNameFixturesTest < ActiveRecord::TestCase
+ fixtures :funny_jokes
+ # Set to false to blow away fixtures cache and ensure our fixtures are loaded
+ # and thus takes into account our lack of set_fixture_class
+ self.use_transactional_tests = false
+
+ def test_raises_error
+ assert_raise ActiveRecord::FixtureClassNotFound do
+ funny_jokes(:a_joke)
+ end
+ end
+end
+
+class CheckEscapedYamlFixturesTest < ActiveRecord::TestCase
+ set_fixture_class funny_jokes: Joke
+ fixtures :funny_jokes
+ # Set to false to blow away fixtures cache and ensure our fixtures are loaded
+ # and thus takes into account our set_fixture_class
+ self.use_transactional_tests = false
+
+ def test_proper_escaped_fixture
+ assert_equal "The \\n Aristocrats\nAte the candy\n", funny_jokes(:another_joke).name
+ end
+end
+
+class DevelopersProject; end
+class ManyToManyFixturesWithClassDefined < ActiveRecord::TestCase
+ fixtures :developers_projects
+
+ def test_this_should_run_cleanly
+ assert true
+ end
+end
+
+class FixturesBrokenRollbackTest < ActiveRecord::TestCase
+ def blank_setup
+ @fixture_connections = [ActiveRecord::Base.connection]
+ end
+ alias_method :ar_setup_fixtures, :setup_fixtures
+ alias_method :setup_fixtures, :blank_setup
+ alias_method :setup, :blank_setup
+
+ def blank_teardown; end
+ alias_method :ar_teardown_fixtures, :teardown_fixtures
+ alias_method :teardown_fixtures, :blank_teardown
+ alias_method :teardown, :blank_teardown
+
+ def test_no_rollback_in_teardown_unless_transaction_active
+ assert_equal 0, ActiveRecord::Base.connection.open_transactions
+ assert_raise(RuntimeError) { ar_setup_fixtures }
+ assert_equal 0, ActiveRecord::Base.connection.open_transactions
+ assert_nothing_raised { ar_teardown_fixtures }
+ assert_equal 0, ActiveRecord::Base.connection.open_transactions
+ end
+
+ private
+ def load_fixtures(config)
+ raise "argh"
+ end
+end
+
+class LoadAllFixturesTest < ActiveRecord::TestCase
+ def test_all_there
+ self.class.fixture_path = FIXTURES_ROOT + "/all"
+ self.class.fixtures :all
+
+ if File.symlink? FIXTURES_ROOT + "/all/admin"
+ assert_equal %w(admin/accounts admin/users developers namespaced/accounts people tasks), fixture_table_names.sort
+ end
+ ensure
+ ActiveRecord::FixtureSet.reset_cache
+ end
+end
+
+class LoadAllFixturesWithPathnameTest < ActiveRecord::TestCase
+ def test_all_there
+ self.class.fixture_path = Pathname.new(FIXTURES_ROOT).join("all")
+ self.class.fixtures :all
+
+ if File.symlink? FIXTURES_ROOT + "/all/admin"
+ assert_equal %w(admin/accounts admin/users developers namespaced/accounts people tasks), fixture_table_names.sort
+ end
+ ensure
+ ActiveRecord::FixtureSet.reset_cache
+ end
+end
+
+class FasterFixturesTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+ fixtures :categories, :authors, :author_addresses
+
+ def load_extra_fixture(name)
+ fixture = create_fixtures(name).first
+ assert fixture.is_a?(ActiveRecord::FixtureSet)
+ @loaded_fixtures[fixture.table_name] = fixture
+ end
+
+ def test_cache
+ assert ActiveRecord::FixtureSet.fixture_is_cached?(ActiveRecord::Base.connection, "categories")
+ assert ActiveRecord::FixtureSet.fixture_is_cached?(ActiveRecord::Base.connection, "authors")
+
+ assert_no_queries do
+ create_fixtures("categories")
+ create_fixtures("authors")
+ end
+
+ load_extra_fixture("posts")
+ assert ActiveRecord::FixtureSet.fixture_is_cached?(ActiveRecord::Base.connection, "posts")
+ self.class.setup_fixture_accessors :posts
+ assert_equal "Welcome to the weblog", posts(:welcome).title
+ end
+end
+
+class FoxyFixturesTest < ActiveRecord::TestCase
+ # Set to false to blow away fixtures cache and ensure our fixtures are loaded
+ self.use_transactional_tests = false
+ fixtures :parrots, :parrots_pirates, :pirates, :treasures, :mateys, :ships, :computers,
+ :developers, :"admin/accounts", :"admin/users", :live_parrots, :dead_parrots, :books
+
+ if ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
+ require "models/uuid_parent"
+ require "models/uuid_child"
+ fixtures :uuid_parents, :uuid_children
+ end
+
+ def test_identifies_strings
+ assert_equal(ActiveRecord::FixtureSet.identify("foo"), ActiveRecord::FixtureSet.identify("foo"))
+ assert_not_equal(ActiveRecord::FixtureSet.identify("foo"), ActiveRecord::FixtureSet.identify("FOO"))
+ end
+
+ def test_identifies_symbols
+ assert_equal(ActiveRecord::FixtureSet.identify(:foo), ActiveRecord::FixtureSet.identify(:foo))
+ end
+
+ def test_identifies_consistently
+ assert_equal 207281424, ActiveRecord::FixtureSet.identify(:ruby)
+ assert_equal 1066363776, ActiveRecord::FixtureSet.identify(:sapphire_2)
+
+ assert_equal "f92b6bda-0d0d-5fe1-9124-502b18badded", ActiveRecord::FixtureSet.identify(:daddy, :uuid)
+ assert_equal "b4b10018-ad47-595d-b42f-d8bdaa6d01bf", ActiveRecord::FixtureSet.identify(:sonny, :uuid)
+ end
+
+ TIMESTAMP_COLUMNS = %w(created_at created_on updated_at updated_on)
+
+ def test_populates_timestamp_columns
+ TIMESTAMP_COLUMNS.each do |property|
+ assert_not_nil(parrots(:george).send(property), "should set #{property}")
+ end
+ end
+
+ def test_does_not_populate_timestamp_columns_if_model_has_set_record_timestamps_to_false
+ TIMESTAMP_COLUMNS.each do |property|
+ assert_nil(ships(:black_pearl).send(property), "should not set #{property}")
+ end
+ end
+
+ def test_populates_all_columns_with_the_same_time
+ last = nil
+
+ TIMESTAMP_COLUMNS.each do |property|
+ current = parrots(:george).send(property)
+ last ||= current
+
+ assert_equal(last, current)
+ last = current
+ end
+ end
+
+ def test_only_populates_columns_that_exist
+ assert_not_nil(pirates(:blackbeard).created_on)
+ assert_not_nil(pirates(:blackbeard).updated_on)
+ end
+
+ def test_preserves_existing_fixture_data
+ assert_equal(2.weeks.ago.to_date, pirates(:redbeard).created_on.to_date)
+ assert_equal(2.weeks.ago.to_date, pirates(:redbeard).updated_on.to_date)
+ end
+
+ def test_generates_unique_ids
+ assert_not_nil(parrots(:george).id)
+ assert_not_equal(parrots(:george).id, parrots(:louis).id)
+ end
+
+ def test_automatically_sets_primary_key
+ assert_not_nil(ships(:black_pearl))
+ end
+
+ def test_preserves_existing_primary_key
+ assert_equal(2, ships(:interceptor).id)
+ end
+
+ def test_resolves_belongs_to_symbols
+ assert_equal(parrots(:george), pirates(:blackbeard).parrot)
+ end
+
+ def test_ignores_belongs_to_symbols_if_association_and_foreign_key_are_named_the_same
+ assert_equal(developers(:david), computers(:workstation).developer)
+ end
+
+ def test_supports_join_tables
+ assert(pirates(:blackbeard).parrots.include?(parrots(:george)))
+ assert(pirates(:blackbeard).parrots.include?(parrots(:louis)))
+ assert(parrots(:george).pirates.include?(pirates(:blackbeard)))
+ end
+
+ def test_supports_inline_habtm
+ assert(parrots(:george).treasures.include?(treasures(:diamond)))
+ assert(parrots(:george).treasures.include?(treasures(:sapphire)))
+ assert_not(parrots(:george).treasures.include?(treasures(:ruby)))
+ end
+
+ def test_supports_inline_habtm_with_specified_id
+ assert(parrots(:polly).treasures.include?(treasures(:ruby)))
+ assert(parrots(:polly).treasures.include?(treasures(:sapphire)))
+ assert_not(parrots(:polly).treasures.include?(treasures(:diamond)))
+ end
+
+ def test_supports_yaml_arrays
+ assert(parrots(:louis).treasures.include?(treasures(:diamond)))
+ assert(parrots(:louis).treasures.include?(treasures(:sapphire)))
+ end
+
+ def test_strips_DEFAULTS_key
+ assert_raise(StandardError) { parrots(:DEFAULTS) }
+
+ # this lets us do YAML defaults and not have an extra fixture entry
+ %w(sapphire ruby).each { |t| assert(parrots(:davey).treasures.include?(treasures(t))) }
+ end
+
+ def test_supports_label_interpolation
+ assert_equal("frederick", parrots(:frederick).name)
+ end
+
+ def test_supports_label_string_interpolation
+ assert_equal("X marks the spot!", pirates(:mark).catchphrase)
+ end
+
+ def test_supports_label_interpolation_for_integer_label
+ assert_equal("#1 pirate!", pirates(1).catchphrase)
+ end
+
+ def test_supports_polymorphic_belongs_to
+ assert_equal(pirates(:redbeard), treasures(:sapphire).looter)
+ assert_equal(parrots(:louis), treasures(:ruby).looter)
+ end
+
+ def test_only_generates_a_pk_if_necessary
+ m = Matey.first
+ m.pirate = pirates(:blackbeard)
+ m.target = pirates(:redbeard)
+ end
+
+ def test_supports_sti
+ assert_kind_of DeadParrot, parrots(:polly)
+ assert_equal pirates(:blackbeard), parrots(:polly).killer
+ end
+
+ def test_supports_sti_with_respective_files
+ assert_kind_of LiveParrot, live_parrots(:dusty)
+ assert_kind_of DeadParrot, dead_parrots(:deadbird)
+ assert_equal pirates(:blackbeard), dead_parrots(:deadbird).killer
+ end
+
+ def test_namespaced_models
+ assert_includes admin_accounts(:signals37).users, admin_users(:david)
+ assert_equal 2, admin_accounts(:signals37).users.size
+ end
+
+ def test_resolves_enums
+ assert_predicate books(:awdr), :published?
+ assert_predicate books(:awdr), :read?
+ assert_predicate books(:rfr), :proposed?
+ assert_predicate books(:ddd), :published?
+ end
+end
+
+class ActiveSupportSubclassWithFixturesTest < ActiveRecord::TestCase
+ fixtures :parrots
+
+ # This seemingly useless assertion catches a bug that caused the fixtures
+ # setup code call nil[]
+ def test_foo
+ assert_equal parrots(:louis), Parrot.find_by_name("King Louis")
+ end
+end
+
+class CustomNameForFixtureOrModelTest < ActiveRecord::TestCase
+ ActiveRecord::FixtureSet.reset_cache
+
+ set_fixture_class :randomly_named_a9 =>
+ ClassNameThatDoesNotFollowCONVENTIONS,
+ :'admin/randomly_named_a9' =>
+ Admin::ClassNameThatDoesNotFollowCONVENTIONS1,
+ "admin/randomly_named_b0" =>
+ Admin::ClassNameThatDoesNotFollowCONVENTIONS2
+
+ fixtures :randomly_named_a9, "admin/randomly_named_a9",
+ :'admin/randomly_named_b0'
+
+ def test_named_accessor_for_randomly_named_fixture_and_class
+ assert_kind_of ClassNameThatDoesNotFollowCONVENTIONS,
+ randomly_named_a9(:first_instance)
+ end
+
+ def test_named_accessor_for_randomly_named_namespaced_fixture_and_class
+ assert_kind_of Admin::ClassNameThatDoesNotFollowCONVENTIONS1,
+ admin_randomly_named_a9(:first_instance)
+ assert_kind_of Admin::ClassNameThatDoesNotFollowCONVENTIONS2,
+ admin_randomly_named_b0(:second_instance)
+ end
+
+ def test_table_name_is_defined_in_the_model
+ assert_equal "randomly_named_table2", ActiveRecord::FixtureSet.all_loaded_fixtures["admin/randomly_named_a9"].table_name
+ assert_equal "randomly_named_table2", Admin::ClassNameThatDoesNotFollowCONVENTIONS1.table_name
+ end
+end
+
+class FixturesWithDefaultScopeTest < ActiveRecord::TestCase
+ fixtures :bulbs
+
+ test "inserts fixtures excluded by a default scope" do
+ assert_equal 1, Bulb.count
+ assert_equal 2, Bulb.unscoped.count
+ end
+
+ test "allows access to fixtures excluded by a default scope" do
+ assert_equal "special", bulbs(:special).name
+ end
+end
+
+class FixturesWithAbstractBelongsTo < ActiveRecord::TestCase
+ fixtures :pirates, :doubloons
+
+ test "creates fixtures with belongs_to associations defined in abstract base classes" do
+ assert_not_nil doubloons(:blackbeards_doubloon)
+ assert_equal pirates(:blackbeard), doubloons(:blackbeards_doubloon).pirate
+ end
+end
+
+class FixtureClassNamesTest < ActiveRecord::TestCase
+ def setup
+ @saved_cache = fixture_class_names.dup
+ end
+
+ def teardown
+ fixture_class_names.replace(@saved_cache)
+ end
+
+ test "fixture_class_names returns nil for unregistered identifier" do
+ assert_nil fixture_class_names["unregistered_identifier"]
+ end
+end
+
+class SameNameDifferentDatabaseFixturesTest < ActiveRecord::TestCase
+ fixtures :dogs, :other_dogs
+
+ test "fixtures are properly loaded" do
+ # Force loading the fixtures again to reproduce issue
+ ActiveRecord::FixtureSet.reset_cache
+ create_fixtures("dogs", "other_dogs")
+
+ assert_kind_of Dog, dogs(:sophie)
+ assert_kind_of OtherDog, other_dogs(:lassie)
+ end
+end
+
+class NilFixturePathTest < ActiveRecord::TestCase
+ test "raises an error when all fixtures loaded" do
+ error = assert_raises(StandardError) do
+ TestCase = Class.new(ActiveRecord::TestCase)
+ TestCase.class_eval do
+ self.fixture_path = nil
+ fixtures :all
+ end
+ end
+ assert_equal <<~MSG.squish, error.message
+ No fixture path found.
+ Please set `NilFixturePathTest::TestCase.fixture_path`.
+ MSG
+ end
+end
diff --git a/activerecord/test/cases/forbidden_attributes_protection_test.rb b/activerecord/test/cases/forbidden_attributes_protection_test.rb
new file mode 100644
index 0000000000..101fa118c8
--- /dev/null
+++ b/activerecord/test/cases/forbidden_attributes_protection_test.rb
@@ -0,0 +1,166 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "active_support/core_ext/hash/indifferent_access"
+
+require "models/company"
+require "models/person"
+require "models/ship"
+require "models/ship_part"
+require "models/treasure"
+
+class ProtectedParams
+ attr_accessor :permitted
+ alias :permitted? :permitted
+
+ delegate :keys, :key?, :has_key?, :empty?, to: :@parameters
+
+ def initialize(attributes)
+ @parameters = attributes.with_indifferent_access
+ @permitted = false
+ end
+
+ def permit!
+ @permitted = true
+ self
+ end
+
+ def [](key)
+ @parameters[key]
+ end
+
+ def to_h
+ @parameters
+ end
+
+ def stringify_keys
+ dup
+ end
+
+ def dup
+ super.tap do |duplicate|
+ duplicate.instance_variable_set :@permitted, @permitted
+ end
+ end
+end
+
+class ForbiddenAttributesProtectionTest < ActiveRecord::TestCase
+ def test_forbidden_attributes_cannot_be_used_for_mass_assignment
+ params = ProtectedParams.new(first_name: "Guille", gender: "m")
+ assert_raises(ActiveModel::ForbiddenAttributesError) do
+ Person.new(params)
+ end
+ end
+
+ def test_permitted_attributes_can_be_used_for_mass_assignment
+ params = ProtectedParams.new(first_name: "Guille", gender: "m")
+ params.permit!
+ person = Person.new(params)
+
+ assert_equal "Guille", person.first_name
+ assert_equal "m", person.gender
+ end
+
+ def test_forbidden_attributes_cannot_be_used_for_sti_inheritance_column
+ params = ProtectedParams.new(type: "Client")
+ assert_raises(ActiveModel::ForbiddenAttributesError) do
+ Company.new(params)
+ end
+ end
+
+ def test_permitted_attributes_can_be_used_for_sti_inheritance_column
+ params = ProtectedParams.new(type: "Client")
+ params.permit!
+ person = Company.new(params)
+ assert_equal person.class, Client
+ end
+
+ def test_regular_hash_should_still_be_used_for_mass_assignment
+ person = Person.new(first_name: "Guille", gender: "m")
+
+ assert_equal "Guille", person.first_name
+ assert_equal "m", person.gender
+ end
+
+ def test_blank_attributes_should_not_raise
+ person = Person.new
+ assert_nil person.assign_attributes(ProtectedParams.new({}))
+ end
+
+ def test_create_with_checks_permitted
+ params = ProtectedParams.new(first_name: "Guille", gender: "m")
+
+ assert_raises(ActiveModel::ForbiddenAttributesError) do
+ Person.create_with(params).create!
+ end
+ end
+
+ def test_create_with_works_with_permitted_params
+ params = ProtectedParams.new(first_name: "Guille").permit!
+
+ person = Person.create_with(params).create!
+ assert_equal "Guille", person.first_name
+ end
+
+ def test_create_with_works_with_params_values
+ params = ProtectedParams.new(first_name: "Guille")
+
+ person = Person.create_with(first_name: params[:first_name]).create!
+ assert_equal "Guille", person.first_name
+ end
+
+ def test_where_checks_permitted
+ params = ProtectedParams.new(first_name: "Guille", gender: "m")
+
+ assert_raises(ActiveModel::ForbiddenAttributesError) do
+ Person.where(params).create!
+ end
+ end
+
+ def test_where_works_with_permitted_params
+ params = ProtectedParams.new(first_name: "Guille").permit!
+
+ person = Person.where(params).create!
+ assert_equal "Guille", person.first_name
+ end
+
+ def test_where_works_with_params_values
+ params = ProtectedParams.new(first_name: "Guille")
+
+ person = Person.where(first_name: params[:first_name]).create!
+ assert_equal "Guille", person.first_name
+ end
+
+ def test_where_not_checks_permitted
+ params = ProtectedParams.new(first_name: "Guille", gender: "m")
+
+ assert_raises(ActiveModel::ForbiddenAttributesError) do
+ Person.where().not(params)
+ end
+ end
+
+ def test_where_not_works_with_permitted_params
+ params = ProtectedParams.new(first_name: "Guille").permit!
+ Person.create!(params)
+ assert_empty Person.where.not(params).select { |p| p.first_name == "Guille" }
+ end
+
+ def test_strong_params_style_objects_work_with_singular_associations
+ params = ProtectedParams.new(name: "Stern", ship_attributes: ProtectedParams.new(name: "The Black Rock").permit!).permit!
+ part = ShipPart.new(params)
+
+ assert_equal "Stern", part.name
+ assert_equal "The Black Rock", part.ship.name
+ end
+
+ def test_strong_params_style_objects_work_with_collection_associations
+ params = ProtectedParams.new(
+ trinkets_attributes: ProtectedParams.new(
+ "0" => ProtectedParams.new(name: "Necklace").permit!,
+ "1" => ProtectedParams.new(name: "Spoon").permit!)).permit!
+ part = ShipPart.new(params)
+
+ assert_equal "Necklace", part.trinkets[0].name
+ assert_equal "Spoon", part.trinkets[1].name
+ end
+end
diff --git a/activerecord/test/cases/habtm_destroy_order_test.rb b/activerecord/test/cases/habtm_destroy_order_test.rb
new file mode 100644
index 0000000000..9dbd339fe7
--- /dev/null
+++ b/activerecord/test/cases/habtm_destroy_order_test.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/lesson"
+require "models/student"
+
+class HabtmDestroyOrderTest < ActiveRecord::TestCase
+ test "may not delete a lesson with students" do
+ sicp = Lesson.new(name: "SICP")
+ ben = Student.new(name: "Ben Bitdiddle")
+ sicp.students << ben
+ sicp.save!
+ assert_raises LessonError do
+ assert_no_difference("Lesson.count") do
+ sicp.destroy
+ end
+ end
+ assert_not_predicate sicp, :destroyed?
+ end
+
+ test "should not raise error if have foreign key in the join table" do
+ student = Student.new(name: "Ben Bitdiddle")
+ lesson = Lesson.new(name: "SICP")
+ lesson.students << student
+ lesson.save!
+ assert_nothing_raised do
+ student.destroy
+ end
+ end
+
+ test "not destroying a student with lessons leaves student<=>lesson association intact" do
+ # test a normal before_destroy doesn't destroy the habtm joins
+ sicp = Lesson.new(name: "SICP")
+ ben = Student.new(name: "Ben Bitdiddle")
+ # add a before destroy to student
+ Student.class_eval do
+ before_destroy do
+ raise ActiveRecord::Rollback unless lessons.empty?
+ end
+ end
+ ben.lessons << sicp
+ ben.save!
+ ben.destroy
+ assert_not_empty ben.reload.lessons
+ ensure
+ # get rid of it so Student is still like it was
+ Student.reset_callbacks(:destroy)
+ end
+
+ test "not destroying a lesson with students leaves student<=>lesson association intact" do
+ # test a more aggressive before_destroy doesn't destroy the habtm joins and still throws the exception
+ sicp = Lesson.new(name: "SICP")
+ ben = Student.new(name: "Ben Bitdiddle")
+ sicp.students << ben
+ sicp.save!
+ assert_raises LessonError do
+ sicp.destroy
+ end
+ assert_not_empty sicp.reload.students
+ end
+end
diff --git a/activerecord/test/cases/helper.rb b/activerecord/test/cases/helper.rb
new file mode 100644
index 0000000000..730cd663a2
--- /dev/null
+++ b/activerecord/test/cases/helper.rb
@@ -0,0 +1,194 @@
+# frozen_string_literal: true
+
+require "config"
+
+require "stringio"
+
+require "active_record"
+require "cases/test_case"
+require "active_support/dependencies"
+require "active_support/logger"
+
+require "support/config"
+require "support/connection"
+
+# TODO: Move all these random hacks into the ARTest namespace and into the support/ dir
+
+Thread.abort_on_exception = true
+
+# Show backtraces for deprecated behavior for quicker cleanup.
+ActiveSupport::Deprecation.debug = true
+
+# Disable available locale checks to avoid warnings running the test suite.
+I18n.enforce_available_locales = false
+
+# Connect to the database
+ARTest.connect
+
+# Quote "type" if it's a reserved word for the current connection.
+QUOTED_TYPE = ActiveRecord::Base.connection.quote_column_name("type")
+
+def current_adapter?(*types)
+ types.any? do |type|
+ ActiveRecord::ConnectionAdapters.const_defined?(type) &&
+ ActiveRecord::Base.connection.is_a?(ActiveRecord::ConnectionAdapters.const_get(type))
+ end
+end
+
+def in_memory_db?
+ current_adapter?(:SQLite3Adapter) &&
+ ActiveRecord::Base.connection_pool.spec.config[:database] == ":memory:"
+end
+
+def subsecond_precision_supported?
+ ActiveRecord::Base.connection.supports_datetime_with_precision?
+end
+
+def mysql_enforcing_gtid_consistency?
+ current_adapter?(:Mysql2Adapter) && "ON" == ActiveRecord::Base.connection.show_variable("enforce_gtid_consistency")
+end
+
+def supports_default_expression?
+ if current_adapter?(:PostgreSQLAdapter)
+ true
+ elsif current_adapter?(:Mysql2Adapter)
+ conn = ActiveRecord::Base.connection
+ !conn.mariadb? && conn.version >= "8.0.13"
+ end
+end
+
+def supports_savepoints?
+ ActiveRecord::Base.connection.supports_savepoints?
+end
+
+def with_env_tz(new_tz = "US/Eastern")
+ old_tz, ENV["TZ"] = ENV["TZ"], new_tz
+ yield
+ensure
+ old_tz ? ENV["TZ"] = old_tz : ENV.delete("TZ")
+end
+
+def with_timezone_config(cfg)
+ verify_default_timezone_config
+
+ old_default_zone = ActiveRecord::Base.default_timezone
+ old_awareness = ActiveRecord::Base.time_zone_aware_attributes
+ old_zone = Time.zone
+
+ if cfg.has_key?(:default)
+ ActiveRecord::Base.default_timezone = cfg[:default]
+ end
+ if cfg.has_key?(:aware_attributes)
+ ActiveRecord::Base.time_zone_aware_attributes = cfg[:aware_attributes]
+ end
+ if cfg.has_key?(:zone)
+ Time.zone = cfg[:zone]
+ end
+ yield
+ensure
+ ActiveRecord::Base.default_timezone = old_default_zone
+ ActiveRecord::Base.time_zone_aware_attributes = old_awareness
+ Time.zone = old_zone
+end
+
+# This method makes sure that tests don't leak global state related to time zones.
+EXPECTED_ZONE = nil
+EXPECTED_DEFAULT_TIMEZONE = :utc
+EXPECTED_TIME_ZONE_AWARE_ATTRIBUTES = false
+def verify_default_timezone_config
+ if Time.zone != EXPECTED_ZONE
+ $stderr.puts <<-MSG
+\n#{self}
+ Global state `Time.zone` was leaked.
+ Expected: #{EXPECTED_ZONE}
+ Got: #{Time.zone}
+ MSG
+ end
+ if ActiveRecord::Base.default_timezone != EXPECTED_DEFAULT_TIMEZONE
+ $stderr.puts <<-MSG
+\n#{self}
+ Global state `ActiveRecord::Base.default_timezone` was leaked.
+ Expected: #{EXPECTED_DEFAULT_TIMEZONE}
+ Got: #{ActiveRecord::Base.default_timezone}
+ MSG
+ end
+ if ActiveRecord::Base.time_zone_aware_attributes != EXPECTED_TIME_ZONE_AWARE_ATTRIBUTES
+ $stderr.puts <<-MSG
+\n#{self}
+ Global state `ActiveRecord::Base.time_zone_aware_attributes` was leaked.
+ Expected: #{EXPECTED_TIME_ZONE_AWARE_ATTRIBUTES}
+ Got: #{ActiveRecord::Base.time_zone_aware_attributes}
+ MSG
+ end
+end
+
+def enable_extension!(extension, connection)
+ return false unless connection.supports_extensions?
+ return connection.reconnect! if connection.extension_enabled?(extension)
+
+ connection.enable_extension extension
+ connection.commit_db_transaction if connection.transaction_open?
+ connection.reconnect!
+end
+
+def disable_extension!(extension, connection)
+ return false unless connection.supports_extensions?
+ return true unless connection.extension_enabled?(extension)
+
+ connection.disable_extension extension
+ connection.reconnect!
+end
+
+def load_schema
+ # silence verbose schema loading
+ original_stdout = $stdout
+ $stdout = StringIO.new
+
+ adapter_name = ActiveRecord::Base.connection.adapter_name.downcase
+ adapter_specific_schema_file = SCHEMA_ROOT + "/#{adapter_name}_specific_schema.rb"
+
+ load SCHEMA_ROOT + "/schema.rb"
+
+ if File.exist?(adapter_specific_schema_file)
+ load adapter_specific_schema_file
+ end
+
+ ActiveRecord::FixtureSet.reset_cache
+ensure
+ $stdout = original_stdout
+end
+
+load_schema
+
+class SQLSubscriber
+ attr_reader :logged
+ attr_reader :payloads
+
+ def initialize
+ @logged = []
+ @payloads = []
+ end
+
+ def start(name, id, payload)
+ @payloads << payload
+ @logged << [payload[:sql].squish, payload[:name], payload[:binds]]
+ end
+
+ def finish(name, id, payload); end
+end
+
+module InTimeZone
+ private
+
+ def in_time_zone(zone)
+ old_zone = Time.zone
+ old_tz = ActiveRecord::Base.time_zone_aware_attributes
+
+ Time.zone = zone ? ActiveSupport::TimeZone[zone] : nil
+ ActiveRecord::Base.time_zone_aware_attributes = !zone.nil?
+ yield
+ ensure
+ Time.zone = old_zone
+ ActiveRecord::Base.time_zone_aware_attributes = old_tz
+ end
+end
diff --git a/activerecord/test/cases/hot_compatibility_test.rb b/activerecord/test/cases/hot_compatibility_test.rb
new file mode 100644
index 0000000000..7b388ebc5e
--- /dev/null
+++ b/activerecord/test/cases/hot_compatibility_test.rb
@@ -0,0 +1,144 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/connection_helper"
+
+class HotCompatibilityTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+ include ConnectionHelper
+
+ setup do
+ @klass = Class.new(ActiveRecord::Base) do
+ connection.create_table :hot_compatibilities, force: true do |t|
+ t.string :foo
+ t.string :bar
+ end
+
+ def self.name; "HotCompatibility"; end
+ end
+ end
+
+ teardown do
+ ActiveRecord::Base.connection.drop_table :hot_compatibilities
+ end
+
+ test "insert after remove_column" do
+ # warm cache
+ @klass.create!
+
+ # we have 3 columns
+ assert_equal 3, @klass.columns.length
+
+ # remove one of them
+ @klass.connection.remove_column :hot_compatibilities, :bar
+
+ # we still have 3 columns in the cache
+ assert_equal 3, @klass.columns.length
+
+ # but we can successfully create a record so long as we don't
+ # reference the removed column
+ record = @klass.create! foo: "foo"
+ record.reload
+ assert_equal "foo", record.foo
+ end
+
+ test "update after remove_column" do
+ record = @klass.create! foo: "foo"
+ assert_equal 3, @klass.columns.length
+ @klass.connection.remove_column :hot_compatibilities, :bar
+ assert_equal 3, @klass.columns.length
+
+ record.reload
+ assert_equal "foo", record.foo
+ record.foo = "bar"
+ record.save!
+ record.reload
+ assert_equal "bar", record.foo
+ end
+
+ if current_adapter?(:PostgreSQLAdapter) && ActiveRecord::Base.connection.prepared_statements
+ test "cleans up after prepared statement failure in a transaction" do
+ with_two_connections do |original_connection, ddl_connection|
+ record = @klass.create! bar: "bar"
+
+ # prepare the reload statement in a transaction
+ @klass.transaction do
+ record.reload
+ end
+
+ assert get_prepared_statement_cache(@klass.connection).any?,
+ "expected prepared statement cache to have something in it"
+
+ # add a new column
+ ddl_connection.add_column :hot_compatibilities, :baz, :string
+
+ assert_raise(ActiveRecord::PreparedStatementCacheExpired) do
+ @klass.transaction do
+ record.reload
+ end
+ end
+
+ assert_empty get_prepared_statement_cache(@klass.connection),
+ "expected prepared statement cache to be empty but it wasn't"
+ end
+ end
+
+ test "cleans up after prepared statement failure in nested transactions" do
+ with_two_connections do |original_connection, ddl_connection|
+ record = @klass.create! bar: "bar"
+
+ # prepare the reload statement in a transaction
+ @klass.transaction do
+ record.reload
+ end
+
+ assert get_prepared_statement_cache(@klass.connection).any?,
+ "expected prepared statement cache to have something in it"
+
+ # add a new column
+ ddl_connection.add_column :hot_compatibilities, :baz, :string
+
+ assert_raise(ActiveRecord::PreparedStatementCacheExpired) do
+ @klass.transaction do
+ @klass.transaction do
+ @klass.transaction do
+ record.reload
+ end
+ end
+ end
+ end
+
+ assert_empty get_prepared_statement_cache(@klass.connection),
+ "expected prepared statement cache to be empty but it wasn't"
+ end
+ end
+ end
+
+ private
+
+ def get_prepared_statement_cache(connection)
+ connection.instance_variable_get(:@statements)
+ .instance_variable_get(:@cache)[Process.pid]
+ end
+
+ # Rails will automatically clear the prepared statements on the connection
+ # that runs the migration, so we use two connections to simulate what would
+ # actually happen on a production system; we'd have one connection running the
+ # migration from the rake task ("ddl_connection" here), and we'd have another
+ # connection in a web worker.
+ def with_two_connections
+ run_without_connection do |original_connection|
+ ActiveRecord::Base.establish_connection(original_connection.merge(pool_size: 2))
+ begin
+ ddl_connection = ActiveRecord::Base.connection_pool.checkout
+ begin
+ yield original_connection, ddl_connection
+ ensure
+ ActiveRecord::Base.connection_pool.checkin ddl_connection
+ end
+ ensure
+ ActiveRecord::Base.clear_all_connections!
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/i18n_test.rb b/activerecord/test/cases/i18n_test.rb
new file mode 100644
index 0000000000..22981c142a
--- /dev/null
+++ b/activerecord/test/cases/i18n_test.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+require "models/reply"
+
+class ActiveRecordI18nTests < ActiveRecord::TestCase
+ def setup
+ I18n.backend = I18n::Backend::Simple.new
+ end
+
+ def test_translated_model_attributes
+ I18n.backend.store_translations "en", activerecord: { attributes: { topic: { title: "topic title attribute" } } }
+ assert_equal "topic title attribute", Topic.human_attribute_name("title")
+ end
+
+ def test_translated_model_attributes_with_symbols
+ I18n.backend.store_translations "en", activerecord: { attributes: { topic: { title: "topic title attribute" } } }
+ assert_equal "topic title attribute", Topic.human_attribute_name(:title)
+ end
+
+ def test_translated_model_attributes_with_sti
+ I18n.backend.store_translations "en", activerecord: { attributes: { reply: { title: "reply title attribute" } } }
+ assert_equal "reply title attribute", Reply.human_attribute_name("title")
+ end
+
+ def test_translated_model_attributes_with_sti_fallback
+ I18n.backend.store_translations "en", activerecord: { attributes: { topic: { title: "topic title attribute" } } }
+ assert_equal "topic title attribute", Reply.human_attribute_name("title")
+ end
+
+ def test_translated_model_names
+ I18n.backend.store_translations "en", activerecord: { models: { topic: "topic model" } }
+ assert_equal "topic model", Topic.model_name.human
+ end
+
+ def test_translated_model_names_with_sti
+ I18n.backend.store_translations "en", activerecord: { models: { reply: "reply model" } }
+ assert_equal "reply model", Reply.model_name.human
+ end
+
+ def test_translated_model_names_with_sti_fallback
+ I18n.backend.store_translations "en", activerecord: { models: { topic: "topic model" } }
+ assert_equal "topic model", Reply.model_name.human
+ end
+end
diff --git a/activerecord/test/cases/inheritance_test.rb b/activerecord/test/cases/inheritance_test.rb
new file mode 100644
index 0000000000..3d3189900f
--- /dev/null
+++ b/activerecord/test/cases/inheritance_test.rb
@@ -0,0 +1,660 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/author"
+require "models/company"
+require "models/membership"
+require "models/person"
+require "models/post"
+require "models/project"
+require "models/subscriber"
+require "models/vegetables"
+require "models/shop"
+require "models/sponsor"
+
+module InheritanceTestHelper
+ def with_store_full_sti_class(&block)
+ assign_store_full_sti_class true, &block
+ end
+
+ def without_store_full_sti_class(&block)
+ assign_store_full_sti_class false, &block
+ end
+
+ def assign_store_full_sti_class(flag)
+ old_store_full_sti_class = ActiveRecord::Base.store_full_sti_class
+ ActiveRecord::Base.store_full_sti_class = flag
+ yield
+ ensure
+ ActiveRecord::Base.store_full_sti_class = old_store_full_sti_class
+ end
+end
+
+class InheritanceTest < ActiveRecord::TestCase
+ include InheritanceTestHelper
+ fixtures :companies, :projects, :subscribers, :accounts, :vegetables, :memberships
+
+ def test_class_with_store_full_sti_class_returns_full_name
+ with_store_full_sti_class do
+ assert_equal "Namespaced::Company", Namespaced::Company.sti_name
+ end
+ end
+
+ def test_class_with_blank_sti_name
+ company = Company.first
+ company = company.dup
+ company.extend(Module.new {
+ def _read_attribute(name)
+ return " " if name == "type"
+ super
+ end
+ })
+ company.save!
+ company = Company.all.to_a.find { |x| x.id == company.id }
+ assert_equal " ", company.type
+ end
+
+ def test_class_without_store_full_sti_class_returns_demodulized_name
+ without_store_full_sti_class do
+ assert_equal "Company", Namespaced::Company.sti_name
+ end
+ end
+
+ def test_compute_type_success
+ assert_equal Author, Company.send(:compute_type, "Author")
+ end
+
+ def test_compute_type_nonexistent_constant
+ e = assert_raises NameError do
+ Company.send :compute_type, "NonexistentModel"
+ end
+ assert_equal "uninitialized constant Company::NonexistentModel", e.message
+ assert_equal "Company::NonexistentModel", e.name
+ end
+
+ def test_compute_type_no_method_error
+ ActiveSupport::Dependencies.stub(:safe_constantize, proc { raise NoMethodError }) do
+ assert_raises NoMethodError do
+ Company.send :compute_type, "InvalidModel"
+ end
+ end
+ end
+
+ def test_compute_type_on_undefined_method
+ error = nil
+ begin
+ Class.new(Author) do
+ alias_method :foo, :bar
+ end
+ rescue => e
+ error = e
+ end
+
+ ActiveSupport::Dependencies.stub(:safe_constantize, proc { raise e }) do
+ exception = assert_raises NameError do
+ Company.send :compute_type, "InvalidModel"
+ end
+ assert_equal error.message, exception.message
+ end
+ end
+
+ def test_compute_type_argument_error
+ ActiveSupport::Dependencies.stub(:safe_constantize, proc { raise ArgumentError }) do
+ assert_raises ArgumentError do
+ Company.send :compute_type, "InvalidModel"
+ end
+ end
+ end
+
+ def test_should_store_demodulized_class_name_with_store_full_sti_class_option_disabled
+ without_store_full_sti_class do
+ item = Namespaced::Company.new
+ assert_equal "Company", item[:type]
+ end
+ end
+
+ def test_should_store_full_class_name_with_store_full_sti_class_option_enabled
+ with_store_full_sti_class do
+ item = Namespaced::Company.new
+ assert_equal "Namespaced::Company", item[:type]
+ end
+ end
+
+ def test_different_namespace_subclass_should_load_correctly_with_store_full_sti_class_option
+ with_store_full_sti_class do
+ item = Namespaced::Company.create name: "Wolverine 2"
+ assert_not_nil Company.find(item.id)
+ assert_not_nil Namespaced::Company.find(item.id)
+ end
+ end
+
+ def test_descends_from_active_record
+ assert_not_predicate ActiveRecord::Base, :descends_from_active_record?
+
+ # Abstract subclass of AR::Base.
+ assert_predicate LoosePerson, :descends_from_active_record?
+
+ # Concrete subclass of an abstract class.
+ assert_predicate LooseDescendant, :descends_from_active_record?
+
+ # Concrete subclass of AR::Base.
+ assert_predicate TightPerson, :descends_from_active_record?
+
+ # Concrete subclass of a concrete class but has no type column.
+ assert_predicate TightDescendant, :descends_from_active_record?
+
+ # Concrete subclass of AR::Base.
+ assert_predicate Post, :descends_from_active_record?
+
+ # Concrete subclasses of a concrete class which has a type column.
+ assert_not_predicate StiPost, :descends_from_active_record?
+ assert_not_predicate SubStiPost, :descends_from_active_record?
+
+ # Abstract subclass of a concrete class which has a type column.
+ # This is pathological, as you'll never have Sub < Abstract < Concrete.
+ assert_not_predicate AbstractStiPost, :descends_from_active_record?
+
+ # Concrete subclass of an abstract class which has a type column.
+ assert_not_predicate SubAbstractStiPost, :descends_from_active_record?
+ end
+
+ def test_company_descends_from_active_record
+ assert_not_predicate ActiveRecord::Base, :descends_from_active_record?
+ assert AbstractCompany.descends_from_active_record?, "AbstractCompany should descend from ActiveRecord::Base"
+ assert Company.descends_from_active_record?, "Company should descend from ActiveRecord::Base"
+ assert_not Class.new(Company).descends_from_active_record?, "Company subclass should not descend from ActiveRecord::Base"
+ end
+
+ def test_abstract_class
+ assert_not_predicate ActiveRecord::Base, :abstract_class?
+ assert_predicate LoosePerson, :abstract_class?
+ assert_not_predicate LooseDescendant, :abstract_class?
+ end
+
+ def test_inheritance_base_class
+ assert_equal Post, Post.base_class
+ assert_predicate Post, :base_class?
+ assert_equal Post, SpecialPost.base_class
+ assert_not_predicate SpecialPost, :base_class?
+ assert_equal Post, StiPost.base_class
+ assert_not_predicate StiPost, :base_class?
+ assert_equal Post, SubStiPost.base_class
+ assert_not_predicate SubStiPost, :base_class?
+ assert_equal SubAbstractStiPost, SubAbstractStiPost.base_class
+ assert_predicate SubAbstractStiPost, :base_class?
+ end
+
+ def test_abstract_inheritance_base_class
+ assert_equal LoosePerson, LoosePerson.base_class
+ assert_predicate LoosePerson, :base_class?
+ assert_equal LooseDescendant, LooseDescendant.base_class
+ assert_predicate LooseDescendant, :base_class?
+ assert_equal TightPerson, TightPerson.base_class
+ assert_predicate TightPerson, :base_class?
+ assert_equal TightPerson, TightDescendant.base_class
+ assert_not_predicate TightDescendant, :base_class?
+ end
+
+ def test_base_class_activerecord_error
+ klass = Class.new { include ActiveRecord::Inheritance }
+ assert_raise(ActiveRecord::ActiveRecordError) { klass.base_class }
+ end
+
+ def test_a_bad_type_column
+ Company.connection.insert "INSERT INTO companies (id, #{QUOTED_TYPE}, name) VALUES(100, 'bad_class!', 'Not happening')"
+
+ assert_raise(ActiveRecord::SubclassNotFound) { Company.find(100) }
+ end
+
+ def test_inheritance_find
+ assert_kind_of Firm, Company.find(1), "37signals should be a firm"
+ assert_kind_of Firm, Firm.find(1), "37signals should be a firm"
+ assert_kind_of Client, Company.find(2), "Summit should be a client"
+ assert_kind_of Client, Client.find(2), "Summit should be a client"
+ end
+
+ def test_alt_inheritance_find
+ assert_kind_of Cucumber, Vegetable.find(1)
+ assert_kind_of Cucumber, Cucumber.find(1)
+ assert_kind_of Cabbage, Vegetable.find(2)
+ assert_kind_of Cabbage, Cabbage.find(2)
+ end
+
+ def test_alt_becomes_works_with_sti
+ vegetable = Vegetable.find(1)
+ assert_kind_of Vegetable, vegetable
+ cabbage = vegetable.becomes(Cabbage)
+ assert_kind_of Cabbage, cabbage
+ end
+
+ def test_becomes_and_change_tracking_for_inheritance_columns
+ cucumber = Vegetable.find(1)
+ cabbage = cucumber.becomes!(Cabbage)
+ assert_equal ["Cucumber", "Cabbage"], cabbage.custom_type_change
+ end
+
+ def test_alt_becomes_bang_resets_inheritance_type_column
+ vegetable = Vegetable.create!(name: "Red Pepper")
+ assert_nil vegetable.custom_type
+
+ cabbage = vegetable.becomes!(Cabbage)
+ assert_equal "Cabbage", cabbage.custom_type
+
+ vegetable = cabbage.becomes!(Vegetable)
+ assert_nil cabbage.custom_type
+ end
+
+ def test_inheritance_find_all
+ companies = Company.all.merge!(order: "id").to_a
+ assert_kind_of Firm, companies[0], "37signals should be a firm"
+ assert_kind_of Client, companies[1], "Summit should be a client"
+ end
+
+ def test_alt_inheritance_find_all
+ companies = Vegetable.all.merge!(order: "id").to_a
+ assert_kind_of Cucumber, companies[0]
+ assert_kind_of Cabbage, companies[1]
+ end
+
+ def test_inheritance_save
+ firm = Firm.new
+ firm.name = "Next Angle"
+ firm.save
+
+ next_angle = Company.find(firm.id)
+ assert_kind_of Firm, next_angle, "Next Angle should be a firm"
+ end
+
+ def test_alt_inheritance_save
+ cabbage = Cabbage.new(name: "Savoy")
+ cabbage.save!
+
+ savoy = Vegetable.find(cabbage.id)
+ assert_kind_of Cabbage, savoy
+ end
+
+ def test_inheritance_new_with_default_class
+ company = Company.new
+ assert_equal Company, company.class
+ end
+
+ def test_inheritance_new_with_base_class
+ company = Company.new(type: "Company")
+ assert_equal Company, company.class
+ end
+
+ def test_inheritance_new_with_subclass
+ firm = Company.new(type: "Firm")
+ assert_equal Firm, firm.class
+ end
+
+ def test_where_new_with_subclass
+ firm = Company.where(type: "Firm").new
+ assert_equal Firm, firm.class
+ end
+
+ def test_where_create_with_subclass
+ firm = Company.where(type: "Firm").create(name: "Basecamp")
+ assert_equal Firm, firm.class
+ end
+
+ def test_where_create_bang_with_subclass
+ firm = Company.where(type: "Firm").create!(name: "Basecamp")
+ assert_equal Firm, firm.class
+ end
+
+ def test_new_with_abstract_class
+ e = assert_raises(NotImplementedError) do
+ AbstractCompany.new
+ end
+ assert_equal("AbstractCompany is an abstract class and cannot be instantiated.", e.message)
+ end
+
+ def test_new_with_ar_base
+ e = assert_raises(NotImplementedError) do
+ ActiveRecord::Base.new
+ end
+ assert_equal("ActiveRecord::Base is an abstract class and cannot be instantiated.", e.message)
+ end
+
+ def test_new_with_invalid_type
+ assert_raise(ActiveRecord::SubclassNotFound) { Company.new(type: "InvalidType") }
+ end
+
+ def test_new_with_unrelated_type
+ assert_raise(ActiveRecord::SubclassNotFound) { Company.new(type: "Account") }
+ end
+
+ def test_where_new_with_invalid_type
+ assert_raise(ActiveRecord::SubclassNotFound) { Company.where(type: "InvalidType").new }
+ end
+
+ def test_where_new_with_unrelated_type
+ assert_raise(ActiveRecord::SubclassNotFound) { Company.where(type: "Account").new }
+ end
+
+ def test_where_create_with_invalid_type
+ assert_raise(ActiveRecord::SubclassNotFound) { Company.where(type: "InvalidType").create }
+ end
+
+ def test_where_create_with_unrelated_type
+ assert_raise(ActiveRecord::SubclassNotFound) { Company.where(type: "Account").create }
+ end
+
+ def test_where_create_bang_with_invalid_type
+ assert_raise(ActiveRecord::SubclassNotFound) { Company.where(type: "InvalidType").create! }
+ end
+
+ def test_where_create_bang_with_unrelated_type
+ assert_raise(ActiveRecord::SubclassNotFound) { Company.where(type: "Account").create! }
+ end
+
+ def test_new_with_unrelated_namespaced_type
+ without_store_full_sti_class do
+ e = assert_raises ActiveRecord::SubclassNotFound do
+ Namespaced::Company.new(type: "Firm")
+ end
+
+ assert_equal "Invalid single-table inheritance type: Namespaced::Firm is not a subclass of Namespaced::Company", e.message
+ end
+ end
+
+ def test_new_with_complex_inheritance
+ assert_nothing_raised { Client.new(type: "VerySpecialClient") }
+ end
+
+ def test_new_without_storing_full_sti_class
+ without_store_full_sti_class do
+ item = Company.new(type: "SpecialCo")
+ assert_instance_of Company::SpecialCo, item
+ end
+ end
+
+ def test_new_with_autoload_paths
+ path = File.expand_path("../models/autoloadable", __dir__)
+ ActiveSupport::Dependencies.autoload_paths << path
+
+ firm = Company.new(type: "ExtraFirm")
+ assert_equal ExtraFirm, firm.class
+ ensure
+ ActiveSupport::Dependencies.autoload_paths.reject! { |p| p == path }
+ ActiveSupport::Dependencies.clear
+ end
+
+ def test_inheritance_condition
+ assert_equal 11, Company.count
+ assert_equal 2, Firm.count
+ assert_equal 5, Client.count
+ end
+
+ def test_alt_inheritance_condition
+ assert_equal 4, Vegetable.count
+ assert_equal 1, Cucumber.count
+ assert_equal 3, Cabbage.count
+ end
+
+ def test_finding_incorrect_type_data
+ assert_raise(ActiveRecord::RecordNotFound) { Firm.find(2) }
+ assert_nothing_raised { Firm.find(1) }
+ end
+
+ def test_alt_finding_incorrect_type_data
+ assert_raise(ActiveRecord::RecordNotFound) { Cucumber.find(2) }
+ assert_nothing_raised { Cucumber.find(1) }
+ end
+
+ def test_update_all_within_inheritance
+ Client.update_all "name = 'I am a client'"
+ assert_equal "I am a client", Client.first.name
+ # Order by added as otherwise Oracle tests were failing because of different order of results
+ assert_equal "37signals", Firm.all.merge!(order: "id").to_a.first.name
+ end
+
+ def test_alt_update_all_within_inheritance
+ Cabbage.update_all "name = 'the cabbage'"
+ assert_equal "the cabbage", Cabbage.first.name
+ assert_equal ["my cucumber"], Cucumber.all.map(&:name).uniq
+ end
+
+ def test_destroy_all_within_inheritance
+ Client.destroy_all
+ assert_equal 0, Client.count
+ assert_equal 2, Firm.count
+ end
+
+ def test_alt_destroy_all_within_inheritance
+ Cabbage.destroy_all
+ assert_equal 0, Cabbage.count
+ assert_equal 1, Cucumber.count
+ end
+
+ def test_find_first_within_inheritance
+ assert_kind_of Firm, Company.all.merge!(where: "name = '37signals'").first
+ assert_kind_of Firm, Firm.all.merge!(where: "name = '37signals'").first
+ assert_nil Client.all.merge!(where: "name = '37signals'").first
+ end
+
+ def test_alt_find_first_within_inheritance
+ assert_kind_of Cabbage, Vegetable.all.merge!(where: "name = 'his cabbage'").first
+ assert_kind_of Cabbage, Cabbage.all.merge!(where: "name = 'his cabbage'").first
+ assert_nil Cucumber.all.merge!(where: "name = 'his cabbage'").first
+ end
+
+ def test_complex_inheritance
+ very_special_client = VerySpecialClient.create("name" => "veryspecial")
+ assert_equal very_special_client, VerySpecialClient.where("name = 'veryspecial'").first
+ assert_equal very_special_client, SpecialClient.all.merge!(where: "name = 'veryspecial'").first
+ assert_equal very_special_client, Company.all.merge!(where: "name = 'veryspecial'").first
+ assert_equal very_special_client, Client.all.merge!(where: "name = 'veryspecial'").first
+ assert_equal 1, Client.all.merge!(where: "name = 'Summit'").to_a.size
+ assert_equal very_special_client, Client.find(very_special_client.id)
+ end
+
+ def test_alt_complex_inheritance
+ king_cole = KingCole.create("name" => "uniform heads")
+ assert_equal king_cole, KingCole.where("name = 'uniform heads'").first
+ assert_equal king_cole, GreenCabbage.all.merge!(where: "name = 'uniform heads'").first
+ assert_equal king_cole, Cabbage.all.merge!(where: "name = 'uniform heads'").first
+ assert_equal king_cole, Vegetable.all.merge!(where: "name = 'uniform heads'").first
+ assert_equal 1, Cabbage.all.merge!(where: "name = 'his cabbage'").to_a.size
+ assert_equal king_cole, Cabbage.find(king_cole.id)
+ end
+
+ def test_eager_load_belongs_to_something_inherited
+ account = Account.all.merge!(includes: :firm).find(1)
+ assert account.association(:firm).loaded?, "association was not eager loaded"
+ end
+
+ def test_alt_eager_loading
+ cabbage = RedCabbage.all.merge!(includes: :seller).find(4)
+ assert cabbage.association(:seller).loaded?, "association was not eager loaded"
+ end
+
+ def test_eager_load_belongs_to_primary_key_quoting
+ con = Account.connection
+ bind_param = Arel::Nodes::BindParam.new(nil)
+ assert_sql(/#{con.quote_table_name('companies')}\.#{con.quote_column_name('id')} = (?:#{Regexp.quote(bind_param.to_sql)}|1)/) do
+ Account.all.merge!(includes: :firm).find(1)
+ end
+ end
+
+ def test_inherits_custom_primary_key
+ assert_equal Subscriber.primary_key, SpecialSubscriber.primary_key
+ end
+
+ def test_inheritance_without_mapping
+ assert_kind_of SpecialSubscriber, SpecialSubscriber.find("webster132")
+ assert_nothing_raised { s = SpecialSubscriber.new("name" => "And breaaaaathe!"); s.id = "roger"; s.save }
+ end
+
+ def test_scope_inherited_properly
+ assert_nothing_raised { Company.of_first_firm }
+ assert_nothing_raised { Client.of_first_firm }
+ end
+
+ def test_inheritance_with_default_scope
+ assert_equal 1, SelectedMembership.count(:all)
+ end
+end
+
+class InheritanceComputeTypeTest < ActiveRecord::TestCase
+ include InheritanceTestHelper
+ fixtures :companies
+
+ teardown do
+ self.class.const_remove :FirmOnTheFly rescue nil
+ Firm.const_remove :FirmOnTheFly rescue nil
+ end
+
+ def test_instantiation_doesnt_try_to_require_corresponding_file
+ without_store_full_sti_class do
+ foo = Firm.first.clone
+ foo.type = "FirmOnTheFly"
+ foo.save!
+
+ # Should fail without FirmOnTheFly in the type condition.
+ assert_raise(ActiveRecord::RecordNotFound) { Firm.find(foo.id) }
+
+ # Nest FirmOnTheFly in the test case where Dependencies won't see it.
+ self.class.const_set :FirmOnTheFly, Class.new(Firm)
+ assert_raise(ActiveRecord::SubclassNotFound) { Firm.find(foo.id) }
+
+ # Nest FirmOnTheFly in Firm where Dependencies will see it.
+ # This is analogous to nesting models in a migration.
+ Firm.const_set :FirmOnTheFly, Class.new(Firm)
+
+ # And instantiate will find the existing constant rather than trying
+ # to require firm_on_the_fly.
+ assert_nothing_raised { assert_kind_of Firm::FirmOnTheFly, Firm.find(foo.id) }
+ end
+ end
+
+ def test_sti_type_from_attributes_disabled_in_non_sti_class
+ phone = Shop::Product::Type.new(name: "Phone")
+ product = Shop::Product.new(type: phone)
+ assert product.save
+ end
+
+ def test_inheritance_new_with_subclass_as_default
+ original_type = Company.columns_hash["type"].default
+ ActiveRecord::Base.connection.change_column_default :companies, :type, "Firm"
+ Company.reset_column_information
+
+ firm = Company.new # without arguments
+ assert_equal "Firm", firm.type
+ assert_instance_of Firm, firm
+
+ firm = Company.new(firm_name: "Shri Hans Plastic") # with arguments
+ assert_equal "Firm", firm.type
+ assert_instance_of Firm, firm
+
+ client = Client.new
+ assert_equal "Client", client.type
+ assert_instance_of Client, client
+
+ firm = Company.new(type: "Client") # overwrite the default type
+ assert_equal "Client", firm.type
+ assert_instance_of Client, firm
+ ensure
+ ActiveRecord::Base.connection.change_column_default :companies, :type, original_type
+ Company.reset_column_information
+ end
+end
+
+class InheritanceAttributeTest < ActiveRecord::TestCase
+ class Company < ActiveRecord::Base
+ self.table_name = "companies"
+ attribute :type, :string, default: "InheritanceAttributeTest::Startup"
+ end
+
+ class Startup < Company
+ end
+
+ class Empire < Company
+ end
+
+ def test_inheritance_new_with_subclass_as_default
+ startup = Company.new # without arguments
+ assert_equal "InheritanceAttributeTest::Startup", startup.type
+ assert_instance_of Startup, startup
+
+ empire = Company.new(type: "InheritanceAttributeTest::Empire") # without arguments
+ assert_equal "InheritanceAttributeTest::Empire", empire.type
+ assert_instance_of Empire, empire
+ end
+end
+
+class InheritanceAttributeMappingTest < ActiveRecord::TestCase
+ setup do
+ @old_registry = ActiveRecord::Type.registry
+ ActiveRecord::Type.registry = ActiveRecord::Type::AdapterSpecificRegistry.new
+ ActiveRecord::Type.register :omg_sti, InheritanceAttributeMappingTest::OmgStiType
+ Company.delete_all
+ Sponsor.delete_all
+ end
+
+ teardown do
+ ActiveRecord::Type.registry = @old_registry
+ end
+
+ class OmgStiType < ActiveRecord::Type::String
+ def cast_value(value)
+ if value =~ /\Aomg_(.+)\z/
+ $1.classify
+ else
+ value
+ end
+ end
+
+ def serialize(value)
+ if value
+ "omg_%s" % value.underscore
+ end
+ end
+ end
+
+ class Company < ActiveRecord::Base
+ self.table_name = "companies"
+ attribute :type, :omg_sti
+ end
+
+ class Startup < Company; end
+ class Empire < Company; end
+
+ class Sponsor < ActiveRecord::Base
+ self.table_name = "sponsors"
+ attribute :sponsorable_type, :omg_sti
+
+ belongs_to :sponsorable, polymorphic: true
+ end
+
+ def test_sti_with_custom_type
+ Startup.create! name: "a Startup"
+ Empire.create! name: "an Empire"
+
+ assert_equal [["a Startup", "omg_inheritance_attribute_mapping_test/startup"],
+ ["an Empire", "omg_inheritance_attribute_mapping_test/empire"]], ActiveRecord::Base.connection.select_rows("SELECT name, type FROM companies").sort
+ assert_equal [["a Startup", "InheritanceAttributeMappingTest::Startup"],
+ ["an Empire", "InheritanceAttributeMappingTest::Empire"]], Company.all.map { |a| [a.name, a.type] }.sort
+
+ startup = Startup.first
+ startup.becomes! Empire
+ startup.save!
+
+ assert_equal [["a Startup", "omg_inheritance_attribute_mapping_test/empire"],
+ ["an Empire", "omg_inheritance_attribute_mapping_test/empire"]], ActiveRecord::Base.connection.select_rows("SELECT name, type FROM companies").sort
+
+ assert_equal [["a Startup", "InheritanceAttributeMappingTest::Empire"],
+ ["an Empire", "InheritanceAttributeMappingTest::Empire"]], Company.all.map { |a| [a.name, a.type] }.sort
+ end
+
+ def test_polymorphic_associations_custom_type
+ startup = Startup.create! name: "a Startup"
+ sponsor = Sponsor.create! sponsorable: startup
+
+ assert_equal ["omg_inheritance_attribute_mapping_test/company"], ActiveRecord::Base.connection.select_values("SELECT sponsorable_type FROM sponsors")
+
+ sponsor = Sponsor.first
+ assert_equal startup, sponsor.sponsorable
+ end
+end
diff --git a/activerecord/test/cases/instrumentation_test.rb b/activerecord/test/cases/instrumentation_test.rb
new file mode 100644
index 0000000000..c09ea32991
--- /dev/null
+++ b/activerecord/test/cases/instrumentation_test.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/book"
+
+module ActiveRecord
+ class InstrumentationTest < ActiveRecord::TestCase
+ def setup
+ ActiveRecord::Base.connection.schema_cache.add(Book.table_name)
+ end
+
+ def test_payload_name_on_load
+ Book.create(name: "test book")
+ subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
+ event = ActiveSupport::Notifications::Event.new(*args)
+ if event.payload[:sql].match "SELECT"
+ assert_equal "Book Load", event.payload[:name]
+ end
+ end
+ Book.first
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
+ end
+
+ def test_payload_name_on_create
+ subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
+ event = ActiveSupport::Notifications::Event.new(*args)
+ if event.payload[:sql].match "INSERT"
+ assert_equal "Book Create", event.payload[:name]
+ end
+ end
+ Book.create(name: "test book")
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
+ end
+
+ def test_payload_name_on_update
+ subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
+ event = ActiveSupport::Notifications::Event.new(*args)
+ if event.payload[:sql].match "UPDATE"
+ assert_equal "Book Update", event.payload[:name]
+ end
+ end
+ book = Book.create(name: "test book")
+ book.update_attribute(:name, "new name")
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
+ end
+
+ def test_payload_name_on_update_all
+ subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
+ event = ActiveSupport::Notifications::Event.new(*args)
+ if event.payload[:sql].match "UPDATE"
+ assert_equal "Book Update All", event.payload[:name]
+ end
+ end
+ Book.create(name: "test book")
+ Book.update_all(name: "new name")
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
+ end
+
+ def test_payload_name_on_destroy
+ subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
+ event = ActiveSupport::Notifications::Event.new(*args)
+ if event.payload[:sql].match "DELETE"
+ assert_equal "Book Destroy", event.payload[:name]
+ end
+ end
+ book = Book.create(name: "test book")
+ book.destroy
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
+ end
+ end
+end
diff --git a/activerecord/test/cases/integration_test.rb b/activerecord/test/cases/integration_test.rb
new file mode 100644
index 0000000000..5687afbc71
--- /dev/null
+++ b/activerecord/test/cases/integration_test.rb
@@ -0,0 +1,258 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/company"
+require "models/developer"
+require "models/computer"
+require "models/owner"
+require "models/pet"
+
+class IntegrationTest < ActiveRecord::TestCase
+ fixtures :companies, :developers, :owners, :pets
+
+ def test_to_param_should_return_string
+ assert_kind_of String, Client.first.to_param
+ end
+
+ def test_to_param_returns_nil_if_not_persisted
+ client = Client.new
+ assert_nil client.to_param
+ end
+
+ def test_to_param_returns_id_if_not_persisted_but_id_is_set
+ client = Client.new
+ client.id = 1
+ assert_equal "1", client.to_param
+ end
+
+ def test_to_param_class_method
+ firm = Firm.find(4)
+ assert_equal "4-flamboyant-software", firm.to_param
+ end
+
+ def test_to_param_class_method_truncates_words_properly
+ firm = Firm.find(4)
+ firm.name << ", Inc."
+ assert_equal "4-flamboyant-software", firm.to_param
+ end
+
+ def test_to_param_class_method_truncates_after_parameterize
+ firm = Firm.find(4)
+ firm.name = "Huey, Dewey, & Louie LLC"
+ # 123456789T123456789v
+ assert_equal "4-huey-dewey-louie-llc", firm.to_param
+ end
+
+ def test_to_param_class_method_truncates_after_parameterize_with_hyphens
+ firm = Firm.find(4)
+ firm.name = "Door-to-Door Wash-n-Fold Service"
+ # 123456789T123456789v
+ assert_equal "4-door-to-door-wash-n", firm.to_param
+ end
+
+ def test_to_param_class_method_truncates
+ firm = Firm.find(4)
+ firm.name = "a " * 100
+ assert_equal "4-a-a-a-a-a-a-a-a-a-a", firm.to_param
+ end
+
+ def test_to_param_class_method_truncates_edge_case
+ firm = Firm.find(4)
+ firm.name = "David HeinemeierHansson"
+ assert_equal "4-david", firm.to_param
+ end
+
+ def test_to_param_class_method_truncates_case_shown_in_doc
+ firm = Firm.find(4)
+ firm.name = "David Heinemeier Hansson"
+ assert_equal "4-david-heinemeier", firm.to_param
+ end
+
+ def test_to_param_class_method_squishes
+ firm = Firm.find(4)
+ firm.name = "ab \n" * 100
+ assert_equal "4-ab-ab-ab-ab-ab-ab-ab", firm.to_param
+ end
+
+ def test_to_param_class_method_multibyte_character
+ firm = Firm.find(4)
+ firm.name = "戦場ヶ原 ひたぎ"
+ assert_equal "4", firm.to_param
+ end
+
+ def test_to_param_class_method_uses_default_if_blank
+ firm = Firm.find(4)
+ firm.name = nil
+ assert_equal "4", firm.to_param
+ firm.name = " "
+ assert_equal "4", firm.to_param
+ end
+
+ def test_to_param_class_method_uses_default_if_not_persisted
+ firm = Firm.new(name: "Fancy Shirts")
+ assert_nil firm.to_param
+ end
+
+ def test_to_param_with_no_arguments
+ assert_equal "Firm", Firm.to_param
+ end
+
+ def test_cache_key_for_existing_record_is_not_timezone_dependent
+ utc_key = Developer.first.cache_key
+
+ with_timezone_config zone: "EST" do
+ est_key = Developer.first.cache_key
+ assert_equal utc_key, est_key
+ end
+ end
+
+ def test_cache_key_format_for_existing_record_with_updated_at
+ dev = Developer.first
+ assert_equal "developers/#{dev.id}-#{dev.updated_at.utc.to_s(:usec)}", dev.cache_key
+ end
+
+ def test_cache_key_format_for_existing_record_with_updated_at_and_custom_cache_timestamp_format
+ dev = CachedDeveloper.first
+ assert_equal "cached_developers/#{dev.id}-#{dev.updated_at.utc.to_s(:number)}", dev.cache_key
+ end
+
+ def test_cache_key_changes_when_child_touched
+ owner = owners(:blackbeard)
+ pet = pets(:parrot)
+
+ owner.update_column :updated_at, Time.current
+ key = owner.cache_key
+
+ travel(1.second) do
+ assert pet.touch
+ end
+ assert_not_equal key, owner.reload.cache_key
+ end
+
+ def test_cache_key_format_for_existing_record_with_nil_updated_timestamps
+ dev = Developer.first
+ dev.update_columns(updated_at: nil, updated_on: nil)
+ assert_match(/\/#{dev.id}$/, dev.cache_key)
+ end
+
+ def test_cache_key_for_updated_on
+ dev = Developer.first
+ dev.updated_at = nil
+ assert_equal "developers/#{dev.id}-#{dev.updated_on.utc.to_s(:usec)}", dev.cache_key
+ end
+
+ def test_cache_key_for_newer_updated_at
+ dev = Developer.first
+ dev.updated_at += 3600
+ assert_equal "developers/#{dev.id}-#{dev.updated_at.utc.to_s(:usec)}", dev.cache_key
+ end
+
+ def test_cache_key_for_newer_updated_on
+ dev = Developer.first
+ dev.updated_on += 3600
+ assert_equal "developers/#{dev.id}-#{dev.updated_on.utc.to_s(:usec)}", dev.cache_key
+ end
+
+ def test_cache_key_format_is_precise_enough
+ skip("Subsecond precision is not supported") unless subsecond_precision_supported?
+ dev = Developer.first
+ key = dev.cache_key
+ travel_to dev.updated_at + 0.000001 do
+ dev.touch
+ end
+ assert_not_equal key, dev.cache_key
+ end
+
+ def test_cache_key_format_is_not_too_precise
+ dev = Developer.first
+ dev.touch
+ key = dev.cache_key
+ assert_equal key, dev.reload.cache_key
+ end
+
+ def test_cache_version_format_is_precise_enough
+ skip("Subsecond precision is not supported") unless subsecond_precision_supported?
+ with_cache_versioning do
+ dev = Developer.first
+ version = dev.cache_version.to_param
+ travel_to Developer.first.updated_at + 0.000001 do
+ dev.touch
+ end
+ assert_not_equal version, dev.cache_version.to_param
+ end
+ end
+
+ def test_cache_version_format_is_not_too_precise
+ with_cache_versioning do
+ dev = Developer.first
+ dev.touch
+ key = dev.cache_version.to_param
+ assert_equal key, dev.reload.cache_version.to_param
+ end
+ end
+
+ def test_named_timestamps_for_cache_key
+ assert_deprecated do
+ owner = owners(:blackbeard)
+ assert_equal "owners/#{owner.id}-#{owner.happy_at.utc.to_s(:usec)}", owner.cache_key(:updated_at, :happy_at)
+ end
+ end
+
+ def test_cache_key_when_named_timestamp_is_nil
+ assert_deprecated do
+ owner = owners(:blackbeard)
+ owner.happy_at = nil
+ assert_equal "owners/#{owner.id}", owner.cache_key(:happy_at)
+ end
+ end
+
+ def test_cache_key_is_stable_with_versioning_on
+ with_cache_versioning do
+ developer = Developer.first
+ first_key = developer.cache_key
+
+ developer.touch
+ second_key = developer.cache_key
+
+ assert_equal first_key, second_key
+ end
+ end
+
+ def test_cache_version_changes_with_versioning_on
+ with_cache_versioning do
+ developer = Developer.first
+ first_version = developer.cache_version
+
+ travel 10.seconds do
+ developer.touch
+ end
+
+ second_version = developer.cache_version
+
+ assert_not_equal first_version, second_version
+ end
+ end
+
+ def test_cache_key_retains_version_when_custom_timestamp_is_used
+ with_cache_versioning do
+ developer = Developer.first
+ first_key = developer.cache_key_with_version
+
+ travel 10.seconds do
+ developer.touch
+ end
+
+ second_key = developer.cache_key_with_version
+
+ assert_not_equal first_key, second_key
+ end
+ end
+
+ def with_cache_versioning(value = true)
+ @old_cache_versioning = ActiveRecord::Base.cache_versioning
+ ActiveRecord::Base.cache_versioning = value
+ yield
+ ensure
+ ActiveRecord::Base.cache_versioning = @old_cache_versioning
+ end
+end
diff --git a/activerecord/test/cases/invalid_connection_test.rb b/activerecord/test/cases/invalid_connection_test.rb
new file mode 100644
index 0000000000..a1be9c2780
--- /dev/null
+++ b/activerecord/test/cases/invalid_connection_test.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+if current_adapter?(:Mysql2Adapter)
+ class TestAdapterWithInvalidConnection < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ class Bird < ActiveRecord::Base
+ end
+
+ def setup
+ # Can't just use current adapter; sqlite3 will create a database
+ # file on the fly.
+ Bird.establish_connection adapter: "mysql2", database: "i_do_not_exist"
+ end
+
+ teardown do
+ Bird.remove_connection
+ end
+
+ test "inspect on Model class does not raise" do
+ assert_equal "#{Bird.name} (call '#{Bird.name}.connection' to establish a connection)", Bird.inspect
+ end
+ end
+end
diff --git a/activerecord/test/cases/invertible_migration_test.rb b/activerecord/test/cases/invertible_migration_test.rb
new file mode 100644
index 0000000000..6cf17ac15d
--- /dev/null
+++ b/activerecord/test/cases/invertible_migration_test.rb
@@ -0,0 +1,425 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class Horse < ActiveRecord::Base
+end
+
+module ActiveRecord
+ class InvertibleMigrationTest < ActiveRecord::TestCase
+ class SilentMigration < ActiveRecord::Migration::Current
+ def write(text = "")
+ # sssshhhhh!!
+ end
+ end
+
+ class InvertibleMigration < SilentMigration
+ def change
+ create_table("horses") do |t|
+ t.column :content, :text
+ t.column :remind_at, :datetime
+ end
+ end
+ end
+
+ class InvertibleTransactionMigration < InvertibleMigration
+ def change
+ transaction do
+ super
+ end
+ end
+ end
+
+ class InvertibleRevertMigration < SilentMigration
+ def change
+ revert do
+ create_table("horses") do |t|
+ t.column :content, :text
+ t.column :remind_at, :datetime
+ end
+ end
+ end
+ end
+
+ class InvertibleByPartsMigration < SilentMigration
+ attr_writer :test
+ def change
+ create_table("new_horses") do |t|
+ t.column :breed, :string
+ end
+ reversible do |dir|
+ @test.yield :both
+ dir.up { @test.yield :up }
+ dir.down { @test.yield :down }
+ end
+ revert do
+ create_table("horses") do |t|
+ t.column :content, :text
+ t.column :remind_at, :datetime
+ end
+ end
+ end
+ end
+
+ class NonInvertibleMigration < SilentMigration
+ def change
+ create_table("horses") do |t|
+ t.column :content, :text
+ t.column :remind_at, :datetime
+ end
+ remove_column "horses", :content
+ end
+ end
+
+ class RemoveIndexMigration1 < SilentMigration
+ def self.up
+ create_table("horses") do |t|
+ t.column :name, :string
+ t.column :color, :string
+ t.index [:name, :color]
+ end
+ end
+ end
+
+ class RemoveIndexMigration2 < SilentMigration
+ def change
+ change_table("horses") do |t|
+ t.remove_index [:name, :color]
+ end
+ end
+ end
+
+ class ChangeColumnDefault1 < SilentMigration
+ def change
+ create_table("horses") do |t|
+ t.column :name, :string, default: "Sekitoba"
+ end
+ end
+ end
+
+ class ChangeColumnDefault2 < SilentMigration
+ def change
+ change_column_default :horses, :name, from: "Sekitoba", to: "Diomed"
+ end
+ end
+
+ class DisableExtension1 < SilentMigration
+ def change
+ enable_extension "hstore"
+ end
+ end
+
+ class DisableExtension2 < SilentMigration
+ def change
+ disable_extension "hstore"
+ end
+ end
+
+ class LegacyMigration < ActiveRecord::Migration::Current
+ def self.up
+ create_table("horses") do |t|
+ t.column :content, :text
+ t.column :remind_at, :datetime
+ end
+ end
+
+ def self.down
+ drop_table("horses")
+ end
+ end
+
+ class RevertWholeMigration < SilentMigration
+ def initialize(name = self.class.name, version = nil, migration)
+ @migration = migration
+ super(name, version)
+ end
+
+ def change
+ revert @migration
+ end
+ end
+
+ class NestedRevertWholeMigration < RevertWholeMigration
+ def change
+ revert { super }
+ end
+ end
+
+ class RevertNamedIndexMigration1 < SilentMigration
+ def change
+ create_table("horses") do |t|
+ t.column :content, :string
+ t.column :remind_at, :datetime
+ end
+ add_index :horses, :content
+ end
+ end
+
+ class RevertNamedIndexMigration2 < SilentMigration
+ def change
+ add_index :horses, :content, name: "horses_index_named"
+ end
+ end
+
+ class RevertCustomForeignKeyTable < SilentMigration
+ def change
+ change_table(:horses) do |t|
+ t.references :owner, foreign_key: { to_table: :developers }
+ end
+ end
+ end
+
+ class UpOnlyMigration < SilentMigration
+ def change
+ add_column :horses, :oldie, :integer, default: 0
+ up_only { execute "update horses set oldie = 1" }
+ end
+ end
+
+ self.use_transactional_tests = false
+
+ setup do
+ @verbose_was, ActiveRecord::Migration.verbose = ActiveRecord::Migration.verbose, false
+ end
+
+ teardown do
+ %w[horses new_horses].each do |table|
+ if ActiveRecord::Base.connection.table_exists?(table)
+ ActiveRecord::Base.connection.drop_table(table)
+ end
+ end
+ ActiveRecord::Migration.verbose = @verbose_was
+ end
+
+ def test_no_reverse
+ migration = NonInvertibleMigration.new
+ migration.migrate(:up)
+ assert_raises(IrreversibleMigration) do
+ migration.migrate(:down)
+ end
+ end
+
+ def test_exception_on_removing_index_without_column_option
+ index_definition = ["horses", [:name, :color]]
+ migration1 = RemoveIndexMigration1.new
+ migration1.migrate(:up)
+ assert migration1.connection.index_exists?(*index_definition)
+
+ migration2 = RemoveIndexMigration2.new
+ migration2.migrate(:up)
+ assert_not migration2.connection.index_exists?(*index_definition)
+
+ migration2.migrate(:down)
+ assert migration2.connection.index_exists?(*index_definition)
+ end
+
+ def test_migrate_up
+ migration = InvertibleMigration.new
+ migration.migrate(:up)
+ assert migration.connection.table_exists?("horses"), "horses should exist"
+ end
+
+ def test_migrate_down
+ migration = InvertibleMigration.new
+ migration.migrate :up
+ migration.migrate :down
+ assert_not migration.connection.table_exists?("horses")
+ end
+
+ def test_migrate_revert
+ migration = InvertibleMigration.new
+ revert = InvertibleRevertMigration.new
+ migration.migrate :up
+ revert.migrate :up
+ assert_not migration.connection.table_exists?("horses")
+ revert.migrate :down
+ assert migration.connection.table_exists?("horses")
+ migration.migrate :down
+ assert_not migration.connection.table_exists?("horses")
+ end
+
+ def test_migrate_revert_by_part
+ InvertibleMigration.new.migrate :up
+ received = []
+ migration = InvertibleByPartsMigration.new
+ migration.test = ->(dir) {
+ assert migration.connection.table_exists?("horses")
+ assert migration.connection.table_exists?("new_horses")
+ received << dir
+ }
+ migration.migrate :up
+ assert_equal [:both, :up], received
+ assert_not migration.connection.table_exists?("horses")
+ assert migration.connection.table_exists?("new_horses")
+ migration.migrate :down
+ assert_equal [:both, :up, :both, :down], received
+ assert migration.connection.table_exists?("horses")
+ assert_not migration.connection.table_exists?("new_horses")
+ end
+
+ def test_migrate_revert_whole_migration
+ migration = InvertibleMigration.new
+ [LegacyMigration, InvertibleMigration].each do |klass|
+ revert = RevertWholeMigration.new(klass)
+ migration.migrate :up
+ revert.migrate :up
+ assert_not migration.connection.table_exists?("horses")
+ revert.migrate :down
+ assert migration.connection.table_exists?("horses")
+ migration.migrate :down
+ assert_not migration.connection.table_exists?("horses")
+ end
+ end
+
+ def test_migrate_nested_revert_whole_migration
+ revert = NestedRevertWholeMigration.new(InvertibleRevertMigration)
+ revert.migrate :down
+ assert revert.connection.table_exists?("horses")
+ revert.migrate :up
+ assert_not revert.connection.table_exists?("horses")
+ end
+
+ def test_migrate_revert_transaction
+ migration = InvertibleTransactionMigration.new
+ migration.migrate :up
+ assert migration.connection.table_exists?("horses")
+ migration.migrate :down
+ assert_not migration.connection.table_exists?("horses")
+ end
+
+ def test_migrate_revert_change_column_default
+ migration1 = ChangeColumnDefault1.new
+ migration1.migrate(:up)
+ assert_equal "Sekitoba", Horse.new.name
+
+ migration2 = ChangeColumnDefault2.new
+ migration2.migrate(:up)
+ Horse.reset_column_information
+ assert_equal "Diomed", Horse.new.name
+
+ migration2.migrate(:down)
+ Horse.reset_column_information
+ assert_equal "Sekitoba", Horse.new.name
+ end
+
+ if current_adapter?(:PostgreSQLAdapter)
+ def test_migrate_enable_and_disable_extension
+ migration1 = InvertibleMigration.new
+ migration2 = DisableExtension1.new
+ migration3 = DisableExtension2.new
+
+ migration1.migrate(:up)
+ migration2.migrate(:up)
+ assert_equal true, Horse.connection.extension_enabled?("hstore")
+
+ migration3.migrate(:up)
+ assert_equal false, Horse.connection.extension_enabled?("hstore")
+
+ migration3.migrate(:down)
+ assert_equal true, Horse.connection.extension_enabled?("hstore")
+
+ migration2.migrate(:down)
+ assert_equal false, Horse.connection.extension_enabled?("hstore")
+ ensure
+ enable_extension!("hstore", ActiveRecord::Base.connection)
+ end
+ end
+
+ def test_revert_order
+ block = Proc.new { |t| t.string :name }
+ recorder = ActiveRecord::Migration::CommandRecorder.new(ActiveRecord::Base.connection)
+ recorder.instance_eval do
+ create_table("apples", &block)
+ revert do
+ create_table("bananas", &block)
+ revert do
+ create_table("clementines")
+ create_table("dates")
+ end
+ create_table("elderberries")
+ end
+ revert do
+ create_table("figs")
+ create_table("grapes")
+ end
+ end
+ assert_equal [[:create_table, ["apples"], block], [:drop_table, ["elderberries"], nil],
+ [:create_table, ["clementines"], nil], [:create_table, ["dates"], nil],
+ [:drop_table, ["bananas"], block], [:drop_table, ["grapes"], nil],
+ [:drop_table, ["figs"], nil]], recorder.commands
+ end
+
+ def test_legacy_up
+ LegacyMigration.migrate :up
+ assert ActiveRecord::Base.connection.table_exists?("horses"), "horses should exist"
+ end
+
+ def test_legacy_down
+ LegacyMigration.migrate :up
+ LegacyMigration.migrate :down
+ assert_not ActiveRecord::Base.connection.table_exists?("horses"), "horses should not exist"
+ end
+
+ def test_up
+ LegacyMigration.up
+ assert ActiveRecord::Base.connection.table_exists?("horses"), "horses should exist"
+ end
+
+ def test_down
+ LegacyMigration.up
+ LegacyMigration.down
+ assert_not ActiveRecord::Base.connection.table_exists?("horses"), "horses should not exist"
+ end
+
+ def test_migrate_down_with_table_name_prefix
+ ActiveRecord::Base.table_name_prefix = "p_"
+ ActiveRecord::Base.table_name_suffix = "_s"
+ migration = InvertibleMigration.new
+ migration.migrate(:up)
+ assert_nothing_raised { migration.migrate(:down) }
+ assert_not ActiveRecord::Base.connection.table_exists?("p_horses_s"), "p_horses_s should not exist"
+ ensure
+ ActiveRecord::Base.table_name_prefix = ActiveRecord::Base.table_name_suffix = ""
+ end
+
+ def test_migrations_can_handle_foreign_keys_to_specific_tables
+ migration = RevertCustomForeignKeyTable.new
+ InvertibleMigration.migrate(:up)
+ migration.migrate(:up)
+ migration.migrate(:down)
+ end
+
+ # MySQL 5.7 and Oracle do not allow to create duplicate indexes on the same columns
+ unless current_adapter?(:Mysql2Adapter, :OracleAdapter)
+ def test_migrate_revert_add_index_with_name
+ RevertNamedIndexMigration1.new.migrate(:up)
+ RevertNamedIndexMigration2.new.migrate(:up)
+ RevertNamedIndexMigration2.new.migrate(:down)
+
+ connection = ActiveRecord::Base.connection
+ assert connection.index_exists?(:horses, :content),
+ "index on content should exist"
+ assert_not connection.index_exists?(:horses, :content, name: "horses_index_named"),
+ "horses_index_named index should not exist"
+ end
+ end
+
+ def test_up_only
+ InvertibleMigration.new.migrate(:up)
+ horse1 = Horse.create
+ # populates existing horses with oldie = 1 but new ones have default 0
+ UpOnlyMigration.new.migrate(:up)
+ Horse.reset_column_information
+ horse1.reload
+ horse2 = Horse.create
+
+ assert 1, horse1.oldie # created before migration
+ assert 0, horse2.oldie # created after migration
+
+ UpOnlyMigration.new.migrate(:down) # should be no error
+ connection = ActiveRecord::Base.connection
+ assert_not connection.column_exists?(:horses, :oldie)
+ Horse.reset_column_information
+ end
+ end
+end
diff --git a/activerecord/test/cases/json_attribute_test.rb b/activerecord/test/cases/json_attribute_test.rb
new file mode 100644
index 0000000000..afc39d0420
--- /dev/null
+++ b/activerecord/test/cases/json_attribute_test.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "cases/json_shared_test_cases"
+
+class JsonAttributeTest < ActiveRecord::TestCase
+ include JSONSharedTestCases
+ self.use_transactional_tests = false
+
+ class JsonDataTypeOnText < ActiveRecord::Base
+ self.table_name = "json_data_type"
+
+ attribute :payload, :json
+ attribute :settings, :json
+
+ store_accessor :settings, :resolution
+ end
+
+ def setup
+ super
+ @connection.create_table("json_data_type") do |t|
+ t.string "payload"
+ t.string "settings"
+ end
+ end
+
+ private
+ def column_type
+ :string
+ end
+
+ def klass
+ JsonDataTypeOnText
+ end
+end
diff --git a/activerecord/test/cases/json_serialization_test.rb b/activerecord/test/cases/json_serialization_test.rb
new file mode 100644
index 0000000000..82cf281cff
--- /dev/null
+++ b/activerecord/test/cases/json_serialization_test.rb
@@ -0,0 +1,311 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/contact"
+require "models/post"
+require "models/author"
+require "models/tagging"
+require "models/tag"
+require "models/comment"
+
+module JsonSerializationHelpers
+ private
+
+ def set_include_root_in_json(value)
+ original_root_in_json = ActiveRecord::Base.include_root_in_json
+ ActiveRecord::Base.include_root_in_json = value
+ yield
+ ensure
+ ActiveRecord::Base.include_root_in_json = original_root_in_json
+ end
+end
+
+class JsonSerializationTest < ActiveRecord::TestCase
+ include JsonSerializationHelpers
+
+ class NamespacedContact < Contact
+ column :name, :string
+ end
+
+ def setup
+ @contact = Contact.new(
+ name: "Konata Izumi",
+ age: 16,
+ avatar: "binarydata",
+ created_at: Time.utc(2006, 8, 1),
+ awesome: true,
+ preferences: { shows: "anime" }
+ )
+ end
+
+ def test_should_demodulize_root_in_json
+ set_include_root_in_json(true) do
+ @contact = NamespacedContact.new name: "whatever"
+ json = @contact.to_json
+ assert_match %r{^\{"namespaced_contact":\{}, json
+ end
+ end
+
+ def test_should_include_root_in_json
+ set_include_root_in_json(true) do
+ json = @contact.to_json
+
+ assert_match %r{^\{"contact":\{}, json
+ assert_match %r{"name":"Konata Izumi"}, json
+ assert_match %r{"age":16}, json
+ assert_includes json, %("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})
+ assert_match %r{"awesome":true}, json
+ assert_match %r{"preferences":\{"shows":"anime"\}}, json
+ end
+ end
+
+ def test_should_encode_all_encodable_attributes
+ json = @contact.to_json
+
+ assert_match %r{"name":"Konata Izumi"}, json
+ assert_match %r{"age":16}, json
+ assert_includes json, %("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})
+ assert_match %r{"awesome":true}, json
+ assert_match %r{"preferences":\{"shows":"anime"\}}, json
+ end
+
+ def test_should_allow_attribute_filtering_with_only
+ json = @contact.to_json(only: [:name, :age])
+
+ assert_match %r{"name":"Konata Izumi"}, json
+ assert_match %r{"age":16}, json
+ assert_no_match %r{"awesome":true}, json
+ assert_not_includes json, %("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})
+ assert_no_match %r{"preferences":\{"shows":"anime"\}}, json
+ end
+
+ def test_should_allow_attribute_filtering_with_except
+ json = @contact.to_json(except: [:name, :age])
+
+ assert_no_match %r{"name":"Konata Izumi"}, json
+ assert_no_match %r{"age":16}, json
+ assert_match %r{"awesome":true}, json
+ assert_includes json, %("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})
+ assert_match %r{"preferences":\{"shows":"anime"\}}, json
+ end
+
+ def test_methods_are_called_on_object
+ # Define methods on fixture.
+ def @contact.label; "Has cheezburger"; end
+ def @contact.favorite_quote; "Constraints are liberating"; end
+
+ # Single method.
+ assert_match %r{"label":"Has cheezburger"}, @contact.to_json(only: :name, methods: :label)
+
+ # Both methods.
+ methods_json = @contact.to_json(only: :name, methods: [:label, :favorite_quote])
+ assert_match %r{"label":"Has cheezburger"}, methods_json
+ assert_match %r{"favorite_quote":"Constraints are liberating"}, methods_json
+ end
+
+ def test_uses_serializable_hash_with_frozen_hash
+ def @contact.serializable_hash(options = nil)
+ super({ only: %w(name) }.freeze)
+ end
+
+ json = @contact.to_json
+ assert_match %r{"name":"Konata Izumi"}, json
+ assert_no_match %r{awesome}, json
+ assert_no_match %r{age}, json
+ end
+
+ def test_uses_serializable_hash_with_only_option
+ def @contact.serializable_hash(options = nil)
+ super(only: %w(name))
+ end
+
+ json = @contact.to_json
+ assert_match %r{"name":"Konata Izumi"}, json
+ assert_no_match %r{awesome}, json
+ assert_no_match %r{age}, json
+ end
+
+ def test_uses_serializable_hash_with_except_option
+ def @contact.serializable_hash(options = nil)
+ super(except: %w(age))
+ end
+
+ json = @contact.to_json
+ assert_match %r{"name":"Konata Izumi"}, json
+ assert_match %r{"awesome":true}, json
+ assert_no_match %r{age}, json
+ end
+
+ def test_does_not_include_inheritance_column_from_sti
+ @contact = ContactSti.new(@contact.attributes)
+ assert_equal "ContactSti", @contact.type
+
+ json = @contact.to_json
+ assert_match %r{"name":"Konata Izumi"}, json
+ assert_no_match %r{type}, json
+ assert_no_match %r{ContactSti}, json
+ end
+
+ def test_serializable_hash_with_default_except_option_and_excluding_inheritance_column_from_sti
+ @contact = ContactSti.new(@contact.attributes)
+ assert_equal "ContactSti", @contact.type
+
+ def @contact.serializable_hash(options = {})
+ super({ except: %w(age) }.merge!(options))
+ end
+
+ json = @contact.to_json
+ assert_match %r{"name":"Konata Izumi"}, json
+ assert_no_match %r{age}, json
+ assert_no_match %r{type}, json
+ assert_no_match %r{ContactSti}, json
+ end
+
+ def test_serializable_hash_should_not_modify_options_in_argument
+ options = { only: :name }.freeze
+ assert_nothing_raised { @contact.serializable_hash(options) }
+ end
+end
+
+class DatabaseConnectedJsonEncodingTest < ActiveRecord::TestCase
+ fixtures :authors, :author_addresses, :posts, :comments, :tags, :taggings
+
+ include JsonSerializationHelpers
+
+ def setup
+ @david = authors(:david)
+ @mary = authors(:mary)
+ end
+
+ def test_includes_uses_association_name
+ json = @david.to_json(include: :posts)
+
+ assert_match %r{"posts":\[}, json
+
+ assert_match %r{"id":1}, json
+ assert_match %r{"name":"David"}, json
+
+ assert_match %r{"author_id":1}, json
+ assert_match %r{"title":"Welcome to the weblog"}, json
+ assert_match %r{"body":"Such a lovely day"}, json
+
+ assert_match %r{"title":"So I was thinking"}, json
+ assert_match %r{"body":"Like I hopefully always am"}, json
+ end
+
+ def test_includes_uses_association_name_and_applies_attribute_filters
+ json = @david.to_json(include: { posts: { only: :title } })
+
+ assert_match %r{"name":"David"}, json
+ assert_match %r{"posts":\[}, json
+
+ assert_match %r{"title":"Welcome to the weblog"}, json
+ assert_no_match %r{"body":"Such a lovely day"}, json
+
+ assert_match %r{"title":"So I was thinking"}, json
+ assert_no_match %r{"body":"Like I hopefully always am"}, json
+ end
+
+ def test_includes_fetches_second_level_associations
+ json = @david.to_json(include: { posts: { include: { comments: { only: :body } } } })
+
+ assert_match %r{"name":"David"}, json
+ assert_match %r{"posts":\[}, json
+
+ assert_match %r{"comments":\[}, json
+ assert_match %r{\{"body":"Thank you again for the welcome"\}}, json
+ assert_match %r{\{"body":"Don't think too hard"\}}, json
+ assert_no_match %r{"post_id":}, json
+ end
+
+ def test_includes_fetches_nth_level_associations
+ json = @david.to_json(
+ include: {
+ posts: {
+ include: {
+ taggings: {
+ include: {
+ tag: { only: :name }
+ }
+ }
+ }
+ }
+ })
+
+ assert_match %r{"name":"David"}, json
+ assert_match %r{"posts":\[}, json
+
+ assert_match %r{"taggings":\[}, json
+ assert_match %r{"tag":\{"name":"General"\}}, json
+ end
+
+ def test_includes_doesnt_merge_opts_from_base
+ json = @david.to_json(
+ only: :id,
+ include: :posts
+ )
+
+ assert_match %{"title":"Welcome to the weblog"}, json
+ end
+
+ def test_should_not_call_methods_on_associations_that_dont_respond
+ def @david.favorite_quote; "Constraints are liberating"; end
+ json = @david.to_json(include: :posts, methods: :favorite_quote)
+
+ assert_not_respond_to @david.posts.first, :favorite_quote
+ assert_match %r{"favorite_quote":"Constraints are liberating"}, json
+ assert_equal 1, %r{"favorite_quote":}.match(json).size
+ end
+
+ def test_should_allow_only_option_for_list_of_authors
+ set_include_root_in_json(false) do
+ authors = [@david, @mary]
+ assert_equal %([{"name":"David"},{"name":"Mary"}]), ActiveSupport::JSON.encode(authors, only: :name)
+ end
+ end
+
+ def test_should_allow_except_option_for_list_of_authors
+ set_include_root_in_json(false) do
+ authors = [@david, @mary]
+ encoded = ActiveSupport::JSON.encode(authors, except: [
+ :name, :author_address_id, :author_address_extra_id,
+ :organization_id, :owned_essay_id
+ ])
+ assert_equal %([{"id":1},{"id":2}]), encoded
+ end
+ end
+
+ def test_should_allow_includes_for_list_of_authors
+ authors = [@david, @mary]
+ json = ActiveSupport::JSON.encode(authors,
+ only: :name,
+ include: {
+ posts: { only: :id }
+ }
+ )
+
+ ['"name":"David"', '"posts":[', '{"id":1}', '{"id":2}', '{"id":4}',
+ '{"id":5}', '{"id":6}', '"name":"Mary"', '"posts":[', '{"id":7}', '{"id":9}'].each do |fragment|
+ assert_includes json, fragment, json
+ end
+ end
+
+ def test_should_allow_options_for_hash_of_authors
+ set_include_root_in_json(true) do
+ authors_hash = {
+ 1 => @david,
+ 2 => @mary
+ }
+ assert_equal %({"1":{"author":{"name":"David"}}}), ActiveSupport::JSON.encode(authors_hash, only: [1, :name])
+ end
+ end
+
+ def test_should_be_able_to_encode_relation
+ set_include_root_in_json(true) do
+ authors_relation = Author.where(id: [@david.id, @mary.id])
+
+ json = ActiveSupport::JSON.encode authors_relation, only: :name
+ assert_equal '[{"author":{"name":"David"}},{"author":{"name":"Mary"}}]', json
+ end
+ end
+end
diff --git a/activerecord/test/cases/json_shared_test_cases.rb b/activerecord/test/cases/json_shared_test_cases.rb
new file mode 100644
index 0000000000..9b79803503
--- /dev/null
+++ b/activerecord/test/cases/json_shared_test_cases.rb
@@ -0,0 +1,269 @@
+# frozen_string_literal: true
+
+require "support/schema_dumping_helper"
+
+module JSONSharedTestCases
+ include SchemaDumpingHelper
+
+ class JsonDataType < ActiveRecord::Base
+ self.table_name = "json_data_type"
+
+ store_accessor :settings, :resolution
+ end
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ end
+
+ def teardown
+ @connection.drop_table :json_data_type, if_exists: true
+ klass.reset_column_information
+ end
+
+ def test_column
+ column = klass.columns_hash["payload"]
+ assert_equal column_type, column.type
+ assert_type_match column_type, column.sql_type
+
+ type = klass.type_for_attribute("payload")
+ assert_not_predicate type, :binary?
+ end
+
+ def test_change_table_supports_json
+ @connection.change_table("json_data_type") do |t|
+ t.public_send column_type, "users"
+ end
+ klass.reset_column_information
+ column = klass.columns_hash["users"]
+ assert_equal column_type, column.type
+ assert_type_match column_type, column.sql_type
+ end
+
+ def test_schema_dumping
+ output = dump_table_schema("json_data_type")
+ assert_match(/t\.#{column_type}\s+"settings"/, output)
+ end
+
+ def test_cast_value_on_write
+ x = klass.new(payload: { "string" => "foo", :symbol => :bar })
+ assert_equal({ "string" => "foo", :symbol => :bar }, x.payload_before_type_cast)
+ assert_equal({ "string" => "foo", "symbol" => "bar" }, x.payload)
+ x.save!
+ assert_equal({ "string" => "foo", "symbol" => "bar" }, x.reload.payload)
+ end
+
+ def test_type_cast_json
+ type = klass.type_for_attribute("payload")
+
+ data = '{"a_key":"a_value"}'
+ hash = type.deserialize(data)
+ assert_equal({ "a_key" => "a_value" }, hash)
+ assert_equal({ "a_key" => "a_value" }, type.deserialize(data))
+
+ assert_equal({}, type.deserialize("{}"))
+ assert_equal({ "key" => nil }, type.deserialize('{"key": null}'))
+ assert_equal({ "c" => "}", '"a"' => 'b "a b' }, type.deserialize(%q({"c":"}", "\"a\"":"b \"a b"})))
+ end
+
+ def test_rewrite
+ @connection.execute(insert_statement_per_database('{"k":"v"}'))
+ x = klass.first
+ x.payload = { '"a\'' => "b" }
+ assert x.save!
+ end
+
+ def test_select
+ @connection.execute(insert_statement_per_database('{"k":"v"}'))
+ x = klass.first
+ assert_equal({ "k" => "v" }, x.payload)
+ end
+
+ def test_select_multikey
+ @connection.execute(insert_statement_per_database('{"k1":"v1", "k2":"v2", "k3":[1,2,3]}'))
+ x = klass.first
+ assert_equal({ "k1" => "v1", "k2" => "v2", "k3" => [1, 2, 3] }, x.payload)
+ end
+
+ def test_null_json
+ @connection.execute(insert_statement_per_database("null"))
+ x = klass.first
+ assert_nil(x.payload)
+ end
+
+ def test_select_nil_json_after_create
+ json = klass.create!(payload: nil)
+ x = klass.where(payload: nil).first
+ assert_equal(json, x)
+ end
+
+ def test_select_nil_json_after_update
+ json = klass.create!(payload: "foo")
+ x = klass.where(payload: nil).first
+ assert_nil(x)
+
+ json.update(payload: nil)
+ x = klass.where(payload: nil).first
+ assert_equal(json.reload, x)
+ end
+
+ def test_select_array_json_value
+ @connection.execute(insert_statement_per_database('["v0",{"k1":"v1"}]'))
+ x = klass.first
+ assert_equal(["v0", { "k1" => "v1" }], x.payload)
+ end
+
+ def test_rewrite_array_json_value
+ @connection.execute(insert_statement_per_database('["v0",{"k1":"v1"}]'))
+ x = klass.first
+ x.payload = ["v1", { "k2" => "v2" }, "v3"]
+ assert x.save!
+ end
+
+ def test_with_store_accessors
+ x = klass.new(resolution: "320×480")
+ assert_equal "320×480", x.resolution
+
+ x.save!
+ x = klass.first
+ assert_equal "320×480", x.resolution
+
+ x.resolution = "640×1136"
+ x.save!
+
+ x = klass.first
+ assert_equal "640×1136", x.resolution
+ end
+
+ def test_duplication_with_store_accessors
+ x = klass.new(resolution: "320×480")
+ assert_equal "320×480", x.resolution
+
+ y = x.dup
+ assert_equal "320×480", y.resolution
+ end
+
+ def test_yaml_round_trip_with_store_accessors
+ x = klass.new(resolution: "320×480")
+ assert_equal "320×480", x.resolution
+
+ y = YAML.load(YAML.dump(x))
+ assert_equal "320×480", y.resolution
+ end
+
+ def test_changes_in_place
+ json = klass.new
+ assert_not_predicate json, :changed?
+
+ json.payload = { "one" => "two" }
+ assert_predicate json, :changed?
+ assert_predicate json, :payload_changed?
+
+ json.save!
+ assert_not_predicate json, :changed?
+
+ json.payload["three"] = "four"
+ assert_predicate json, :payload_changed?
+
+ json.save!
+ json.reload
+
+ assert_equal({ "one" => "two", "three" => "four" }, json.payload)
+ assert_not_predicate json, :changed?
+ end
+
+ def test_changes_in_place_ignores_key_order
+ json = klass.new
+ assert_not_predicate json, :changed?
+
+ json.payload = { "three" => "four", "one" => "two" }
+ json.save!
+ json.reload
+
+ json.payload = { "three" => "four", "one" => "two" }
+ assert_not_predicate json, :changed?
+
+ json.payload = [{ "three" => "four", "one" => "two" }, { "seven" => "eight", "five" => "six" }]
+ json.save!
+ json.reload
+
+ json.payload = [{ "three" => "four", "one" => "two" }, { "seven" => "eight", "five" => "six" }]
+ assert_not_predicate json, :changed?
+ end
+
+ def test_changes_in_place_with_ruby_object
+ time = Time.now.utc
+ json = klass.create!(payload: time)
+
+ json.reload
+ assert_not_predicate json, :changed?
+
+ json.payload = time
+ assert_not_predicate json, :changed?
+ end
+
+ def test_assigning_string_literal
+ json = klass.create!(payload: "foo")
+ assert_equal "foo", json.payload
+ end
+
+ def test_assigning_number
+ json = klass.create!(payload: 1.234)
+ assert_equal 1.234, json.payload
+ end
+
+ def test_assigning_boolean
+ json = klass.create!(payload: true)
+ assert_equal true, json.payload
+ end
+
+ def test_not_compatible_with_serialize_json
+ new_klass = Class.new(klass) do
+ serialize :payload, JSON
+ end
+ assert_raises(ActiveRecord::AttributeMethods::Serialization::ColumnNotSerializableError) do
+ new_klass.new
+ end
+ end
+
+ class MySettings
+ def initialize(hash); @hash = hash end
+ def to_hash; @hash end
+ def self.load(hash); new(hash) end
+ def self.dump(object); object.to_hash end
+ end
+
+ def test_json_with_serialized_attributes
+ new_klass = Class.new(klass) do
+ serialize :settings, MySettings
+ end
+
+ new_klass.create!(settings: MySettings.new("one" => "two"))
+ record = new_klass.first
+
+ assert_instance_of MySettings, record.settings
+ assert_equal({ "one" => "two" }, record.settings.to_hash)
+
+ record.settings = MySettings.new("three" => "four")
+ record.save!
+
+ assert_equal({ "three" => "four" }, record.reload.settings.to_hash)
+ end
+
+ private
+ def klass
+ JsonDataType
+ end
+
+ def assert_type_match(type, sql_type)
+ native_type = ActiveRecord::Base.connection.native_database_types[type][:name]
+ assert_match %r(\A#{native_type}\b), sql_type
+ end
+
+ def insert_statement_per_database(values)
+ if current_adapter?(:OracleAdapter)
+ "insert into json_data_type (id, payload) VALUES (json_data_type_seq.nextval, '#{values}')"
+ else
+ "insert into json_data_type (payload) VALUES ('#{values}')"
+ end
+ end
+end
diff --git a/activerecord/test/cases/legacy_configurations_test.rb b/activerecord/test/cases/legacy_configurations_test.rb
new file mode 100644
index 0000000000..c36feb5116
--- /dev/null
+++ b/activerecord/test/cases/legacy_configurations_test.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ class LegacyConfigurationsTest < ActiveRecord::TestCase
+ def test_can_turn_configurations_into_a_hash
+ assert ActiveRecord::Base.configurations.to_h.is_a?(Hash), "expected to be a hash but was not."
+ assert_equal ["arunit", "arunit2", "arunit_without_prepared_statements"].sort, ActiveRecord::Base.configurations.to_h.keys.sort
+ end
+
+ def test_each_is_deprecated
+ assert_deprecated do
+ ActiveRecord::Base.configurations.each do |db_config|
+ assert_equal "primary", db_config.spec_name
+ end
+ end
+ end
+
+ def test_first_is_deprecated
+ assert_deprecated do
+ db_config = ActiveRecord::Base.configurations.first
+ assert_equal "arunit", db_config.env_name
+ assert_equal "primary", db_config.spec_name
+ end
+ end
+
+ def test_fetch_is_deprecated
+ assert_deprecated do
+ db_config = ActiveRecord::Base.configurations.fetch("arunit").first
+ assert_equal "arunit", db_config.env_name
+ assert_equal "primary", db_config.spec_name
+ end
+ end
+
+ def test_values_are_deprecated
+ config_hashes = ActiveRecord::Base.configurations.configurations.map(&:config)
+ assert_deprecated do
+ assert_equal config_hashes, ActiveRecord::Base.configurations.values
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/locking_test.rb b/activerecord/test/cases/locking_test.rb
new file mode 100644
index 0000000000..33bd74e114
--- /dev/null
+++ b/activerecord/test/cases/locking_test.rb
@@ -0,0 +1,738 @@
+# frozen_string_literal: true
+
+require "thread"
+require "cases/helper"
+require "models/person"
+require "models/job"
+require "models/reader"
+require "models/ship"
+require "models/legacy_thing"
+require "models/personal_legacy_thing"
+require "models/reference"
+require "models/string_key_object"
+require "models/car"
+require "models/bulb"
+require "models/engine"
+require "models/wheel"
+require "models/treasure"
+require "models/frog"
+
+class LockWithoutDefault < ActiveRecord::Base; end
+
+class LockWithCustomColumnWithoutDefault < ActiveRecord::Base
+ self.table_name = :lock_without_defaults_cust
+ column_defaults # to test @column_defaults caching.
+ self.locking_column = :custom_lock_version
+end
+
+class ReadonlyNameShip < Ship
+ attr_readonly :name
+end
+
+class OptimisticLockingTest < ActiveRecord::TestCase
+ fixtures :people, :legacy_things, :references, :string_key_objects, :peoples_treasures
+
+ def test_quote_value_passed_lock_col
+ p1 = Person.find(1)
+ assert_equal 0, p1.lock_version
+
+ p1.first_name = "anika2"
+ p1.save!
+
+ assert_equal 1, p1.lock_version
+ end
+
+ def test_non_integer_lock_existing
+ s1 = StringKeyObject.find("record1")
+ s2 = StringKeyObject.find("record1")
+ assert_equal 0, s1.lock_version
+ assert_equal 0, s2.lock_version
+
+ s1.name = "updated record"
+ s1.save!
+ assert_equal 1, s1.lock_version
+ assert_equal 0, s2.lock_version
+
+ s2.name = "doubly updated record"
+ assert_raise(ActiveRecord::StaleObjectError) { s2.save! }
+ end
+
+ def test_non_integer_lock_destroy
+ s1 = StringKeyObject.find("record1")
+ s2 = StringKeyObject.find("record1")
+ assert_equal 0, s1.lock_version
+ assert_equal 0, s2.lock_version
+
+ s1.name = "updated record"
+ s1.save!
+ assert_equal 1, s1.lock_version
+ assert_equal 0, s2.lock_version
+ assert_raise(ActiveRecord::StaleObjectError) { s2.destroy }
+
+ assert s1.destroy
+ assert_predicate s1, :frozen?
+ assert_predicate s1, :destroyed?
+ assert_raises(ActiveRecord::RecordNotFound) { StringKeyObject.find("record1") }
+ end
+
+ def test_lock_existing
+ p1 = Person.find(1)
+ p2 = Person.find(1)
+ assert_equal 0, p1.lock_version
+ assert_equal 0, p2.lock_version
+
+ p1.first_name = "stu"
+ p1.save!
+ assert_equal 1, p1.lock_version
+ assert_equal 0, p2.lock_version
+
+ p2.first_name = "sue"
+ assert_raise(ActiveRecord::StaleObjectError) { p2.save! }
+ end
+
+ # See Lighthouse ticket #1966
+ def test_lock_destroy
+ p1 = Person.find(1)
+ p2 = Person.find(1)
+ assert_equal 0, p1.lock_version
+ assert_equal 0, p2.lock_version
+
+ p1.first_name = "stu"
+ p1.save!
+ assert_equal 1, p1.lock_version
+ assert_equal 0, p2.lock_version
+
+ assert_raises(ActiveRecord::StaleObjectError) { p2.destroy }
+
+ assert p1.destroy
+ assert_predicate p1, :frozen?
+ assert_predicate p1, :destroyed?
+ assert_raises(ActiveRecord::RecordNotFound) { Person.find(1) }
+ end
+
+ def test_lock_repeating
+ p1 = Person.find(1)
+ p2 = Person.find(1)
+ assert_equal 0, p1.lock_version
+ assert_equal 0, p2.lock_version
+
+ p1.first_name = "stu"
+ p1.save!
+ assert_equal 1, p1.lock_version
+ assert_equal 0, p2.lock_version
+
+ p2.first_name = "sue"
+ assert_raise(ActiveRecord::StaleObjectError) { p2.save! }
+ p2.first_name = "sue2"
+ assert_raise(ActiveRecord::StaleObjectError) { p2.save! }
+ end
+
+ def test_lock_new
+ p1 = Person.new(first_name: "anika")
+ assert_equal 0, p1.lock_version
+
+ p1.first_name = "anika2"
+ p1.save!
+ p2 = Person.find(p1.id)
+ assert_equal 0, p1.lock_version
+ assert_equal 0, p2.lock_version
+
+ p1.first_name = "anika3"
+ p1.save!
+ assert_equal 1, p1.lock_version
+ assert_equal 0, p2.lock_version
+
+ p2.first_name = "sue"
+ assert_raise(ActiveRecord::StaleObjectError) { p2.save! }
+ end
+
+ def test_lock_exception_record
+ p1 = Person.new(first_name: "mira")
+ assert_equal 0, p1.lock_version
+
+ p1.first_name = "mira2"
+ p1.save!
+ p2 = Person.find(p1.id)
+ assert_equal 0, p1.lock_version
+ assert_equal 0, p2.lock_version
+
+ p1.first_name = "mira3"
+ p1.save!
+
+ p2.first_name = "sue"
+ error = assert_raise(ActiveRecord::StaleObjectError) { p2.save! }
+ assert_equal(error.record.object_id, p2.object_id)
+ end
+
+ def test_lock_new_when_explicitly_passing_nil
+ p1 = Person.new(first_name: "anika", lock_version: nil)
+ p1.save!
+ assert_equal 0, p1.lock_version
+ end
+
+ def test_lock_new_when_explicitly_passing_value
+ p1 = Person.new(first_name: "Douglas Adams", lock_version: 42)
+ p1.save!
+ assert_equal 42, p1.lock_version
+ end
+
+ def test_touch_existing_lock
+ p1 = Person.find(1)
+ assert_equal 0, p1.lock_version
+
+ p1.touch
+ assert_equal 1, p1.lock_version
+ assert_not p1.changed?, "Changes should have been cleared"
+ end
+
+ def test_touch_stale_object
+ person = Person.create!(first_name: "Mehmet Emin")
+ stale_person = Person.find(person.id)
+ person.update_attribute(:gender, "M")
+
+ assert_raises(ActiveRecord::StaleObjectError) do
+ stale_person.touch
+ end
+ end
+
+ def test_update_with_dirty_primary_key
+ assert_raises(ActiveRecord::RecordNotUnique) do
+ person = Person.find(1)
+ person.id = 2
+ person.save!
+ end
+
+ person = Person.find(1)
+ person.id = 42
+ person.save!
+
+ assert Person.find(42)
+ assert_raises(ActiveRecord::RecordNotFound) do
+ Person.find(1)
+ end
+ end
+
+ def test_delete_with_dirty_primary_key
+ person = Person.find(1)
+ person.id = 2
+ person.delete
+
+ assert Person.find(2)
+ assert_raises(ActiveRecord::RecordNotFound) do
+ Person.find(1)
+ end
+ end
+
+ def test_destroy_with_dirty_primary_key
+ person = Person.find(1)
+ person.id = 2
+ person.destroy
+
+ assert Person.find(2)
+ assert_raises(ActiveRecord::RecordNotFound) do
+ Person.find(1)
+ end
+ end
+
+ def test_explicit_update_lock_column_raise_error
+ person = Person.find(1)
+
+ assert_raises(ActiveRecord::StaleObjectError) do
+ person.first_name = "Douglas Adams"
+ person.lock_version = 42
+
+ assert_predicate person, :lock_version_changed?
+
+ person.save
+ end
+ end
+
+ def test_lock_column_name_existing
+ t1 = LegacyThing.find(1)
+ t2 = LegacyThing.find(1)
+ assert_equal 0, t1.version
+ assert_equal 0, t2.version
+
+ t1.tps_report_number = 700
+ t1.save!
+ assert_equal 1, t1.version
+ assert_equal 0, t2.version
+
+ t2.tps_report_number = 800
+ assert_raise(ActiveRecord::StaleObjectError) { t2.save! }
+ end
+
+ def test_lock_column_is_mass_assignable
+ p1 = Person.create(first_name: "bianca")
+ assert_equal 0, p1.lock_version
+ assert_equal p1.lock_version, Person.new(p1.attributes).lock_version
+
+ p1.first_name = "bianca2"
+ p1.save!
+ assert_equal 1, p1.lock_version
+ assert_equal p1.lock_version, Person.new(p1.attributes).lock_version
+ end
+
+ def test_lock_without_default_sets_version_to_zero
+ t1 = LockWithoutDefault.new
+
+ assert_equal 0, t1.lock_version
+ assert_nil t1.lock_version_before_type_cast
+
+ t1.save!
+ t1.reload
+
+ assert_equal 0, t1.lock_version
+ assert_equal 0, t1.lock_version_before_type_cast
+ end
+
+ def test_touch_existing_lock_without_default_should_work_with_null_in_the_database
+ ActiveRecord::Base.connection.execute("INSERT INTO lock_without_defaults(title) VALUES('title1')")
+ t1 = LockWithoutDefault.last
+
+ assert_equal 0, t1.lock_version
+ assert_nil t1.lock_version_before_type_cast
+
+ t1.touch
+
+ assert_equal 1, t1.lock_version
+ end
+
+ def test_touch_stale_object_with_lock_without_default
+ t1 = LockWithoutDefault.create!(title: "title1")
+ stale_object = LockWithoutDefault.find(t1.id)
+
+ t1.update!(title: "title2")
+
+ assert_raises(ActiveRecord::StaleObjectError) do
+ stale_object.touch
+ end
+ end
+
+ def test_lock_without_default_should_work_with_null_in_the_database
+ ActiveRecord::Base.connection.execute("INSERT INTO lock_without_defaults(title) VALUES('title1')")
+ t1 = LockWithoutDefault.last
+ t2 = LockWithoutDefault.find(t1.id)
+
+ assert_equal 0, t1.lock_version
+ assert_nil t1.lock_version_before_type_cast
+ assert_equal 0, t2.lock_version
+ assert_nil t2.lock_version_before_type_cast
+
+ t1.title = "new title1"
+ t2.title = "new title2"
+
+ assert_nothing_raised { t1.save! }
+ assert_equal 1, t1.lock_version
+ assert_equal "new title1", t1.title
+
+ assert_raise(ActiveRecord::StaleObjectError) { t2.save! }
+ assert_equal 0, t2.lock_version
+ assert_equal "new title2", t2.title
+ end
+
+ def test_lock_without_default_queries_count
+ t1 = LockWithoutDefault.create(title: "title1")
+
+ assert_equal "title1", t1.title
+ assert_equal 0, t1.lock_version
+
+ assert_queries(1) { t1.update(title: "title2") }
+
+ t1.reload
+ assert_equal "title2", t1.title
+ assert_equal 1, t1.lock_version
+
+ t2 = LockWithoutDefault.new(title: "title1")
+
+ assert_queries(1) { t2.save! }
+
+ t2.reload
+ assert_equal "title1", t2.title
+ assert_equal 0, t2.lock_version
+ end
+
+ def test_lock_with_custom_column_without_default_sets_version_to_zero
+ t1 = LockWithCustomColumnWithoutDefault.new
+
+ assert_equal 0, t1.custom_lock_version
+ assert_nil t1.custom_lock_version_before_type_cast
+
+ t1.save!
+ t1.reload
+
+ assert_equal 0, t1.custom_lock_version
+ assert_equal 0, t1.custom_lock_version_before_type_cast
+ end
+
+ def test_lock_with_custom_column_without_default_should_work_with_null_in_the_database
+ ActiveRecord::Base.connection.execute("INSERT INTO lock_without_defaults_cust(title) VALUES('title1')")
+
+ t1 = LockWithCustomColumnWithoutDefault.last
+ t2 = LockWithCustomColumnWithoutDefault.find(t1.id)
+
+ assert_equal 0, t1.custom_lock_version
+ assert_nil t1.custom_lock_version_before_type_cast
+ assert_equal 0, t2.custom_lock_version
+ assert_nil t2.custom_lock_version_before_type_cast
+
+ t1.title = "new title1"
+ t2.title = "new title2"
+
+ assert_nothing_raised { t1.save! }
+ assert_equal 1, t1.custom_lock_version
+ assert_equal "new title1", t1.title
+
+ assert_raise(ActiveRecord::StaleObjectError) { t2.save! }
+ assert_equal 0, t2.custom_lock_version
+ assert_equal "new title2", t2.title
+ end
+
+ def test_lock_with_custom_column_without_default_queries_count
+ t1 = LockWithCustomColumnWithoutDefault.create(title: "title1")
+
+ assert_equal "title1", t1.title
+ assert_equal 0, t1.custom_lock_version
+
+ assert_queries(1) { t1.update(title: "title2") }
+
+ t1.reload
+ assert_equal "title2", t1.title
+ assert_equal 1, t1.custom_lock_version
+
+ t2 = LockWithCustomColumnWithoutDefault.new(title: "title1")
+
+ assert_queries(1) { t2.save! }
+
+ t2.reload
+ assert_equal "title1", t2.title
+ assert_equal 0, t2.custom_lock_version
+ end
+
+ def test_readonly_attributes
+ assert_equal Set.new([ "name" ]), ReadonlyNameShip.readonly_attributes
+
+ s = ReadonlyNameShip.create(name: "unchangeable name")
+ s.reload
+ assert_equal "unchangeable name", s.name
+
+ s.update(name: "changed name")
+ s.reload
+ assert_equal "unchangeable name", s.name
+ end
+
+ def test_quote_table_name
+ ref = references(:michael_magician)
+ ref.favourite = !ref.favourite
+ assert ref.save
+ end
+
+ # Useful for partial updates, don't only update the lock_version if there
+ # is nothing else being updated.
+ def test_update_without_attributes_does_not_only_update_lock_version
+ assert_nothing_raised do
+ p1 = Person.create!(first_name: "anika")
+ lock_version = p1.lock_version
+ p1.save
+ p1.reload
+ assert_equal lock_version, p1.lock_version
+ end
+ end
+
+ def test_counter_cache_with_touch_and_lock_version
+ car = Car.create!
+
+ assert_equal 0, car.wheels_count
+ assert_equal 0, car.lock_version
+
+ previously_updated_at = car.updated_at
+ previously_wheels_owned_at = car.wheels_owned_at
+ travel(1.second) do
+ Wheel.create!(wheelable: car)
+ end
+
+ assert_equal 1, car.reload.wheels_count
+ assert_equal 1, car.lock_version
+ assert_operator previously_updated_at, :<, car.updated_at
+ assert_operator previously_wheels_owned_at, :<, car.wheels_owned_at
+
+ previously_updated_at = car.updated_at
+ previously_wheels_owned_at = car.wheels_owned_at
+ travel(2.second) do
+ car.wheels.first.update(size: 42)
+ end
+
+ assert_equal 1, car.reload.wheels_count
+ assert_equal 2, car.lock_version
+ assert_operator previously_updated_at, :<, car.updated_at
+ assert_operator previously_wheels_owned_at, :<, car.wheels_owned_at
+
+ previously_updated_at = car.updated_at
+ previously_wheels_owned_at = car.wheels_owned_at
+ travel(3.second) do
+ car.wheels.first.destroy!
+ end
+
+ assert_equal 0, car.reload.wheels_count
+ assert_equal 3, car.lock_version
+ assert_operator previously_updated_at, :<, car.updated_at
+ assert_operator previously_wheels_owned_at, :<, car.wheels_owned_at
+ end
+
+ def test_polymorphic_destroy_with_dependencies_and_lock_version
+ car = Car.create!
+
+ assert_difference "car.wheels.count" do
+ car.wheels.create
+ end
+ assert_difference "car.wheels.count", -1 do
+ car.reload.destroy
+ end
+ assert_predicate car, :destroyed?
+ end
+
+ def test_removing_has_and_belongs_to_many_associations_upon_destroy
+ p = RichPerson.create! first_name: "Jon"
+ p.treasures.create!
+ assert_not_empty p.treasures
+ p.destroy
+ assert_empty p.treasures
+ assert_empty RichPerson.connection.select_all("SELECT * FROM peoples_treasures WHERE rich_person_id = 1")
+ end
+
+ def test_yaml_dumping_with_lock_column
+ t1 = LockWithoutDefault.new
+ t2 = YAML.load(YAML.dump(t1))
+
+ assert_equal t1.attributes, t2.attributes
+ end
+end
+
+class OptimisticLockingWithSchemaChangeTest < ActiveRecord::TestCase
+ fixtures :people, :legacy_things, :references
+
+ # need to disable transactional tests, because otherwise the sqlite3
+ # adapter (at least) chokes when we try and change the schema in the middle
+ # of a test (see test_increment_counter_*).
+ self.use_transactional_tests = false
+
+ { lock_version: Person, custom_lock_version: LegacyThing }.each do |name, model|
+ define_method("test_increment_counter_updates_#{name}") do
+ counter_test model, 1 do |id|
+ model.increment_counter :test_count, id
+ end
+ end
+
+ define_method("test_decrement_counter_updates_#{name}") do
+ counter_test model, -1 do |id|
+ model.decrement_counter :test_count, id
+ end
+ end
+
+ define_method("test_update_counters_updates_#{name}") do
+ counter_test model, 1 do |id|
+ model.update_counters id, test_count: 1
+ end
+ end
+ end
+
+ # See Lighthouse ticket #1966
+ def test_destroy_dependents
+ # Establish dependent relationship between Person and PersonalLegacyThing
+ add_counter_column_to(Person, "personal_legacy_things_count")
+ PersonalLegacyThing.reset_column_information
+
+ # Make sure that counter incrementing doesn't cause problems
+ p1 = Person.new(first_name: "fjord")
+ p1.save!
+ t = PersonalLegacyThing.new(person: p1)
+ t.save!
+ p1.reload
+ assert_equal 1, p1.personal_legacy_things_count
+ assert p1.destroy
+ assert_equal true, p1.frozen?
+ assert_raises(ActiveRecord::RecordNotFound) { Person.find(p1.id) }
+ assert_raises(ActiveRecord::RecordNotFound) { PersonalLegacyThing.find(t.id) }
+ ensure
+ remove_counter_column_from(Person, "personal_legacy_things_count")
+ PersonalLegacyThing.reset_column_information
+ end
+
+ def test_destroy_existing_object_with_locking_column_value_null_in_the_database
+ ActiveRecord::Base.connection.execute("INSERT INTO lock_without_defaults(title) VALUES('title1')")
+ t1 = LockWithoutDefault.last
+
+ assert_equal 0, t1.lock_version
+ assert_nil t1.lock_version_before_type_cast
+
+ t1.destroy
+
+ assert_predicate t1, :destroyed?
+ end
+
+ def test_destroy_stale_object
+ t1 = LockWithoutDefault.create!(title: "title1")
+ stale_object = LockWithoutDefault.find(t1.id)
+
+ t1.update!(title: "title2")
+
+ assert_raises(ActiveRecord::StaleObjectError) do
+ stale_object.destroy!
+ end
+
+ assert_not_predicate stale_object, :destroyed?
+ end
+
+ private
+
+ def add_counter_column_to(model, col = "test_count")
+ model.connection.add_column model.table_name, col, :integer, null: false, default: 0
+ model.reset_column_information
+ end
+
+ def remove_counter_column_from(model, col = :test_count)
+ model.connection.remove_column model.table_name, col
+ model.reset_column_information
+ end
+
+ def counter_test(model, expected_count)
+ add_counter_column_to(model)
+ object = model.first
+ assert_equal 0, object.test_count
+ assert_equal 0, object.send(model.locking_column)
+ yield object.id
+ object.reload
+ assert_equal expected_count, object.test_count
+ assert_equal 1, object.send(model.locking_column)
+ ensure
+ remove_counter_column_from(model)
+ end
+end
+
+# TODO: test against the generated SQL since testing locking behavior itself
+# is so cumbersome. Will deadlock Ruby threads if the underlying db.execute
+# blocks, so separate script called by Kernel#system is needed.
+# (See exec vs. async_exec in the PostgreSQL adapter.)
+unless in_memory_db?
+ class PessimisticLockingTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+ fixtures :people, :readers
+
+ def setup
+ Person.connection_pool.clear_reloadable_connections!
+ # Avoid introspection queries during tests.
+ Person.columns; Reader.columns
+ end
+
+ # Test typical find.
+ def test_sane_find_with_lock
+ assert_nothing_raised do
+ Person.transaction do
+ Person.lock.find(1)
+ end
+ end
+ end
+
+ # PostgreSQL protests SELECT ... FOR UPDATE on an outer join.
+ unless current_adapter?(:PostgreSQLAdapter)
+ # Test locked eager find.
+ def test_eager_find_with_lock
+ assert_nothing_raised do
+ Person.transaction do
+ Person.includes(:readers).lock.find(1)
+ end
+ end
+ end
+ end
+
+ def test_lock_does_not_raise_when_the_object_is_not_dirty
+ person = Person.find 1
+ assert_nothing_raised do
+ person.lock!
+ end
+ end
+
+ def test_lock_raises_when_the_record_is_dirty
+ person = Person.find 1
+ person.first_name = "fooman"
+ assert_raises(RuntimeError) do
+ person.lock!
+ end
+ end
+
+ def test_locking_in_after_save_callback
+ assert_nothing_raised do
+ frog = ::Frog.create(name: "Old Frog")
+ frog.name = "New Frog"
+ assert_not_deprecated do
+ frog.save!
+ end
+ end
+ end
+
+ def test_with_lock_commits_transaction
+ person = Person.find 1
+ person.with_lock do
+ person.first_name = "fooman"
+ person.save!
+ end
+ assert_equal "fooman", person.reload.first_name
+ end
+
+ def test_with_lock_rolls_back_transaction
+ person = Person.find 1
+ old = person.first_name
+ person.with_lock do
+ person.first_name = "fooman"
+ person.save!
+ raise "oops"
+ end rescue nil
+ assert_equal old, person.reload.first_name
+ end
+
+ if current_adapter?(:PostgreSQLAdapter)
+ def test_lock_sending_custom_lock_statement
+ Person.transaction do
+ person = Person.find(1)
+ assert_sql(/LIMIT \$?\d FOR SHARE NOWAIT/) do
+ person.lock!("FOR SHARE NOWAIT")
+ end
+ end
+ end
+ end
+
+ def test_no_locks_no_wait
+ first, second = duel { Person.find 1 }
+ assert first.end > second.end
+ end
+
+ private
+ def duel(zzz = 5)
+ t0, t1, t2, t3 = nil, nil, nil, nil
+
+ a = Thread.new do
+ t0 = Time.now
+ Person.transaction do
+ yield
+ sleep zzz # block thread 2 for zzz seconds
+ end
+ t1 = Time.now
+ end
+
+ b = Thread.new do
+ sleep zzz / 2.0 # ensure thread 1 tx starts first
+ t2 = Time.now
+ Person.transaction { yield }
+ t3 = Time.now
+ end
+
+ a.join
+ b.join
+
+ assert t1 > t0 + zzz
+ assert t2 > t0
+ assert t3 > t2
+ [t0.to_f..t1.to_f, t2.to_f..t3.to_f]
+ end
+ end
+end
diff --git a/activerecord/test/cases/log_subscriber_test.rb b/activerecord/test/cases/log_subscriber_test.rb
new file mode 100644
index 0000000000..ae2597adc8
--- /dev/null
+++ b/activerecord/test/cases/log_subscriber_test.rb
@@ -0,0 +1,251 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/binary"
+require "models/developer"
+require "models/post"
+require "active_support/log_subscriber/test_helper"
+
+class LogSubscriberTest < ActiveRecord::TestCase
+ include ActiveSupport::LogSubscriber::TestHelper
+ include ActiveSupport::Logger::Severity
+ REGEXP_CLEAR = Regexp.escape(ActiveRecord::LogSubscriber::CLEAR)
+ REGEXP_BOLD = Regexp.escape(ActiveRecord::LogSubscriber::BOLD)
+ REGEXP_MAGENTA = Regexp.escape(ActiveRecord::LogSubscriber::MAGENTA)
+ REGEXP_CYAN = Regexp.escape(ActiveRecord::LogSubscriber::CYAN)
+ SQL_COLORINGS = {
+ SELECT: Regexp.escape(ActiveRecord::LogSubscriber::BLUE),
+ INSERT: Regexp.escape(ActiveRecord::LogSubscriber::GREEN),
+ UPDATE: Regexp.escape(ActiveRecord::LogSubscriber::YELLOW),
+ DELETE: Regexp.escape(ActiveRecord::LogSubscriber::RED),
+ LOCK: Regexp.escape(ActiveRecord::LogSubscriber::WHITE),
+ ROLLBACK: Regexp.escape(ActiveRecord::LogSubscriber::RED),
+ TRANSACTION: REGEXP_CYAN,
+ OTHER: REGEXP_MAGENTA
+ }
+ Event = Struct.new(:duration, :payload)
+
+ class TestDebugLogSubscriber < ActiveRecord::LogSubscriber
+ attr_reader :debugs
+
+ def initialize
+ @debugs = []
+ super
+ end
+
+ def debug(progname = nil, &block)
+ @debugs << progname
+ super
+ end
+ end
+
+ fixtures :posts
+
+ def setup
+ @old_logger = ActiveRecord::Base.logger
+ Developer.primary_key
+ ActiveRecord::Base.connection.materialize_transactions
+ super
+ ActiveRecord::LogSubscriber.attach_to(:active_record)
+ end
+
+ def teardown
+ super
+ ActiveRecord::LogSubscriber.log_subscribers.pop
+ ActiveRecord::Base.logger = @old_logger
+ end
+
+ def set_logger(logger)
+ ActiveRecord::Base.logger = logger
+ end
+
+ def test_schema_statements_are_ignored
+ logger = TestDebugLogSubscriber.new
+ assert_equal 0, logger.debugs.length
+
+ logger.sql(Event.new(0.9, sql: "hi mom!"))
+ assert_equal 1, logger.debugs.length
+
+ logger.sql(Event.new(0.9, sql: "hi mom!", name: "foo"))
+ assert_equal 2, logger.debugs.length
+
+ logger.sql(Event.new(0.9, sql: "hi mom!", name: "SCHEMA"))
+ assert_equal 2, logger.debugs.length
+ end
+
+ def test_sql_statements_are_not_squeezed
+ logger = TestDebugLogSubscriber.new
+ logger.sql(Event.new(0.9, sql: "ruby rails"))
+ assert_match(/ruby rails/, logger.debugs.first)
+ end
+
+ def test_basic_query_logging
+ Developer.all.load
+ wait
+ assert_equal 1, @logger.logged(:debug).size
+ assert_match(/Developer Load/, @logger.logged(:debug).last)
+ assert_match(/SELECT .*?FROM .?developers.?/i, @logger.logged(:debug).last)
+ end
+
+ def test_basic_query_logging_coloration
+ logger = TestDebugLogSubscriber.new
+ logger.colorize_logging = true
+ SQL_COLORINGS.each do |verb, color_regex|
+ logger.sql(Event.new(0.9, sql: verb.to_s))
+ assert_match(/#{REGEXP_BOLD}#{color_regex}#{verb}#{REGEXP_CLEAR}/i, logger.debugs.last)
+ end
+ end
+
+ def test_basic_payload_name_logging_coloration_generic_sql
+ logger = TestDebugLogSubscriber.new
+ logger.colorize_logging = true
+ SQL_COLORINGS.each do |verb, _|
+ logger.sql(Event.new(0.9, sql: verb.to_s))
+ assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0\.9ms\)#{REGEXP_CLEAR}/i, logger.debugs.last)
+
+ logger.sql(Event.new(0.9, sql: verb.to_s, name: "SQL"))
+ assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA}SQL \(0\.9ms\)#{REGEXP_CLEAR}/i, logger.debugs.last)
+ end
+ end
+
+ def test_basic_payload_name_logging_coloration_named_sql
+ logger = TestDebugLogSubscriber.new
+ logger.colorize_logging = true
+ SQL_COLORINGS.each do |verb, _|
+ logger.sql(Event.new(0.9, sql: verb.to_s, name: "Model Load"))
+ assert_match(/#{REGEXP_BOLD}#{REGEXP_CYAN}Model Load \(0\.9ms\)#{REGEXP_CLEAR}/i, logger.debugs.last)
+
+ logger.sql(Event.new(0.9, sql: verb.to_s, name: "Model Exists"))
+ assert_match(/#{REGEXP_BOLD}#{REGEXP_CYAN}Model Exists \(0\.9ms\)#{REGEXP_CLEAR}/i, logger.debugs.last)
+
+ logger.sql(Event.new(0.9, sql: verb.to_s, name: "ANY SPECIFIC NAME"))
+ assert_match(/#{REGEXP_BOLD}#{REGEXP_CYAN}ANY SPECIFIC NAME \(0\.9ms\)#{REGEXP_CLEAR}/i, logger.debugs.last)
+ end
+ end
+
+ def test_query_logging_coloration_with_nested_select
+ logger = TestDebugLogSubscriber.new
+ logger.colorize_logging = true
+ SQL_COLORINGS.slice(:SELECT, :INSERT, :UPDATE, :DELETE).each do |verb, color_regex|
+ logger.sql(Event.new(0.9, sql: "#{verb} WHERE ID IN SELECT"))
+ assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0\.9ms\)#{REGEXP_CLEAR} #{REGEXP_BOLD}#{color_regex}#{verb} WHERE ID IN SELECT#{REGEXP_CLEAR}/i, logger.debugs.last)
+ end
+ end
+
+ def test_query_logging_coloration_with_multi_line_nested_select
+ logger = TestDebugLogSubscriber.new
+ logger.colorize_logging = true
+ SQL_COLORINGS.slice(:SELECT, :INSERT, :UPDATE, :DELETE).each do |verb, color_regex|
+ sql = <<-EOS
+ #{verb}
+ WHERE ID IN (
+ SELECT ID FROM THINGS
+ )
+ EOS
+ logger.sql(Event.new(0.9, sql: sql))
+ assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0\.9ms\)#{REGEXP_CLEAR} #{REGEXP_BOLD}#{color_regex}.*#{verb}.*#{REGEXP_CLEAR}/mi, logger.debugs.last)
+ end
+ end
+
+ def test_query_logging_coloration_with_lock
+ logger = TestDebugLogSubscriber.new
+ logger.colorize_logging = true
+ sql = <<-EOS
+ SELECT * FROM
+ (SELECT * FROM mytable FOR UPDATE) ss
+ WHERE col1 = 5;
+ EOS
+ logger.sql(Event.new(0.9, sql: sql))
+ assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0\.9ms\)#{REGEXP_CLEAR} #{REGEXP_BOLD}#{SQL_COLORINGS[:LOCK]}.*FOR UPDATE.*#{REGEXP_CLEAR}/mi, logger.debugs.last)
+
+ sql = <<-EOS
+ LOCK TABLE films IN SHARE MODE;
+ EOS
+ logger.sql(Event.new(0.9, sql: sql))
+ assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0\.9ms\)#{REGEXP_CLEAR} #{REGEXP_BOLD}#{SQL_COLORINGS[:LOCK]}.*LOCK TABLE.*#{REGEXP_CLEAR}/mi, logger.debugs.last)
+ end
+
+ def test_exists_query_logging
+ Developer.exists? 1
+ wait
+ assert_equal 1, @logger.logged(:debug).size
+ assert_match(/Developer Exists/, @logger.logged(:debug).last)
+ assert_match(/SELECT .*?FROM .?developers.?/i, @logger.logged(:debug).last)
+ end
+
+ def test_vebose_query_logs
+ ActiveRecord::Base.verbose_query_logs = true
+
+ logger = TestDebugLogSubscriber.new
+ logger.sql(Event.new(0, sql: "hi mom!"))
+ assert_equal 2, @logger.logged(:debug).size
+ assert_match(/↳/, @logger.logged(:debug).last)
+ ensure
+ ActiveRecord::Base.verbose_query_logs = false
+ end
+
+ def test_verbose_query_with_ignored_callstack
+ ActiveRecord::Base.verbose_query_logs = true
+
+ logger = TestDebugLogSubscriber.new
+ def logger.extract_query_source_location(*); nil; end
+
+ logger.sql(Event.new(0, sql: "hi mom!"))
+ assert_equal 1, @logger.logged(:debug).size
+ assert_no_match(/↳/, @logger.logged(:debug).last)
+ ensure
+ ActiveRecord::Base.verbose_query_logs = false
+ end
+
+ def test_verbose_query_logs_disabled_by_default
+ logger = TestDebugLogSubscriber.new
+ logger.sql(Event.new(0, sql: "hi mom!"))
+ assert_no_match(/↳/, @logger.logged(:debug).last)
+ end
+
+ def test_cached_queries
+ ActiveRecord::Base.cache do
+ Developer.all.load
+ Developer.all.load
+ end
+ wait
+ assert_equal 2, @logger.logged(:debug).size
+ assert_match(/CACHE/, @logger.logged(:debug).last)
+ assert_match(/SELECT .*?FROM .?developers.?/i, @logger.logged(:debug).last)
+ end
+
+ def test_basic_query_doesnt_log_when_level_is_not_debug
+ @logger.level = INFO
+ Developer.all.load
+ wait
+ assert_equal 0, @logger.logged(:debug).size
+ end
+
+ def test_cached_queries_doesnt_log_when_level_is_not_debug
+ @logger.level = INFO
+ ActiveRecord::Base.cache do
+ Developer.all.load
+ Developer.all.load
+ end
+ wait
+ assert_equal 0, @logger.logged(:debug).size
+ end
+
+ def test_initializes_runtime
+ Thread.new { assert_equal 0, ActiveRecord::LogSubscriber.runtime }.join
+ end
+
+ if ActiveRecord::Base.connection.prepared_statements
+ def test_binary_data_is_not_logged
+ Binary.create(data: "some binary data")
+ wait
+ assert_match(/<16 bytes of binary data>/, @logger.logged(:debug).join)
+ end
+
+ def test_binary_data_hash
+ Binary.create(data: { a: 1 })
+ wait
+ assert_match(/<7 bytes of binary data>/, @logger.logged(:debug).join)
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration/change_schema_test.rb b/activerecord/test/cases/migration/change_schema_test.rb
new file mode 100644
index 0000000000..7777508349
--- /dev/null
+++ b/activerecord/test/cases/migration/change_schema_test.rb
@@ -0,0 +1,478 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ class Migration
+ class ChangeSchemaTest < ActiveRecord::TestCase
+ attr_reader :connection, :table_name
+
+ def setup
+ super
+ @connection = ActiveRecord::Base.connection
+ @table_name = :testings
+ end
+
+ teardown do
+ connection.drop_table :testings rescue nil
+ ActiveRecord::Base.primary_key_prefix_type = nil
+ ActiveRecord::Base.clear_cache!
+ end
+
+ def test_create_table_without_id
+ testing_table_with_only_foo_attribute do
+ assert_equal connection.columns(:testings).size, 1
+ end
+ end
+
+ def test_add_column_with_primary_key_attribute
+ testing_table_with_only_foo_attribute do
+ connection.add_column :testings, :id, :primary_key
+ assert_equal connection.columns(:testings).size, 2
+ end
+ end
+
+ def test_create_table_adds_id
+ connection.create_table :testings do |t|
+ t.column :foo, :string
+ end
+
+ assert_equal %w(id foo), connection.columns(:testings).map(&:name)
+ end
+
+ def test_create_table_with_not_null_column
+ connection.create_table :testings do |t|
+ t.column :foo, :string, null: false
+ end
+
+ assert_raises(ActiveRecord::NotNullViolation) do
+ connection.execute "insert into testings (foo) values (NULL)"
+ end
+ end
+
+ def test_create_table_with_defaults
+ # MySQL doesn't allow defaults on TEXT or BLOB columns.
+ mysql = current_adapter?(:Mysql2Adapter)
+
+ connection.create_table :testings do |t|
+ t.column :one, :string, default: "hello"
+ t.column :two, :boolean, default: true
+ t.column :three, :boolean, default: false
+ t.column :four, :integer, default: 1
+ t.column :five, :text, default: "hello" unless mysql
+ end
+
+ columns = connection.columns(:testings)
+ one = columns.detect { |c| c.name == "one" }
+ two = columns.detect { |c| c.name == "two" }
+ three = columns.detect { |c| c.name == "three" }
+ four = columns.detect { |c| c.name == "four" }
+ five = columns.detect { |c| c.name == "five" } unless mysql
+
+ assert_equal "hello", one.default
+ assert_equal true, connection.lookup_cast_type_from_column(two).deserialize(two.default)
+ assert_equal false, connection.lookup_cast_type_from_column(three).deserialize(three.default)
+ assert_equal "1", four.default
+ assert_equal "hello", five.default unless mysql
+ end
+
+ if current_adapter?(:PostgreSQLAdapter)
+ def test_add_column_with_array
+ connection.create_table :testings
+ connection.add_column :testings, :foo, :string, array: true
+
+ columns = connection.columns(:testings)
+ array_column = columns.detect { |c| c.name == "foo" }
+
+ assert_predicate array_column, :array?
+ end
+
+ def test_create_table_with_array_column
+ connection.create_table :testings do |t|
+ t.string :foo, array: true
+ end
+
+ columns = connection.columns(:testings)
+ array_column = columns.detect { |c| c.name == "foo" }
+
+ assert_predicate array_column, :array?
+ end
+ end
+
+ def test_create_table_with_bigint
+ connection.create_table :testings do |t|
+ t.bigint :eight_int
+ end
+ columns = connection.columns(:testings)
+ eight = columns.detect { |c| c.name == "eight_int" }
+
+ if current_adapter?(:OracleAdapter)
+ assert_equal "NUMBER(19)", eight.sql_type
+ elsif current_adapter?(:SQLite3Adapter)
+ assert_equal "bigint", eight.sql_type
+ else
+ assert_equal :integer, eight.type
+ assert_equal 8, eight.limit
+ end
+ ensure
+ connection.drop_table :testings
+ end
+
+ def test_create_table_with_limits
+ connection.create_table :testings do |t|
+ t.column :foo, :string, limit: 255
+
+ t.column :default_int, :integer
+
+ t.column :one_int, :integer, limit: 1
+ t.column :four_int, :integer, limit: 4
+ t.column :eight_int, :integer, limit: 8
+ end
+
+ columns = connection.columns(:testings)
+ foo = columns.detect { |c| c.name == "foo" }
+ assert_equal 255, foo.limit
+
+ default = columns.detect { |c| c.name == "default_int" }
+ one = columns.detect { |c| c.name == "one_int" }
+ four = columns.detect { |c| c.name == "four_int" }
+ eight = columns.detect { |c| c.name == "eight_int" }
+
+ if current_adapter?(:PostgreSQLAdapter)
+ assert_equal "integer", default.sql_type
+ assert_equal "smallint", one.sql_type
+ assert_equal "integer", four.sql_type
+ assert_equal "bigint", eight.sql_type
+ elsif current_adapter?(:Mysql2Adapter)
+ assert_match "int(11)", default.sql_type
+ assert_match "tinyint", one.sql_type
+ assert_match "int", four.sql_type
+ assert_match "bigint", eight.sql_type
+ elsif current_adapter?(:OracleAdapter)
+ assert_equal "NUMBER(38)", default.sql_type
+ assert_equal "NUMBER(1)", one.sql_type
+ assert_equal "NUMBER(4)", four.sql_type
+ assert_equal "NUMBER(8)", eight.sql_type
+ end
+ end
+
+ def test_create_table_with_primary_key_prefix_as_table_name_with_underscore
+ ActiveRecord::Base.primary_key_prefix_type = :table_name_with_underscore
+
+ connection.create_table :testings do |t|
+ t.column :foo, :string
+ end
+
+ assert_equal %w(testing_id foo), connection.columns(:testings).map(&:name)
+ end
+
+ def test_create_table_with_primary_key_prefix_as_table_name
+ ActiveRecord::Base.primary_key_prefix_type = :table_name
+
+ connection.create_table :testings do |t|
+ t.column :foo, :string
+ end
+
+ assert_equal %w(testingid foo), connection.columns(:testings).map(&:name)
+ end
+
+ def test_create_table_raises_when_redefining_primary_key_column
+ error = assert_raise(ArgumentError) do
+ connection.create_table :testings do |t|
+ t.column :id, :string
+ end
+ end
+
+ assert_equal "you can't redefine the primary key column 'id'. To define a custom primary key, pass { id: false } to create_table.", error.message
+ end
+
+ def test_create_table_raises_when_redefining_custom_primary_key_column
+ error = assert_raise(ArgumentError) do
+ connection.create_table :testings, primary_key: :testing_id do |t|
+ t.column :testing_id, :string
+ end
+ end
+
+ assert_equal "you can't redefine the primary key column 'testing_id'. To define a custom primary key, pass { id: false } to create_table.", error.message
+ end
+
+ def test_create_table_raises_when_defining_existing_column
+ error = assert_raise(ArgumentError) do
+ connection.create_table :testings do |t|
+ t.column :testing_column, :string
+ t.column :testing_column, :integer
+ end
+ end
+
+ assert_equal "you can't define an already defined column 'testing_column'.", error.message
+ end
+
+ def test_create_table_with_timestamps_should_create_datetime_columns
+ connection.create_table table_name do |t|
+ t.timestamps
+ end
+ created_columns = connection.columns(table_name)
+
+ created_at_column = created_columns.detect { |c| c.name == "created_at" }
+ updated_at_column = created_columns.detect { |c| c.name == "updated_at" }
+
+ assert_not created_at_column.null
+ assert_not updated_at_column.null
+ end
+
+ def test_create_table_with_timestamps_should_create_datetime_columns_with_options
+ connection.create_table table_name do |t|
+ t.timestamps null: true
+ end
+ created_columns = connection.columns(table_name)
+
+ created_at_column = created_columns.detect { |c| c.name == "created_at" }
+ updated_at_column = created_columns.detect { |c| c.name == "updated_at" }
+
+ assert created_at_column.null
+ assert updated_at_column.null
+ end
+
+ def test_create_table_without_a_block
+ connection.create_table table_name
+ end
+
+ # SQLite3 will not allow you to add a NOT NULL
+ # column to a table without a default value.
+ unless current_adapter?(:SQLite3Adapter)
+ def test_add_column_not_null_without_default
+ connection.create_table :testings do |t|
+ t.column :foo, :string
+ end
+ connection.add_column :testings, :bar, :string, null: false
+
+ assert_raise(ActiveRecord::NotNullViolation) do
+ connection.execute "insert into testings (foo, bar) values ('hello', NULL)"
+ end
+ end
+ end
+
+ def test_add_column_not_null_with_default
+ connection.create_table :testings do |t|
+ t.column :foo, :string
+ end
+
+ quoted_id = connection.quote_column_name("id")
+ quoted_foo = connection.quote_column_name("foo")
+ quoted_bar = connection.quote_column_name("bar")
+ connection.execute("insert into testings (#{quoted_id}, #{quoted_foo}) values (1, 'hello')")
+ assert_nothing_raised do
+ connection.add_column :testings, :bar, :string, null: false, default: "default"
+ end
+
+ assert_raises(ActiveRecord::NotNullViolation) do
+ connection.execute("insert into testings (#{quoted_id}, #{quoted_foo}, #{quoted_bar}) values (2, 'hello', NULL)")
+ end
+ end
+
+ def test_add_column_with_timestamp_type
+ connection.create_table :testings do |t|
+ t.column :foo, :timestamp
+ end
+
+ column = connection.columns(:testings).find { |c| c.name == "foo" }
+
+ assert_equal :datetime, column.type
+
+ if current_adapter?(:PostgreSQLAdapter)
+ assert_equal "timestamp without time zone", column.sql_type
+ elsif current_adapter?(:Mysql2Adapter)
+ assert_equal "timestamp", column.sql_type
+ elsif current_adapter?(:OracleAdapter)
+ assert_equal "TIMESTAMP(6)", column.sql_type
+ else
+ assert_equal connection.type_to_sql("datetime"), column.sql_type
+ end
+ end
+
+ def test_change_column_quotes_column_names
+ connection.create_table :testings do |t|
+ t.column :select, :string
+ end
+
+ connection.change_column :testings, :select, :string, limit: 10
+
+ # Oracle needs primary key value from sequence
+ if current_adapter?(:OracleAdapter)
+ connection.execute "insert into testings (id, #{connection.quote_column_name('select')}) values (testings_seq.nextval, '7 chars')"
+ else
+ connection.execute "insert into testings (#{connection.quote_column_name('select')}) values ('7 chars')"
+ end
+ end
+
+ def test_keeping_default_and_notnull_constraints_on_change
+ connection.create_table :testings do |t|
+ t.column :title, :string
+ end
+ person_klass = Class.new(ActiveRecord::Base)
+ person_klass.table_name = "testings"
+
+ person_klass.connection.add_column "testings", "wealth", :integer, null: false, default: 99
+ person_klass.reset_column_information
+ assert_equal 99, person_klass.column_defaults["wealth"]
+ assert_equal false, person_klass.columns_hash["wealth"].null
+ # Oracle needs primary key value from sequence
+ if current_adapter?(:OracleAdapter)
+ assert_nothing_raised { person_klass.connection.execute("insert into testings (id, title) values (testings_seq.nextval, 'tester')") }
+ else
+ assert_nothing_raised { person_klass.connection.execute("insert into testings (title) values ('tester')") }
+ end
+
+ # change column default to see that column doesn't lose its not null definition
+ person_klass.connection.change_column_default "testings", "wealth", 100
+ person_klass.reset_column_information
+ assert_equal 100, person_klass.column_defaults["wealth"]
+ assert_equal false, person_klass.columns_hash["wealth"].null
+
+ # rename column to see that column doesn't lose its not null and/or default definition
+ person_klass.connection.rename_column "testings", "wealth", "money"
+ person_klass.reset_column_information
+ assert_nil person_klass.columns_hash["wealth"]
+ assert_equal 100, person_klass.column_defaults["money"]
+ assert_equal false, person_klass.columns_hash["money"].null
+
+ # change column
+ person_klass.connection.change_column "testings", "money", :integer, null: false, default: 1000
+ person_klass.reset_column_information
+ assert_equal 1000, person_klass.column_defaults["money"]
+ assert_equal false, person_klass.columns_hash["money"].null
+
+ # change column, make it nullable and clear default
+ person_klass.connection.change_column "testings", "money", :integer, null: true, default: nil
+ person_klass.reset_column_information
+ assert_nil person_klass.columns_hash["money"].default
+ assert_equal true, person_klass.columns_hash["money"].null
+
+ # change_column_null, make it not nullable and set null values to a default value
+ person_klass.connection.execute("UPDATE testings SET money = NULL")
+ person_klass.connection.change_column_null "testings", "money", false, 2000
+ person_klass.reset_column_information
+ assert_nil person_klass.columns_hash["money"].default
+ assert_equal false, person_klass.columns_hash["money"].null
+ assert_equal 2000, connection.select_values("SELECT money FROM testings").first.to_i
+ end
+
+ def test_change_column_null
+ testing_table_with_only_foo_attribute do
+ notnull_migration = Class.new(ActiveRecord::Migration::Current) do
+ def change
+ change_column_null :testings, :foo, false
+ end
+ end
+ notnull_migration.new.suppress_messages do
+ notnull_migration.migrate(:up)
+ assert_equal false, connection.columns(:testings).find { |c| c.name == "foo" }.null
+ notnull_migration.migrate(:down)
+ assert connection.columns(:testings).find { |c| c.name == "foo" }.null
+ end
+ end
+ end
+
+ def test_column_exists
+ connection.create_table :testings do |t|
+ t.column :foo, :string
+ end
+
+ assert connection.column_exists?(:testings, :foo)
+ assert_not connection.column_exists?(:testings, :bar)
+ end
+
+ def test_column_exists_with_type
+ connection.create_table :testings do |t|
+ t.column :foo, :string
+ t.column :bar, :decimal, precision: 8, scale: 2
+ end
+
+ assert connection.column_exists?(:testings, :foo, :string)
+ assert_not connection.column_exists?(:testings, :foo, :integer)
+
+ assert connection.column_exists?(:testings, :bar, :decimal)
+ assert_not connection.column_exists?(:testings, :bar, :integer)
+ end
+
+ def test_column_exists_with_definition
+ connection.create_table :testings do |t|
+ t.column :foo, :string, limit: 100
+ t.column :bar, :decimal, precision: 8, scale: 2
+ t.column :taggable_id, :integer, null: false
+ t.column :taggable_type, :string, default: "Photo"
+ end
+
+ assert connection.column_exists?(:testings, :foo, :string, limit: 100)
+ assert_not connection.column_exists?(:testings, :foo, :string, limit: nil)
+ assert connection.column_exists?(:testings, :bar, :decimal, precision: 8, scale: 2)
+ assert_not connection.column_exists?(:testings, :bar, :decimal, precision: nil, scale: nil)
+ assert connection.column_exists?(:testings, :taggable_id, :integer, null: false)
+ assert_not connection.column_exists?(:testings, :taggable_id, :integer, null: true)
+ assert connection.column_exists?(:testings, :taggable_type, :string, default: "Photo")
+ assert_not connection.column_exists?(:testings, :taggable_type, :string, default: nil)
+ end
+
+ def test_column_exists_on_table_with_no_options_parameter_supplied
+ connection.create_table :testings do |t|
+ t.string :foo
+ end
+ connection.change_table :testings do |t|
+ assert t.column_exists?(:foo)
+ assert_not (t.column_exists?(:bar))
+ end
+ end
+
+ def test_drop_table_if_exists
+ connection.create_table(:testings)
+ assert connection.table_exists?(:testings)
+ connection.drop_table(:testings, if_exists: true)
+ assert_not connection.table_exists?(:testings)
+ end
+
+ def test_drop_table_if_exists_nothing_raised
+ assert_nothing_raised { connection.drop_table(:nonexistent, if_exists: true) }
+ end
+
+ private
+ def testing_table_with_only_foo_attribute
+ connection.create_table :testings, id: false do |t|
+ t.column :foo, :string
+ end
+
+ yield
+ end
+ end
+
+ if ActiveRecord::Base.connection.supports_foreign_keys?
+ class ChangeSchemaWithDependentObjectsTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table :trains
+ @connection.create_table(:wagons) { |t| t.references :train }
+ @connection.add_foreign_key :wagons, :trains
+ end
+
+ teardown do
+ [:wagons, :trains].each do |table|
+ @connection.drop_table table, if_exists: true
+ end
+ end
+
+ def test_create_table_with_force_cascade_drops_dependent_objects
+ skip "MySQL > 5.5 does not drop dependent objects with DROP TABLE CASCADE" if current_adapter?(:Mysql2Adapter)
+ # can't re-create table referenced by foreign key
+ assert_raises(ActiveRecord::StatementInvalid) do
+ @connection.create_table :trains, force: true
+ end
+
+ # can recreate referenced table with force: :cascade
+ @connection.create_table :trains, force: :cascade
+ assert_equal [], @connection.foreign_keys(:wagons)
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration/change_table_test.rb b/activerecord/test/cases/migration/change_table_test.rb
new file mode 100644
index 0000000000..c108d372d1
--- /dev/null
+++ b/activerecord/test/cases/migration/change_table_test.rb
@@ -0,0 +1,266 @@
+# frozen_string_literal: true
+
+require "cases/migration/helper"
+
+module ActiveRecord
+ class Migration
+ class TableTest < ActiveRecord::TestCase
+ def setup
+ @connection = Minitest::Mock.new
+ end
+
+ teardown do
+ assert @connection.verify
+ end
+
+ def with_change_table
+ yield ActiveRecord::Base.connection.update_table_definition(:delete_me, @connection)
+ end
+
+ def test_references_column_type_adds_id
+ with_change_table do |t|
+ @connection.expect :add_reference, nil, [:delete_me, :customer, {}]
+ t.references :customer
+ end
+ end
+
+ def test_remove_references_column_type_removes_id
+ with_change_table do |t|
+ @connection.expect :remove_reference, nil, [:delete_me, :customer, {}]
+ t.remove_references :customer
+ end
+ end
+
+ def test_add_belongs_to_works_like_add_references
+ with_change_table do |t|
+ @connection.expect :add_reference, nil, [:delete_me, :customer, {}]
+ t.belongs_to :customer
+ end
+ end
+
+ def test_remove_belongs_to_works_like_remove_references
+ with_change_table do |t|
+ @connection.expect :remove_reference, nil, [:delete_me, :customer, {}]
+ t.remove_belongs_to :customer
+ end
+ end
+
+ def test_references_column_type_with_polymorphic_adds_type
+ with_change_table do |t|
+ @connection.expect :add_reference, nil, [:delete_me, :taggable, polymorphic: true]
+ t.references :taggable, polymorphic: true
+ end
+ end
+
+ def test_remove_references_column_type_with_polymorphic_removes_type
+ with_change_table do |t|
+ @connection.expect :remove_reference, nil, [:delete_me, :taggable, polymorphic: true]
+ t.remove_references :taggable, polymorphic: true
+ end
+ end
+
+ def test_references_column_type_with_polymorphic_and_options_null_is_false_adds_table_flag
+ with_change_table do |t|
+ @connection.expect :add_reference, nil, [:delete_me, :taggable, polymorphic: true, null: false]
+ t.references :taggable, polymorphic: true, null: false
+ end
+ end
+
+ def test_remove_references_column_type_with_polymorphic_and_options_null_is_false_removes_table_flag
+ with_change_table do |t|
+ @connection.expect :remove_reference, nil, [:delete_me, :taggable, polymorphic: true, null: false]
+ t.remove_references :taggable, polymorphic: true, null: false
+ end
+ end
+
+ def test_references_column_type_with_polymorphic_and_type
+ with_change_table do |t|
+ @connection.expect :add_reference, nil, [:delete_me, :taggable, polymorphic: true, type: :string]
+ t.references :taggable, polymorphic: true, type: :string
+ end
+ end
+
+ def test_remove_references_column_type_with_polymorphic_and_type
+ with_change_table do |t|
+ @connection.expect :remove_reference, nil, [:delete_me, :taggable, polymorphic: true, type: :string]
+ t.remove_references :taggable, polymorphic: true, type: :string
+ end
+ end
+
+ def test_timestamps_creates_updated_at_and_created_at
+ with_change_table do |t|
+ @connection.expect :add_timestamps, nil, [:delete_me, null: true]
+ t.timestamps null: true
+ end
+ end
+
+ def test_remove_timestamps_creates_updated_at_and_created_at
+ with_change_table do |t|
+ @connection.expect :remove_timestamps, nil, [:delete_me, { null: true }]
+ t.remove_timestamps(null: true)
+ end
+ end
+
+ def test_primary_key_creates_primary_key_column
+ with_change_table do |t|
+ @connection.expect :add_column, nil, [:delete_me, :id, :primary_key, primary_key: true, first: true]
+ t.primary_key :id, first: true
+ end
+ end
+
+ def test_integer_creates_integer_column
+ with_change_table do |t|
+ @connection.expect :add_column, nil, [:delete_me, :foo, :integer, {}]
+ @connection.expect :add_column, nil, [:delete_me, :bar, :integer, {}]
+ t.integer :foo, :bar
+ end
+ end
+
+ def test_bigint_creates_bigint_column
+ with_change_table do |t|
+ @connection.expect :add_column, nil, [:delete_me, :foo, :bigint, {}]
+ @connection.expect :add_column, nil, [:delete_me, :bar, :bigint, {}]
+ t.bigint :foo, :bar
+ end
+ end
+
+ def test_string_creates_string_column
+ with_change_table do |t|
+ @connection.expect :add_column, nil, [:delete_me, :foo, :string, {}]
+ @connection.expect :add_column, nil, [:delete_me, :bar, :string, {}]
+ t.string :foo, :bar
+ end
+ end
+
+ if current_adapter?(:PostgreSQLAdapter)
+ def test_json_creates_json_column
+ with_change_table do |t|
+ @connection.expect :add_column, nil, [:delete_me, :foo, :json, {}]
+ @connection.expect :add_column, nil, [:delete_me, :bar, :json, {}]
+ t.json :foo, :bar
+ end
+ end
+
+ def test_xml_creates_xml_column
+ with_change_table do |t|
+ @connection.expect :add_column, nil, [:delete_me, :foo, :xml, {}]
+ @connection.expect :add_column, nil, [:delete_me, :bar, :xml, {}]
+ t.xml :foo, :bar
+ end
+ end
+ end
+
+ def test_column_creates_column
+ with_change_table do |t|
+ @connection.expect :add_column, nil, [:delete_me, :bar, :integer, {}]
+ t.column :bar, :integer
+ end
+ end
+
+ def test_column_creates_column_with_options
+ with_change_table do |t|
+ @connection.expect :add_column, nil, [:delete_me, :bar, :integer, { null: false }]
+ t.column :bar, :integer, null: false
+ end
+ end
+
+ def test_column_creates_column_with_index
+ with_change_table do |t|
+ @connection.expect :add_column, nil, [:delete_me, :bar, :integer, {}]
+ @connection.expect :add_index, nil, [:delete_me, :bar, {}]
+ t.column :bar, :integer, index: true
+ end
+ end
+
+ def test_index_creates_index
+ with_change_table do |t|
+ @connection.expect :add_index, nil, [:delete_me, :bar, {}]
+ t.index :bar
+ end
+ end
+
+ def test_index_creates_index_with_options
+ with_change_table do |t|
+ @connection.expect :add_index, nil, [:delete_me, :bar, { unique: true }]
+ t.index :bar, unique: true
+ end
+ end
+
+ def test_index_exists
+ with_change_table do |t|
+ @connection.expect :index_exists?, nil, [:delete_me, :bar, {}]
+ t.index_exists?(:bar)
+ end
+ end
+
+ def test_index_exists_with_options
+ with_change_table do |t|
+ @connection.expect :index_exists?, nil, [:delete_me, :bar, { unique: true }]
+ t.index_exists?(:bar, unique: true)
+ end
+ end
+
+ def test_rename_index_renames_index
+ with_change_table do |t|
+ @connection.expect :rename_index, nil, [:delete_me, :bar, :baz]
+ t.rename_index :bar, :baz
+ end
+ end
+
+ def test_change_changes_column
+ with_change_table do |t|
+ @connection.expect :change_column, nil, [:delete_me, :bar, :string, {}]
+ t.change :bar, :string
+ end
+ end
+
+ def test_change_changes_column_with_options
+ with_change_table do |t|
+ @connection.expect :change_column, nil, [:delete_me, :bar, :string, { null: true }]
+ t.change :bar, :string, null: true
+ end
+ end
+
+ def test_change_default_changes_column
+ with_change_table do |t|
+ @connection.expect :change_column_default, nil, [:delete_me, :bar, :string]
+ t.change_default :bar, :string
+ end
+ end
+
+ def test_remove_drops_single_column
+ with_change_table do |t|
+ @connection.expect :remove_columns, nil, [:delete_me, :bar]
+ t.remove :bar
+ end
+ end
+
+ def test_remove_drops_multiple_columns
+ with_change_table do |t|
+ @connection.expect :remove_columns, nil, [:delete_me, :bar, :baz]
+ t.remove :bar, :baz
+ end
+ end
+
+ def test_remove_index_removes_index_with_options
+ with_change_table do |t|
+ @connection.expect :remove_index, nil, [:delete_me, { unique: true }]
+ t.remove_index unique: true
+ end
+ end
+
+ def test_rename_renames_column
+ with_change_table do |t|
+ @connection.expect :rename_column, nil, [:delete_me, :bar, :baz]
+ t.rename :bar, :baz
+ end
+ end
+
+ def test_table_name_set
+ with_change_table do |t|
+ assert_equal :delete_me, t.name
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration/column_attributes_test.rb b/activerecord/test/cases/migration/column_attributes_test.rb
new file mode 100644
index 0000000000..3022121f4c
--- /dev/null
+++ b/activerecord/test/cases/migration/column_attributes_test.rb
@@ -0,0 +1,188 @@
+# frozen_string_literal: true
+
+require "cases/migration/helper"
+
+module ActiveRecord
+ class Migration
+ class ColumnAttributesTest < ActiveRecord::TestCase
+ include ActiveRecord::Migration::TestHelper
+
+ self.use_transactional_tests = false
+
+ def test_add_column_newline_default
+ string = "foo\nbar"
+ add_column "test_models", "command", :string, default: string
+ TestModel.reset_column_information
+
+ assert_equal string, TestModel.new.command
+ end
+
+ def test_add_remove_single_field_using_string_arguments
+ assert_no_column TestModel, :last_name
+
+ add_column "test_models", "last_name", :string
+ assert_column TestModel, :last_name
+
+ remove_column "test_models", "last_name"
+ assert_no_column TestModel, :last_name
+ end
+
+ def test_add_remove_single_field_using_symbol_arguments
+ assert_no_column TestModel, :last_name
+
+ add_column :test_models, :last_name, :string
+ assert_column TestModel, :last_name
+
+ remove_column :test_models, :last_name
+ assert_no_column TestModel, :last_name
+ end
+
+ def test_add_column_without_limit
+ # TODO: limit: nil should work with all adapters.
+ skip "MySQL wrongly enforces a limit of 255" if current_adapter?(:Mysql2Adapter)
+ add_column :test_models, :description, :string, limit: nil
+ TestModel.reset_column_information
+ assert_nil TestModel.columns_hash["description"].limit
+ end
+
+ if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter)
+ def test_unabstracted_database_dependent_types
+ add_column :test_models, :intelligence_quotient, :smallint
+ TestModel.reset_column_information
+ assert_match(/smallint/, TestModel.columns_hash["intelligence_quotient"].sql_type)
+ end
+ end
+
+ unless current_adapter?(:SQLite3Adapter)
+ # We specifically do a manual INSERT here, and then test only the SELECT
+ # functionality. This allows us to more easily catch INSERT being broken,
+ # but SELECT actually working fine.
+ def test_native_decimal_insert_manual_vs_automatic
+ correct_value = "0012345678901234567890.0123456789".to_d
+
+ connection.add_column "test_models", "wealth", :decimal, precision: "30", scale: "10"
+
+ # Do a manual insertion
+ if current_adapter?(:OracleAdapter)
+ connection.execute "insert into test_models (id, wealth) values (people_seq.nextval, 12345678901234567890.0123456789)"
+ else
+ connection.execute "insert into test_models (wealth) values (12345678901234567890.0123456789)"
+ end
+
+ # SELECT
+ row = TestModel.first
+ assert_kind_of BigDecimal, row.wealth
+
+ # If this assert fails, that means the SELECT is broken!
+ assert_equal correct_value, row.wealth
+
+ # Reset to old state
+ TestModel.delete_all
+
+ # Now use the Rails insertion
+ TestModel.create wealth: BigDecimal("12345678901234567890.0123456789")
+
+ # SELECT
+ row = TestModel.first
+ assert_kind_of BigDecimal, row.wealth
+
+ # If these asserts fail, that means the INSERT (create function, or cast to SQL) is broken!
+ assert_equal correct_value, row.wealth
+ end
+ end
+
+ def test_add_column_with_precision_and_scale
+ connection.add_column "test_models", "wealth", :decimal, precision: 9, scale: 7
+
+ wealth_column = TestModel.columns_hash["wealth"]
+ assert_equal 9, wealth_column.precision
+ assert_equal 7, wealth_column.scale
+ end
+
+ # Test SQLite3 adapter specifically for decimal types with precision and scale
+ # attributes, since these need to be maintained in schema but aren't actually
+ # used in SQLite3 itself
+ if current_adapter?(:SQLite3Adapter)
+ def test_change_column_with_new_precision_and_scale
+ connection.add_column "test_models", "wealth", :decimal, precision: 9, scale: 7
+
+ connection.change_column "test_models", "wealth", :decimal, precision: 12, scale: 8
+ TestModel.reset_column_information
+
+ wealth_column = TestModel.columns_hash["wealth"]
+ assert_equal 12, wealth_column.precision
+ assert_equal 8, wealth_column.scale
+ end
+
+ def test_change_column_preserve_other_column_precision_and_scale
+ connection.add_column "test_models", "last_name", :string
+ connection.add_column "test_models", "wealth", :decimal, precision: 9, scale: 7
+
+ wealth_column = TestModel.columns_hash["wealth"]
+ assert_equal 9, wealth_column.precision
+ assert_equal 7, wealth_column.scale
+
+ connection.change_column "test_models", "last_name", :string, null: false
+ TestModel.reset_column_information
+
+ wealth_column = TestModel.columns_hash["wealth"]
+ assert_equal 9, wealth_column.precision
+ assert_equal 7, wealth_column.scale
+ end
+ end
+
+ unless current_adapter?(:SQLite3Adapter)
+ def test_native_types
+ add_column "test_models", "first_name", :string
+ add_column "test_models", "last_name", :string
+ add_column "test_models", "bio", :text
+ add_column "test_models", "age", :integer
+ add_column "test_models", "height", :float
+ add_column "test_models", "wealth", :decimal, precision: "30", scale: "10"
+ add_column "test_models", "birthday", :datetime
+ add_column "test_models", "favorite_day", :date
+ add_column "test_models", "moment_of_truth", :datetime
+ add_column "test_models", "male", :boolean
+
+ TestModel.create first_name: "bob", last_name: "bobsen",
+ bio: "I was born ....", age: 18, height: 1.78,
+ wealth: BigDecimal("12345678901234567890.0123456789"),
+ birthday: 18.years.ago, favorite_day: 10.days.ago,
+ moment_of_truth: "1782-10-10 21:40:18", male: true
+
+ bob = TestModel.first
+ assert_equal "bob", bob.first_name
+ assert_equal "bobsen", bob.last_name
+ assert_equal "I was born ....", bob.bio
+ assert_equal 18, bob.age
+
+ # Test for 30 significant digits (beyond the 16 of float), 10 of them
+ # after the decimal place.
+
+ assert_equal BigDecimal("0012345678901234567890.0123456789"), bob.wealth
+
+ assert_equal true, bob.male?
+
+ assert_equal String, bob.first_name.class
+ assert_equal String, bob.last_name.class
+ assert_equal String, bob.bio.class
+ assert_kind_of Integer, bob.age
+ assert_equal Time, bob.birthday.class
+ assert_equal Date, bob.favorite_day.class
+ assert_instance_of TrueClass, bob.male?
+ assert_kind_of BigDecimal, bob.wealth
+ end
+ end
+
+ if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter)
+ def test_out_of_range_limit_should_raise
+ assert_raise(ActiveRecordError) { add_column :test_models, :integer_too_big, :integer, limit: 10 }
+
+ unless current_adapter?(:PostgreSQLAdapter)
+ assert_raise(ActiveRecordError) { add_column :test_models, :text_too_big, :text, limit: 0xfffffffff }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration/column_positioning_test.rb b/activerecord/test/cases/migration/column_positioning_test.rb
new file mode 100644
index 0000000000..1c62a68cf9
--- /dev/null
+++ b/activerecord/test/cases/migration/column_positioning_test.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ class Migration
+ class ColumnPositioningTest < ActiveRecord::TestCase
+ attr_reader :connection
+ alias :conn :connection
+
+ def setup
+ super
+
+ @connection = ActiveRecord::Base.connection
+
+ connection.create_table :testings, id: false do |t|
+ t.column :first, :integer
+ t.column :second, :integer
+ t.column :third, :integer
+ end
+ end
+
+ teardown do
+ connection.drop_table :testings rescue nil
+ ActiveRecord::Base.primary_key_prefix_type = nil
+ end
+
+ if current_adapter?(:Mysql2Adapter)
+ def test_column_positioning
+ assert_equal %w(first second third), conn.columns(:testings).map(&:name)
+ end
+
+ def test_add_column_with_positioning
+ conn.add_column :testings, :new_col, :integer
+ assert_equal %w(first second third new_col), conn.columns(:testings).map(&:name)
+ end
+
+ def test_add_column_with_positioning_first
+ conn.add_column :testings, :new_col, :integer, first: true
+ assert_equal %w(new_col first second third), conn.columns(:testings).map(&:name)
+ end
+
+ def test_add_column_with_positioning_after
+ conn.add_column :testings, :new_col, :integer, after: :first
+ assert_equal %w(first new_col second third), conn.columns(:testings).map(&:name)
+ end
+
+ def test_change_column_with_positioning
+ conn.change_column :testings, :second, :integer, first: true
+ assert_equal %w(second first third), conn.columns(:testings).map(&:name)
+
+ conn.change_column :testings, :second, :integer, after: :third
+ assert_equal %w(first third second), conn.columns(:testings).map(&:name)
+ end
+
+ def test_add_reference_with_positioning_first
+ conn.add_reference :testings, :new, polymorphic: true, first: true
+ assert_equal %w(new_id new_type first second third), conn.columns(:testings).map(&:name)
+ end
+
+ def test_add_reference_with_positioning_after
+ conn.add_reference :testings, :new, polymorphic: true, after: :first
+ assert_equal %w(first new_id new_type second third), conn.columns(:testings).map(&:name)
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration/columns_test.rb b/activerecord/test/cases/migration/columns_test.rb
new file mode 100644
index 0000000000..cedd9c44e3
--- /dev/null
+++ b/activerecord/test/cases/migration/columns_test.rb
@@ -0,0 +1,323 @@
+# frozen_string_literal: true
+
+require "cases/migration/helper"
+
+module ActiveRecord
+ class Migration
+ class ColumnsTest < ActiveRecord::TestCase
+ include ActiveRecord::Migration::TestHelper
+
+ self.use_transactional_tests = false
+
+ # FIXME: this is more of an integration test with AR::Base and the
+ # schema modifications. Maybe we should move this?
+ def test_add_rename
+ add_column "test_models", "girlfriend", :string
+ TestModel.reset_column_information
+
+ TestModel.create girlfriend: "bobette"
+
+ rename_column "test_models", "girlfriend", "exgirlfriend"
+
+ TestModel.reset_column_information
+ bob = TestModel.first
+
+ assert_equal "bobette", bob.exgirlfriend
+ end
+
+ # FIXME: another integration test. We should decouple this from the
+ # AR::Base implementation.
+ def test_rename_column_using_symbol_arguments
+ add_column :test_models, :first_name, :string
+
+ TestModel.create first_name: "foo"
+
+ rename_column :test_models, :first_name, :nick_name
+ TestModel.reset_column_information
+ assert_includes TestModel.column_names, "nick_name"
+ assert_equal ["foo"], TestModel.all.map(&:nick_name)
+ end
+
+ # FIXME: another integration test. We should decouple this from the
+ # AR::Base implementation.
+ def test_rename_column
+ add_column "test_models", "first_name", "string"
+
+ TestModel.create first_name: "foo"
+
+ rename_column "test_models", "first_name", "nick_name"
+ TestModel.reset_column_information
+ assert_includes TestModel.column_names, "nick_name"
+ assert_equal ["foo"], TestModel.all.map(&:nick_name)
+ end
+
+ def test_rename_column_preserves_default_value_not_null
+ add_column "test_models", "salary", :integer, default: 70000
+
+ default_before = connection.columns("test_models").find { |c| c.name == "salary" }.default
+ assert_equal "70000", default_before
+
+ rename_column "test_models", "salary", "annual_salary"
+
+ assert_includes TestModel.column_names, "annual_salary"
+ default_after = connection.columns("test_models").find { |c| c.name == "annual_salary" }.default
+ assert_equal "70000", default_after
+ end
+
+ if current_adapter?(:Mysql2Adapter)
+ def test_mysql_rename_column_preserves_auto_increment
+ rename_column "test_models", "id", "id_test"
+ assert_predicate connection.columns("test_models").find { |c| c.name == "id_test" }, :auto_increment?
+ TestModel.reset_column_information
+ ensure
+ rename_column "test_models", "id_test", "id"
+ end
+ end
+
+ def test_rename_nonexistent_column
+ exception = if current_adapter?(:PostgreSQLAdapter, :OracleAdapter)
+ ActiveRecord::StatementInvalid
+ else
+ ActiveRecord::ActiveRecordError
+ end
+
+ assert_raise(exception) do
+ rename_column "test_models", "nonexistent", "should_fail"
+ end
+ end
+
+ def test_rename_column_with_sql_reserved_word
+ add_column "test_models", "first_name", :string
+ rename_column "test_models", "first_name", "group"
+
+ assert_includes TestModel.column_names, "group"
+ end
+
+ def test_rename_column_with_an_index
+ add_column "test_models", :hat_name, :string
+ add_index :test_models, :hat_name
+
+ assert_equal 1, connection.indexes("test_models").size
+ rename_column "test_models", "hat_name", "name"
+
+ assert_equal ["index_test_models_on_name"], connection.indexes("test_models").map(&:name)
+ end
+
+ def test_rename_column_with_multi_column_index
+ add_column "test_models", :hat_size, :integer
+ add_column "test_models", :hat_style, :string, limit: 100
+ add_index "test_models", ["hat_style", "hat_size"], unique: true
+
+ rename_column "test_models", "hat_size", "size"
+ if current_adapter? :OracleAdapter
+ assert_equal ["i_test_models_hat_style_size"], connection.indexes("test_models").map(&:name)
+ else
+ assert_equal ["index_test_models_on_hat_style_and_size"], connection.indexes("test_models").map(&:name)
+ end
+
+ rename_column "test_models", "hat_style", "style"
+ if current_adapter? :OracleAdapter
+ assert_equal ["i_test_models_style_size"], connection.indexes("test_models").map(&:name)
+ else
+ assert_equal ["index_test_models_on_style_and_size"], connection.indexes("test_models").map(&:name)
+ end
+ end
+
+ def test_rename_column_does_not_rename_custom_named_index
+ add_column "test_models", :hat_name, :string
+ add_index :test_models, :hat_name, name: "idx_hat_name"
+
+ assert_equal 1, connection.indexes("test_models").size
+ rename_column "test_models", "hat_name", "name"
+ assert_equal ["idx_hat_name"], connection.indexes("test_models").map(&:name)
+ end
+
+ def test_remove_column_with_index
+ add_column "test_models", :hat_name, :string
+ add_index :test_models, :hat_name
+
+ assert_equal 1, connection.indexes("test_models").size
+ remove_column("test_models", "hat_name")
+ assert_equal 0, connection.indexes("test_models").size
+ end
+
+ def test_remove_column_with_multi_column_index
+ # MariaDB starting with 10.2.8
+ # Dropping a column that is part of a multi-column UNIQUE constraint is not permitted.
+ skip if current_adapter?(:Mysql2Adapter) && connection.mariadb? && connection.version >= "10.2.8"
+
+ add_column "test_models", :hat_size, :integer
+ add_column "test_models", :hat_style, :string, limit: 100
+ add_index "test_models", ["hat_style", "hat_size"], unique: true
+
+ assert_equal 1, connection.indexes("test_models").size
+ remove_column("test_models", "hat_size")
+
+ # Every database and/or database adapter has their own behavior
+ # if it drops the multi-column index when any of the indexed columns dropped by remove_column.
+ if current_adapter?(:PostgreSQLAdapter, :OracleAdapter)
+ assert_equal [], connection.indexes("test_models").map(&:name)
+ else
+ assert_equal ["index_test_models_on_hat_style_and_hat_size"], connection.indexes("test_models").map(&:name)
+ end
+ end
+
+ def test_change_type_of_not_null_column
+ change_column "test_models", "updated_at", :datetime, null: false
+ change_column "test_models", "updated_at", :datetime, null: false
+
+ TestModel.reset_column_information
+ assert_equal false, TestModel.columns_hash["updated_at"].null
+ ensure
+ change_column "test_models", "updated_at", :datetime, null: true
+ end
+
+ def test_change_column_nullability
+ add_column "test_models", "funny", :boolean
+ assert TestModel.columns_hash["funny"].null, "Column 'funny' must initially allow nulls"
+
+ change_column "test_models", "funny", :boolean, null: false, default: true
+
+ TestModel.reset_column_information
+ assert_not TestModel.columns_hash["funny"].null, "Column 'funny' must *not* allow nulls at this point"
+
+ change_column "test_models", "funny", :boolean, null: true
+ TestModel.reset_column_information
+ assert TestModel.columns_hash["funny"].null, "Column 'funny' must allow nulls again at this point"
+ end
+
+ def test_change_column
+ add_column "test_models", "age", :integer
+ add_column "test_models", "approved", :boolean, default: true
+
+ old_columns = connection.columns(TestModel.table_name)
+
+ assert old_columns.find { |c| c.name == "age" && c.type == :integer }
+
+ change_column "test_models", "age", :string
+
+ new_columns = connection.columns(TestModel.table_name)
+
+ assert_not new_columns.find { |c| c.name == "age" && c.type == :integer }
+ assert new_columns.find { |c| c.name == "age" && c.type == :string }
+
+ old_columns = connection.columns(TestModel.table_name)
+ assert old_columns.find { |c|
+ default = connection.lookup_cast_type_from_column(c).deserialize(c.default)
+ c.name == "approved" && c.type == :boolean && default == true
+ }
+
+ change_column :test_models, :approved, :boolean, default: false
+ new_columns = connection.columns(TestModel.table_name)
+
+ assert_not new_columns.find { |c|
+ default = connection.lookup_cast_type_from_column(c).deserialize(c.default)
+ c.name == "approved" && c.type == :boolean && default == true
+ }
+ assert new_columns.find { |c|
+ default = connection.lookup_cast_type_from_column(c).deserialize(c.default)
+ c.name == "approved" && c.type == :boolean && default == false
+ }
+ change_column :test_models, :approved, :boolean, default: true
+ end
+
+ def test_change_column_with_nil_default
+ add_column "test_models", "contributor", :boolean, default: true
+ assert_predicate TestModel.new, :contributor?
+
+ change_column "test_models", "contributor", :boolean, default: nil
+ TestModel.reset_column_information
+ assert_not_predicate TestModel.new, :contributor?
+ assert_nil TestModel.new.contributor
+ end
+
+ def test_change_column_to_drop_default_with_null_false
+ add_column "test_models", "contributor", :boolean, default: true, null: false
+ assert_predicate TestModel.new, :contributor?
+
+ change_column "test_models", "contributor", :boolean, default: nil, null: false
+ TestModel.reset_column_information
+ assert_not_predicate TestModel.new, :contributor?
+ assert_nil TestModel.new.contributor
+ end
+
+ def test_change_column_with_new_default
+ add_column "test_models", "administrator", :boolean, default: true
+ assert_predicate TestModel.new, :administrator?
+
+ change_column "test_models", "administrator", :boolean, default: false
+ TestModel.reset_column_information
+ assert_not_predicate TestModel.new, :administrator?
+ end
+
+ def test_change_column_with_custom_index_name
+ add_column "test_models", "category", :string
+ add_index :test_models, :category, name: "test_models_categories_idx"
+
+ assert_equal ["test_models_categories_idx"], connection.indexes("test_models").map(&:name)
+ change_column "test_models", "category", :string, null: false, default: "article"
+
+ assert_equal ["test_models_categories_idx"], connection.indexes("test_models").map(&:name)
+ end
+
+ def test_change_column_with_long_index_name
+ table_name_prefix = "test_models_"
+ long_index_name = table_name_prefix + ("x" * (connection.allowed_index_name_length - table_name_prefix.length))
+ add_column "test_models", "category", :string
+ add_index :test_models, :category, name: long_index_name
+
+ change_column "test_models", "category", :string, null: false, default: "article"
+
+ assert_equal [long_index_name], connection.indexes("test_models").map(&:name)
+ end
+
+ def test_change_column_default
+ add_column "test_models", "first_name", :string
+ connection.change_column_default "test_models", "first_name", "Tester"
+
+ assert_equal "Tester", TestModel.new.first_name
+ end
+
+ def test_change_column_default_to_null
+ add_column "test_models", "first_name", :string
+ connection.change_column_default "test_models", "first_name", nil
+ assert_nil TestModel.new.first_name
+ end
+
+ def test_change_column_default_with_from_and_to
+ add_column "test_models", "first_name", :string
+ connection.change_column_default "test_models", "first_name", from: nil, to: "Tester"
+
+ assert_equal "Tester", TestModel.new.first_name
+ end
+
+ def test_remove_column_no_second_parameter_raises_exception
+ assert_raise(ArgumentError) { connection.remove_column("funny") }
+ end
+
+ def test_removing_and_renaming_column_preserves_custom_primary_key
+ connection.create_table "my_table", primary_key: "my_table_id", force: true do |t|
+ t.integer "col_one"
+ t.string "col_two", limit: 128, null: false
+ end
+
+ remove_column("my_table", "col_two")
+ rename_column("my_table", "col_one", "col_three")
+
+ assert_equal "my_table_id", connection.primary_key("my_table")
+ ensure
+ connection.drop_table(:my_table) rescue nil
+ end
+
+ def test_column_with_index
+ connection.create_table "my_table", force: true do |t|
+ t.string :item_number, index: true
+ end
+
+ assert connection.index_exists?("my_table", :item_number, name: :index_my_table_on_item_number)
+ ensure
+ connection.drop_table(:my_table) rescue nil
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration/command_recorder_test.rb b/activerecord/test/cases/migration/command_recorder_test.rb
new file mode 100644
index 0000000000..01f8628fc5
--- /dev/null
+++ b/activerecord/test/cases/migration/command_recorder_test.rb
@@ -0,0 +1,375 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ class Migration
+ class CommandRecorderTest < ActiveRecord::TestCase
+ def setup
+ connection = ActiveRecord::Base.connection
+ @recorder = CommandRecorder.new(connection)
+ end
+
+ def test_respond_to_delegates
+ recorder = CommandRecorder.new(Class.new {
+ def america; end
+ }.new)
+ assert_respond_to recorder, :america
+ end
+
+ def test_send_calls_super
+ assert_raises(NoMethodError) do
+ @recorder.send(:non_existing_method, :horses)
+ end
+ end
+
+ def test_send_delegates_to_record
+ recorder = CommandRecorder.new(Class.new {
+ def create_table(name); end
+ }.new)
+ assert_respond_to recorder, :create_table
+ recorder.send(:create_table, :horses)
+ assert_equal [[:create_table, [:horses], nil]], recorder.commands
+ end
+
+ def test_unknown_commands_delegate
+ recorder = Struct.new(:foo)
+ recorder = CommandRecorder.new(recorder.new("bar"))
+ assert_equal "bar", recorder.foo
+ end
+
+ def test_inverse_of_raise_exception_on_unknown_commands
+ assert_raises(ActiveRecord::IrreversibleMigration) do
+ @recorder.inverse_of :execute, ["some sql"]
+ end
+ end
+
+ def test_irreversible_commands_raise_exception
+ assert_raises(ActiveRecord::IrreversibleMigration) do
+ @recorder.revert { @recorder.execute "some sql" }
+ end
+ end
+
+ def test_record
+ @recorder.record :create_table, [:system_settings]
+ assert_equal 1, @recorder.commands.length
+ end
+
+ def test_inverted_commands_are_reversed
+ @recorder.revert do
+ @recorder.record :create_table, [:hello]
+ @recorder.record :create_table, [:world]
+ end
+ tables = @recorder.commands.map { |_cmd, args, _block| args }
+ assert_equal [[:world], [:hello]], tables
+ end
+
+ def test_revert_order
+ block = Proc.new { |t| t.string :name }
+ @recorder.instance_eval do
+ create_table("apples", &block)
+ revert do
+ create_table("bananas", &block)
+ revert do
+ create_table("clementines", &block)
+ create_table("dates")
+ end
+ create_table("elderberries")
+ end
+ revert do
+ create_table("figs", &block)
+ create_table("grapes")
+ end
+ end
+ assert_equal [[:create_table, ["apples"], block], [:drop_table, ["elderberries"], nil],
+ [:create_table, ["clementines"], block], [:create_table, ["dates"], nil],
+ [:drop_table, ["bananas"], block], [:drop_table, ["grapes"], nil],
+ [:drop_table, ["figs"], block]], @recorder.commands
+ end
+
+ def test_invert_change_table
+ @recorder.revert do
+ @recorder.change_table :fruits do |t|
+ t.string :name
+ t.rename :kind, :cultivar
+ end
+ end
+ assert_equal [
+ [:rename_column, [:fruits, :cultivar, :kind]],
+ [:remove_column, [:fruits, :name, :string, {}], nil],
+ ], @recorder.commands
+
+ assert_raises(ActiveRecord::IrreversibleMigration) do
+ @recorder.revert do
+ @recorder.change_table :fruits do |t|
+ t.remove :kind
+ end
+ end
+ end
+ end
+
+ def test_invert_create_table
+ @recorder.revert do
+ @recorder.record :create_table, [:system_settings]
+ end
+ drop_table = @recorder.commands.first
+ assert_equal [:drop_table, [:system_settings], nil], drop_table
+ end
+
+ def test_invert_create_table_with_options_and_block
+ block = Proc.new { }
+ drop_table = @recorder.inverse_of :create_table, [:people_reminders, id: false], &block
+ assert_equal [:drop_table, [:people_reminders, id: false], block], drop_table
+ end
+
+ def test_invert_drop_table
+ block = Proc.new { }
+ create_table = @recorder.inverse_of :drop_table, [:people_reminders, id: false], &block
+ assert_equal [:create_table, [:people_reminders, id: false], block], create_table
+ end
+
+ def test_invert_drop_table_without_a_block_nor_option
+ assert_raises(ActiveRecord::IrreversibleMigration) do
+ @recorder.inverse_of :drop_table, [:people_reminders]
+ end
+ end
+
+ def test_invert_create_join_table
+ drop_join_table = @recorder.inverse_of :create_join_table, [:musics, :artists]
+ assert_equal [:drop_join_table, [:musics, :artists], nil], drop_join_table
+ end
+
+ def test_invert_create_join_table_with_table_name
+ drop_join_table = @recorder.inverse_of :create_join_table, [:musics, :artists, table_name: :catalog]
+ assert_equal [:drop_join_table, [:musics, :artists, table_name: :catalog], nil], drop_join_table
+ end
+
+ def test_invert_drop_join_table
+ block = Proc.new { }
+ create_join_table = @recorder.inverse_of :drop_join_table, [:musics, :artists, table_name: :catalog], &block
+ assert_equal [:create_join_table, [:musics, :artists, table_name: :catalog], block], create_join_table
+ end
+
+ def test_invert_rename_table
+ rename = @recorder.inverse_of :rename_table, [:old, :new]
+ assert_equal [:rename_table, [:new, :old]], rename
+ end
+
+ def test_invert_add_column
+ remove = @recorder.inverse_of :add_column, [:table, :column, :type, {}]
+ assert_equal [:remove_column, [:table, :column, :type, {}], nil], remove
+ end
+
+ def test_invert_change_column
+ assert_raises(ActiveRecord::IrreversibleMigration) do
+ @recorder.inverse_of :change_column, [:table, :column, :type, {}]
+ end
+ end
+
+ def test_invert_change_column_default
+ assert_raises(ActiveRecord::IrreversibleMigration) do
+ @recorder.inverse_of :change_column_default, [:table, :column, "default_value"]
+ end
+ end
+
+ def test_invert_change_column_default_with_from_and_to
+ change = @recorder.inverse_of :change_column_default, [:table, :column, from: "old_value", to: "new_value"]
+ assert_equal [:change_column_default, [:table, :column, from: "new_value", to: "old_value"]], change
+ end
+
+ def test_invert_change_column_default_with_from_and_to_with_boolean
+ change = @recorder.inverse_of :change_column_default, [:table, :column, from: true, to: false]
+ assert_equal [:change_column_default, [:table, :column, from: false, to: true]], change
+ end
+
+ def test_invert_change_column_null
+ add = @recorder.inverse_of :change_column_null, [:table, :column, true]
+ assert_equal [:change_column_null, [:table, :column, false]], add
+ end
+
+ def test_invert_remove_column
+ add = @recorder.inverse_of :remove_column, [:table, :column, :type, {}]
+ assert_equal [:add_column, [:table, :column, :type, {}], nil], add
+ end
+
+ def test_invert_remove_column_without_type
+ assert_raises(ActiveRecord::IrreversibleMigration) do
+ @recorder.inverse_of :remove_column, [:table, :column]
+ end
+ end
+
+ def test_invert_rename_column
+ rename = @recorder.inverse_of :rename_column, [:table, :old, :new]
+ assert_equal [:rename_column, [:table, :new, :old]], rename
+ end
+
+ def test_invert_add_index
+ remove = @recorder.inverse_of :add_index, [:table, [:one, :two]]
+ assert_equal [:remove_index, [:table, { column: [:one, :two] }]], remove
+ end
+
+ def test_invert_add_index_with_name
+ remove = @recorder.inverse_of :add_index, [:table, [:one, :two], name: "new_index"]
+ assert_equal [:remove_index, [:table, { name: "new_index" }]], remove
+ end
+
+ def test_invert_add_index_with_algorithm_option
+ remove = @recorder.inverse_of :add_index, [:table, :one, algorithm: :concurrently]
+ assert_equal [:remove_index, [:table, { column: :one, algorithm: :concurrently }]], remove
+ end
+
+ def test_invert_remove_index
+ add = @recorder.inverse_of :remove_index, [:table, :one]
+ assert_equal [:add_index, [:table, :one]], add
+ end
+
+ def test_invert_remove_index_with_column
+ add = @recorder.inverse_of :remove_index, [:table, { column: [:one, :two], options: true }]
+ assert_equal [:add_index, [:table, [:one, :two], options: true]], add
+ end
+
+ def test_invert_remove_index_with_name
+ add = @recorder.inverse_of :remove_index, [:table, { column: [:one, :two], name: "new_index" }]
+ assert_equal [:add_index, [:table, [:one, :two], name: "new_index"]], add
+ end
+
+ def test_invert_remove_index_with_no_special_options
+ add = @recorder.inverse_of :remove_index, [:table, { column: [:one, :two] }]
+ assert_equal [:add_index, [:table, [:one, :two], {}]], add
+ end
+
+ def test_invert_remove_index_with_no_column
+ assert_raises(ActiveRecord::IrreversibleMigration) do
+ @recorder.inverse_of :remove_index, [:table, name: "new_index"]
+ end
+ end
+
+ def test_invert_rename_index
+ rename = @recorder.inverse_of :rename_index, [:table, :old, :new]
+ assert_equal [:rename_index, [:table, :new, :old]], rename
+ end
+
+ def test_invert_add_timestamps
+ remove = @recorder.inverse_of :add_timestamps, [:table]
+ assert_equal [:remove_timestamps, [:table], nil], remove
+ end
+
+ def test_invert_remove_timestamps
+ add = @recorder.inverse_of :remove_timestamps, [:table, { null: true }]
+ assert_equal [:add_timestamps, [:table, { null: true }], nil], add
+ end
+
+ def test_invert_add_reference
+ remove = @recorder.inverse_of :add_reference, [:table, :taggable, { polymorphic: true }]
+ assert_equal [:remove_reference, [:table, :taggable, { polymorphic: true }], nil], remove
+ end
+
+ def test_invert_add_belongs_to_alias
+ remove = @recorder.inverse_of :add_belongs_to, [:table, :user]
+ assert_equal [:remove_reference, [:table, :user], nil], remove
+ end
+
+ def test_invert_remove_reference
+ add = @recorder.inverse_of :remove_reference, [:table, :taggable, { polymorphic: true }]
+ assert_equal [:add_reference, [:table, :taggable, { polymorphic: true }], nil], add
+ end
+
+ def test_invert_remove_reference_with_index_and_foreign_key
+ add = @recorder.inverse_of :remove_reference, [:table, :taggable, { index: true, foreign_key: true }]
+ assert_equal [:add_reference, [:table, :taggable, { index: true, foreign_key: true }], nil], add
+ end
+
+ def test_invert_remove_belongs_to_alias
+ add = @recorder.inverse_of :remove_belongs_to, [:table, :user]
+ assert_equal [:add_reference, [:table, :user], nil], add
+ end
+
+ def test_invert_enable_extension
+ disable = @recorder.inverse_of :enable_extension, ["uuid-ossp"]
+ assert_equal [:disable_extension, ["uuid-ossp"], nil], disable
+ end
+
+ def test_invert_disable_extension
+ enable = @recorder.inverse_of :disable_extension, ["uuid-ossp"]
+ assert_equal [:enable_extension, ["uuid-ossp"], nil], enable
+ end
+
+ def test_invert_add_foreign_key
+ enable = @recorder.inverse_of :add_foreign_key, [:dogs, :people]
+ assert_equal [:remove_foreign_key, [:dogs, :people]], enable
+ end
+
+ def test_invert_remove_foreign_key
+ enable = @recorder.inverse_of :remove_foreign_key, [:dogs, :people]
+ assert_equal [:add_foreign_key, [:dogs, :people]], enable
+ end
+
+ def test_invert_add_foreign_key_with_column
+ enable = @recorder.inverse_of :add_foreign_key, [:dogs, :people, column: "owner_id"]
+ assert_equal [:remove_foreign_key, [:dogs, column: "owner_id"]], enable
+ end
+
+ def test_invert_remove_foreign_key_with_column
+ enable = @recorder.inverse_of :remove_foreign_key, [:dogs, :people, column: "owner_id"]
+ assert_equal [:add_foreign_key, [:dogs, :people, column: "owner_id"]], enable
+ end
+
+ def test_invert_add_foreign_key_with_column_and_name
+ enable = @recorder.inverse_of :add_foreign_key, [:dogs, :people, column: "owner_id", name: "fk"]
+ assert_equal [:remove_foreign_key, [:dogs, name: "fk"]], enable
+ end
+
+ def test_invert_remove_foreign_key_with_column_and_name
+ enable = @recorder.inverse_of :remove_foreign_key, [:dogs, :people, column: "owner_id", name: "fk"]
+ assert_equal [:add_foreign_key, [:dogs, :people, column: "owner_id", name: "fk"]], enable
+ end
+
+ def test_invert_remove_foreign_key_with_primary_key
+ enable = @recorder.inverse_of :remove_foreign_key, [:dogs, :people, primary_key: "person_id"]
+ assert_equal [:add_foreign_key, [:dogs, :people, primary_key: "person_id"]], enable
+ end
+
+ def test_invert_remove_foreign_key_with_primary_key_and_to_table_in_options
+ enable = @recorder.inverse_of :remove_foreign_key, [:dogs, to_table: :people, primary_key: "uuid"]
+ assert_equal [:add_foreign_key, [:dogs, :people, primary_key: "uuid"]], enable
+ end
+
+ def test_invert_remove_foreign_key_with_on_delete_on_update
+ enable = @recorder.inverse_of :remove_foreign_key, [:dogs, :people, on_delete: :nullify, on_update: :cascade]
+ assert_equal [:add_foreign_key, [:dogs, :people, on_delete: :nullify, on_update: :cascade]], enable
+ end
+
+ def test_invert_remove_foreign_key_with_to_table_in_options
+ enable = @recorder.inverse_of :remove_foreign_key, [:dogs, to_table: :people]
+ assert_equal [:add_foreign_key, [:dogs, :people]], enable
+
+ enable = @recorder.inverse_of :remove_foreign_key, [:dogs, to_table: :people, column: :owner_id]
+ assert_equal [:add_foreign_key, [:dogs, :people, column: :owner_id]], enable
+ end
+
+ def test_invert_remove_foreign_key_is_irreversible_without_to_table
+ assert_raises ActiveRecord::IrreversibleMigration do
+ @recorder.inverse_of :remove_foreign_key, [:dogs, column: "owner_id"]
+ end
+
+ assert_raises ActiveRecord::IrreversibleMigration do
+ @recorder.inverse_of :remove_foreign_key, [:dogs, name: "fk"]
+ end
+
+ assert_raises ActiveRecord::IrreversibleMigration do
+ @recorder.inverse_of :remove_foreign_key, [:dogs]
+ end
+ end
+
+ def test_invert_transaction_with_irreversible_inside_is_irreversible
+ assert_raises(ActiveRecord::IrreversibleMigration) do
+ @recorder.revert do
+ @recorder.transaction do
+ @recorder.execute "some sql"
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration/compatibility_test.rb b/activerecord/test/cases/migration/compatibility_test.rb
new file mode 100644
index 0000000000..017ee7951e
--- /dev/null
+++ b/activerecord/test/cases/migration/compatibility_test.rb
@@ -0,0 +1,383 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+module ActiveRecord
+ class Migration
+ class CompatibilityTest < ActiveRecord::TestCase
+ attr_reader :connection
+ self.use_transactional_tests = false
+
+ def setup
+ super
+ @connection = ActiveRecord::Base.connection
+ @verbose_was = ActiveRecord::Migration.verbose
+ ActiveRecord::Migration.verbose = false
+
+ connection.create_table :testings do |t|
+ t.column :foo, :string, limit: 5
+ t.column :bar, :string, limit: 100
+ end
+ end
+
+ teardown do
+ connection.drop_table :testings rescue nil
+ ActiveRecord::Migration.verbose = @verbose_was
+ ActiveRecord::SchemaMigration.delete_all rescue nil
+ end
+
+ def test_migration_doesnt_remove_named_index
+ connection.add_index :testings, :foo, name: "custom_index_name"
+
+ migration = Class.new(ActiveRecord::Migration[4.2]) {
+ def version; 101 end
+ def migrate(x)
+ remove_index :testings, :foo
+ end
+ }.new
+
+ assert connection.index_exists?(:testings, :foo, name: "custom_index_name")
+ assert_raise(StandardError) { ActiveRecord::Migrator.new(:up, [migration]).migrate }
+ assert connection.index_exists?(:testings, :foo, name: "custom_index_name")
+ end
+
+ def test_migration_does_remove_unnamed_index
+ connection.add_index :testings, :bar
+
+ migration = Class.new(ActiveRecord::Migration[4.2]) {
+ def version; 101 end
+ def migrate(x)
+ remove_index :testings, :bar
+ end
+ }.new
+
+ assert connection.index_exists?(:testings, :bar)
+ ActiveRecord::Migrator.new(:up, [migration]).migrate
+ assert_not connection.index_exists?(:testings, :bar)
+ end
+
+ def test_references_does_not_add_index_by_default
+ migration = Class.new(ActiveRecord::Migration[4.2]) {
+ def migrate(x)
+ create_table :more_testings do |t|
+ t.references :foo
+ t.belongs_to :bar, index: false
+ end
+ end
+ }.new
+
+ ActiveRecord::Migrator.new(:up, [migration]).migrate
+
+ assert_not connection.index_exists?(:more_testings, :foo_id)
+ assert_not connection.index_exists?(:more_testings, :bar_id)
+ ensure
+ connection.drop_table :more_testings rescue nil
+ end
+
+ def test_timestamps_have_null_constraints_if_not_present_in_migration_of_create_table
+ migration = Class.new(ActiveRecord::Migration[4.2]) {
+ def migrate(x)
+ create_table :more_testings do |t|
+ t.timestamps
+ end
+ end
+ }.new
+
+ ActiveRecord::Migrator.new(:up, [migration]).migrate
+
+ assert connection.columns(:more_testings).find { |c| c.name == "created_at" }.null
+ assert connection.columns(:more_testings).find { |c| c.name == "updated_at" }.null
+ ensure
+ connection.drop_table :more_testings rescue nil
+ end
+
+ def test_timestamps_have_null_constraints_if_not_present_in_migration_of_change_table
+ migration = Class.new(ActiveRecord::Migration[4.2]) {
+ def migrate(x)
+ change_table :testings do |t|
+ t.timestamps
+ end
+ end
+ }.new
+
+ ActiveRecord::Migrator.new(:up, [migration]).migrate
+
+ assert connection.columns(:testings).find { |c| c.name == "created_at" }.null
+ assert connection.columns(:testings).find { |c| c.name == "updated_at" }.null
+ end
+
+ def test_timestamps_have_null_constraints_if_not_present_in_migration_for_adding_timestamps_to_existing_table
+ migration = Class.new(ActiveRecord::Migration[4.2]) {
+ def migrate(x)
+ add_timestamps :testings
+ end
+ }.new
+
+ ActiveRecord::Migrator.new(:up, [migration]).migrate
+
+ assert connection.columns(:testings).find { |c| c.name == "created_at" }.null
+ assert connection.columns(:testings).find { |c| c.name == "updated_at" }.null
+ end
+
+ def test_legacy_migrations_raises_exception_when_inherited
+ e = assert_raises(StandardError) do
+ class_eval("class LegacyMigration < ActiveRecord::Migration; end")
+ end
+ assert_match(/LegacyMigration < ActiveRecord::Migration\[4\.2\]/, e.message)
+ end
+
+ def test_legacy_migrations_not_raise_exception_on_reverting_transaction
+ migration = Class.new(ActiveRecord::Migration[5.2]) {
+ def change
+ transaction do
+ execute "select 1"
+ end
+ end
+ }.new
+
+ assert_nothing_raised do
+ migration.migrate(:down)
+ end
+ end
+
+ if current_adapter?(:PostgreSQLAdapter)
+ class Testing < ActiveRecord::Base
+ end
+
+ def test_legacy_change_column_with_null_executes_update
+ migration = Class.new(ActiveRecord::Migration[5.1]) {
+ def migrate(x)
+ change_column :testings, :foo, :string, limit: 10, null: false, default: "foobar"
+ end
+ }.new
+
+ Testing.create!
+ ActiveRecord::Migrator.new(:up, [migration]).migrate
+ assert_equal ["foobar"], Testing.all.map(&:foo)
+ ensure
+ ActiveRecord::Base.clear_cache!
+ end
+ end
+ end
+ end
+end
+
+module LegacyPrimaryKeyTestCases
+ include SchemaDumpingHelper
+
+ class LegacyPrimaryKey < ActiveRecord::Base
+ end
+
+ def setup
+ @migration = nil
+ @verbose_was = ActiveRecord::Migration.verbose
+ ActiveRecord::Migration.verbose = false
+ end
+
+ def teardown
+ @migration.migrate(:down) if @migration
+ ActiveRecord::Migration.verbose = @verbose_was
+ ActiveRecord::SchemaMigration.delete_all rescue nil
+ LegacyPrimaryKey.reset_column_information
+ end
+
+ def test_legacy_primary_key_should_be_auto_incremented
+ @migration = Class.new(migration_class) {
+ def change
+ create_table :legacy_primary_keys do |t|
+ t.references :legacy_ref
+ end
+ end
+ }.new
+
+ @migration.migrate(:up)
+
+ assert_legacy_primary_key
+
+ legacy_ref = LegacyPrimaryKey.columns_hash["legacy_ref_id"]
+ assert_not_predicate legacy_ref, :bigint?
+
+ record1 = LegacyPrimaryKey.create!
+ assert_not_nil record1.id
+
+ record1.destroy
+
+ record2 = LegacyPrimaryKey.create!
+ assert_not_nil record2.id
+ assert_operator record2.id, :>, record1.id
+ end
+
+ def test_legacy_integer_primary_key_should_not_be_auto_incremented
+ skip if current_adapter?(:SQLite3Adapter)
+
+ @migration = Class.new(migration_class) {
+ def change
+ create_table :legacy_primary_keys, id: :integer do |t|
+ end
+ end
+ }.new
+
+ @migration.migrate(:up)
+
+ assert_raises(ActiveRecord::NotNullViolation) do
+ LegacyPrimaryKey.create!
+ end
+
+ schema = dump_table_schema "legacy_primary_keys"
+ assert_match %r{create_table "legacy_primary_keys", id: :integer, default: nil}, schema
+ end
+
+ def test_legacy_primary_key_in_create_table_should_be_integer
+ @migration = Class.new(migration_class) {
+ def change
+ create_table :legacy_primary_keys, id: false do |t|
+ t.primary_key :id
+ end
+ end
+ }.new
+
+ @migration.migrate(:up)
+
+ assert_legacy_primary_key
+ end
+
+ def test_legacy_primary_key_in_change_table_should_be_integer
+ @migration = Class.new(migration_class) {
+ def change
+ create_table :legacy_primary_keys, id: false do |t|
+ t.integer :dummy
+ end
+ change_table :legacy_primary_keys do |t|
+ t.primary_key :id
+ end
+ end
+ }.new
+
+ @migration.migrate(:up)
+
+ assert_legacy_primary_key
+ end
+
+ def test_add_column_with_legacy_primary_key_should_be_integer
+ @migration = Class.new(migration_class) {
+ def change
+ create_table :legacy_primary_keys, id: false do |t|
+ t.integer :dummy
+ end
+ add_column :legacy_primary_keys, :id, :primary_key
+ end
+ }.new
+
+ @migration.migrate(:up)
+
+ assert_legacy_primary_key
+ end
+
+ def test_legacy_join_table_foreign_keys_should_be_integer
+ @migration = Class.new(migration_class) {
+ def change
+ create_join_table :apples, :bananas do |t|
+ end
+ end
+ }.new
+
+ @migration.migrate(:up)
+
+ schema = dump_table_schema "apples_bananas"
+ assert_match %r{integer "apple_id", null: false}, schema
+ assert_match %r{integer "banana_id", null: false}, schema
+ end
+
+ def test_legacy_join_table_column_options_should_be_overwritten
+ @migration = Class.new(migration_class) {
+ def change
+ create_join_table :apples, :bananas, column_options: { type: :bigint } do |t|
+ end
+ end
+ }.new
+
+ @migration.migrate(:up)
+
+ schema = dump_table_schema "apples_bananas"
+ assert_match %r{bigint "apple_id", null: false}, schema
+ assert_match %r{bigint "banana_id", null: false}, schema
+ end
+
+ if current_adapter?(:Mysql2Adapter)
+ def test_legacy_bigint_primary_key_should_be_auto_incremented
+ @migration = Class.new(migration_class) {
+ def change
+ create_table :legacy_primary_keys, id: :bigint
+ end
+ }.new
+
+ @migration.migrate(:up)
+
+ legacy_pk = LegacyPrimaryKey.columns_hash["id"]
+ assert_predicate legacy_pk, :bigint?
+ assert_predicate legacy_pk, :auto_increment?
+
+ schema = dump_table_schema "legacy_primary_keys"
+ assert_match %r{create_table "legacy_primary_keys", (?!id: :bigint, default: nil)}, schema
+ end
+ else
+ def test_legacy_bigint_primary_key_should_not_be_auto_incremented
+ @migration = Class.new(migration_class) {
+ def change
+ create_table :legacy_primary_keys, id: :bigint do |t|
+ end
+ end
+ }.new
+
+ @migration.migrate(:up)
+
+ assert_raises(ActiveRecord::NotNullViolation) do
+ LegacyPrimaryKey.create!
+ end
+
+ schema = dump_table_schema "legacy_primary_keys"
+ assert_match %r{create_table "legacy_primary_keys", id: :bigint, default: nil}, schema
+ end
+ end
+
+ private
+ def assert_legacy_primary_key
+ assert_equal "id", LegacyPrimaryKey.primary_key
+
+ legacy_pk = LegacyPrimaryKey.columns_hash["id"]
+
+ assert_equal :integer, legacy_pk.type
+ assert_not_predicate legacy_pk, :bigint?
+ assert_not legacy_pk.null
+
+ if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter)
+ schema = dump_table_schema "legacy_primary_keys"
+ assert_match %r{create_table "legacy_primary_keys", id: :(?:integer|serial), (?!default: nil)}, schema
+ end
+ end
+end
+
+module LegacyPrimaryKeyTest
+ class V5_0 < ActiveRecord::TestCase
+ include LegacyPrimaryKeyTestCases
+
+ self.use_transactional_tests = false
+
+ private
+ def migration_class
+ ActiveRecord::Migration[5.0]
+ end
+ end
+
+ class V4_2 < ActiveRecord::TestCase
+ include LegacyPrimaryKeyTestCases
+
+ self.use_transactional_tests = false
+
+ private
+ def migration_class
+ ActiveRecord::Migration[4.2]
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration/create_join_table_test.rb b/activerecord/test/cases/migration/create_join_table_test.rb
new file mode 100644
index 0000000000..e0cbb29dcf
--- /dev/null
+++ b/activerecord/test/cases/migration/create_join_table_test.rb
@@ -0,0 +1,168 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ class Migration
+ class CreateJoinTableTest < ActiveRecord::TestCase
+ attr_reader :connection
+
+ def setup
+ super
+ @connection = ActiveRecord::Base.connection
+ end
+
+ teardown do
+ %w(artists_musics musics_videos catalog).each do |table_name|
+ connection.drop_table table_name, if_exists: true
+ end
+ end
+
+ def test_create_join_table
+ connection.create_join_table :artists, :musics
+
+ assert_equal %w(artist_id music_id), connection.columns(:artists_musics).map(&:name).sort
+ end
+
+ def test_create_join_table_set_not_null_by_default
+ connection.create_join_table :artists, :musics
+
+ assert_equal [false, false], connection.columns(:artists_musics).map(&:null)
+ end
+
+ def test_create_join_table_with_strings
+ connection.create_join_table "artists", "musics"
+
+ assert_equal %w(artist_id music_id), connection.columns(:artists_musics).map(&:name).sort
+ end
+
+ def test_create_join_table_with_symbol_and_string
+ connection.create_join_table :artists, "musics"
+
+ assert_equal %w(artist_id music_id), connection.columns(:artists_musics).map(&:name).sort
+ end
+
+ def test_create_join_table_with_the_proper_order
+ connection.create_join_table :videos, :musics
+
+ assert_equal %w(music_id video_id), connection.columns(:musics_videos).map(&:name).sort
+ end
+
+ def test_create_join_table_with_the_table_name
+ connection.create_join_table :artists, :musics, table_name: :catalog
+
+ assert_equal %w(artist_id music_id), connection.columns(:catalog).map(&:name).sort
+ end
+
+ def test_create_join_table_with_the_table_name_as_string
+ connection.create_join_table :artists, :musics, table_name: "catalog"
+
+ assert_equal %w(artist_id music_id), connection.columns(:catalog).map(&:name).sort
+ end
+
+ def test_create_join_table_with_column_options
+ connection.create_join_table :artists, :musics, column_options: { null: true }
+
+ assert_equal [true, true], connection.columns(:artists_musics).map(&:null)
+ end
+
+ def test_create_join_table_without_indexes
+ connection.create_join_table :artists, :musics
+
+ assert_predicate connection.indexes(:artists_musics), :blank?
+ end
+
+ def test_create_join_table_with_index
+ connection.create_join_table :artists, :musics do |t|
+ t.index [:artist_id, :music_id]
+ end
+
+ assert_equal [%w(artist_id music_id)], connection.indexes(:artists_musics).map(&:columns)
+ end
+
+ def test_create_join_table_respects_reference_key_type
+ connection.create_join_table :artists, :musics do |t|
+ t.references :video
+ end
+
+ artist_id, music_id, video_id = connection.columns(:artists_musics).sort_by(&:name)
+
+ assert_equal video_id.sql_type, artist_id.sql_type
+ assert_equal video_id.sql_type, music_id.sql_type
+ end
+
+ def test_drop_join_table
+ connection.create_join_table :artists, :musics
+ connection.drop_join_table :artists, :musics
+
+ assert_not connection.table_exists?("artists_musics")
+ end
+
+ def test_drop_join_table_with_strings
+ connection.create_join_table :artists, :musics
+ connection.drop_join_table "artists", "musics"
+
+ assert_not connection.table_exists?("artists_musics")
+ end
+
+ def test_drop_join_table_with_the_proper_order
+ connection.create_join_table :videos, :musics
+ connection.drop_join_table :videos, :musics
+
+ assert_not connection.table_exists?("musics_videos")
+ end
+
+ def test_drop_join_table_with_the_table_name
+ connection.create_join_table :artists, :musics, table_name: :catalog
+ connection.drop_join_table :artists, :musics, table_name: :catalog
+
+ assert_not connection.table_exists?("catalog")
+ end
+
+ def test_drop_join_table_with_the_table_name_as_string
+ connection.create_join_table :artists, :musics, table_name: "catalog"
+ connection.drop_join_table :artists, :musics, table_name: "catalog"
+
+ assert_not connection.table_exists?("catalog")
+ end
+
+ def test_drop_join_table_with_column_options
+ connection.create_join_table :artists, :musics, column_options: { null: true }
+ connection.drop_join_table :artists, :musics, column_options: { null: true }
+
+ assert_not connection.table_exists?("artists_musics")
+ end
+
+ def test_create_and_drop_join_table_with_common_prefix
+ with_table_cleanup do
+ connection.create_join_table "audio_artists", "audio_musics"
+ assert connection.table_exists?("audio_artists_musics")
+
+ connection.drop_join_table "audio_artists", "audio_musics"
+ assert_not connection.table_exists?("audio_artists_musics"), "Should have dropped join table, but didn't"
+ end
+ end
+
+ if current_adapter?(:PostgreSQLAdapter)
+ def test_create_join_table_with_uuid
+ connection.create_join_table :artists, :musics, column_options: { type: :uuid }
+ assert_equal [:uuid, :uuid], connection.columns(:artists_musics).map(&:type)
+ end
+ end
+
+ private
+
+ def with_table_cleanup
+ tables_before = connection.data_sources
+
+ yield
+ ensure
+ tables_after = connection.data_sources - tables_before
+
+ tables_after.each do |table|
+ connection.drop_table table
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration/foreign_key_test.rb b/activerecord/test/cases/migration/foreign_key_test.rb
new file mode 100644
index 0000000000..bb233fbf74
--- /dev/null
+++ b/activerecord/test/cases/migration/foreign_key_test.rb
@@ -0,0 +1,497 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+if ActiveRecord::Base.connection.supports_foreign_keys_in_create?
+ module ActiveRecord
+ class Migration
+ class ForeignKeyInCreateTest < ActiveRecord::TestCase
+ def test_foreign_keys
+ foreign_keys = ActiveRecord::Base.connection.foreign_keys("fk_test_has_fk")
+ assert_equal 1, foreign_keys.size
+
+ fk = foreign_keys.first
+ assert_equal "fk_test_has_fk", fk.from_table
+ assert_equal "fk_test_has_pk", fk.to_table
+ assert_equal "fk_id", fk.column
+ assert_equal "pk_id", fk.primary_key
+ assert_equal "fk_name", fk.name unless current_adapter?(:SQLite3Adapter)
+ end
+ end
+
+ class ForeignKeyChangeColumnTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ class Rocket < ActiveRecord::Base
+ has_many :astronauts
+ end
+
+ class Astronaut < ActiveRecord::Base
+ belongs_to :rocket
+ end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table "rockets", force: true do |t|
+ t.string :name
+ end
+
+ @connection.create_table "astronauts", force: true do |t|
+ t.string :name
+ t.references :rocket, foreign_key: true
+ end
+ Rocket.reset_column_information
+ Astronaut.reset_column_information
+ end
+
+ teardown do
+ @connection.drop_table "astronauts", if_exists: true
+ @connection.drop_table "rockets", if_exists: true
+ Rocket.reset_column_information
+ Astronaut.reset_column_information
+ end
+
+ def test_change_column_of_parent_table
+ rocket = Rocket.create!(name: "myrocket")
+ rocket.astronauts << Astronaut.create!
+
+ @connection.change_column_null :rockets, :name, false
+
+ foreign_keys = @connection.foreign_keys("astronauts")
+ assert_equal 1, foreign_keys.size
+
+ fk = foreign_keys.first
+ assert_equal "myrocket", Rocket.first.name
+ assert_equal "astronauts", fk.from_table
+ assert_equal "rockets", fk.to_table
+ end
+
+ def test_rename_column_of_child_table
+ rocket = Rocket.create!(name: "myrocket")
+ rocket.astronauts << Astronaut.create!
+
+ @connection.rename_column :astronauts, :name, :astronaut_name
+
+ foreign_keys = @connection.foreign_keys("astronauts")
+ assert_equal 1, foreign_keys.size
+
+ fk = foreign_keys.first
+ assert_equal "myrocket", Rocket.first.name
+ assert_equal "astronauts", fk.from_table
+ assert_equal "rockets", fk.to_table
+ end
+
+ def test_rename_reference_column_of_child_table
+ rocket = Rocket.create!(name: "myrocket")
+ rocket.astronauts << Astronaut.create!
+
+ @connection.rename_column :astronauts, :rocket_id, :new_rocket_id
+
+ foreign_keys = @connection.foreign_keys("astronauts")
+ assert_equal 1, foreign_keys.size
+
+ fk = foreign_keys.first
+ assert_equal "myrocket", Rocket.first.name
+ assert_equal "astronauts", fk.from_table
+ assert_equal "rockets", fk.to_table
+ assert_equal "new_rocket_id", fk.options[:column]
+ end
+ end
+ end
+ end
+end
+
+if ActiveRecord::Base.connection.supports_foreign_keys?
+ module ActiveRecord
+ class Migration
+ class ForeignKeyTest < ActiveRecord::TestCase
+ include SchemaDumpingHelper
+ include ActiveSupport::Testing::Stream
+
+ class Rocket < ActiveRecord::Base
+ end
+
+ class Astronaut < ActiveRecord::Base
+ end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table "rockets", force: true do |t|
+ t.string :name
+ end
+
+ @connection.create_table "astronauts", force: true do |t|
+ t.string :name
+ t.references :rocket
+ end
+ end
+
+ teardown do
+ @connection.drop_table "astronauts", if_exists: true
+ @connection.drop_table "rockets", if_exists: true
+ end
+
+ def test_foreign_keys
+ foreign_keys = @connection.foreign_keys("fk_test_has_fk")
+ assert_equal 1, foreign_keys.size
+
+ fk = foreign_keys.first
+ assert_equal "fk_test_has_fk", fk.from_table
+ assert_equal "fk_test_has_pk", fk.to_table
+ assert_equal "fk_id", fk.column
+ assert_equal "pk_id", fk.primary_key
+ assert_equal "fk_name", fk.name
+ end
+
+ def test_add_foreign_key_inferes_column
+ @connection.add_foreign_key :astronauts, :rockets
+
+ foreign_keys = @connection.foreign_keys("astronauts")
+ assert_equal 1, foreign_keys.size
+
+ fk = foreign_keys.first
+ assert_equal "astronauts", fk.from_table
+ assert_equal "rockets", fk.to_table
+ assert_equal "rocket_id", fk.column
+ assert_equal "id", fk.primary_key
+ assert_equal("fk_rails_78146ddd2e", fk.name)
+ end
+
+ def test_add_foreign_key_with_column
+ @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id"
+
+ foreign_keys = @connection.foreign_keys("astronauts")
+ assert_equal 1, foreign_keys.size
+
+ fk = foreign_keys.first
+ assert_equal "astronauts", fk.from_table
+ assert_equal "rockets", fk.to_table
+ assert_equal "rocket_id", fk.column
+ assert_equal "id", fk.primary_key
+ assert_equal("fk_rails_78146ddd2e", fk.name)
+ end
+
+ def test_add_foreign_key_with_non_standard_primary_key
+ @connection.create_table :space_shuttles, id: false, force: true do |t|
+ t.bigint :pk, primary_key: true
+ end
+
+ @connection.add_foreign_key(:astronauts, :space_shuttles,
+ column: "rocket_id", primary_key: "pk", name: "custom_pk")
+
+ foreign_keys = @connection.foreign_keys("astronauts")
+ assert_equal 1, foreign_keys.size
+
+ fk = foreign_keys.first
+ assert_equal "astronauts", fk.from_table
+ assert_equal "space_shuttles", fk.to_table
+ assert_equal "pk", fk.primary_key
+ ensure
+ @connection.remove_foreign_key :astronauts, name: "custom_pk"
+ @connection.drop_table :space_shuttles
+ end
+
+ def test_add_on_delete_restrict_foreign_key
+ @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", on_delete: :restrict
+
+ foreign_keys = @connection.foreign_keys("astronauts")
+ assert_equal 1, foreign_keys.size
+
+ fk = foreign_keys.first
+ if current_adapter?(:Mysql2Adapter)
+ # ON DELETE RESTRICT is the default on MySQL
+ assert_nil fk.on_delete
+ else
+ assert_equal :restrict, fk.on_delete
+ end
+ end
+
+ def test_add_on_delete_cascade_foreign_key
+ @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", on_delete: :cascade
+
+ foreign_keys = @connection.foreign_keys("astronauts")
+ assert_equal 1, foreign_keys.size
+
+ fk = foreign_keys.first
+ assert_equal :cascade, fk.on_delete
+ end
+
+ def test_add_on_delete_nullify_foreign_key
+ @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", on_delete: :nullify
+
+ foreign_keys = @connection.foreign_keys("astronauts")
+ assert_equal 1, foreign_keys.size
+
+ fk = foreign_keys.first
+ assert_equal :nullify, fk.on_delete
+ end
+
+ def test_on_update_and_on_delete_raises_with_invalid_values
+ assert_raises ArgumentError do
+ @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", on_delete: :invalid
+ end
+
+ assert_raises ArgumentError do
+ @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", on_update: :invalid
+ end
+ end
+
+ def test_add_foreign_key_with_on_update
+ @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", on_update: :nullify
+
+ foreign_keys = @connection.foreign_keys("astronauts")
+ assert_equal 1, foreign_keys.size
+
+ fk = foreign_keys.first
+ assert_equal :nullify, fk.on_update
+ end
+
+ def test_foreign_key_exists
+ @connection.add_foreign_key :astronauts, :rockets
+
+ assert @connection.foreign_key_exists?(:astronauts, :rockets)
+ assert_not @connection.foreign_key_exists?(:astronauts, :stars)
+ end
+
+ def test_foreign_key_exists_by_column
+ @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id"
+
+ assert @connection.foreign_key_exists?(:astronauts, column: "rocket_id")
+ assert_not @connection.foreign_key_exists?(:astronauts, column: "star_id")
+ end
+
+ def test_foreign_key_exists_by_name
+ @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", name: "fancy_named_fk"
+
+ assert @connection.foreign_key_exists?(:astronauts, name: "fancy_named_fk")
+ assert_not @connection.foreign_key_exists?(:astronauts, name: "other_fancy_named_fk")
+ end
+
+ def test_remove_foreign_key_inferes_column
+ @connection.add_foreign_key :astronauts, :rockets
+
+ assert_equal 1, @connection.foreign_keys("astronauts").size
+ @connection.remove_foreign_key :astronauts, :rockets
+ assert_equal [], @connection.foreign_keys("astronauts")
+ end
+
+ def test_remove_foreign_key_by_column
+ @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id"
+
+ assert_equal 1, @connection.foreign_keys("astronauts").size
+ @connection.remove_foreign_key :astronauts, column: "rocket_id"
+ assert_equal [], @connection.foreign_keys("astronauts")
+ end
+
+ def test_remove_foreign_key_by_symbol_column
+ @connection.add_foreign_key :astronauts, :rockets, column: :rocket_id
+
+ assert_equal 1, @connection.foreign_keys("astronauts").size
+ @connection.remove_foreign_key :astronauts, column: :rocket_id
+ assert_equal [], @connection.foreign_keys("astronauts")
+ end
+
+ def test_remove_foreign_key_by_name
+ @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", name: "fancy_named_fk"
+
+ assert_equal 1, @connection.foreign_keys("astronauts").size
+ @connection.remove_foreign_key :astronauts, name: "fancy_named_fk"
+ assert_equal [], @connection.foreign_keys("astronauts")
+ end
+
+ def test_remove_foreign_non_existing_foreign_key_raises
+ assert_raises ArgumentError do
+ @connection.remove_foreign_key :astronauts, :rockets
+ end
+ end
+
+ if ActiveRecord::Base.connection.supports_validate_constraints?
+ def test_add_invalid_foreign_key
+ @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", validate: false
+
+ foreign_keys = @connection.foreign_keys("astronauts")
+ assert_equal 1, foreign_keys.size
+
+ fk = foreign_keys.first
+ assert_not_predicate fk, :validated?
+ end
+
+ def test_validate_foreign_key_infers_column
+ @connection.add_foreign_key :astronauts, :rockets, validate: false
+ assert_not_predicate @connection.foreign_keys("astronauts").first, :validated?
+
+ @connection.validate_foreign_key :astronauts, :rockets
+ assert_predicate @connection.foreign_keys("astronauts").first, :validated?
+ end
+
+ def test_validate_foreign_key_by_column
+ @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", validate: false
+ assert_not_predicate @connection.foreign_keys("astronauts").first, :validated?
+
+ @connection.validate_foreign_key :astronauts, column: "rocket_id"
+ assert_predicate @connection.foreign_keys("astronauts").first, :validated?
+ end
+
+ def test_validate_foreign_key_by_symbol_column
+ @connection.add_foreign_key :astronauts, :rockets, column: :rocket_id, validate: false
+ assert_not_predicate @connection.foreign_keys("astronauts").first, :validated?
+
+ @connection.validate_foreign_key :astronauts, column: :rocket_id
+ assert_predicate @connection.foreign_keys("astronauts").first, :validated?
+ end
+
+ def test_validate_foreign_key_by_name
+ @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", name: "fancy_named_fk", validate: false
+ assert_not_predicate @connection.foreign_keys("astronauts").first, :validated?
+
+ @connection.validate_foreign_key :astronauts, name: "fancy_named_fk"
+ assert_predicate @connection.foreign_keys("astronauts").first, :validated?
+ end
+
+ def test_validate_foreign_non_existing_foreign_key_raises
+ assert_raises ArgumentError do
+ @connection.validate_foreign_key :astronauts, :rockets
+ end
+ end
+
+ def test_validate_constraint_by_name
+ @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", name: "fancy_named_fk", validate: false
+
+ @connection.validate_constraint :astronauts, "fancy_named_fk"
+ assert_predicate @connection.foreign_keys("astronauts").first, :validated?
+ end
+ else
+ # Foreign key should still be created, but should not be invalid
+ def test_add_invalid_foreign_key
+ @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", validate: false
+
+ foreign_keys = @connection.foreign_keys("astronauts")
+ assert_equal 1, foreign_keys.size
+
+ fk = foreign_keys.first
+ assert_predicate fk, :validated?
+ end
+ end
+
+ def test_schema_dumping
+ @connection.add_foreign_key :astronauts, :rockets
+ output = dump_table_schema "astronauts"
+ assert_match %r{\s+add_foreign_key "astronauts", "rockets"$}, output
+ end
+
+ def test_schema_dumping_with_options
+ output = dump_table_schema "fk_test_has_fk"
+ assert_match %r{\s+add_foreign_key "fk_test_has_fk", "fk_test_has_pk", column: "fk_id", primary_key: "pk_id", name: "fk_name"$}, output
+ end
+
+ def test_schema_dumping_with_custom_fk_ignore_pattern
+ original_pattern = ActiveRecord::SchemaDumper.fk_ignore_pattern
+ ActiveRecord::SchemaDumper.fk_ignore_pattern = /^ignored_/
+ @connection.add_foreign_key :astronauts, :rockets, name: :ignored_fk_astronauts_rockets
+
+ output = dump_table_schema "astronauts"
+ assert_match %r{\s+add_foreign_key "astronauts", "rockets"$}, output
+
+ ActiveRecord::SchemaDumper.fk_ignore_pattern = original_pattern
+ end
+
+ def test_schema_dumping_on_delete_and_on_update_options
+ @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", on_delete: :nullify, on_update: :cascade
+
+ output = dump_table_schema "astronauts"
+ assert_match %r{\s+add_foreign_key "astronauts",.+on_update: :cascade,.+on_delete: :nullify$}, output
+ end
+
+ class CreateCitiesAndHousesMigration < ActiveRecord::Migration::Current
+ def change
+ create_table("cities") { |t| }
+
+ create_table("houses") do |t|
+ t.references :city
+ end
+ add_foreign_key :houses, :cities, column: "city_id"
+
+ # remove and re-add to test that schema is updated and not accidentally cached
+ remove_foreign_key :houses, :cities
+ add_foreign_key :houses, :cities, column: "city_id", on_delete: :cascade
+ end
+ end
+
+ def test_add_foreign_key_is_reversible
+ migration = CreateCitiesAndHousesMigration.new
+ silence_stream($stdout) { migration.migrate(:up) }
+ assert_equal 1, @connection.foreign_keys("houses").size
+ ensure
+ silence_stream($stdout) { migration.migrate(:down) }
+ end
+
+ def test_foreign_key_constraint_is_not_cached_incorrectly
+ migration = CreateCitiesAndHousesMigration.new
+ silence_stream($stdout) { migration.migrate(:up) }
+ output = dump_table_schema "houses"
+ assert_match %r{\s+add_foreign_key "houses",.+on_delete: :cascade$}, output
+ ensure
+ silence_stream($stdout) { migration.migrate(:down) }
+ end
+
+ class CreateSchoolsAndClassesMigration < ActiveRecord::Migration::Current
+ def change
+ create_table(:schools)
+
+ create_table(:classes) do |t|
+ t.references :school
+ end
+ add_foreign_key :classes, :schools
+ end
+ end
+
+ def test_add_foreign_key_with_prefix
+ ActiveRecord::Base.table_name_prefix = "p_"
+ migration = CreateSchoolsAndClassesMigration.new
+ silence_stream($stdout) { migration.migrate(:up) }
+ assert_equal 1, @connection.foreign_keys("p_classes").size
+ ensure
+ silence_stream($stdout) { migration.migrate(:down) }
+ ActiveRecord::Base.table_name_prefix = nil
+ end
+
+ def test_add_foreign_key_with_suffix
+ ActiveRecord::Base.table_name_suffix = "_s"
+ migration = CreateSchoolsAndClassesMigration.new
+ silence_stream($stdout) { migration.migrate(:up) }
+ assert_equal 1, @connection.foreign_keys("classes_s").size
+ ensure
+ silence_stream($stdout) { migration.migrate(:down) }
+ ActiveRecord::Base.table_name_suffix = nil
+ end
+ end
+ end
+ end
+else
+ module ActiveRecord
+ class Migration
+ class NoForeignKeySupportTest < ActiveRecord::TestCase
+ setup do
+ @connection = ActiveRecord::Base.connection
+ end
+
+ def test_add_foreign_key_should_be_noop
+ @connection.add_foreign_key :clubs, :categories
+ end
+
+ def test_remove_foreign_key_should_be_noop
+ @connection.remove_foreign_key :clubs, :categories
+ end
+
+ unless current_adapter?(:SQLite3Adapter)
+ def test_foreign_keys_should_raise_not_implemented
+ assert_raises NotImplementedError do
+ @connection.foreign_keys("clubs")
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration/helper.rb b/activerecord/test/cases/migration/helper.rb
new file mode 100644
index 0000000000..c056199140
--- /dev/null
+++ b/activerecord/test/cases/migration/helper.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ class Migration
+ class << self; attr_accessor :message_count; end
+ self.message_count = 0
+
+ module TestHelper
+ attr_reader :connection, :table_name
+
+ CONNECTION_METHODS = %w[add_column remove_column rename_column add_index change_column rename_table column_exists? index_exists? add_reference add_belongs_to remove_reference remove_references remove_belongs_to]
+
+ class TestModel < ActiveRecord::Base
+ self.table_name = :test_models
+ end
+
+ def setup
+ super
+ @connection = ActiveRecord::Base.connection
+ connection.create_table :test_models do |t|
+ t.timestamps null: true
+ end
+
+ TestModel.reset_column_information
+ end
+
+ def teardown
+ super
+ TestModel.reset_table_name
+ TestModel.reset_sequence_name
+ connection.drop_table :test_models, if_exists: true
+ end
+
+ private
+
+ delegate(*CONNECTION_METHODS, to: :connection)
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration/index_test.rb b/activerecord/test/cases/migration/index_test.rb
new file mode 100644
index 0000000000..f8fecc83cd
--- /dev/null
+++ b/activerecord/test/cases/migration/index_test.rb
@@ -0,0 +1,219 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ class Migration
+ class IndexTest < ActiveRecord::TestCase
+ attr_reader :connection, :table_name
+
+ def setup
+ super
+ @connection = ActiveRecord::Base.connection
+ @table_name = :testings
+
+ connection.create_table table_name do |t|
+ t.column :foo, :string, limit: 100
+ t.column :bar, :string, limit: 100
+
+ t.string :first_name
+ t.string :last_name, limit: 100
+ t.string :key, limit: 100
+ t.boolean :administrator
+ end
+ end
+
+ teardown do
+ connection.drop_table :testings rescue nil
+ ActiveRecord::Base.primary_key_prefix_type = nil
+ end
+
+ def test_rename_index
+ # keep the names short to make Oracle and similar behave
+ connection.add_index(table_name, [:foo], name: "old_idx")
+ connection.rename_index(table_name, "old_idx", "new_idx")
+
+ assert_not connection.index_name_exists?(table_name, "old_idx")
+ assert connection.index_name_exists?(table_name, "new_idx")
+ end
+
+ def test_rename_index_too_long
+ too_long_index_name = good_index_name + "x"
+ # keep the names short to make Oracle and similar behave
+ connection.add_index(table_name, [:foo], name: "old_idx")
+ e = assert_raises(ArgumentError) {
+ connection.rename_index(table_name, "old_idx", too_long_index_name)
+ }
+ assert_match(/too long; the limit is #{connection.allowed_index_name_length} characters/, e.message)
+
+ assert connection.index_name_exists?(table_name, "old_idx")
+ end
+
+ def test_double_add_index
+ connection.add_index(table_name, [:foo], name: "some_idx")
+ assert_raises(ArgumentError) {
+ connection.add_index(table_name, [:foo], name: "some_idx")
+ }
+ end
+
+ def test_remove_nonexistent_index
+ assert_raise(ArgumentError) { connection.remove_index(table_name, "no_such_index") }
+ end
+
+ def test_add_index_works_with_long_index_names
+ connection.add_index(table_name, "foo", name: good_index_name)
+
+ assert connection.index_name_exists?(table_name, good_index_name)
+ connection.remove_index(table_name, name: good_index_name)
+ end
+
+ def test_add_index_does_not_accept_too_long_index_names
+ too_long_index_name = good_index_name + "x"
+
+ e = assert_raises(ArgumentError) {
+ connection.add_index(table_name, "foo", name: too_long_index_name)
+ }
+ assert_match(/too long; the limit is #{connection.allowed_index_name_length} characters/, e.message)
+
+ assert_not connection.index_name_exists?(table_name, too_long_index_name)
+ connection.add_index(table_name, "foo", name: good_index_name)
+ end
+
+ def test_internal_index_with_name_matching_database_limit
+ good_index_name = "x" * connection.index_name_length
+ connection.add_index(table_name, "foo", name: good_index_name, internal: true)
+
+ assert connection.index_name_exists?(table_name, good_index_name)
+ connection.remove_index(table_name, name: good_index_name)
+ end
+
+ def test_index_symbol_names
+ connection.add_index table_name, :foo, name: :symbol_index_name
+ assert connection.index_exists?(table_name, :foo, name: :symbol_index_name)
+
+ connection.remove_index table_name, name: :symbol_index_name
+ assert_not connection.index_exists?(table_name, :foo, name: :symbol_index_name)
+ end
+
+ def test_index_exists
+ connection.add_index :testings, :foo
+
+ assert connection.index_exists?(:testings, :foo)
+ assert_not connection.index_exists?(:testings, :bar)
+ end
+
+ def test_index_exists_on_multiple_columns
+ connection.add_index :testings, [:foo, :bar]
+
+ assert connection.index_exists?(:testings, [:foo, :bar])
+ end
+
+ def test_index_exists_with_custom_name_checks_columns
+ connection.add_index :testings, [:foo, :bar], name: "my_index"
+ assert connection.index_exists?(:testings, [:foo, :bar], name: "my_index")
+ assert_not connection.index_exists?(:testings, [:foo], name: "my_index")
+ end
+
+ def test_valid_index_options
+ assert_raise ArgumentError do
+ connection.add_index :testings, :foo, unqiue: true
+ end
+ end
+
+ def test_unique_index_exists
+ connection.add_index :testings, :foo, unique: true
+
+ assert connection.index_exists?(:testings, :foo, unique: true)
+ end
+
+ def test_named_index_exists
+ connection.add_index :testings, :foo, name: "custom_index_name"
+
+ assert connection.index_exists?(:testings, :foo)
+ assert connection.index_exists?(:testings, :foo, name: "custom_index_name")
+ assert_not connection.index_exists?(:testings, :foo, name: "other_index_name")
+ end
+
+ def test_remove_named_index
+ connection.add_index :testings, :foo, name: "index_testings_on_custom_index_name"
+
+ assert connection.index_exists?(:testings, :foo)
+
+ assert_raise(ArgumentError) { connection.remove_index(:testings, "custom_index_name") }
+
+ connection.remove_index :testings, :foo
+ assert_not connection.index_exists?(:testings, :foo)
+ end
+
+ def test_add_index_attribute_length_limit
+ connection.add_index :testings, [:foo, :bar], length: { foo: 10, bar: nil }
+
+ assert connection.index_exists?(:testings, [:foo, :bar])
+ end
+
+ def test_add_index
+ connection.add_index("testings", "last_name")
+ connection.remove_index("testings", "last_name")
+
+ connection.add_index("testings", ["last_name", "first_name"])
+ connection.remove_index("testings", column: ["last_name", "first_name"])
+
+ # Oracle adapter cannot have specified index name larger than 30 characters
+ # Oracle adapter is shortening index name when just column list is given
+ unless current_adapter?(:OracleAdapter)
+ connection.add_index("testings", ["last_name", "first_name"])
+ connection.remove_index("testings", name: :index_testings_on_last_name_and_first_name)
+ connection.add_index("testings", ["last_name", "first_name"])
+ connection.remove_index("testings", "last_name_and_first_name")
+ end
+ connection.add_index("testings", ["last_name", "first_name"])
+ connection.remove_index("testings", ["last_name", "first_name"])
+
+ connection.add_index("testings", ["last_name"], length: 10)
+ connection.remove_index("testings", "last_name")
+
+ connection.add_index("testings", ["last_name"], length: { last_name: 10 })
+ connection.remove_index("testings", ["last_name"])
+
+ connection.add_index("testings", ["last_name", "first_name"], length: 10)
+ connection.remove_index("testings", ["last_name", "first_name"])
+
+ connection.add_index("testings", ["last_name", "first_name"], length: { last_name: 10, first_name: 20 })
+ connection.remove_index("testings", ["last_name", "first_name"])
+
+ connection.add_index("testings", ["key"], name: "key_idx", unique: true)
+ connection.remove_index("testings", name: "key_idx", unique: true)
+
+ connection.add_index("testings", %w(last_name first_name administrator), name: "named_admin")
+ connection.remove_index("testings", name: "named_admin")
+
+ # Selected adapters support index sort order
+ if current_adapter?(:SQLite3Adapter, :Mysql2Adapter, :PostgreSQLAdapter)
+ connection.add_index("testings", ["last_name"], order: { last_name: :desc })
+ connection.remove_index("testings", ["last_name"])
+ connection.add_index("testings", ["last_name", "first_name"], order: { last_name: :desc })
+ connection.remove_index("testings", ["last_name", "first_name"])
+ connection.add_index("testings", ["last_name", "first_name"], order: { last_name: :desc, first_name: :asc })
+ connection.remove_index("testings", ["last_name", "first_name"])
+ connection.add_index("testings", ["last_name", "first_name"], order: :desc)
+ connection.remove_index("testings", ["last_name", "first_name"])
+ end
+ end
+
+ if current_adapter?(:PostgreSQLAdapter)
+ def test_add_partial_index
+ connection.add_index("testings", "last_name", where: "first_name = 'john doe'")
+ assert connection.index_exists?("testings", "last_name")
+
+ connection.remove_index("testings", "last_name")
+ assert_not connection.index_exists?("testings", "last_name")
+ end
+ end
+
+ private
+ def good_index_name
+ "x" * connection.allowed_index_name_length
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration/logger_test.rb b/activerecord/test/cases/migration/logger_test.rb
new file mode 100644
index 0000000000..28f4cc124b
--- /dev/null
+++ b/activerecord/test/cases/migration/logger_test.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ class Migration
+ class LoggerTest < ActiveRecord::TestCase
+ # MySQL can't roll back ddl changes
+ self.use_transactional_tests = false
+
+ Migration = Struct.new(:name, :version) do
+ def disable_ddl_transaction; false end
+ def migrate(direction)
+ # do nothing
+ end
+ end
+
+ def setup
+ super
+ ActiveRecord::SchemaMigration.create_table
+ ActiveRecord::SchemaMigration.delete_all
+ end
+
+ teardown do
+ ActiveRecord::SchemaMigration.drop_table
+ end
+
+ def test_migration_should_be_run_without_logger
+ previous_logger = ActiveRecord::Base.logger
+ ActiveRecord::Base.logger = nil
+ migrations = [Migration.new("a", 1), Migration.new("b", 2), Migration.new("c", 3)]
+ ActiveRecord::Migrator.new(:up, migrations).migrate
+ ensure
+ ActiveRecord::Base.logger = previous_logger
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration/pending_migrations_test.rb b/activerecord/test/cases/migration/pending_migrations_test.rb
new file mode 100644
index 0000000000..119bfd372a
--- /dev/null
+++ b/activerecord/test/cases/migration/pending_migrations_test.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ class Migration
+ if current_adapter?(:SQLite3Adapter) && !in_memory_db?
+ class PendingMigrationsTest < ActiveRecord::TestCase
+ setup do
+ file = ActiveRecord::Base.connection.raw_connection.filename
+ @conn = ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:", migrations_paths: MIGRATIONS_ROOT + "/valid"
+ source_db = SQLite3::Database.new file
+ dest_db = ActiveRecord::Base.connection.raw_connection
+ backup = SQLite3::Backup.new(dest_db, "main", source_db, "main")
+ backup.step(-1)
+ backup.finish
+ end
+
+ teardown do
+ @conn.release_connection if @conn
+ ActiveRecord::Base.establish_connection :arunit
+ end
+
+ def test_errors_if_pending
+ ActiveRecord::Base.connection.drop_table "schema_migrations", if_exists: true
+
+ assert_raises ActiveRecord::PendingMigrationError do
+ CheckPending.new(Proc.new { }).call({})
+ end
+ end
+
+ def test_checks_if_supported
+ ActiveRecord::SchemaMigration.create_table
+ migrator = Base.connection.migration_context
+ capture(:stdout) { migrator.migrate }
+
+ assert_nil CheckPending.new(Proc.new { }).call({})
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration/references_foreign_key_test.rb b/activerecord/test/cases/migration/references_foreign_key_test.rb
new file mode 100644
index 0000000000..620e9ab6ca
--- /dev/null
+++ b/activerecord/test/cases/migration/references_foreign_key_test.rb
@@ -0,0 +1,255 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+if ActiveRecord::Base.connection.supports_foreign_keys_in_create?
+ module ActiveRecord
+ class Migration
+ class ReferencesForeignKeyInCreateTest < ActiveRecord::TestCase
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table(:testing_parents, force: true)
+ end
+
+ teardown do
+ @connection.drop_table "testings", if_exists: true
+ @connection.drop_table "testing_parents", if_exists: true
+ end
+
+ test "foreign keys can be created with the table" do
+ @connection.create_table :testings do |t|
+ t.references :testing_parent, foreign_key: true
+ end
+
+ fk = @connection.foreign_keys("testings").first
+ assert_equal "testings", fk.from_table
+ assert_equal "testing_parents", fk.to_table
+ end
+
+ test "no foreign key is created by default" do
+ @connection.create_table :testings do |t|
+ t.references :testing_parent
+ end
+
+ assert_equal [], @connection.foreign_keys("testings")
+ end
+
+ test "foreign keys can be created in one query when index is not added" do
+ assert_queries(1) do
+ @connection.create_table :testings do |t|
+ t.references :testing_parent, foreign_key: true, index: false
+ end
+ end
+ end
+
+ test "options hash can be passed" do
+ @connection.change_table :testing_parents do |t|
+ t.references :other, index: { unique: true }
+ end
+ @connection.create_table :testings do |t|
+ t.references :testing_parent, foreign_key: { primary_key: :other_id }
+ end
+
+ fk = @connection.foreign_keys("testings").find { |k| k.to_table == "testing_parents" }
+ assert_equal "other_id", fk.primary_key
+ end
+
+ test "to_table option can be passed" do
+ @connection.create_table :testings do |t|
+ t.references :parent, foreign_key: { to_table: :testing_parents }
+ end
+ fks = @connection.foreign_keys("testings")
+ assert_equal([["testings", "testing_parents", "parent_id"]],
+ fks.map { |fk| [fk.from_table, fk.to_table, fk.column] })
+ end
+ end
+ end
+ end
+end
+
+if ActiveRecord::Base.connection.supports_foreign_keys?
+ module ActiveRecord
+ class Migration
+ class ReferencesForeignKeyTest < ActiveRecord::TestCase
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table(:testing_parents, force: true)
+ end
+
+ teardown do
+ @connection.drop_table "testings", if_exists: true
+ @connection.drop_table "testing_parents", if_exists: true
+ end
+
+ test "foreign keys cannot be added to polymorphic relations when creating the table" do
+ @connection.create_table :testings do |t|
+ assert_raises(ArgumentError) do
+ t.references :testing_parent, polymorphic: true, foreign_key: true
+ end
+ end
+ end
+
+ test "foreign keys can be created while changing the table" do
+ @connection.create_table :testings
+ @connection.change_table :testings do |t|
+ t.references :testing_parent, foreign_key: true
+ end
+
+ fk = @connection.foreign_keys("testings").first
+ assert_equal "testings", fk.from_table
+ assert_equal "testing_parents", fk.to_table
+ end
+
+ test "foreign keys are not added by default when changing the table" do
+ @connection.create_table :testings
+ @connection.change_table :testings do |t|
+ t.references :testing_parent
+ end
+
+ assert_equal [], @connection.foreign_keys("testings")
+ end
+
+ test "foreign keys accept options when changing the table" do
+ @connection.change_table :testing_parents do |t|
+ t.references :other, index: { unique: true }
+ end
+ @connection.create_table :testings
+ @connection.change_table :testings do |t|
+ t.references :testing_parent, foreign_key: { primary_key: :other_id }
+ end
+
+ fk = @connection.foreign_keys("testings").find { |k| k.to_table == "testing_parents" }
+ assert_equal "other_id", fk.primary_key
+ end
+
+ test "foreign keys cannot be added to polymorphic relations when changing the table" do
+ @connection.create_table :testings
+ @connection.change_table :testings do |t|
+ assert_raises(ArgumentError) do
+ t.references :testing_parent, polymorphic: true, foreign_key: true
+ end
+ end
+ end
+
+ test "foreign key column can be removed" do
+ @connection.create_table :testings do |t|
+ t.references :testing_parent, index: true, foreign_key: true
+ end
+
+ assert_difference "@connection.foreign_keys('testings').size", -1 do
+ @connection.remove_reference :testings, :testing_parent, foreign_key: true
+ end
+ end
+
+ test "removing column removes foreign key" do
+ @connection.create_table :testings do |t|
+ t.references :testing_parent, index: true, foreign_key: true
+ end
+
+ assert_difference "@connection.foreign_keys('testings').size", -1 do
+ @connection.remove_column :testings, :testing_parent_id
+ end
+ end
+
+ test "foreign key methods respect pluralize_table_names" do
+ original_pluralize_table_names = ActiveRecord::Base.pluralize_table_names
+ ActiveRecord::Base.pluralize_table_names = false
+ @connection.create_table :testing
+ @connection.change_table :testing_parents do |t|
+ t.references :testing, foreign_key: true
+ end
+
+ fk = @connection.foreign_keys("testing_parents").first
+ assert_equal "testing_parents", fk.from_table
+ assert_equal "testing", fk.to_table
+
+ assert_difference "@connection.foreign_keys('testing_parents').size", -1 do
+ @connection.remove_reference :testing_parents, :testing, foreign_key: true
+ end
+ ensure
+ ActiveRecord::Base.pluralize_table_names = original_pluralize_table_names
+ @connection.drop_table "testing", if_exists: true
+ end
+
+ class CreateDogsMigration < ActiveRecord::Migration::Current
+ def change
+ create_table :dog_owners
+
+ create_table :dogs do |t|
+ t.references :dog_owner, foreign_key: true
+ end
+ end
+ end
+
+ def test_references_foreign_key_with_prefix
+ ActiveRecord::Base.table_name_prefix = "p_"
+ migration = CreateDogsMigration.new
+ silence_stream($stdout) { migration.migrate(:up) }
+ assert_equal 1, @connection.foreign_keys("p_dogs").size
+ ensure
+ silence_stream($stdout) { migration.migrate(:down) }
+ ActiveRecord::Base.table_name_prefix = nil
+ end
+
+ def test_references_foreign_key_with_suffix
+ ActiveRecord::Base.table_name_suffix = "_s"
+ migration = CreateDogsMigration.new
+ silence_stream($stdout) { migration.migrate(:up) }
+ assert_equal 1, @connection.foreign_keys("dogs_s").size
+ ensure
+ silence_stream($stdout) { migration.migrate(:down) }
+ ActiveRecord::Base.table_name_suffix = nil
+ end
+
+ test "multiple foreign keys can be added to the same table" do
+ @connection.create_table :testings do |t|
+ t.references :parent1, foreign_key: { to_table: :testing_parents }
+ t.references :parent2, foreign_key: { to_table: :testing_parents }
+ end
+
+ fks = @connection.foreign_keys("testings").sort_by(&:column)
+
+ fk_definitions = fks.map { |fk| [fk.from_table, fk.to_table, fk.column] }
+ assert_equal([["testings", "testing_parents", "parent1_id"],
+ ["testings", "testing_parents", "parent2_id"]], fk_definitions)
+ end
+
+ test "multiple foreign keys can be removed to the selected one" do
+ @connection.create_table :testings do |t|
+ t.references :parent1, foreign_key: { to_table: :testing_parents }
+ t.references :parent2, foreign_key: { to_table: :testing_parents }
+ end
+
+ assert_difference "@connection.foreign_keys('testings').size", -1 do
+ @connection.remove_reference :testings, :parent1, foreign_key: { to_table: :testing_parents }
+ end
+
+ fks = @connection.foreign_keys("testings").sort_by(&:column)
+
+ fk_definitions = fks.map { |fk| [fk.from_table, fk.to_table, fk.column] }
+ assert_equal([["testings", "testing_parents", "parent2_id"]], fk_definitions)
+ end
+ end
+ end
+ end
+else
+ class ReferencesWithoutForeignKeySupportTest < ActiveRecord::TestCase
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table(:testing_parents, force: true)
+ end
+
+ teardown do
+ @connection.drop_table("testings", if_exists: true)
+ @connection.drop_table("testing_parents", if_exists: true)
+ end
+
+ test "ignores foreign keys defined with the table" do
+ @connection.create_table :testings do |t|
+ t.references :testing_parent, foreign_key: true
+ end
+
+ assert_includes @connection.data_sources, "testings"
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration/references_index_test.rb b/activerecord/test/cases/migration/references_index_test.rb
new file mode 100644
index 0000000000..e41377d817
--- /dev/null
+++ b/activerecord/test/cases/migration/references_index_test.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ class Migration
+ class ReferencesIndexTest < ActiveRecord::TestCase
+ attr_reader :connection, :table_name
+
+ def setup
+ super
+ @connection = ActiveRecord::Base.connection
+ @table_name = :testings
+ end
+
+ teardown do
+ connection.drop_table :testings rescue nil
+ end
+
+ def test_creates_index
+ connection.create_table table_name do |t|
+ t.references :foo, index: true
+ end
+
+ assert connection.index_exists?(table_name, :foo_id, name: :index_testings_on_foo_id)
+ end
+
+ def test_creates_index_by_default_even_if_index_option_is_not_passed
+ connection.create_table table_name do |t|
+ t.references :foo
+ end
+
+ assert connection.index_exists?(table_name, :foo_id, name: :index_testings_on_foo_id)
+ end
+
+ def test_does_not_create_index_explicit
+ connection.create_table table_name do |t|
+ t.references :foo, index: false
+ end
+
+ assert_not connection.index_exists?(table_name, :foo_id, name: :index_testings_on_foo_id)
+ end
+
+ def test_creates_index_with_options
+ connection.create_table table_name do |t|
+ t.references :foo, index: { name: :index_testings_on_yo_momma }
+ t.references :bar, index: { unique: true }
+ end
+
+ assert connection.index_exists?(table_name, :foo_id, name: :index_testings_on_yo_momma)
+ assert connection.index_exists?(table_name, :bar_id, name: :index_testings_on_bar_id, unique: true)
+ end
+
+ unless current_adapter? :OracleAdapter
+ def test_creates_polymorphic_index
+ connection.create_table table_name do |t|
+ t.references :foo, polymorphic: true, index: true
+ end
+
+ assert connection.index_exists?(table_name, [:foo_type, :foo_id], name: :index_testings_on_foo_type_and_foo_id)
+ end
+ end
+
+ def test_creates_index_for_existing_table
+ connection.create_table table_name
+ connection.change_table table_name do |t|
+ t.references :foo, index: true
+ end
+
+ assert connection.index_exists?(table_name, :foo_id, name: :index_testings_on_foo_id)
+ end
+
+ def test_creates_index_for_existing_table_even_if_index_option_is_not_passed
+ connection.create_table table_name
+ connection.change_table table_name do |t|
+ t.references :foo
+ end
+
+ assert connection.index_exists?(table_name, :foo_id, name: :index_testings_on_foo_id)
+ end
+
+ def test_does_not_create_index_for_existing_table_explicit
+ connection.create_table table_name
+ connection.change_table table_name do |t|
+ t.references :foo, index: false
+ end
+
+ assert_not connection.index_exists?(table_name, :foo_id, name: :index_testings_on_foo_id)
+ end
+
+ unless current_adapter? :OracleAdapter
+ def test_creates_polymorphic_index_for_existing_table
+ connection.create_table table_name
+ connection.change_table table_name do |t|
+ t.references :foo, polymorphic: true, index: true
+ end
+
+ assert connection.index_exists?(table_name, [:foo_type, :foo_id], name: :index_testings_on_foo_type_and_foo_id)
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration/references_statements_test.rb b/activerecord/test/cases/migration/references_statements_test.rb
new file mode 100644
index 0000000000..769241ba12
--- /dev/null
+++ b/activerecord/test/cases/migration/references_statements_test.rb
@@ -0,0 +1,138 @@
+# frozen_string_literal: true
+
+require "cases/migration/helper"
+
+module ActiveRecord
+ class Migration
+ class ReferencesStatementsTest < ActiveRecord::TestCase
+ include ActiveRecord::Migration::TestHelper
+
+ self.use_transactional_tests = false
+
+ def setup
+ super
+ @table_name = :test_models
+
+ add_column table_name, :supplier_id, :integer
+ add_index table_name, :supplier_id
+ end
+
+ def test_creates_reference_id_column
+ add_reference table_name, :user
+ assert column_exists?(table_name, :user_id, :integer)
+ end
+
+ def test_does_not_create_reference_type_column
+ add_reference table_name, :taggable
+ assert_not column_exists?(table_name, :taggable_type, :string)
+ end
+
+ def test_creates_reference_type_column
+ add_reference table_name, :taggable, polymorphic: true
+ assert column_exists?(table_name, :taggable_type, :string)
+ end
+
+ def test_does_not_create_reference_id_index_if_index_is_false
+ add_reference table_name, :user, index: false
+ assert_not index_exists?(table_name, :user_id)
+ end
+
+ def test_create_reference_id_index_even_if_index_option_is_not_passed
+ add_reference table_name, :user
+ assert index_exists?(table_name, :user_id)
+ end
+
+ def test_creates_polymorphic_index
+ add_reference table_name, :taggable, polymorphic: true, index: true
+ assert index_exists?(table_name, [:taggable_type, :taggable_id])
+ end
+
+ def test_creates_reference_type_column_with_default
+ add_reference table_name, :taggable, polymorphic: { default: "Photo" }, index: true
+ assert column_exists?(table_name, :taggable_type, :string, default: "Photo")
+ end
+
+ def test_creates_reference_type_column_with_not_null
+ connection.create_table table_name, force: true do |t|
+ t.references :taggable, null: false, polymorphic: true
+ end
+ assert column_exists?(table_name, :taggable_id, :integer, null: false)
+ assert column_exists?(table_name, :taggable_type, :string, null: false)
+ end
+
+ def test_does_not_share_options_with_reference_type_column
+ add_reference table_name, :taggable, type: :integer, limit: 2, polymorphic: true
+ assert column_exists?(table_name, :taggable_id, :integer, limit: 2)
+ assert column_exists?(table_name, :taggable_type, :string)
+ assert_not column_exists?(table_name, :taggable_type, :string, limit: 2)
+ end
+
+ def test_creates_named_index
+ add_reference table_name, :tag, index: { name: "index_taggings_on_tag_id" }
+ assert index_exists?(table_name, :tag_id, name: "index_taggings_on_tag_id")
+ end
+
+ def test_creates_named_unique_index
+ add_reference table_name, :tag, index: { name: "index_taggings_on_tag_id", unique: true }
+ assert index_exists?(table_name, :tag_id, name: "index_taggings_on_tag_id", unique: true)
+ end
+
+ def test_creates_reference_id_with_specified_type
+ add_reference table_name, :user, type: :string
+ assert column_exists?(table_name, :user_id, :string)
+ end
+
+ def test_deletes_reference_id_column
+ remove_reference table_name, :supplier
+ assert_not column_exists?(table_name, :supplier_id, :integer)
+ end
+
+ def test_deletes_reference_id_index
+ remove_reference table_name, :supplier
+ assert_not index_exists?(table_name, :supplier_id)
+ end
+
+ def test_does_not_delete_reference_type_column
+ with_polymorphic_column do
+ remove_reference table_name, :supplier
+
+ assert_not column_exists?(table_name, :supplier_id, :integer)
+ assert column_exists?(table_name, :supplier_type, :string)
+ end
+ end
+
+ def test_deletes_reference_type_column
+ with_polymorphic_column do
+ remove_reference table_name, :supplier, polymorphic: true
+ assert_not column_exists?(table_name, :supplier_type, :string)
+ end
+ end
+
+ def test_deletes_polymorphic_index
+ with_polymorphic_column do
+ remove_reference table_name, :supplier, polymorphic: true
+ assert_not index_exists?(table_name, [:supplier_id, :supplier_type])
+ end
+ end
+
+ def test_add_belongs_to_alias
+ add_belongs_to table_name, :user
+ assert column_exists?(table_name, :user_id, :integer)
+ end
+
+ def test_remove_belongs_to_alias
+ remove_belongs_to table_name, :supplier
+ assert_not column_exists?(table_name, :supplier_id, :integer)
+ end
+
+ private
+
+ def with_polymorphic_column
+ add_column table_name, :supplier_type, :string
+ add_index table_name, [:supplier_id, :supplier_type]
+
+ yield
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration/rename_table_test.rb b/activerecord/test/cases/migration/rename_table_test.rb
new file mode 100644
index 0000000000..a9deb92585
--- /dev/null
+++ b/activerecord/test/cases/migration/rename_table_test.rb
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+require "cases/migration/helper"
+
+module ActiveRecord
+ class Migration
+ class RenameTableTest < ActiveRecord::TestCase
+ include ActiveRecord::Migration::TestHelper
+
+ self.use_transactional_tests = false
+
+ def setup
+ super
+ add_column "test_models", :url, :string
+ remove_column "test_models", :created_at
+ remove_column "test_models", :updated_at
+ end
+
+ def teardown
+ rename_table :octopi, :test_models if connection.table_exists? :octopi
+ super
+ end
+
+ if current_adapter?(:SQLite3Adapter)
+ def test_rename_table_for_sqlite_should_work_with_reserved_words
+ renamed = false
+
+ add_column :test_models, :url, :string
+ connection.rename_table :references, :old_references
+ connection.rename_table :test_models, :references
+
+ renamed = true
+
+ # Using explicit id in insert for compatibility across all databases
+ connection.execute "INSERT INTO 'references' (url, created_at, updated_at) VALUES ('http://rubyonrails.com', 0, 0)"
+ assert_equal "http://rubyonrails.com", connection.select_value("SELECT url FROM 'references' WHERE id=1")
+ ensure
+ return unless renamed
+ connection.rename_table :references, :test_models
+ connection.rename_table :old_references, :references
+ end
+ end
+
+ unless current_adapter?(:FbAdapter) # Firebird cannot rename tables
+ def test_rename_table
+ rename_table :test_models, :octopi
+
+ connection.execute "INSERT INTO octopi (#{connection.quote_column_name('id')}, #{connection.quote_column_name('url')}) VALUES (1, 'http://www.foreverflying.com/octopus-black7.jpg')"
+
+ assert_equal "http://www.foreverflying.com/octopus-black7.jpg", connection.select_value("SELECT url FROM octopi WHERE id=1")
+ end
+
+ def test_rename_table_with_an_index
+ add_index :test_models, :url
+
+ rename_table :test_models, :octopi
+
+ connection.execute "INSERT INTO octopi (#{connection.quote_column_name('id')}, #{connection.quote_column_name('url')}) VALUES (1, 'http://www.foreverflying.com/octopus-black7.jpg')"
+
+ assert_equal "http://www.foreverflying.com/octopus-black7.jpg", connection.select_value("SELECT url FROM octopi WHERE id=1")
+ index = connection.indexes(:octopi).first
+ assert_includes index.columns, "url"
+ assert_equal "index_octopi_on_url", index.name
+ end
+
+ def test_rename_table_does_not_rename_custom_named_index
+ add_index :test_models, :url, name: "special_url_idx"
+
+ rename_table :test_models, :octopi
+
+ assert_equal ["special_url_idx"], connection.indexes(:octopi).map(&:name)
+ end
+ end
+
+ if current_adapter?(:PostgreSQLAdapter)
+ def test_rename_table_for_postgresql_should_also_rename_default_sequence
+ rename_table :test_models, :octopi
+
+ pk, seq = connection.pk_and_sequence_for("octopi")
+
+ assert_equal ConnectionAdapters::PostgreSQL::Name.new("public", "octopi_#{pk}_seq"), seq
+ end
+
+ def test_renaming_table_renames_primary_key
+ connection.create_table :cats, id: :uuid, default: "uuid_generate_v4()"
+ rename_table :cats, :felines
+
+ assert connection.table_exists? :felines
+ assert_not connection.table_exists? :cats
+
+ primary_key_name = connection.select_values(<<~SQL, "SCHEMA")[0]
+ SELECT c.relname
+ FROM pg_class c
+ JOIN pg_index i
+ ON c.oid = i.indexrelid
+ WHERE i.indisprimary
+ AND i.indrelid = 'felines'::regclass
+ SQL
+
+ assert_equal "felines_pkey", primary_key_name
+ ensure
+ connection.drop_table :cats, if_exists: true
+ connection.drop_table :felines, if_exists: true
+ end
+
+ def test_renaming_table_doesnt_attempt_to_rename_non_existent_sequences
+ connection.create_table :cats, id: :uuid, default: "uuid_generate_v4()"
+ assert_nothing_raised { rename_table :cats, :felines }
+ assert connection.table_exists? :felines
+ assert_not connection.table_exists? :cats
+ ensure
+ connection.drop_table :cats, if_exists: true
+ connection.drop_table :felines, if_exists: true
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration_test.rb b/activerecord/test/cases/migration_test.rb
new file mode 100644
index 0000000000..8b0ecd2516
--- /dev/null
+++ b/activerecord/test/cases/migration_test.rb
@@ -0,0 +1,1180 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "cases/migration/helper"
+require "bigdecimal/util"
+require "concurrent/atomic/count_down_latch"
+
+require "models/person"
+require "models/topic"
+require "models/developer"
+require "models/computer"
+
+require MIGRATIONS_ROOT + "/valid/2_we_need_reminders"
+require MIGRATIONS_ROOT + "/rename/1_we_need_things"
+require MIGRATIONS_ROOT + "/rename/2_rename_things"
+require MIGRATIONS_ROOT + "/decimal/1_give_me_big_numbers"
+
+class BigNumber < ActiveRecord::Base
+ unless current_adapter?(:PostgreSQLAdapter, :SQLite3Adapter)
+ attribute :value_of_e, :integer
+ end
+ attribute :my_house_population, :integer
+end
+
+class Reminder < ActiveRecord::Base; end
+
+class Thing < ActiveRecord::Base; end
+
+class MigrationTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ fixtures :people
+
+ def setup
+ super
+ %w(reminders people_reminders prefix_reminders_suffix p_things_s).each do |table|
+ Reminder.connection.drop_table(table) rescue nil
+ end
+ Reminder.reset_column_information
+ @verbose_was, ActiveRecord::Migration.verbose = ActiveRecord::Migration.verbose, false
+ ActiveRecord::Base.connection.schema_cache.clear!
+ end
+
+ teardown do
+ ActiveRecord::Base.table_name_prefix = ""
+ ActiveRecord::Base.table_name_suffix = ""
+
+ ActiveRecord::SchemaMigration.create_table
+ ActiveRecord::SchemaMigration.delete_all
+
+ %w(things awesome_things prefix_things_suffix p_awesome_things_s).each do |table|
+ Thing.connection.drop_table(table) rescue nil
+ end
+ Thing.reset_column_information
+
+ %w(reminders people_reminders prefix_reminders_suffix).each do |table|
+ Reminder.connection.drop_table(table) rescue nil
+ end
+ Reminder.reset_table_name
+ Reminder.reset_column_information
+
+ %w(last_name key bio age height wealth birthday favorite_day
+ moment_of_truth male administrator funny).each do |column|
+ Person.connection.remove_column("people", column) rescue nil
+ end
+ Person.connection.remove_column("people", "first_name") rescue nil
+ Person.connection.remove_column("people", "middle_name") rescue nil
+ Person.connection.add_column("people", "first_name", :string)
+ Person.reset_column_information
+
+ ActiveRecord::Migration.verbose = @verbose_was
+ end
+
+ def test_migrator_migrations_path_is_deprecated
+ assert_deprecated do
+ ActiveRecord::Migrator.migrations_path = "/whatever"
+ end
+ ensure
+ assert_deprecated do
+ ActiveRecord::Migrator.migrations_path = "db/migrate"
+ end
+ end
+
+ def test_migration_version_matches_component_version
+ assert_equal ActiveRecord::VERSION::STRING.to_f, ActiveRecord::Migration.current_version
+ end
+
+ def test_migrator_versions
+ migrations_path = MIGRATIONS_ROOT + "/valid"
+ migrator = ActiveRecord::MigrationContext.new(migrations_path)
+
+ migrator.up
+ assert_equal 3, migrator.current_version
+ assert_equal false, migrator.needs_migration?
+
+ migrator.down
+ assert_equal 0, migrator.current_version
+ assert_equal true, migrator.needs_migration?
+
+ ActiveRecord::SchemaMigration.create!(version: 3)
+ assert_equal true, migrator.needs_migration?
+ end
+
+ def test_migration_detection_without_schema_migration_table
+ ActiveRecord::Base.connection.drop_table "schema_migrations", if_exists: true
+
+ migrations_path = MIGRATIONS_ROOT + "/valid"
+ migrator = ActiveRecord::MigrationContext.new(migrations_path)
+
+ assert_equal true, migrator.needs_migration?
+ end
+
+ def test_any_migrations
+ migrator = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/valid")
+
+ assert_predicate migrator, :any_migrations?
+
+ migrator_empty = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/empty")
+
+ assert_not_predicate migrator_empty, :any_migrations?
+ end
+
+ def test_migration_version
+ migrator = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/version_check")
+ assert_equal 0, migrator.current_version
+ migrator.up(20131219224947)
+ assert_equal 20131219224947, migrator.current_version
+ end
+
+ def test_create_table_raises_if_already_exists
+ connection = Person.connection
+ connection.create_table :testings, force: true do |t|
+ t.string :foo
+ end
+
+ assert_raise(ActiveRecord::StatementInvalid) do
+ connection.create_table :testings do |t|
+ t.string :foo
+ end
+ end
+ ensure
+ connection.drop_table :testings, if_exists: true
+ end
+
+ def test_create_table_with_if_not_exists_true
+ connection = Person.connection
+ connection.create_table :testings, force: true do |t|
+ t.string :foo
+ end
+
+ assert_nothing_raised do
+ connection.create_table :testings, if_not_exists: true do |t|
+ t.string :foo
+ end
+ end
+ ensure
+ connection.drop_table :testings, if_exists: true
+ end
+
+ def test_create_table_with_force_true_does_not_drop_nonexisting_table
+ # using a copy as we need the drop_table method to
+ # continue to work for the ensure block of the test
+ temp_conn = Person.connection.dup
+
+ assert_not_equal temp_conn, Person.connection
+
+ temp_conn.create_table :testings2, force: true do |t|
+ t.column :foo, :string
+ end
+ ensure
+ Person.connection.drop_table :testings2, if_exists: true
+ end
+
+ def test_migration_instance_has_connection
+ migration = Class.new(ActiveRecord::Migration::Current).new
+ assert_equal ActiveRecord::Base.connection, migration.connection
+ end
+
+ def test_method_missing_delegates_to_connection
+ migration = Class.new(ActiveRecord::Migration::Current) {
+ def connection
+ Class.new {
+ def create_table; "hi mom!"; end
+ }.new
+ end
+ }.new
+
+ assert_equal "hi mom!", migration.method_missing(:create_table)
+ end
+
+ def test_add_table_with_decimals
+ Person.connection.drop_table :big_numbers rescue nil
+
+ assert_not_predicate BigNumber, :table_exists?
+ GiveMeBigNumbers.up
+ BigNumber.reset_column_information
+
+ assert BigNumber.create(
+ bank_balance: 1586.43,
+ big_bank_balance: BigDecimal("1000234000567.95"),
+ world_population: 2**62,
+ my_house_population: 3,
+ value_of_e: BigDecimal("2.7182818284590452353602875")
+ )
+
+ b = BigNumber.first
+ assert_not_nil b
+
+ assert_not_nil b.bank_balance
+ assert_not_nil b.big_bank_balance
+ assert_not_nil b.world_population
+ assert_not_nil b.my_house_population
+ assert_not_nil b.value_of_e
+
+ assert_kind_of Integer, b.world_population
+ assert_equal 2**62, b.world_population
+ assert_kind_of Integer, b.my_house_population
+ assert_equal 3, b.my_house_population
+ assert_kind_of BigDecimal, b.bank_balance
+ assert_equal BigDecimal("1586.43"), b.bank_balance
+ assert_kind_of BigDecimal, b.big_bank_balance
+ assert_equal BigDecimal("1000234000567.95"), b.big_bank_balance
+
+ # This one is fun. The 'value_of_e' field is defined as 'DECIMAL' with
+ # precision/scale explicitly left out. By the SQL standard, numbers
+ # assigned to this field should be truncated but that's seldom respected.
+ if current_adapter?(:PostgreSQLAdapter)
+ # - PostgreSQL changes the SQL spec on columns declared simply as
+ # "decimal" to something more useful: instead of being given a scale
+ # of 0, they take on the compile-time limit for precision and scale,
+ # so the following should succeed unless you have used really wacky
+ # compilation options
+ assert_kind_of BigDecimal, b.value_of_e
+ assert_equal BigDecimal("2.7182818284590452353602875"), b.value_of_e
+ elsif current_adapter?(:SQLite3Adapter)
+ # - SQLite3 stores a float, in violation of SQL
+ assert_kind_of BigDecimal, b.value_of_e
+ assert_in_delta BigDecimal("2.71828182845905"), b.value_of_e, 0.00000000000001
+ else
+ # - SQL standard is an integer
+ assert_kind_of Integer, b.value_of_e
+ assert_equal 2, b.value_of_e
+ end
+
+ GiveMeBigNumbers.down
+ assert_raise(ActiveRecord::StatementInvalid) { BigNumber.first }
+ end
+
+ def test_filtering_migrations
+ assert_no_column Person, :last_name
+ assert_not_predicate Reminder, :table_exists?
+
+ name_filter = lambda { |migration| migration.name == "ValidPeopleHaveLastNames" }
+ migrator = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/valid")
+ migrator.up(&name_filter)
+
+ assert_column Person, :last_name
+ assert_raise(ActiveRecord::StatementInvalid) { Reminder.first }
+
+ migrator.down(&name_filter)
+
+ assert_no_column Person, :last_name
+ assert_raise(ActiveRecord::StatementInvalid) { Reminder.first }
+ end
+
+ class MockMigration < ActiveRecord::Migration::Current
+ attr_reader :went_up, :went_down
+ def initialize
+ @went_up = false
+ @went_down = false
+ end
+
+ def up
+ @went_up = true
+ super
+ end
+
+ def down
+ @went_down = true
+ super
+ end
+ end
+
+ def test_instance_based_migration_up
+ migration = MockMigration.new
+ assert_not migration.went_up, "have not gone up"
+ assert_not migration.went_down, "have not gone down"
+
+ migration.migrate :up
+ assert migration.went_up, "have gone up"
+ assert_not migration.went_down, "have not gone down"
+ end
+
+ def test_instance_based_migration_down
+ migration = MockMigration.new
+ assert_not migration.went_up, "have not gone up"
+ assert_not migration.went_down, "have not gone down"
+
+ migration.migrate :down
+ assert_not migration.went_up, "have gone up"
+ assert migration.went_down, "have not gone down"
+ end
+
+ if ActiveRecord::Base.connection.supports_ddl_transactions?
+ def test_migrator_one_up_with_exception_and_rollback
+ assert_no_column Person, :last_name
+
+ migration = Class.new(ActiveRecord::Migration::Current) {
+ def version; 100 end
+ def migrate(x)
+ add_column "people", "last_name", :string
+ raise "Something broke"
+ end
+ }.new
+
+ migrator = ActiveRecord::Migrator.new(:up, [migration], 100)
+
+ e = assert_raise(StandardError) { migrator.migrate }
+
+ assert_equal "An error has occurred, this and all later migrations canceled:\n\nSomething broke", e.message
+
+ assert_no_column Person, :last_name,
+ "On error, the Migrator should revert schema changes but it did not."
+ end
+
+ def test_migrator_one_up_with_exception_and_rollback_using_run
+ assert_no_column Person, :last_name
+
+ migration = Class.new(ActiveRecord::Migration::Current) {
+ def version; 100 end
+ def migrate(x)
+ add_column "people", "last_name", :string
+ raise "Something broke"
+ end
+ }.new
+
+ migrator = ActiveRecord::Migrator.new(:up, [migration], 100)
+
+ e = assert_raise(StandardError) { migrator.run }
+
+ assert_equal "An error has occurred, this and all later migrations canceled:\n\nSomething broke", e.message
+
+ assert_no_column Person, :last_name,
+ "On error, the Migrator should revert schema changes but it did not."
+ end
+
+ def test_migration_without_transaction
+ assert_no_column Person, :last_name
+
+ migration = Class.new(ActiveRecord::Migration::Current) {
+ disable_ddl_transaction!
+
+ def version; 101 end
+ def migrate(x)
+ add_column "people", "last_name", :string
+ raise "Something broke"
+ end
+ }.new
+
+ migrator = ActiveRecord::Migrator.new(:up, [migration], 101)
+ e = assert_raise(StandardError) { migrator.migrate }
+ assert_equal "An error has occurred, all later migrations canceled:\n\nSomething broke", e.message
+
+ assert_column Person, :last_name,
+ "without ddl transactions, the Migrator should not rollback on error but it did."
+ ensure
+ Person.reset_column_information
+ if Person.column_names.include?("last_name")
+ Person.connection.remove_column("people", "last_name")
+ end
+ end
+ end
+
+ def test_schema_migrations_table_name
+ original_schema_migrations_table_name = ActiveRecord::Base.schema_migrations_table_name
+
+ assert_equal "schema_migrations", ActiveRecord::SchemaMigration.table_name
+ ActiveRecord::Base.table_name_prefix = "prefix_"
+ ActiveRecord::Base.table_name_suffix = "_suffix"
+ Reminder.reset_table_name
+ assert_equal "prefix_schema_migrations_suffix", ActiveRecord::SchemaMigration.table_name
+ ActiveRecord::Base.schema_migrations_table_name = "changed"
+ Reminder.reset_table_name
+ assert_equal "prefix_changed_suffix", ActiveRecord::SchemaMigration.table_name
+ ActiveRecord::Base.table_name_prefix = ""
+ ActiveRecord::Base.table_name_suffix = ""
+ Reminder.reset_table_name
+ assert_equal "changed", ActiveRecord::SchemaMigration.table_name
+ ensure
+ ActiveRecord::Base.schema_migrations_table_name = original_schema_migrations_table_name
+ Reminder.reset_table_name
+ end
+
+ def test_internal_metadata_table_name
+ original_internal_metadata_table_name = ActiveRecord::Base.internal_metadata_table_name
+
+ assert_equal "ar_internal_metadata", ActiveRecord::InternalMetadata.table_name
+ ActiveRecord::Base.table_name_prefix = "p_"
+ ActiveRecord::Base.table_name_suffix = "_s"
+ Reminder.reset_table_name
+ assert_equal "p_ar_internal_metadata_s", ActiveRecord::InternalMetadata.table_name
+ ActiveRecord::Base.internal_metadata_table_name = "changed"
+ Reminder.reset_table_name
+ assert_equal "p_changed_s", ActiveRecord::InternalMetadata.table_name
+ ActiveRecord::Base.table_name_prefix = ""
+ ActiveRecord::Base.table_name_suffix = ""
+ Reminder.reset_table_name
+ assert_equal "changed", ActiveRecord::InternalMetadata.table_name
+ ensure
+ ActiveRecord::Base.internal_metadata_table_name = original_internal_metadata_table_name
+ Reminder.reset_table_name
+ end
+
+ def test_internal_metadata_stores_environment
+ current_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call
+ migrations_path = MIGRATIONS_ROOT + "/valid"
+ migrator = ActiveRecord::MigrationContext.new(migrations_path)
+
+ migrator.up
+ assert_equal current_env, ActiveRecord::InternalMetadata[:environment]
+
+ original_rails_env = ENV["RAILS_ENV"]
+ original_rack_env = ENV["RACK_ENV"]
+ ENV["RAILS_ENV"] = ENV["RACK_ENV"] = "foofoo"
+ new_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call
+
+ assert_not_equal current_env, new_env
+
+ sleep 1 # mysql by default does not store fractional seconds in the database
+ migrator.up
+ assert_equal new_env, ActiveRecord::InternalMetadata[:environment]
+ ensure
+ ENV["RAILS_ENV"] = original_rails_env
+ ENV["RACK_ENV"] = original_rack_env
+ migrator.up
+ end
+
+ def test_internal_metadata_stores_environment_when_other_data_exists
+ ActiveRecord::InternalMetadata.delete_all
+ ActiveRecord::InternalMetadata[:foo] = "bar"
+
+ current_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call
+ migrations_path = MIGRATIONS_ROOT + "/valid"
+
+ current_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call
+ migrator = ActiveRecord::MigrationContext.new(migrations_path)
+ migrator.up
+ assert_equal current_env, ActiveRecord::InternalMetadata[:environment]
+ assert_equal "bar", ActiveRecord::InternalMetadata[:foo]
+ end
+
+ def test_proper_table_name_on_migration
+ reminder_class = new_isolated_reminder_class
+ migration = ActiveRecord::Migration.new
+ assert_equal "table", migration.proper_table_name("table")
+ assert_equal "table", migration.proper_table_name(:table)
+ assert_equal "reminders", migration.proper_table_name(reminder_class)
+ reminder_class.reset_table_name
+ assert_equal reminder_class.table_name, migration.proper_table_name(reminder_class)
+
+ # Use the model's own prefix/suffix if a model is given
+ ActiveRecord::Base.table_name_prefix = "ARprefix_"
+ ActiveRecord::Base.table_name_suffix = "_ARsuffix"
+ reminder_class.table_name_prefix = "prefix_"
+ reminder_class.table_name_suffix = "_suffix"
+ reminder_class.reset_table_name
+ assert_equal "prefix_reminders_suffix", migration.proper_table_name(reminder_class)
+ reminder_class.table_name_prefix = ""
+ reminder_class.table_name_suffix = ""
+ reminder_class.reset_table_name
+
+ # Use AR::Base's prefix/suffix if string or symbol is given
+ ActiveRecord::Base.table_name_prefix = "prefix_"
+ ActiveRecord::Base.table_name_suffix = "_suffix"
+ reminder_class.reset_table_name
+ assert_equal "prefix_table_suffix", migration.proper_table_name("table", migration.table_name_options)
+ assert_equal "prefix_table_suffix", migration.proper_table_name(:table, migration.table_name_options)
+ end
+
+ def test_rename_table_with_prefix_and_suffix
+ assert_not_predicate Thing, :table_exists?
+ ActiveRecord::Base.table_name_prefix = "p_"
+ ActiveRecord::Base.table_name_suffix = "_s"
+ Thing.reset_table_name
+ Thing.reset_sequence_name
+ WeNeedThings.up
+ Thing.reset_column_information
+
+ assert Thing.create("content" => "hello world")
+ assert_equal "hello world", Thing.first.content
+
+ RenameThings.up
+ Thing.table_name = "p_awesome_things_s"
+
+ assert_equal "hello world", Thing.first.content
+ ensure
+ Thing.reset_table_name
+ Thing.reset_sequence_name
+ end
+
+ def test_add_drop_table_with_prefix_and_suffix
+ assert_not_predicate Reminder, :table_exists?
+ ActiveRecord::Base.table_name_prefix = "prefix_"
+ ActiveRecord::Base.table_name_suffix = "_suffix"
+ Reminder.reset_table_name
+ Reminder.reset_sequence_name
+ Reminder.reset_column_information
+ WeNeedReminders.up
+ assert Reminder.create("content" => "hello world", "remind_at" => Time.now)
+ assert_equal "hello world", Reminder.first.content
+
+ WeNeedReminders.down
+ assert_raise(ActiveRecord::StatementInvalid) { Reminder.first }
+ ensure
+ Reminder.reset_sequence_name
+ end
+
+ def test_create_table_with_binary_column
+ assert_nothing_raised {
+ Person.connection.create_table :binary_testings do |t|
+ t.column "data", :binary, null: false
+ end
+ }
+
+ columns = Person.connection.columns(:binary_testings)
+ data_column = columns.detect { |c| c.name == "data" }
+
+ assert_nil data_column.default
+ ensure
+ Person.connection.drop_table :binary_testings, if_exists: true
+ end
+
+ unless mysql_enforcing_gtid_consistency?
+ def test_create_table_with_query
+ Person.connection.create_table :table_from_query_testings, as: "SELECT id FROM people WHERE id = 1"
+
+ columns = Person.connection.columns(:table_from_query_testings)
+ assert_equal [1], Person.connection.select_values("SELECT * FROM table_from_query_testings")
+ assert_equal 1, columns.length
+ assert_equal "id", columns.first.name
+ ensure
+ Person.connection.drop_table :table_from_query_testings rescue nil
+ end
+
+ def test_create_table_with_query_from_relation
+ Person.connection.create_table :table_from_query_testings, as: Person.select(:id).where(id: 1)
+
+ columns = Person.connection.columns(:table_from_query_testings)
+ assert_equal [1], Person.connection.select_values("SELECT * FROM table_from_query_testings")
+ assert_equal 1, columns.length
+ assert_equal "id", columns.first.name
+ ensure
+ Person.connection.drop_table :table_from_query_testings rescue nil
+ end
+ end
+
+ if current_adapter?(:SQLite3Adapter)
+ def test_allows_sqlite3_rollback_on_invalid_column_type
+ Person.connection.create_table :something, force: true do |t|
+ t.column :number, :integer
+ t.column :name, :string
+ t.column :foo, :bar
+ end
+ assert Person.connection.column_exists?(:something, :foo)
+ assert_nothing_raised { Person.connection.remove_column :something, :foo, :bar }
+ assert_not Person.connection.column_exists?(:something, :foo)
+ assert Person.connection.column_exists?(:something, :name)
+ assert Person.connection.column_exists?(:something, :number)
+ ensure
+ Person.connection.drop_table :something, if_exists: true
+ end
+ end
+
+ if current_adapter? :OracleAdapter
+ def test_create_table_with_custom_sequence_name
+ # table name is 29 chars, the standard sequence name will
+ # be 33 chars and should be shortened
+ assert_nothing_raised do
+ Person.connection.create_table :table_with_name_thats_just_ok do |t|
+ t.column :foo, :string, null: false
+ end
+ ensure
+ Person.connection.drop_table :table_with_name_thats_just_ok rescue nil
+ end
+
+ # should be all good w/ a custom sequence name
+ assert_nothing_raised do
+ Person.connection.create_table :table_with_name_thats_just_ok,
+ sequence_name: "suitably_short_seq" do |t|
+ t.column :foo, :string, null: false
+ end
+
+ Person.connection.execute("select suitably_short_seq.nextval from dual")
+
+ ensure
+ Person.connection.drop_table :table_with_name_thats_just_ok,
+ sequence_name: "suitably_short_seq" rescue nil
+ end
+
+ # confirm the custom sequence got dropped
+ assert_raise(ActiveRecord::StatementInvalid) do
+ Person.connection.execute("select suitably_short_seq.nextval from dual")
+ end
+ end
+ end
+
+ if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter)
+ def test_out_of_range_integer_limit_should_raise
+ e = assert_raise(ActiveRecord::ActiveRecordError, "integer limit didn't raise") do
+ Person.connection.create_table :test_integer_limits, force: true do |t|
+ t.column :bigone, :integer, limit: 10
+ end
+ end
+
+ assert_match(/No integer type has byte size 10/, e.message)
+ ensure
+ Person.connection.drop_table :test_integer_limits, if_exists: true
+ end
+ end
+
+ if current_adapter?(:Mysql2Adapter)
+ def test_out_of_range_text_limit_should_raise
+ e = assert_raise(ActiveRecord::ActiveRecordError, "text limit didn't raise") do
+ Person.connection.create_table :test_text_limits, force: true do |t|
+ t.text :bigtext, limit: 0xfffffffff
+ end
+ end
+
+ assert_match(/No text type has byte length #{0xfffffffff}/, e.message)
+ ensure
+ Person.connection.drop_table :test_text_limits, if_exists: true
+ end
+ end
+
+ if ActiveRecord::Base.connection.supports_advisory_locks?
+ def test_migrator_generates_valid_lock_id
+ migration = Class.new(ActiveRecord::Migration::Current).new
+ migrator = ActiveRecord::Migrator.new(:up, [migration], 100)
+
+ lock_id = migrator.send(:generate_migrator_advisory_lock_id)
+
+ assert ActiveRecord::Base.connection.get_advisory_lock(lock_id),
+ "the Migrator should have generated a valid lock id, but it didn't"
+ assert ActiveRecord::Base.connection.release_advisory_lock(lock_id),
+ "the Migrator should have generated a valid lock id, but it didn't"
+ end
+
+ def test_generate_migrator_advisory_lock_id
+ # It is important we are consistent with how we generate this so that
+ # exclusive locking works across migrator versions
+ migration = Class.new(ActiveRecord::Migration::Current).new
+ migrator = ActiveRecord::Migrator.new(:up, [migration], 100)
+
+ lock_id = migrator.send(:generate_migrator_advisory_lock_id)
+
+ current_database = ActiveRecord::Base.connection.current_database
+ salt = ActiveRecord::Migrator::MIGRATOR_SALT
+ expected_id = Zlib.crc32(current_database) * salt
+
+ assert lock_id == expected_id, "expected lock id generated by the migrator to be #{expected_id}, but it was #{lock_id} instead"
+ assert lock_id.bit_length <= 63, "lock id must be a signed integer of max 63 bits magnitude"
+ end
+
+ def test_migrator_one_up_with_unavailable_lock
+ assert_no_column Person, :last_name
+
+ migration = Class.new(ActiveRecord::Migration::Current) {
+ def version; 100 end
+ def migrate(x)
+ add_column "people", "last_name", :string
+ end
+ }.new
+
+ migrator = ActiveRecord::Migrator.new(:up, [migration], 100)
+ lock_id = migrator.send(:generate_migrator_advisory_lock_id)
+
+ with_another_process_holding_lock(lock_id) do
+ assert_raise(ActiveRecord::ConcurrentMigrationError) { migrator.migrate }
+ end
+
+ assert_no_column Person, :last_name,
+ "without an advisory lock, the Migrator should not make any changes, but it did."
+ end
+
+ def test_migrator_one_up_with_unavailable_lock_using_run
+ assert_no_column Person, :last_name
+
+ migration = Class.new(ActiveRecord::Migration::Current) {
+ def version; 100 end
+ def migrate(x)
+ add_column "people", "last_name", :string
+ end
+ }.new
+
+ migrator = ActiveRecord::Migrator.new(:up, [migration], 100)
+ lock_id = migrator.send(:generate_migrator_advisory_lock_id)
+
+ with_another_process_holding_lock(lock_id) do
+ assert_raise(ActiveRecord::ConcurrentMigrationError) { migrator.run }
+ end
+
+ assert_no_column Person, :last_name,
+ "without an advisory lock, the Migrator should not make any changes, but it did."
+ end
+
+ def test_with_advisory_lock_raises_the_right_error_when_it_fails_to_release_lock
+ migration = Class.new(ActiveRecord::Migration::Current).new
+ migrator = ActiveRecord::Migrator.new(:up, [migration], 100)
+ lock_id = migrator.send(:generate_migrator_advisory_lock_id)
+
+ e = assert_raises(ActiveRecord::ConcurrentMigrationError) do
+ silence_stream($stderr) do
+ migrator.send(:with_advisory_lock) do
+ ActiveRecord::Base.connection.release_advisory_lock(lock_id)
+ end
+ end
+ end
+
+ assert_match(
+ /#{ActiveRecord::ConcurrentMigrationError::RELEASE_LOCK_FAILED_MESSAGE}/,
+ e.message
+ )
+ end
+ end
+
+ private
+ # This is needed to isolate class_attribute assignments like `table_name_prefix`
+ # for each test case.
+ def new_isolated_reminder_class
+ Class.new(Reminder) {
+ def self.name; "Reminder"; end
+ def self.base_class; self; end
+ }
+ end
+
+ def with_another_process_holding_lock(lock_id)
+ thread_lock = Concurrent::CountDownLatch.new
+ test_terminated = Concurrent::CountDownLatch.new
+
+ other_process = Thread.new do
+ conn = ActiveRecord::Base.connection_pool.checkout
+ conn.get_advisory_lock(lock_id)
+ thread_lock.count_down
+ test_terminated.wait # hold the lock open until we tested everything
+ ensure
+ conn.release_advisory_lock(lock_id)
+ ActiveRecord::Base.connection_pool.checkin(conn)
+ end
+
+ thread_lock.wait # wait until the 'other process' has the lock
+
+ yield
+
+ test_terminated.count_down
+ other_process.join
+ end
+end
+
+class ReservedWordsMigrationTest < ActiveRecord::TestCase
+ def test_drop_index_from_table_named_values
+ connection = Person.connection
+ connection.create_table :values, force: true do |t|
+ t.integer :value
+ end
+
+ assert_nothing_raised do
+ connection.add_index :values, :value
+ connection.remove_index :values, column: :value
+ end
+ ensure
+ connection.drop_table :values rescue nil
+ end
+end
+
+class ExplicitlyNamedIndexMigrationTest < ActiveRecord::TestCase
+ def test_drop_index_by_name
+ connection = Person.connection
+ connection.create_table :values, force: true do |t|
+ t.integer :value
+ end
+
+ assert_nothing_raised do
+ connection.add_index :values, :value, name: "a_different_name"
+ connection.remove_index :values, column: :value, name: "a_different_name"
+ end
+ ensure
+ connection.drop_table :values rescue nil
+ end
+end
+
+if ActiveRecord::Base.connection.supports_bulk_alter?
+ class BulkAlterTableMigrationsTest < ActiveRecord::TestCase
+ def setup
+ @connection = Person.connection
+ @connection.create_table(:delete_me, force: true) { |t| }
+ Person.reset_column_information
+ Person.reset_sequence_name
+ end
+
+ teardown do
+ Person.connection.drop_table(:delete_me) rescue nil
+ end
+
+ def test_adding_multiple_columns
+ classname = ActiveRecord::Base.connection.class.name[/[^:]*$/]
+ expected_query_count = {
+ "Mysql2Adapter" => 1,
+ "PostgreSQLAdapter" => 2, # one for bulk change, one for comment
+ }.fetch(classname) {
+ raise "need an expected query count for #{classname}"
+ }
+
+ assert_queries(expected_query_count) do
+ with_bulk_change_table do |t|
+ t.column :name, :string
+ t.string :qualification, :experience
+ t.integer :age, default: 0
+ t.date :birthdate, comment: "This is a comment"
+ t.timestamps null: true
+ end
+ end
+
+ assert_equal 8, columns.size
+ [:name, :qualification, :experience].each { |s| assert_equal :string, column(s).type }
+ assert_equal "0", column(:age).default
+ assert_equal "This is a comment", column(:birthdate).comment
+ end
+
+ def test_removing_columns
+ with_bulk_change_table do |t|
+ t.string :qualification, :experience
+ end
+
+ [:qualification, :experience].each { |c| assert column(c) }
+
+ assert_queries(1) do
+ with_bulk_change_table do |t|
+ t.remove :qualification, :experience
+ t.string :qualification_experience
+ end
+ end
+
+ [:qualification, :experience].each { |c| assert_not column(c) }
+ assert column(:qualification_experience)
+ end
+
+ def test_adding_indexes
+ with_bulk_change_table do |t|
+ t.string :username
+ t.string :name
+ t.integer :age
+ end
+
+ classname = ActiveRecord::Base.connection.class.name[/[^:]*$/]
+ expected_query_count = {
+ "Mysql2Adapter" => 3, # Adding an index fires a query every time to check if an index already exists or not
+ "PostgreSQLAdapter" => 2,
+ }.fetch(classname) {
+ raise "need an expected query count for #{classname}"
+ }
+
+ assert_queries(expected_query_count) do
+ with_bulk_change_table do |t|
+ t.index :username, unique: true, name: :awesome_username_index
+ t.index [:name, :age]
+ end
+ end
+
+ assert_equal 2, indexes.size
+
+ name_age_index = index(:index_delete_me_on_name_and_age)
+ assert_equal ["name", "age"].sort, name_age_index.columns.sort
+ assert_not name_age_index.unique
+
+ assert index(:awesome_username_index).unique
+ end
+
+ def test_removing_index
+ with_bulk_change_table do |t|
+ t.string :name
+ t.index :name
+ end
+
+ assert index(:index_delete_me_on_name)
+
+ classname = ActiveRecord::Base.connection.class.name[/[^:]*$/]
+ expected_query_count = {
+ "Mysql2Adapter" => 3, # Adding an index fires a query every time to check if an index already exists or not
+ "PostgreSQLAdapter" => 2,
+ }.fetch(classname) {
+ raise "need an expected query count for #{classname}"
+ }
+
+ assert_queries(expected_query_count) do
+ with_bulk_change_table do |t|
+ t.remove_index :name
+ t.index :name, name: :new_name_index, unique: true
+ end
+ end
+
+ assert_not index(:index_delete_me_on_name)
+
+ new_name_index = index(:new_name_index)
+ assert new_name_index.unique
+ end
+
+ def test_changing_columns
+ with_bulk_change_table do |t|
+ t.string :name
+ t.date :birthdate
+ end
+
+ assert_not column(:name).default
+ assert_equal :date, column(:birthdate).type
+
+ classname = ActiveRecord::Base.connection.class.name[/[^:]*$/]
+ expected_query_count = {
+ "Mysql2Adapter" => 3, # one query for columns, one query for primary key, one query to do the bulk change
+ "PostgreSQLAdapter" => 3, # one query for columns, one for bulk change, one for comment
+ }.fetch(classname) {
+ raise "need an expected query count for #{classname}"
+ }
+
+ assert_queries(expected_query_count, ignore_none: true) do
+ with_bulk_change_table do |t|
+ t.change :name, :string, default: "NONAME"
+ t.change :birthdate, :datetime, comment: "This is a comment"
+ end
+ end
+
+ assert_equal "NONAME", column(:name).default
+ assert_equal :datetime, column(:birthdate).type
+ assert_equal "This is a comment", column(:birthdate).comment
+ end
+
+ private
+
+ def with_bulk_change_table
+ # Reset columns/indexes cache as we're changing the table
+ @columns = @indexes = nil
+
+ Person.connection.change_table(:delete_me, bulk: true) do |t|
+ yield t
+ end
+ end
+
+ def column(name)
+ columns.detect { |c| c.name == name.to_s }
+ end
+
+ def columns
+ @columns ||= Person.connection.columns("delete_me")
+ end
+
+ def index(name)
+ indexes.detect { |i| i.name == name.to_s }
+ end
+
+ def indexes
+ @indexes ||= Person.connection.indexes("delete_me")
+ end
+ end # AlterTableMigrationsTest
+end
+
+class CopyMigrationsTest < ActiveRecord::TestCase
+ include ActiveSupport::Testing::Stream
+
+ def setup
+ end
+
+ def clear
+ ActiveRecord::Base.timestamped_migrations = true
+ to_delete = Dir[@migrations_path + "/*.rb"] - @existing_migrations
+ File.delete(*to_delete)
+ end
+
+ def test_copying_migrations_without_timestamps
+ ActiveRecord::Base.timestamped_migrations = false
+ @migrations_path = MIGRATIONS_ROOT + "/valid"
+ @existing_migrations = Dir[@migrations_path + "/*.rb"]
+
+ copied = ActiveRecord::Migration.copy(@migrations_path, bukkits: MIGRATIONS_ROOT + "/to_copy")
+ assert File.exist?(@migrations_path + "/4_people_have_hobbies.bukkits.rb")
+ assert File.exist?(@migrations_path + "/5_people_have_descriptions.bukkits.rb")
+ assert_equal [@migrations_path + "/4_people_have_hobbies.bukkits.rb", @migrations_path + "/5_people_have_descriptions.bukkits.rb"], copied.map(&:filename)
+
+ expected = "# This migration comes from bukkits (originally 1)"
+ assert_equal expected, IO.readlines(@migrations_path + "/4_people_have_hobbies.bukkits.rb")[1].chomp
+
+ files_count = Dir[@migrations_path + "/*.rb"].length
+ copied = ActiveRecord::Migration.copy(@migrations_path, bukkits: MIGRATIONS_ROOT + "/to_copy")
+ assert_equal files_count, Dir[@migrations_path + "/*.rb"].length
+ assert_empty copied
+ ensure
+ clear
+ end
+
+ def test_copying_migrations_without_timestamps_from_2_sources
+ ActiveRecord::Base.timestamped_migrations = false
+ @migrations_path = MIGRATIONS_ROOT + "/valid"
+ @existing_migrations = Dir[@migrations_path + "/*.rb"]
+
+ sources = {}
+ sources[:bukkits] = MIGRATIONS_ROOT + "/to_copy"
+ sources[:omg] = MIGRATIONS_ROOT + "/to_copy2"
+ ActiveRecord::Migration.copy(@migrations_path, sources)
+ assert File.exist?(@migrations_path + "/4_people_have_hobbies.bukkits.rb")
+ assert File.exist?(@migrations_path + "/5_people_have_descriptions.bukkits.rb")
+ assert File.exist?(@migrations_path + "/6_create_articles.omg.rb")
+ assert File.exist?(@migrations_path + "/7_create_comments.omg.rb")
+
+ files_count = Dir[@migrations_path + "/*.rb"].length
+ ActiveRecord::Migration.copy(@migrations_path, sources)
+ assert_equal files_count, Dir[@migrations_path + "/*.rb"].length
+ ensure
+ clear
+ end
+
+ def test_copying_migrations_with_timestamps
+ @migrations_path = MIGRATIONS_ROOT + "/valid_with_timestamps"
+ @existing_migrations = Dir[@migrations_path + "/*.rb"]
+
+ travel_to(Time.utc(2010, 7, 26, 10, 10, 10)) do
+ copied = ActiveRecord::Migration.copy(@migrations_path, bukkits: MIGRATIONS_ROOT + "/to_copy_with_timestamps")
+ assert File.exist?(@migrations_path + "/20100726101010_people_have_hobbies.bukkits.rb")
+ assert File.exist?(@migrations_path + "/20100726101011_people_have_descriptions.bukkits.rb")
+ expected = [@migrations_path + "/20100726101010_people_have_hobbies.bukkits.rb",
+ @migrations_path + "/20100726101011_people_have_descriptions.bukkits.rb"]
+ assert_equal expected, copied.map(&:filename)
+
+ files_count = Dir[@migrations_path + "/*.rb"].length
+ copied = ActiveRecord::Migration.copy(@migrations_path, bukkits: MIGRATIONS_ROOT + "/to_copy_with_timestamps")
+ assert_equal files_count, Dir[@migrations_path + "/*.rb"].length
+ assert_empty copied
+ end
+ ensure
+ clear
+ end
+
+ def test_copying_migrations_with_timestamps_from_2_sources
+ @migrations_path = MIGRATIONS_ROOT + "/valid_with_timestamps"
+ @existing_migrations = Dir[@migrations_path + "/*.rb"]
+
+ sources = {}
+ sources[:bukkits] = MIGRATIONS_ROOT + "/to_copy_with_timestamps"
+ sources[:omg] = MIGRATIONS_ROOT + "/to_copy_with_timestamps2"
+
+ travel_to(Time.utc(2010, 7, 26, 10, 10, 10)) do
+ copied = ActiveRecord::Migration.copy(@migrations_path, sources)
+ assert File.exist?(@migrations_path + "/20100726101010_people_have_hobbies.bukkits.rb")
+ assert File.exist?(@migrations_path + "/20100726101011_people_have_descriptions.bukkits.rb")
+ assert File.exist?(@migrations_path + "/20100726101012_create_articles.omg.rb")
+ assert File.exist?(@migrations_path + "/20100726101013_create_comments.omg.rb")
+ assert_equal 4, copied.length
+
+ files_count = Dir[@migrations_path + "/*.rb"].length
+ ActiveRecord::Migration.copy(@migrations_path, sources)
+ assert_equal files_count, Dir[@migrations_path + "/*.rb"].length
+ end
+ ensure
+ clear
+ end
+
+ def test_copying_migrations_with_timestamps_to_destination_with_timestamps_in_future
+ @migrations_path = MIGRATIONS_ROOT + "/valid_with_timestamps"
+ @existing_migrations = Dir[@migrations_path + "/*.rb"]
+
+ travel_to(Time.utc(2010, 2, 20, 10, 10, 10)) do
+ ActiveRecord::Migration.copy(@migrations_path, bukkits: MIGRATIONS_ROOT + "/to_copy_with_timestamps")
+ assert File.exist?(@migrations_path + "/20100301010102_people_have_hobbies.bukkits.rb")
+ assert File.exist?(@migrations_path + "/20100301010103_people_have_descriptions.bukkits.rb")
+
+ files_count = Dir[@migrations_path + "/*.rb"].length
+ copied = ActiveRecord::Migration.copy(@migrations_path, bukkits: MIGRATIONS_ROOT + "/to_copy_with_timestamps")
+ assert_equal files_count, Dir[@migrations_path + "/*.rb"].length
+ assert_empty copied
+ end
+ ensure
+ clear
+ end
+
+ def test_copying_migrations_preserving_magic_comments
+ ActiveRecord::Base.timestamped_migrations = false
+ @migrations_path = MIGRATIONS_ROOT + "/valid"
+ @existing_migrations = Dir[@migrations_path + "/*.rb"]
+
+ copied = ActiveRecord::Migration.copy(@migrations_path, bukkits: MIGRATIONS_ROOT + "/magic")
+ assert File.exist?(@migrations_path + "/4_currencies_have_symbols.bukkits.rb")
+ assert_equal [@migrations_path + "/4_currencies_have_symbols.bukkits.rb"], copied.map(&:filename)
+
+ expected = "# frozen_string_literal: true\n# coding: ISO-8859-15\n# This migration comes from bukkits (originally 1)"
+ assert_equal expected, IO.readlines(@migrations_path + "/4_currencies_have_symbols.bukkits.rb")[0..2].join.chomp
+
+ files_count = Dir[@migrations_path + "/*.rb"].length
+ copied = ActiveRecord::Migration.copy(@migrations_path, bukkits: MIGRATIONS_ROOT + "/magic")
+ assert_equal files_count, Dir[@migrations_path + "/*.rb"].length
+ assert_empty copied
+ ensure
+ clear
+ end
+
+ def test_skipping_migrations
+ @migrations_path = MIGRATIONS_ROOT + "/valid_with_timestamps"
+ @existing_migrations = Dir[@migrations_path + "/*.rb"]
+
+ sources = {}
+ sources[:bukkits] = MIGRATIONS_ROOT + "/to_copy_with_timestamps"
+ sources[:omg] = MIGRATIONS_ROOT + "/to_copy_with_name_collision"
+
+ skipped = []
+ on_skip = Proc.new { |name, migration| skipped << "#{name} #{migration.name}" }
+ copied = ActiveRecord::Migration.copy(@migrations_path, sources, on_skip: on_skip)
+ assert_equal 2, copied.length
+
+ assert_equal 1, skipped.length
+ assert_equal ["omg PeopleHaveHobbies"], skipped
+ ensure
+ clear
+ end
+
+ def test_skip_is_not_called_if_migrations_are_from_the_same_plugin
+ @migrations_path = MIGRATIONS_ROOT + "/valid_with_timestamps"
+ @existing_migrations = Dir[@migrations_path + "/*.rb"]
+
+ sources = {}
+ sources[:bukkits] = MIGRATIONS_ROOT + "/to_copy_with_timestamps"
+
+ skipped = []
+ on_skip = Proc.new { |name, migration| skipped << "#{name} #{migration.name}" }
+ copied = ActiveRecord::Migration.copy(@migrations_path, sources, on_skip: on_skip)
+ ActiveRecord::Migration.copy(@migrations_path, sources, on_skip: on_skip)
+
+ assert_equal 2, copied.length
+ assert_equal 0, skipped.length
+ ensure
+ clear
+ end
+
+ def test_copying_migrations_to_non_existing_directory
+ @migrations_path = MIGRATIONS_ROOT + "/non_existing"
+ @existing_migrations = []
+
+ travel_to(Time.utc(2010, 7, 26, 10, 10, 10)) do
+ copied = ActiveRecord::Migration.copy(@migrations_path, bukkits: MIGRATIONS_ROOT + "/to_copy_with_timestamps")
+ assert File.exist?(@migrations_path + "/20100726101010_people_have_hobbies.bukkits.rb")
+ assert File.exist?(@migrations_path + "/20100726101011_people_have_descriptions.bukkits.rb")
+ assert_equal 2, copied.length
+ end
+ ensure
+ clear
+ Dir.delete(@migrations_path)
+ end
+
+ def test_copying_migrations_to_empty_directory
+ @migrations_path = MIGRATIONS_ROOT + "/empty"
+ @existing_migrations = []
+
+ travel_to(Time.utc(2010, 7, 26, 10, 10, 10)) do
+ copied = ActiveRecord::Migration.copy(@migrations_path, bukkits: MIGRATIONS_ROOT + "/to_copy_with_timestamps")
+ assert File.exist?(@migrations_path + "/20100726101010_people_have_hobbies.bukkits.rb")
+ assert File.exist?(@migrations_path + "/20100726101011_people_have_descriptions.bukkits.rb")
+ assert_equal 2, copied.length
+ end
+ ensure
+ clear
+ end
+
+ def test_check_pending_with_stdlib_logger
+ old, ActiveRecord::Base.logger = ActiveRecord::Base.logger, ::Logger.new($stdout)
+ quietly do
+ assert_nothing_raised { ActiveRecord::Migration::CheckPending.new(Proc.new { }).call({}) }
+ end
+ ensure
+ ActiveRecord::Base.logger = old
+ end
+
+ def test_unknown_migration_version_should_raise_an_argument_error
+ assert_raise(ArgumentError) { ActiveRecord::Migration[1.0] }
+ end
+end
diff --git a/activerecord/test/cases/migrator_test.rb b/activerecord/test/cases/migrator_test.rb
new file mode 100644
index 0000000000..30e199f1c5
--- /dev/null
+++ b/activerecord/test/cases/migrator_test.rb
@@ -0,0 +1,508 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "cases/migration/helper"
+
+class MigratorTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ # Use this class to sense if migrations have gone
+ # up or down.
+ class Sensor < ActiveRecord::Migration::Current
+ attr_reader :went_up, :went_down
+
+ def initialize(name = self.class.name, version = nil)
+ super
+ @went_up = false
+ @went_down = false
+ end
+
+ def up; @went_up = true; end
+ def down; @went_down = true; end
+ end
+
+ def setup
+ super
+ ActiveRecord::SchemaMigration.create_table
+ ActiveRecord::SchemaMigration.delete_all rescue nil
+ @verbose_was = ActiveRecord::Migration.verbose
+ ActiveRecord::Migration.message_count = 0
+ ActiveRecord::Migration.class_eval do
+ undef :puts
+ def puts(*)
+ ActiveRecord::Migration.message_count += 1
+ end
+ end
+ end
+
+ teardown do
+ ActiveRecord::SchemaMigration.delete_all rescue nil
+ ActiveRecord::Migration.verbose = @verbose_was
+ ActiveRecord::Migration.class_eval do
+ undef :puts
+ def puts(*)
+ super
+ end
+ end
+ end
+
+ def test_migrator_with_duplicate_names
+ e = assert_raises(ActiveRecord::DuplicateMigrationNameError) do
+ list = [ActiveRecord::Migration.new("Chunky"), ActiveRecord::Migration.new("Chunky")]
+ ActiveRecord::Migrator.new(:up, list)
+ end
+ assert_match(/Multiple migrations have the name Chunky/, e.message)
+ end
+
+ def test_migrator_with_duplicate_versions
+ assert_raises(ActiveRecord::DuplicateMigrationVersionError) do
+ list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 1)]
+ ActiveRecord::Migrator.new(:up, list)
+ end
+ end
+
+ def test_migrator_with_missing_version_numbers
+ assert_raises(ActiveRecord::UnknownMigrationVersionError) do
+ list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)]
+ ActiveRecord::Migrator.new(:up, list, 3).run
+ end
+
+ assert_raises(ActiveRecord::UnknownMigrationVersionError) do
+ list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)]
+ ActiveRecord::Migrator.new(:up, list, -1).run
+ end
+
+ assert_raises(ActiveRecord::UnknownMigrationVersionError) do
+ list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)]
+ ActiveRecord::Migrator.new(:up, list, 0).run
+ end
+
+ assert_raises(ActiveRecord::UnknownMigrationVersionError) do
+ list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)]
+ ActiveRecord::Migrator.new(:up, list, 3).migrate
+ end
+
+ assert_raises(ActiveRecord::UnknownMigrationVersionError) do
+ list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)]
+ ActiveRecord::Migrator.new(:up, list, -1).migrate
+ end
+ end
+
+ def test_finds_migrations
+ migrations = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/valid").migrations
+
+ [[1, "ValidPeopleHaveLastNames"], [2, "WeNeedReminders"], [3, "InnocentJointable"]].each_with_index do |pair, i|
+ assert_equal migrations[i].version, pair.first
+ assert_equal migrations[i].name, pair.last
+ end
+ end
+
+ def test_finds_migrations_in_subdirectories
+ migrations = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/valid_with_subdirectories").migrations
+
+ [[1, "ValidPeopleHaveLastNames"], [2, "WeNeedReminders"], [3, "InnocentJointable"]].each_with_index do |pair, i|
+ assert_equal migrations[i].version, pair.first
+ assert_equal migrations[i].name, pair.last
+ end
+ end
+
+ def test_finds_migrations_from_two_directories
+ directories = [MIGRATIONS_ROOT + "/valid_with_timestamps", MIGRATIONS_ROOT + "/to_copy_with_timestamps"]
+ migrations = ActiveRecord::MigrationContext.new(directories).migrations
+
+ [[20090101010101, "PeopleHaveHobbies"],
+ [20090101010202, "PeopleHaveDescriptions"],
+ [20100101010101, "ValidWithTimestampsPeopleHaveLastNames"],
+ [20100201010101, "ValidWithTimestampsWeNeedReminders"],
+ [20100301010101, "ValidWithTimestampsInnocentJointable"]].each_with_index do |pair, i|
+ assert_equal pair.first, migrations[i].version
+ assert_equal pair.last, migrations[i].name
+ end
+ end
+
+ def test_finds_migrations_in_numbered_directory
+ migrations = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/10_urban").migrations
+ assert_equal 9, migrations[0].version
+ assert_equal "AddExpressions", migrations[0].name
+ end
+
+ def test_relative_migrations
+ list = Dir.chdir(MIGRATIONS_ROOT) do
+ ActiveRecord::MigrationContext.new("valid").migrations
+ end
+
+ migration_proxy = list.find { |item|
+ item.name == "ValidPeopleHaveLastNames"
+ }
+ assert migration_proxy, "should find pending migration"
+ end
+
+ def test_finds_pending_migrations
+ ActiveRecord::SchemaMigration.create!(version: "1")
+ migration_list = [ActiveRecord::Migration.new("foo", 1), ActiveRecord::Migration.new("bar", 3)]
+ migrations = ActiveRecord::Migrator.new(:up, migration_list).pending_migrations
+
+ assert_equal 1, migrations.size
+ assert_equal migration_list.last, migrations.first
+ end
+
+ def test_migrations_status
+ path = MIGRATIONS_ROOT + "/valid"
+
+ ActiveRecord::SchemaMigration.create(version: 2)
+ ActiveRecord::SchemaMigration.create(version: 10)
+
+ assert_equal [
+ ["down", "001", "Valid people have last names"],
+ ["up", "002", "We need reminders"],
+ ["down", "003", "Innocent jointable"],
+ ["up", "010", "********** NO FILE **********"],
+ ], ActiveRecord::MigrationContext.new(path).migrations_status
+ end
+
+ def test_migrations_status_in_subdirectories
+ path = MIGRATIONS_ROOT + "/valid_with_subdirectories"
+
+ ActiveRecord::SchemaMigration.create(version: 2)
+ ActiveRecord::SchemaMigration.create(version: 10)
+
+ assert_equal [
+ ["down", "001", "Valid people have last names"],
+ ["up", "002", "We need reminders"],
+ ["down", "003", "Innocent jointable"],
+ ["up", "010", "********** NO FILE **********"],
+ ], ActiveRecord::MigrationContext.new(path).migrations_status
+ end
+
+ def test_migrations_status_with_schema_define_in_subdirectories
+ path = MIGRATIONS_ROOT + "/valid_with_subdirectories"
+ prev_paths = ActiveRecord::Migrator.migrations_paths
+ ActiveRecord::Migrator.migrations_paths = path
+
+ ActiveRecord::Schema.define(version: 3) do
+ end
+
+ assert_equal [
+ ["up", "001", "Valid people have last names"],
+ ["up", "002", "We need reminders"],
+ ["up", "003", "Innocent jointable"],
+ ], ActiveRecord::MigrationContext.new(path).migrations_status
+ ensure
+ ActiveRecord::Migrator.migrations_paths = prev_paths
+ end
+
+ def test_migrations_status_from_two_directories
+ paths = [MIGRATIONS_ROOT + "/valid_with_timestamps", MIGRATIONS_ROOT + "/to_copy_with_timestamps"]
+
+ ActiveRecord::SchemaMigration.create(version: "20100101010101")
+ ActiveRecord::SchemaMigration.create(version: "20160528010101")
+
+ assert_equal [
+ ["down", "20090101010101", "People have hobbies"],
+ ["down", "20090101010202", "People have descriptions"],
+ ["up", "20100101010101", "Valid with timestamps people have last names"],
+ ["down", "20100201010101", "Valid with timestamps we need reminders"],
+ ["down", "20100301010101", "Valid with timestamps innocent jointable"],
+ ["up", "20160528010101", "********** NO FILE **********"],
+ ], ActiveRecord::MigrationContext.new(paths).migrations_status
+ end
+
+ def test_migrator_interleaved_migrations
+ pass_one = [Sensor.new("One", 1)]
+
+ ActiveRecord::Migrator.new(:up, pass_one).migrate
+ assert pass_one.first.went_up
+ assert_not pass_one.first.went_down
+
+ pass_two = [Sensor.new("One", 1), Sensor.new("Three", 3)]
+ ActiveRecord::Migrator.new(:up, pass_two).migrate
+ assert_not pass_two[0].went_up
+ assert pass_two[1].went_up
+ assert pass_two.all? { |x| !x.went_down }
+
+ pass_three = [Sensor.new("One", 1),
+ Sensor.new("Two", 2),
+ Sensor.new("Three", 3)]
+
+ ActiveRecord::Migrator.new(:down, pass_three).migrate
+ assert pass_three[0].went_down
+ assert_not pass_three[1].went_down
+ assert pass_three[2].went_down
+ end
+
+ def test_up_calls_up
+ migrations = [Sensor.new(nil, 0), Sensor.new(nil, 1), Sensor.new(nil, 2)]
+ migrator = ActiveRecord::Migrator.new(:up, migrations)
+ migrator.migrate
+ assert migrations.all?(&:went_up)
+ assert migrations.all? { |m| !m.went_down }
+ assert_equal 2, migrator.current_version
+ end
+
+ def test_down_calls_down
+ test_up_calls_up
+
+ migrations = [Sensor.new(nil, 0), Sensor.new(nil, 1), Sensor.new(nil, 2)]
+ migrator = ActiveRecord::Migrator.new(:down, migrations)
+ migrator.migrate
+ assert migrations.all? { |m| !m.went_up }
+ assert migrations.all?(&:went_down)
+ assert_equal 0, migrator.current_version
+ end
+
+ def test_current_version
+ ActiveRecord::SchemaMigration.create!(version: "1000")
+ migrator = ActiveRecord::MigrationContext.new("db/migrate")
+ assert_equal 1000, migrator.current_version
+ end
+
+ def test_migrator_one_up
+ calls, migrations = sensors(3)
+
+ ActiveRecord::Migrator.new(:up, migrations, 1).migrate
+ assert_equal [[:up, 1]], calls
+ calls.clear
+
+ ActiveRecord::Migrator.new(:up, migrations, 2).migrate
+ assert_equal [[:up, 2]], calls
+ end
+
+ def test_migrator_one_down
+ calls, migrations = sensors(3)
+
+ ActiveRecord::Migrator.new(:up, migrations).migrate
+ assert_equal [[:up, 1], [:up, 2], [:up, 3]], calls
+ calls.clear
+
+ ActiveRecord::Migrator.new(:down, migrations, 1).migrate
+
+ assert_equal [[:down, 3], [:down, 2]], calls
+ end
+
+ def test_migrator_one_up_one_down
+ calls, migrations = sensors(3)
+
+ ActiveRecord::Migrator.new(:up, migrations, 1).migrate
+ assert_equal [[:up, 1]], calls
+ calls.clear
+
+ ActiveRecord::Migrator.new(:down, migrations, 0).migrate
+ assert_equal [[:down, 1]], calls
+ end
+
+ def test_migrator_double_up
+ calls, migrations = sensors(3)
+ migrator = ActiveRecord::Migrator.new(:up, migrations, 1)
+ assert_equal(0, migrator.current_version)
+
+ migrator.migrate
+ assert_equal [[:up, 1]], calls
+ calls.clear
+
+ migrator.migrate
+ assert_equal [], calls
+ end
+
+ def test_migrator_double_down
+ calls, migrations = sensors(3)
+ migrator = ActiveRecord::Migrator.new(:up, migrations, 1)
+
+ assert_equal 0, migrator.current_version
+
+ migrator.run
+ assert_equal [[:up, 1]], calls
+ calls.clear
+
+ migrator = ActiveRecord::Migrator.new(:down, migrations, 1)
+ migrator.run
+ assert_equal [[:down, 1]], calls
+ calls.clear
+
+ migrator.run
+ assert_equal [], calls
+
+ assert_equal 0, migrator.current_version
+ end
+
+ def test_migrator_verbosity
+ _, migrations = sensors(3)
+
+ ActiveRecord::Migration.verbose = true
+ ActiveRecord::Migrator.new(:up, migrations, 1).migrate
+ assert_not_equal 0, ActiveRecord::Migration.message_count
+
+ ActiveRecord::Migration.message_count = 0
+
+ ActiveRecord::Migrator.new(:down, migrations, 0).migrate
+ assert_not_equal 0, ActiveRecord::Migration.message_count
+ end
+
+ def test_migrator_verbosity_off
+ _, migrations = sensors(3)
+
+ ActiveRecord::Migration.verbose = false
+ ActiveRecord::Migrator.new(:up, migrations, 1).migrate
+ assert_equal 0, ActiveRecord::Migration.message_count
+ ActiveRecord::Migrator.new(:down, migrations, 0).migrate
+ assert_equal 0, ActiveRecord::Migration.message_count
+ end
+
+ def test_target_version_zero_should_run_only_once
+ calls, migrations = sensors(3)
+
+ # migrate up to 1
+ ActiveRecord::Migrator.new(:up, migrations, 1).migrate
+ assert_equal [[:up, 1]], calls
+ calls.clear
+
+ # migrate down to 0
+ ActiveRecord::Migrator.new(:down, migrations, 0).migrate
+ assert_equal [[:down, 1]], calls
+ calls.clear
+
+ # migrate down to 0 again
+ ActiveRecord::Migrator.new(:down, migrations, 0).migrate
+ assert_equal [], calls
+ end
+
+ def test_migrator_going_down_due_to_version_target
+ calls, migrator = migrator_class(3)
+ migrator = migrator.new("valid")
+
+ migrator.up(1)
+ assert_equal [[:up, 1]], calls
+ calls.clear
+
+ migrator.migrate(0)
+ assert_equal [[:down, 1]], calls
+ calls.clear
+
+ migrator.migrate
+ assert_equal [[:up, 1], [:up, 2], [:up, 3]], calls
+ end
+
+ def test_migrator_output_when_running_multiple_migrations
+ _, migrator = migrator_class(3)
+ migrator = migrator.new("valid")
+
+ result = migrator.migrate
+ assert_equal(3, result.count)
+
+ # Nothing migrated from duplicate run
+ result = migrator.migrate
+ assert_equal(0, result.count)
+
+ result = migrator.rollback
+ assert_equal(1, result.count)
+ end
+
+ def test_migrator_output_when_running_single_migration
+ _, migrator = migrator_class(1)
+ migrator = migrator.new("valid")
+
+ result = migrator.run(:up, 1)
+
+ assert_equal(1, result.version)
+ end
+
+ def test_migrator_rollback
+ _, migrator = migrator_class(3)
+ migrator = migrator.new("valid")
+
+ migrator.migrate
+ assert_equal(3, migrator.current_version)
+
+ migrator.rollback
+ assert_equal(2, migrator.current_version)
+
+ migrator.rollback
+ assert_equal(1, migrator.current_version)
+
+ migrator.rollback
+ assert_equal(0, migrator.current_version)
+
+ migrator.rollback
+ assert_equal(0, migrator.current_version)
+ end
+
+ def test_migrator_db_has_no_schema_migrations_table
+ _, migrator = migrator_class(3)
+ migrator = migrator.new("valid")
+
+ ActiveRecord::Base.connection.drop_table "schema_migrations", if_exists: true
+ assert_not ActiveRecord::Base.connection.table_exists?("schema_migrations")
+ migrator.migrate(1)
+ assert ActiveRecord::Base.connection.table_exists?("schema_migrations")
+ end
+
+ def test_migrator_forward
+ _, migrator = migrator_class(3)
+ migrator = migrator.new("/valid")
+ migrator.migrate(1)
+ assert_equal(1, migrator.current_version)
+
+ migrator.forward(2)
+ assert_equal(3, migrator.current_version)
+
+ migrator.forward
+ assert_equal(3, migrator.current_version)
+ end
+
+ def test_only_loads_pending_migrations
+ # migrate up to 1
+ ActiveRecord::SchemaMigration.create!(version: "1")
+
+ calls, migrator = migrator_class(3)
+ migrator = migrator.new("valid")
+ migrator.migrate
+
+ assert_equal [[:up, 2], [:up, 3]], calls
+ end
+
+ def test_get_all_versions
+ _, migrator = migrator_class(3)
+ migrator = migrator.new("valid")
+
+ migrator.migrate
+ assert_equal([1, 2, 3], migrator.get_all_versions)
+
+ migrator.rollback
+ assert_equal([1, 2], migrator.get_all_versions)
+
+ migrator.rollback
+ assert_equal([1], migrator.get_all_versions)
+
+ migrator.rollback
+ assert_equal([], migrator.get_all_versions)
+ end
+
+ private
+ def m(name, version)
+ x = Sensor.new name, version
+ x.extend(Module.new {
+ define_method(:up) { yield(:up, x); super() }
+ define_method(:down) { yield(:down, x); super() }
+ }) if block_given?
+ end
+
+ def sensors(count)
+ calls = []
+ migrations = count.times.map { |i|
+ m(nil, i + 1) { |c, migration|
+ calls << [c, migration.version]
+ }
+ }
+ [calls, migrations]
+ end
+
+ def migrator_class(count)
+ calls, migrations = sensors(count)
+
+ migrator = Class.new(ActiveRecord::MigrationContext) {
+ define_method(:migrations) { |*|
+ migrations
+ }
+ }
+ [calls, migrator]
+ end
+end
diff --git a/activerecord/test/cases/mixin_test.rb b/activerecord/test/cases/mixin_test.rb
new file mode 100644
index 0000000000..fdb8ac6ab3
--- /dev/null
+++ b/activerecord/test/cases/mixin_test.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class Mixin < ActiveRecord::Base
+end
+
+class TouchTest < ActiveRecord::TestCase
+ fixtures :mixins
+
+ setup do
+ travel_to Time.now
+ end
+
+ def test_update
+ stamped = Mixin.new
+
+ assert_nil stamped.updated_at
+ assert_nil stamped.created_at
+ stamped.save
+ assert_equal Time.now, stamped.updated_at
+ assert_equal Time.now, stamped.created_at
+ end
+
+ def test_create
+ obj = Mixin.create
+ assert_equal Time.now, obj.updated_at
+ assert_equal Time.now, obj.created_at
+ end
+
+ def test_many_updates
+ stamped = Mixin.new
+
+ assert_nil stamped.updated_at
+ assert_nil stamped.created_at
+ stamped.save
+ assert_equal Time.now, stamped.created_at
+ assert_equal Time.now, stamped.updated_at
+
+ old_updated_at = stamped.updated_at
+
+ travel 5.minutes
+ stamped.lft_will_change!
+ stamped.save
+
+ assert_equal Time.now, stamped.updated_at
+ assert_equal old_updated_at, stamped.created_at
+ end
+
+ def test_create_turned_off
+ Mixin.record_timestamps = false
+
+ mixin = Mixin.new
+
+ assert_nil mixin.updated_at
+ mixin.save
+ assert_nil mixin.updated_at
+
+ # Make sure Mixin.record_timestamps gets reset, even if this test fails,
+ # so that other tests do not fail because Mixin.record_timestamps == false
+ ensure
+ Mixin.record_timestamps = true
+ end
+end
diff --git a/activerecord/test/cases/modules_test.rb b/activerecord/test/cases/modules_test.rb
new file mode 100644
index 0000000000..87455e4fcb
--- /dev/null
+++ b/activerecord/test/cases/modules_test.rb
@@ -0,0 +1,174 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/company_in_module"
+require "models/shop"
+require "models/developer"
+require "models/computer"
+
+class ModulesTest < ActiveRecord::TestCase
+ fixtures :accounts, :companies, :projects, :developers, :collections, :products, :variants
+
+ def setup
+ # need to make sure Object::Firm and Object::Client are not defined,
+ # so that constantize will not be able to cheat when having to load namespaced classes
+ @undefined_consts = {}
+
+ [:Firm, :Client].each do |const|
+ @undefined_consts.merge! const => Object.send(:remove_const, const) if Object.const_defined?(const)
+ end
+
+ ActiveRecord::Base.store_full_sti_class = false
+ end
+
+ teardown do
+ # reinstate the constants that we undefined in the setup
+ @undefined_consts.each do |constant, value|
+ Object.send :const_set, constant, value unless value.nil?
+ end
+
+ ActiveRecord::Base.store_full_sti_class = true
+ end
+
+ def test_module_spanning_associations
+ firm = MyApplication::Business::Firm.first
+ assert_not firm.clients.empty?, "Firm should have clients"
+ assert_nil firm.class.table_name.match("::"), "Firm shouldn't have the module appear in its table name"
+ end
+
+ def test_module_spanning_has_and_belongs_to_many_associations
+ project = MyApplication::Business::Project.first
+ project.developers << MyApplication::Business::Developer.create("name" => "John")
+ assert_equal "John", project.developers.last.name
+ end
+
+ def test_associations_spanning_cross_modules
+ account = MyApplication::Billing::Account.all.merge!(order: "id").first
+ assert_kind_of MyApplication::Business::Firm, account.firm
+ assert_kind_of MyApplication::Billing::Firm, account.qualified_billing_firm
+ assert_kind_of MyApplication::Billing::Firm, account.unqualified_billing_firm
+ assert_kind_of MyApplication::Billing::Nested::Firm, account.nested_qualified_billing_firm
+ assert_kind_of MyApplication::Billing::Nested::Firm, account.nested_unqualified_billing_firm
+ end
+
+ def test_find_account_and_include_company
+ account = MyApplication::Billing::Account.all.merge!(includes: :firm).find(1)
+ assert_kind_of MyApplication::Business::Firm, account.firm
+ end
+
+ def test_table_name
+ assert_equal "accounts", MyApplication::Billing::Account.table_name, "table_name for ActiveRecord model in module"
+ assert_equal "companies", MyApplication::Business::Client.table_name, "table_name for ActiveRecord model subclass"
+ assert_equal "company_contacts", MyApplication::Business::Client::Contact.table_name, "table_name for ActiveRecord model enclosed by another ActiveRecord model"
+ end
+
+ def test_assign_ids
+ firm = MyApplication::Business::Firm.first
+
+ assert_nothing_raised do
+ firm.client_ids = [MyApplication::Business::Client.first.id]
+ end
+ end
+
+ # An eager loading condition to force the eager loading model into the old join model.
+ def test_eager_loading_in_modules
+ clients = []
+
+ assert_nothing_raised do
+ clients << MyApplication::Business::Client.references(:accounts).merge!(includes: { firm: :account }, where: "accounts.id IS NOT NULL").find(3)
+ clients << MyApplication::Business::Client.includes(firm: :account).find(3)
+ end
+
+ clients.each do |client|
+ assert_no_queries do
+ assert_not_nil(client.firm.account)
+ end
+ end
+ end
+
+ def test_module_table_name_prefix
+ assert_equal "prefixed_companies", MyApplication::Business::Prefixed::Company.table_name, "inferred table_name for ActiveRecord model in module with table_name_prefix"
+ assert_equal "prefixed_companies", MyApplication::Business::Prefixed::Nested::Company.table_name, "table_name for ActiveRecord model in nested module with a parent table_name_prefix"
+ assert_equal "companies", MyApplication::Business::Prefixed::Firm.table_name, "explicit table_name for ActiveRecord model in module with table_name_prefix should not be prefixed"
+ end
+
+ def test_module_table_name_prefix_with_global_prefix
+ classes = [ MyApplication::Business::Company,
+ MyApplication::Business::Firm,
+ MyApplication::Business::Client,
+ MyApplication::Business::Client::Contact,
+ MyApplication::Business::Developer,
+ MyApplication::Business::Project,
+ MyApplication::Business::Prefixed::Company,
+ MyApplication::Business::Prefixed::Nested::Company,
+ MyApplication::Billing::Account ]
+
+ ActiveRecord::Base.table_name_prefix = "global_"
+ classes.each(&:reset_table_name)
+ assert_equal "global_companies", MyApplication::Business::Company.table_name, "inferred table_name for ActiveRecord model in module without table_name_prefix"
+ assert_equal "prefixed_companies", MyApplication::Business::Prefixed::Company.table_name, "inferred table_name for ActiveRecord model in module with table_name_prefix"
+ assert_equal "prefixed_companies", MyApplication::Business::Prefixed::Nested::Company.table_name, "table_name for ActiveRecord model in nested module with a parent table_name_prefix"
+ assert_equal "companies", MyApplication::Business::Prefixed::Firm.table_name, "explicit table_name for ActiveRecord model in module with table_name_prefix should not be prefixed"
+ ensure
+ ActiveRecord::Base.table_name_prefix = ""
+ classes.each(&:reset_table_name)
+ end
+
+ def test_module_table_name_suffix
+ assert_equal "companies_suffixed", MyApplication::Business::Suffixed::Company.table_name, "inferred table_name for ActiveRecord model in module with table_name_suffix"
+ assert_equal "companies_suffixed", MyApplication::Business::Suffixed::Nested::Company.table_name, "table_name for ActiveRecord model in nested module with a parent table_name_suffix"
+ assert_equal "companies", MyApplication::Business::Suffixed::Firm.table_name, "explicit table_name for ActiveRecord model in module with table_name_suffix should not be suffixed"
+ end
+
+ def test_module_table_name_suffix_with_global_suffix
+ classes = [ MyApplication::Business::Company,
+ MyApplication::Business::Firm,
+ MyApplication::Business::Client,
+ MyApplication::Business::Client::Contact,
+ MyApplication::Business::Developer,
+ MyApplication::Business::Project,
+ MyApplication::Business::Suffixed::Company,
+ MyApplication::Business::Suffixed::Nested::Company,
+ MyApplication::Billing::Account ]
+
+ ActiveRecord::Base.table_name_suffix = "_global"
+ classes.each(&:reset_table_name)
+ assert_equal "companies_global", MyApplication::Business::Company.table_name, "inferred table_name for ActiveRecord model in module without table_name_suffix"
+ assert_equal "companies_suffixed", MyApplication::Business::Suffixed::Company.table_name, "inferred table_name for ActiveRecord model in module with table_name_suffix"
+ assert_equal "companies_suffixed", MyApplication::Business::Suffixed::Nested::Company.table_name, "table_name for ActiveRecord model in nested module with a parent table_name_suffix"
+ assert_equal "companies", MyApplication::Business::Suffixed::Firm.table_name, "explicit table_name for ActiveRecord model in module with table_name_suffix should not be suffixed"
+ ensure
+ ActiveRecord::Base.table_name_suffix = ""
+ classes.each(&:reset_table_name)
+ end
+
+ def test_compute_type_can_infer_class_name_of_sibling_inside_module
+ old = ActiveRecord::Base.store_full_sti_class
+ ActiveRecord::Base.store_full_sti_class = true
+ assert_equal MyApplication::Business::Firm, MyApplication::Business::Client.send(:compute_type, "Firm")
+ ensure
+ ActiveRecord::Base.store_full_sti_class = old
+ end
+
+ def test_nested_models_should_not_raise_exception_when_using_delete_all_dependency_on_association
+ old = ActiveRecord::Base.store_full_sti_class
+ ActiveRecord::Base.store_full_sti_class = true
+
+ collection = Shop::Collection.first
+ assert_not collection.products.empty?, "Collection should have products"
+ assert_nothing_raised { collection.destroy }
+ ensure
+ ActiveRecord::Base.store_full_sti_class = old
+ end
+
+ def test_nested_models_should_not_raise_exception_when_using_nullify_dependency_on_association
+ old = ActiveRecord::Base.store_full_sti_class
+ ActiveRecord::Base.store_full_sti_class = true
+
+ product = Shop::Product.first
+ assert_not product.variants.empty?, "Product should have variants"
+ assert_nothing_raised { product.destroy }
+ ensure
+ ActiveRecord::Base.store_full_sti_class = old
+ end
+end
diff --git a/activerecord/test/cases/multiparameter_attributes_test.rb b/activerecord/test/cases/multiparameter_attributes_test.rb
new file mode 100644
index 0000000000..6f3903eed4
--- /dev/null
+++ b/activerecord/test/cases/multiparameter_attributes_test.rb
@@ -0,0 +1,399 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+require "models/customer"
+
+class MultiParameterAttributeTest < ActiveRecord::TestCase
+ fixtures :topics
+
+ def test_multiparameter_attributes_on_date
+ attributes = { "last_read(1i)" => "2004", "last_read(2i)" => "6", "last_read(3i)" => "24" }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ # note that extra #to_date call allows test to pass for Oracle, which
+ # treats dates/times the same
+ assert_equal Date.new(2004, 6, 24), topic.last_read.to_date
+ end
+
+ def test_multiparameter_attributes_on_date_with_empty_year
+ attributes = { "last_read(1i)" => "", "last_read(2i)" => "6", "last_read(3i)" => "24" }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ assert_nil topic.last_read
+ end
+
+ def test_multiparameter_attributes_on_date_with_empty_month
+ attributes = { "last_read(1i)" => "2004", "last_read(2i)" => "", "last_read(3i)" => "24" }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ assert_nil topic.last_read
+ end
+
+ def test_multiparameter_attributes_on_date_with_empty_day
+ attributes = { "last_read(1i)" => "2004", "last_read(2i)" => "6", "last_read(3i)" => "" }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ assert_nil topic.last_read
+ end
+
+ def test_multiparameter_attributes_on_date_with_empty_day_and_year
+ attributes = { "last_read(1i)" => "", "last_read(2i)" => "6", "last_read(3i)" => "" }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ assert_nil topic.last_read
+ end
+
+ def test_multiparameter_attributes_on_date_with_empty_day_and_month
+ attributes = { "last_read(1i)" => "2004", "last_read(2i)" => "", "last_read(3i)" => "" }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ assert_nil topic.last_read
+ end
+
+ def test_multiparameter_attributes_on_date_with_empty_year_and_month
+ attributes = { "last_read(1i)" => "", "last_read(2i)" => "", "last_read(3i)" => "24" }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ assert_nil topic.last_read
+ end
+
+ def test_multiparameter_attributes_on_date_with_all_empty
+ attributes = { "last_read(1i)" => "", "last_read(2i)" => "", "last_read(3i)" => "" }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ assert_nil topic.last_read
+ end
+
+ def test_multiparameter_attributes_on_time
+ with_timezone_config default: :local do
+ attributes = {
+ "written_on(1i)" => "2004", "written_on(2i)" => "6", "written_on(3i)" => "24",
+ "written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "00"
+ }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ assert_equal Time.local(2004, 6, 24, 16, 24, 0), topic.written_on
+ end
+ end
+
+ def test_multiparameter_attributes_on_time_with_no_date
+ ex = assert_raise(ActiveRecord::MultiparameterAssignmentErrors) do
+ attributes = {
+ "written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "00"
+ }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ end
+ assert_equal("written_on", ex.errors[0].attribute)
+ end
+
+ def test_multiparameter_attributes_on_time_with_invalid_time_params
+ ex = assert_raise(ActiveRecord::MultiparameterAssignmentErrors) do
+ attributes = {
+ "written_on(1i)" => "2004", "written_on(2i)" => "6", "written_on(3i)" => "24",
+ "written_on(4i)" => "2004", "written_on(5i)" => "36", "written_on(6i)" => "64",
+ }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ end
+ assert_equal("written_on", ex.errors[0].attribute)
+ end
+
+ def test_multiparameter_attributes_on_time_with_old_date
+ attributes = {
+ "written_on(1i)" => "1850", "written_on(2i)" => "6", "written_on(3i)" => "24",
+ "written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "00"
+ }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ # testing against to_s(:db) representation because either a Time or a DateTime might be returned, depending on platform
+ assert_equal "1850-06-24 16:24:00", topic.written_on.to_s(:db)
+ end
+
+ def test_multiparameter_attributes_on_time_will_raise_on_big_time_if_missing_date_parts
+ ex = assert_raise(ActiveRecord::MultiparameterAssignmentErrors) do
+ attributes = {
+ "written_on(4i)" => "16", "written_on(5i)" => "24"
+ }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ end
+ assert_equal("written_on", ex.errors[0].attribute)
+ end
+
+ def test_multiparameter_attributes_on_time_with_raise_on_small_time_if_missing_date_parts
+ ex = assert_raise(ActiveRecord::MultiparameterAssignmentErrors) do
+ attributes = {
+ "written_on(4i)" => "16", "written_on(5i)" => "12", "written_on(6i)" => "02"
+ }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ end
+ assert_equal("written_on", ex.errors[0].attribute)
+ end
+
+ def test_multiparameter_attributes_on_time_will_ignore_hour_if_missing
+ with_timezone_config default: :local do
+ attributes = {
+ "written_on(1i)" => "2004", "written_on(2i)" => "12", "written_on(3i)" => "12",
+ "written_on(5i)" => "12", "written_on(6i)" => "02"
+ }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ assert_equal Time.local(2004, 12, 12, 0, 12, 2), topic.written_on
+ end
+ end
+
+ def test_multiparameter_attributes_on_time_will_ignore_hour_if_blank
+ attributes = {
+ "written_on(1i)" => "", "written_on(2i)" => "", "written_on(3i)" => "",
+ "written_on(4i)" => "", "written_on(5i)" => "12", "written_on(6i)" => "02"
+ }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ assert_nil topic.written_on
+ end
+
+ def test_multiparameter_attributes_on_time_will_ignore_date_if_empty
+ attributes = {
+ "written_on(1i)" => "", "written_on(2i)" => "", "written_on(3i)" => "",
+ "written_on(4i)" => "16", "written_on(5i)" => "24"
+ }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ assert_nil topic.written_on
+ end
+
+ def test_multiparameter_attributes_on_time_with_seconds_will_ignore_date_if_empty
+ attributes = {
+ "written_on(1i)" => "", "written_on(2i)" => "", "written_on(3i)" => "",
+ "written_on(4i)" => "16", "written_on(5i)" => "12", "written_on(6i)" => "02"
+ }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ assert_nil topic.written_on
+ end
+
+ def test_multiparameter_attributes_on_time_with_utc
+ with_timezone_config default: :utc do
+ attributes = {
+ "written_on(1i)" => "2004", "written_on(2i)" => "6", "written_on(3i)" => "24",
+ "written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "00"
+ }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ assert_equal Time.utc(2004, 6, 24, 16, 24, 0), topic.written_on
+ end
+ end
+
+ def test_multiparameter_attributes_on_time_with_time_zone_aware_attributes
+ with_timezone_config default: :utc, aware_attributes: true, zone: -28800 do
+ Topic.reset_column_information
+ attributes = {
+ "written_on(1i)" => "2004", "written_on(2i)" => "6", "written_on(3i)" => "24",
+ "written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "00"
+ }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ assert_equal Time.utc(2004, 6, 24, 23, 24, 0), topic.written_on
+ assert_equal Time.utc(2004, 6, 24, 16, 24, 0), topic.written_on.time
+ assert_equal Time.zone, topic.written_on.time_zone
+ end
+ ensure
+ Topic.reset_column_information
+ end
+
+ def test_multiparameter_attributes_on_time_with_time_zone_aware_attributes_and_invalid_time_params
+ with_timezone_config aware_attributes: true do
+ Topic.reset_column_information
+ attributes = {
+ "written_on(1i)" => "2004", "written_on(2i)" => "", "written_on(3i)" => ""
+ }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ assert_nil topic.written_on
+ end
+ ensure
+ Topic.reset_column_information
+ end
+
+ def test_multiparameter_attributes_on_time_with_time_zone_aware_attributes_false
+ with_timezone_config default: :local, aware_attributes: false, zone: -28800 do
+ attributes = {
+ "written_on(1i)" => "2004", "written_on(2i)" => "6", "written_on(3i)" => "24",
+ "written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "00"
+ }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ assert_equal Time.local(2004, 6, 24, 16, 24, 0), topic.written_on
+ assert_not_respond_to topic.written_on, :time_zone
+ end
+ end
+
+ def test_multiparameter_attributes_on_time_with_skip_time_zone_conversion_for_attributes
+ with_timezone_config default: :utc, aware_attributes: true, zone: -28800 do
+ Topic.skip_time_zone_conversion_for_attributes = [:written_on]
+ Topic.reset_column_information
+ attributes = {
+ "written_on(1i)" => "2004", "written_on(2i)" => "6", "written_on(3i)" => "24",
+ "written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "00"
+ }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ assert_equal Time.utc(2004, 6, 24, 16, 24, 0), topic.written_on
+ assert_not_respond_to topic.written_on, :time_zone
+ end
+ ensure
+ Topic.skip_time_zone_conversion_for_attributes = []
+ Topic.reset_column_information
+ end
+
+ # Oracle does not have a TIME datatype.
+ unless current_adapter?(:OracleAdapter)
+ def test_multiparameter_attributes_on_time_only_column_with_time_zone_aware_attributes_does_not_do_time_zone_conversion
+ with_timezone_config default: :utc, aware_attributes: true, zone: -28800 do
+ Topic.reset_column_information
+ attributes = {
+ "bonus_time(1i)" => "2000", "bonus_time(2i)" => "1", "bonus_time(3i)" => "1",
+ "bonus_time(4i)" => "16", "bonus_time(5i)" => "24"
+ }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ assert_equal Time.zone.local(2000, 1, 1, 16, 24, 0), topic.bonus_time
+ assert_not_predicate topic.bonus_time, :utc?
+
+ attributes = {
+ "written_on(1i)" => "2000", "written_on(2i)" => "", "written_on(3i)" => "",
+ "written_on(4i)" => "", "written_on(5i)" => ""
+ }
+ topic.attributes = attributes
+ assert_nil topic.written_on
+ end
+ ensure
+ Topic.reset_column_information
+ end
+
+ def test_multiparameter_attributes_setting_time_attribute
+ topic = Topic.new("bonus_time(4i)" => "01", "bonus_time(5i)" => "05")
+ assert_equal 1, topic.bonus_time.hour
+ assert_equal 5, topic.bonus_time.min
+ end
+ end
+
+ def test_multiparameter_attributes_on_time_with_empty_seconds
+ with_timezone_config default: :local do
+ attributes = {
+ "written_on(1i)" => "2004", "written_on(2i)" => "6", "written_on(3i)" => "24",
+ "written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => ""
+ }
+ topic = Topic.find(1)
+ topic.attributes = attributes
+ assert_equal Time.local(2004, 6, 24, 16, 24, 0), topic.written_on
+ end
+ end
+
+ def test_multiparameter_attributes_setting_date_attribute
+ topic = Topic.new("written_on(1i)" => "1952", "written_on(2i)" => "3", "written_on(3i)" => "11")
+ assert_equal 1952, topic.written_on.year
+ assert_equal 3, topic.written_on.month
+ assert_equal 11, topic.written_on.day
+ end
+
+ def test_create_with_multiparameter_attributes_setting_date_attribute
+ topic = Topic.create_with("written_on(1i)" => "1952", "written_on(2i)" => "3", "written_on(3i)" => "11").new
+ assert_equal 1952, topic.written_on.year
+ assert_equal 3, topic.written_on.month
+ assert_equal 11, topic.written_on.day
+ end
+
+ def test_multiparameter_attributes_setting_date_and_time_attribute
+ topic = Topic.new(
+ "written_on(1i)" => "1952",
+ "written_on(2i)" => "3",
+ "written_on(3i)" => "11",
+ "written_on(4i)" => "13",
+ "written_on(5i)" => "55")
+ assert_equal 1952, topic.written_on.year
+ assert_equal 3, topic.written_on.month
+ assert_equal 11, topic.written_on.day
+ assert_equal 13, topic.written_on.hour
+ assert_equal 55, topic.written_on.min
+ end
+
+ def test_create_with_multiparameter_attributes_setting_date_and_time_attribute
+ topic = Topic.create_with(
+ "written_on(1i)" => "1952",
+ "written_on(2i)" => "3",
+ "written_on(3i)" => "11",
+ "written_on(4i)" => "13",
+ "written_on(5i)" => "55").new
+ assert_equal 1952, topic.written_on.year
+ assert_equal 3, topic.written_on.month
+ assert_equal 11, topic.written_on.day
+ assert_equal 13, topic.written_on.hour
+ assert_equal 55, topic.written_on.min
+ end
+
+ def test_multiparameter_attributes_setting_time_but_not_date_on_date_field
+ assert_raise(ActiveRecord::MultiparameterAssignmentErrors) do
+ Topic.new("written_on(4i)" => "13", "written_on(5i)" => "55")
+ end
+ end
+
+ def test_multiparameter_assignment_of_aggregation
+ customer = Customer.new
+ address = Address.new("The Street", "The City", "The Country")
+ attributes = { "address(1)" => address.street, "address(2)" => address.city, "address(3)" => address.country }
+ customer.attributes = attributes
+ assert_equal address, customer.address
+ end
+
+ def test_multiparameter_assignment_of_aggregation_out_of_order
+ customer = Customer.new
+ address = Address.new("The Street", "The City", "The Country")
+ attributes = { "address(3)" => address.country, "address(2)" => address.city, "address(1)" => address.street }
+ customer.attributes = attributes
+ assert_equal address, customer.address
+ end
+
+ def test_multiparameter_assignment_of_aggregation_with_missing_values
+ ex = assert_raise(ActiveRecord::MultiparameterAssignmentErrors) do
+ customer = Customer.new
+ address = Address.new("The Street", "The City", "The Country")
+ attributes = { "address(2)" => address.city, "address(3)" => address.country }
+ customer.attributes = attributes
+ end
+ assert_equal("address", ex.errors[0].attribute)
+ end
+
+ def test_multiparameter_assignment_of_aggregation_with_blank_values
+ customer = Customer.new
+ address = Address.new("The Street", "The City", "The Country")
+ attributes = { "address(1)" => "", "address(2)" => address.city, "address(3)" => address.country }
+ customer.attributes = attributes
+ assert_equal Address.new(nil, "The City", "The Country"), customer.address
+ end
+
+ def test_multiparameter_assignment_of_aggregation_with_large_index
+ ex = assert_raise(ActiveRecord::MultiparameterAssignmentErrors) do
+ customer = Customer.new
+ address = Address.new("The Street", "The City", "The Country")
+ attributes = { "address(1)" => "The Street", "address(2)" => address.city, "address(3000)" => address.country }
+ customer.attributes = attributes
+ end
+
+ assert_equal("address", ex.errors[0].attribute)
+ end
+
+ def test_multiparameter_assigned_attributes_did_not_come_from_user
+ topic = Topic.new(
+ "written_on(1i)" => "1952",
+ "written_on(2i)" => "3",
+ "written_on(3i)" => "11",
+ "written_on(4i)" => "13",
+ "written_on(5i)" => "55",
+ )
+ assert_not_predicate topic, :written_on_came_from_user?
+ end
+end
diff --git a/activerecord/test/cases/multiple_db_test.rb b/activerecord/test/cases/multiple_db_test.rb
new file mode 100644
index 0000000000..f11c441c65
--- /dev/null
+++ b/activerecord/test/cases/multiple_db_test.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/entrant"
+require "models/bird"
+require "models/course"
+
+class MultipleDbTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ def setup
+ @courses = create_fixtures("courses") { Course.retrieve_connection }
+ @colleges = create_fixtures("colleges") { College.retrieve_connection }
+ @entrants = create_fixtures("entrants")
+ end
+
+ def test_connected
+ assert_not_nil Entrant.connection
+ assert_not_nil Course.connection
+ end
+
+ def test_proper_connection
+ assert_not_equal(Entrant.connection, Course.connection)
+ assert_equal(Entrant.connection, Entrant.retrieve_connection)
+ assert_equal(Course.connection, Course.retrieve_connection)
+ assert_equal(ActiveRecord::Base.connection, Entrant.connection)
+ end
+
+ def test_swapping_the_connection
+ old_spec_name, Course.connection_specification_name = Course.connection_specification_name, "primary"
+ assert_equal(Entrant.connection, Course.connection)
+ ensure
+ Course.connection_specification_name = old_spec_name
+ end
+
+ def test_find
+ c1 = Course.find(1)
+ assert_equal "Ruby Development", c1.name
+ c2 = Course.find(2)
+ assert_equal "Java Development", c2.name
+ e1 = Entrant.find(1)
+ assert_equal "Ruby Developer", e1.name
+ e2 = Entrant.find(2)
+ assert_equal "Ruby Guru", e2.name
+ e3 = Entrant.find(3)
+ assert_equal "Java Lover", e3.name
+ end
+
+ def test_associations
+ c1 = Course.find(1)
+ assert_equal 2, c1.entrants.count
+ e1 = Entrant.find(1)
+ assert_equal e1.course.id, c1.id
+ c2 = Course.find(2)
+ assert_equal 1, c2.entrants.count
+ e3 = Entrant.find(3)
+ assert_equal e3.course.id, c2.id
+ end
+
+ def test_course_connection_should_survive_dependency_reload
+ assert Course.connection
+
+ ActiveSupport::Dependencies.clear
+ Object.send(:remove_const, :Course)
+ require_dependency "models/course"
+
+ assert Course.connection
+ end
+
+ def test_transactions_across_databases
+ c1 = Course.find(1)
+ e1 = Entrant.find(1)
+
+ begin
+ Course.transaction do
+ Entrant.transaction do
+ c1.name = "Typo"
+ e1.name = "Typo"
+ c1.save
+ e1.save
+ raise "No I messed up."
+ end
+ end
+ rescue
+ # Yup caught it
+ end
+
+ assert_equal "Typo", c1.name
+ assert_equal "Typo", e1.name
+
+ assert_equal "Ruby Development", Course.find(1).name
+ assert_equal "Ruby Developer", Entrant.find(1).name
+ end
+
+ def test_connection
+ assert_same Entrant.connection, Bird.connection
+ assert_not_same Entrant.connection, Course.connection
+ end
+
+ unless in_memory_db?
+ def test_count_on_custom_connection
+ ActiveRecord::Base.remove_connection
+ assert_equal 1, College.count
+ ensure
+ ActiveRecord::Base.establish_connection :arunit
+ end
+
+ def test_associations_should_work_when_model_has_no_connection
+ ActiveRecord::Base.remove_connection
+ assert_nothing_raised do
+ College.first.courses.first
+ end
+ ensure
+ ActiveRecord::Base.establish_connection :arunit
+ end
+ end
+end
diff --git a/activerecord/test/cases/nested_attributes_test.rb b/activerecord/test/cases/nested_attributes_test.rb
new file mode 100644
index 0000000000..bb1c1ea17d
--- /dev/null
+++ b/activerecord/test/cases/nested_attributes_test.rb
@@ -0,0 +1,1120 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/pirate"
+require "models/ship"
+require "models/ship_part"
+require "models/bird"
+require "models/parrot"
+require "models/treasure"
+require "models/man"
+require "models/interest"
+require "models/owner"
+require "models/pet"
+require "active_support/hash_with_indifferent_access"
+
+class TestNestedAttributesInGeneral < ActiveRecord::TestCase
+ teardown do
+ Pirate.accepts_nested_attributes_for :ship, allow_destroy: true, reject_if: proc(&:empty?)
+ end
+
+ def test_base_should_have_an_empty_nested_attributes_options
+ assert_equal Hash.new, ActiveRecord::Base.nested_attributes_options
+ end
+
+ def test_should_add_a_proc_to_nested_attributes_options
+ assert_equal ActiveRecord::NestedAttributes::ClassMethods::REJECT_ALL_BLANK_PROC,
+ Pirate.nested_attributes_options[:birds_with_reject_all_blank][:reject_if]
+
+ [:parrots, :birds].each do |name|
+ assert_instance_of Proc, Pirate.nested_attributes_options[name][:reject_if]
+ end
+ end
+
+ def test_should_not_build_a_new_record_using_reject_all_even_if_destroy_is_given
+ pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?")
+ pirate.birds_with_reject_all_blank_attributes = [{ name: "", color: "", _destroy: "0" }]
+ pirate.save!
+
+ assert_empty pirate.birds_with_reject_all_blank
+ end
+
+ def test_should_not_build_a_new_record_if_reject_all_blank_returns_false
+ pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?")
+ pirate.birds_with_reject_all_blank_attributes = [{ name: "", color: "" }]
+ pirate.save!
+
+ assert_empty pirate.birds_with_reject_all_blank
+ end
+
+ def test_should_build_a_new_record_if_reject_all_blank_does_not_return_false
+ pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?")
+ pirate.birds_with_reject_all_blank_attributes = [{ name: "Tweetie", color: "" }]
+ pirate.save!
+
+ assert_equal 1, pirate.birds_with_reject_all_blank.count
+ assert_equal "Tweetie", pirate.birds_with_reject_all_blank.first.name
+ end
+
+ def test_should_raise_an_ArgumentError_for_non_existing_associations
+ exception = assert_raise ArgumentError do
+ Pirate.accepts_nested_attributes_for :honesty
+ end
+ assert_equal "No association found for name `honesty'. Has it been defined yet?", exception.message
+ end
+
+ def test_should_raise_an_UnknownAttributeError_for_non_existing_nested_attributes
+ exception = assert_raise ActiveModel::UnknownAttributeError do
+ Pirate.new(ship_attributes: { sail: true })
+ end
+ assert_equal "unknown attribute 'sail' for Ship.", exception.message
+ end
+
+ def test_should_disable_allow_destroy_by_default
+ Pirate.accepts_nested_attributes_for :ship
+
+ pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?")
+ ship = pirate.create_ship(name: "Nights Dirty Lightning")
+
+ pirate.update(ship_attributes: { "_destroy" => true, :id => ship.id })
+
+ assert_nothing_raised { pirate.ship.reload }
+ end
+
+ def test_a_model_should_respond_to_underscore_destroy_and_return_if_it_is_marked_for_destruction
+ ship = Ship.create!(name: "Nights Dirty Lightning")
+ assert_not ship._destroy
+ ship.mark_for_destruction
+ assert ship._destroy
+ end
+
+ def test_reject_if_method_without_arguments
+ Pirate.accepts_nested_attributes_for :ship, reject_if: :new_record?
+
+ pirate = Pirate.new(catchphrase: "Stop wastin' me time")
+ pirate.ship_attributes = { name: "Black Pearl" }
+ assert_no_difference("Ship.count") { pirate.save! }
+ end
+
+ def test_reject_if_method_with_arguments
+ Pirate.accepts_nested_attributes_for :ship, reject_if: :reject_empty_ships_on_create
+
+ pirate = Pirate.new(catchphrase: "Stop wastin' me time")
+ pirate.ship_attributes = { name: "Red Pearl", _reject_me_if_new: true }
+ assert_no_difference("Ship.count") { pirate.save! }
+
+ # pirate.reject_empty_ships_on_create returns false for saved pirate records
+ # in the previous step note that pirate gets saved but ship fails
+ pirate.ship_attributes = { name: "Red Pearl", _reject_me_if_new: true }
+ assert_difference("Ship.count") { pirate.save! }
+ end
+
+ def test_reject_if_with_indifferent_keys
+ Pirate.accepts_nested_attributes_for :ship, reject_if: proc { |attributes| attributes[:name].blank? }
+
+ pirate = Pirate.new(catchphrase: "Stop wastin' me time")
+ pirate.ship_attributes = { name: "Hello Pearl" }
+ assert_difference("Ship.count") { pirate.save! }
+ end
+
+ def test_reject_if_with_a_proc_which_returns_true_always_for_has_one
+ Pirate.accepts_nested_attributes_for :ship, reject_if: proc { |attributes| true }
+ pirate = Pirate.create(catchphrase: "Stop wastin' me time")
+ ship = pirate.create_ship(name: "s1")
+ pirate.update(ship_attributes: { name: "s2", id: ship.id })
+ assert_equal "s1", ship.reload.name
+ end
+
+ def test_reuse_already_built_new_record
+ pirate = Pirate.new
+ ship_built_first = pirate.build_ship
+ pirate.ship_attributes = { name: "Ship 1" }
+ assert_equal ship_built_first.object_id, pirate.ship.object_id
+ end
+
+ def test_do_not_allow_assigning_foreign_key_when_reusing_existing_new_record
+ pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?")
+ pirate.build_ship
+ pirate.ship_attributes = { name: "Ship 1", pirate_id: pirate.id + 1 }
+ assert_equal pirate.id, pirate.ship.pirate_id
+ end
+
+ def test_reject_if_with_a_proc_which_returns_true_always_for_has_many
+ Man.accepts_nested_attributes_for :interests, reject_if: proc { |attributes| true }
+ man = Man.create(name: "John")
+ interest = man.interests.create(topic: "photography")
+ man.update(interests_attributes: { topic: "gardening", id: interest.id })
+ assert_equal "photography", interest.reload.topic
+ end
+
+ def test_destroy_works_independent_of_reject_if
+ Man.accepts_nested_attributes_for :interests, reject_if: proc { |attributes| true }, allow_destroy: true
+ man = Man.create(name: "Jon")
+ interest = man.interests.create(topic: "the ladies")
+ man.update(interests_attributes: { _destroy: "1", id: interest.id })
+ assert_empty man.reload.interests
+ end
+
+ def test_reject_if_is_not_short_circuited_if_allow_destroy_is_false
+ Pirate.accepts_nested_attributes_for :ship, reject_if: ->(a) { a[:name] == "The Golden Hind" }, allow_destroy: false
+
+ pirate = Pirate.create!(catchphrase: "Stop wastin' me time", ship_attributes: { name: "White Pearl", _destroy: "1" })
+ assert_equal "White Pearl", pirate.reload.ship.name
+
+ pirate.update!(ship_attributes: { id: pirate.ship.id, name: "The Golden Hind", _destroy: "1" })
+ assert_equal "White Pearl", pirate.reload.ship.name
+
+ pirate.update!(ship_attributes: { id: pirate.ship.id, name: "Black Pearl", _destroy: "1" })
+ assert_equal "Black Pearl", pirate.reload.ship.name
+ end
+
+ def test_has_many_association_updating_a_single_record
+ Man.accepts_nested_attributes_for(:interests)
+ man = Man.create(name: "John")
+ interest = man.interests.create(topic: "photography")
+ man.update(interests_attributes: { topic: "gardening", id: interest.id })
+ assert_equal "gardening", interest.reload.topic
+ end
+
+ def test_reject_if_with_blank_nested_attributes_id
+ # When using a select list to choose an existing 'ship' id, with include_blank: true
+ Pirate.accepts_nested_attributes_for :ship, reject_if: proc { |attributes| attributes[:id].blank? }
+
+ pirate = Pirate.new(catchphrase: "Stop wastin' me time")
+ pirate.ship_attributes = { id: "" }
+ assert_nothing_raised { pirate.save! }
+ end
+
+ def test_first_and_array_index_zero_methods_return_the_same_value_when_nested_attributes_are_set_to_update_existing_record
+ Man.accepts_nested_attributes_for(:interests)
+ man = Man.create(name: "John")
+ interest = man.interests.create topic: "gardening"
+ man = Man.find man.id
+ man.interests_attributes = [{ id: interest.id, topic: "gardening" }]
+ assert_equal man.interests.first.topic, man.interests[0].topic
+ end
+
+ def test_allows_class_to_override_setter_and_call_super
+ mean_pirate_class = Class.new(Pirate) do
+ accepts_nested_attributes_for :parrot
+ def parrot_attributes=(attrs)
+ super(attrs.merge(color: "blue"))
+ end
+ end
+ mean_pirate = mean_pirate_class.new
+ mean_pirate.parrot_attributes = { name: "James" }
+ assert_equal "James", mean_pirate.parrot.name
+ assert_equal "blue", mean_pirate.parrot.color
+ end
+
+ def test_accepts_nested_attributes_for_can_be_overridden_in_subclasses
+ Pirate.accepts_nested_attributes_for(:parrot)
+
+ mean_pirate_class = Class.new(Pirate) do
+ accepts_nested_attributes_for :parrot
+ end
+ mean_pirate = mean_pirate_class.new
+ mean_pirate.parrot_attributes = { name: "James" }
+ assert_equal "James", mean_pirate.parrot.name
+ end
+
+ def test_should_not_create_duplicates_with_create_with
+ Man.accepts_nested_attributes_for(:interests)
+
+ assert_difference("Interest.count", 1) do
+ Man.create_with(
+ interests_attributes: [{ topic: "Pirate king" }]
+ ).find_or_create_by!(
+ name: "Monkey D. Luffy"
+ )
+ end
+ end
+end
+
+class TestNestedAttributesOnAHasOneAssociation < ActiveRecord::TestCase
+ def setup
+ @pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?")
+ @ship = @pirate.create_ship(name: "Nights Dirty Lightning")
+ end
+
+ def test_should_raise_argument_error_if_trying_to_build_polymorphic_belongs_to
+ exception = assert_raise ArgumentError do
+ Treasure.new(name: "pearl", looter_attributes: { catchphrase: "Arrr" })
+ end
+ assert_equal "Cannot build association `looter'. Are you trying to build a polymorphic one-to-one association?", exception.message
+ end
+
+ def test_should_define_an_attribute_writer_method_for_the_association
+ assert_respond_to @pirate, :ship_attributes=
+ end
+
+ def test_should_build_a_new_record_if_there_is_no_id
+ @ship.destroy
+ @pirate.reload.ship_attributes = { name: "Davy Jones Gold Dagger" }
+
+ assert_not_predicate @pirate.ship, :persisted?
+ assert_equal "Davy Jones Gold Dagger", @pirate.ship.name
+ end
+
+ def test_should_not_build_a_new_record_if_there_is_no_id_and_destroy_is_truthy
+ @ship.destroy
+ @pirate.reload.ship_attributes = { name: "Davy Jones Gold Dagger", _destroy: "1" }
+
+ assert_nil @pirate.ship
+ end
+
+ def test_should_not_build_a_new_record_if_a_reject_if_proc_returns_false
+ @ship.destroy
+ @pirate.reload.ship_attributes = {}
+
+ assert_nil @pirate.ship
+ end
+
+ def test_should_replace_an_existing_record_if_there_is_no_id
+ @pirate.reload.ship_attributes = { name: "Davy Jones Gold Dagger" }
+
+ assert_not_predicate @pirate.ship, :persisted?
+ assert_equal "Davy Jones Gold Dagger", @pirate.ship.name
+ assert_equal "Nights Dirty Lightning", @ship.name
+ end
+
+ def test_should_not_replace_an_existing_record_if_there_is_no_id_and_destroy_is_truthy
+ @pirate.reload.ship_attributes = { name: "Davy Jones Gold Dagger", _destroy: "1" }
+
+ assert_equal @ship, @pirate.ship
+ assert_equal "Nights Dirty Lightning", @pirate.ship.name
+ end
+
+ def test_should_modify_an_existing_record_if_there_is_a_matching_id
+ @pirate.reload.ship_attributes = { id: @ship.id, name: "Davy Jones Gold Dagger" }
+
+ assert_equal @ship, @pirate.ship
+ assert_equal "Davy Jones Gold Dagger", @pirate.ship.name
+ end
+
+ def test_should_raise_RecordNotFound_if_an_id_is_given_but_doesnt_return_a_record
+ exception = assert_raise ActiveRecord::RecordNotFound do
+ @pirate.ship_attributes = { id: 1234567890 }
+ end
+ assert_equal "Couldn't find Ship with ID=1234567890 for Pirate with ID=#{@pirate.id}", exception.message
+ end
+
+ def test_should_take_a_hash_with_string_keys_and_update_the_associated_model
+ @pirate.reload.ship_attributes = { "id" => @ship.id, "name" => "Davy Jones Gold Dagger" }
+
+ assert_equal @ship, @pirate.ship
+ assert_equal "Davy Jones Gold Dagger", @pirate.ship.name
+ end
+
+ def test_should_modify_an_existing_record_if_there_is_a_matching_composite_id
+ @ship.stub(:id, "ABC1X") do
+ @pirate.ship_attributes = { id: @ship.id, name: "Davy Jones Gold Dagger" }
+
+ assert_equal "Davy Jones Gold Dagger", @pirate.ship.name
+ end
+ end
+
+ def test_should_destroy_an_existing_record_if_there_is_a_matching_id_and_destroy_is_truthy
+ @pirate.ship.destroy
+
+ [1, "1", true, "true"].each do |truth|
+ ship = @pirate.reload.create_ship(name: "Mister Pablo")
+ @pirate.update(ship_attributes: { id: ship.id, _destroy: truth })
+
+ assert_nil @pirate.reload.ship
+ assert_raise(ActiveRecord::RecordNotFound) { Ship.find(ship.id) }
+ end
+ end
+
+ def test_should_not_destroy_an_existing_record_if_destroy_is_not_truthy
+ [nil, "0", 0, "false", false].each do |not_truth|
+ @pirate.update(ship_attributes: { id: @pirate.ship.id, _destroy: not_truth })
+
+ assert_equal @ship, @pirate.reload.ship
+ end
+ end
+
+ def test_should_not_destroy_an_existing_record_if_allow_destroy_is_false
+ Pirate.accepts_nested_attributes_for :ship, allow_destroy: false, reject_if: proc(&:empty?)
+
+ @pirate.update(ship_attributes: { id: @pirate.ship.id, _destroy: "1" })
+
+ assert_equal @ship, @pirate.reload.ship
+
+ Pirate.accepts_nested_attributes_for :ship, allow_destroy: true, reject_if: proc(&:empty?)
+ end
+
+ def test_should_also_work_with_a_HashWithIndifferentAccess
+ @pirate.ship_attributes = ActiveSupport::HashWithIndifferentAccess.new(id: @ship.id, name: "Davy Jones Gold Dagger")
+
+ assert_predicate @pirate.ship, :persisted?
+ assert_equal "Davy Jones Gold Dagger", @pirate.ship.name
+ end
+
+ def test_should_work_with_update_as_well
+ @pirate.update(catchphrase: "Arr", ship_attributes: { id: @ship.id, name: "Mister Pablo" })
+ @pirate.reload
+
+ assert_equal "Arr", @pirate.catchphrase
+ assert_equal "Mister Pablo", @pirate.ship.name
+ end
+
+ def test_should_not_destroy_the_associated_model_until_the_parent_is_saved
+ @pirate.attributes = { ship_attributes: { id: @ship.id, _destroy: "1" } }
+
+ assert_not_predicate @pirate.ship, :destroyed?
+ assert_predicate @pirate.ship, :marked_for_destruction?
+
+ @pirate.save
+
+ assert_predicate @pirate.ship, :destroyed?
+ assert_nil @pirate.reload.ship
+ end
+
+ def test_should_automatically_enable_autosave_on_the_association
+ assert Pirate.reflect_on_association(:ship).options[:autosave]
+ end
+
+ def test_should_accept_update_only_option
+ @pirate.update(update_only_ship_attributes: { id: @pirate.ship.id, name: "Mayflower" })
+ end
+
+ def test_should_create_new_model_when_nothing_is_there_and_update_only_is_true
+ @ship.delete
+
+ @pirate.reload.update(update_only_ship_attributes: { name: "Mayflower" })
+
+ assert_not_nil @pirate.ship
+ end
+
+ def test_should_update_existing_when_update_only_is_true_and_no_id_is_given
+ @ship.delete
+ @ship = @pirate.create_update_only_ship(name: "Nights Dirty Lightning")
+
+ @pirate.update(update_only_ship_attributes: { name: "Mayflower" })
+
+ assert_equal "Mayflower", @ship.reload.name
+ assert_equal @ship, @pirate.reload.ship
+ end
+
+ def test_should_update_existing_when_update_only_is_true_and_id_is_given
+ @ship.delete
+ @ship = @pirate.create_update_only_ship(name: "Nights Dirty Lightning")
+
+ @pirate.update(update_only_ship_attributes: { name: "Mayflower", id: @ship.id })
+
+ assert_equal "Mayflower", @ship.reload.name
+ assert_equal @ship, @pirate.reload.ship
+ end
+
+ def test_should_destroy_existing_when_update_only_is_true_and_id_is_given_and_is_marked_for_destruction
+ Pirate.accepts_nested_attributes_for :update_only_ship, update_only: true, allow_destroy: true
+ @ship.delete
+ @ship = @pirate.create_update_only_ship(name: "Nights Dirty Lightning")
+
+ @pirate.update(update_only_ship_attributes: { name: "Mayflower", id: @ship.id, _destroy: true })
+
+ assert_nil @pirate.reload.ship
+ assert_raise(ActiveRecord::RecordNotFound) { Ship.find(@ship.id) }
+
+ Pirate.accepts_nested_attributes_for :update_only_ship, update_only: true, allow_destroy: false
+ end
+end
+
+class TestNestedAttributesOnABelongsToAssociation < ActiveRecord::TestCase
+ def setup
+ @ship = Ship.new(name: "Nights Dirty Lightning")
+ @pirate = @ship.build_pirate(catchphrase: "Aye")
+ @ship.save!
+ end
+
+ def test_should_define_an_attribute_writer_method_for_the_association
+ assert_respond_to @ship, :pirate_attributes=
+ end
+
+ def test_should_build_a_new_record_if_there_is_no_id
+ @pirate.destroy
+ @ship.reload.pirate_attributes = { catchphrase: "Arr" }
+
+ assert_not_predicate @ship.pirate, :persisted?
+ assert_equal "Arr", @ship.pirate.catchphrase
+ end
+
+ def test_should_not_build_a_new_record_if_there_is_no_id_and_destroy_is_truthy
+ @pirate.destroy
+ @ship.reload.pirate_attributes = { catchphrase: "Arr", _destroy: "1" }
+
+ assert_nil @ship.pirate
+ end
+
+ def test_should_not_build_a_new_record_if_a_reject_if_proc_returns_false
+ @pirate.destroy
+ @ship.reload.pirate_attributes = {}
+
+ assert_nil @ship.pirate
+ end
+
+ def test_should_replace_an_existing_record_if_there_is_no_id
+ @ship.reload.pirate_attributes = { catchphrase: "Arr" }
+
+ assert_not_predicate @ship.pirate, :persisted?
+ assert_equal "Arr", @ship.pirate.catchphrase
+ assert_equal "Aye", @pirate.catchphrase
+ end
+
+ def test_should_not_replace_an_existing_record_if_there_is_no_id_and_destroy_is_truthy
+ @ship.reload.pirate_attributes = { catchphrase: "Arr", _destroy: "1" }
+
+ assert_equal @pirate, @ship.pirate
+ assert_equal "Aye", @ship.pirate.catchphrase
+ end
+
+ def test_should_modify_an_existing_record_if_there_is_a_matching_id
+ @ship.reload.pirate_attributes = { id: @pirate.id, catchphrase: "Arr" }
+
+ assert_equal @pirate, @ship.pirate
+ assert_equal "Arr", @ship.pirate.catchphrase
+ end
+
+ def test_should_raise_RecordNotFound_if_an_id_is_given_but_doesnt_return_a_record
+ exception = assert_raise ActiveRecord::RecordNotFound do
+ @ship.pirate_attributes = { id: 1234567890 }
+ end
+ assert_equal "Couldn't find Pirate with ID=1234567890 for Ship with ID=#{@ship.id}", exception.message
+ end
+
+ def test_should_take_a_hash_with_string_keys_and_update_the_associated_model
+ @ship.reload.pirate_attributes = { "id" => @pirate.id, "catchphrase" => "Arr" }
+
+ assert_equal @pirate, @ship.pirate
+ assert_equal "Arr", @ship.pirate.catchphrase
+ end
+
+ def test_should_modify_an_existing_record_if_there_is_a_matching_composite_id
+ @pirate.stub(:id, "ABC1X") do
+ @ship.pirate_attributes = { id: @pirate.id, catchphrase: "Arr" }
+
+ assert_equal "Arr", @ship.pirate.catchphrase
+ end
+ end
+
+ def test_should_destroy_an_existing_record_if_there_is_a_matching_id_and_destroy_is_truthy
+ @ship.pirate.destroy
+ [1, "1", true, "true"].each do |truth|
+ pirate = @ship.reload.create_pirate(catchphrase: "Arr")
+ @ship.update(pirate_attributes: { id: pirate.id, _destroy: truth })
+ assert_raise(ActiveRecord::RecordNotFound) { pirate.reload }
+ end
+ end
+
+ def test_should_unset_association_when_an_existing_record_is_destroyed
+ original_pirate_id = @ship.pirate.id
+ @ship.update! pirate_attributes: { id: @ship.pirate.id, _destroy: true }
+
+ assert_empty Pirate.where(id: original_pirate_id)
+ assert_nil @ship.pirate_id
+ assert_nil @ship.pirate
+
+ @ship.reload
+ assert_empty Pirate.where(id: original_pirate_id)
+ assert_nil @ship.pirate_id
+ assert_nil @ship.pirate
+ end
+
+ def test_should_not_destroy_an_existing_record_if_destroy_is_not_truthy
+ [nil, "0", 0, "false", false].each do |not_truth|
+ @ship.update(pirate_attributes: { id: @ship.pirate.id, _destroy: not_truth })
+ assert_nothing_raised { @ship.pirate.reload }
+ end
+ end
+
+ def test_should_not_destroy_an_existing_record_if_allow_destroy_is_false
+ Ship.accepts_nested_attributes_for :pirate, allow_destroy: false, reject_if: proc(&:empty?)
+
+ @ship.update(pirate_attributes: { id: @ship.pirate.id, _destroy: "1" })
+ assert_nothing_raised { @ship.pirate.reload }
+ ensure
+ Ship.accepts_nested_attributes_for :pirate, allow_destroy: true, reject_if: proc(&:empty?)
+ end
+
+ def test_should_work_with_update_as_well
+ @ship.update(name: "Mister Pablo", pirate_attributes: { catchphrase: "Arr" })
+ @ship.reload
+
+ assert_equal "Mister Pablo", @ship.name
+ assert_equal "Arr", @ship.pirate.catchphrase
+ end
+
+ def test_should_not_destroy_the_associated_model_until_the_parent_is_saved
+ pirate = @ship.pirate
+
+ @ship.attributes = { pirate_attributes: { :id => pirate.id, "_destroy" => true } }
+ assert_nothing_raised { Pirate.find(pirate.id) }
+ @ship.save
+ assert_raise(ActiveRecord::RecordNotFound) { Pirate.find(pirate.id) }
+ end
+
+ def test_should_automatically_enable_autosave_on_the_association
+ assert Ship.reflect_on_association(:pirate).options[:autosave]
+ end
+
+ def test_should_create_new_model_when_nothing_is_there_and_update_only_is_true
+ @pirate.delete
+ @ship.reload.attributes = { update_only_pirate_attributes: { catchphrase: "Arr" } }
+
+ assert_not_predicate @ship.update_only_pirate, :persisted?
+ end
+
+ def test_should_update_existing_when_update_only_is_true_and_no_id_is_given
+ @pirate.delete
+ @pirate = @ship.create_update_only_pirate(catchphrase: "Aye")
+
+ @ship.update(update_only_pirate_attributes: { catchphrase: "Arr" })
+ assert_equal "Arr", @pirate.reload.catchphrase
+ assert_equal @pirate, @ship.reload.update_only_pirate
+ end
+
+ def test_should_update_existing_when_update_only_is_true_and_id_is_given
+ @pirate.delete
+ @pirate = @ship.create_update_only_pirate(catchphrase: "Aye")
+
+ @ship.update(update_only_pirate_attributes: { catchphrase: "Arr", id: @pirate.id })
+
+ assert_equal "Arr", @pirate.reload.catchphrase
+ assert_equal @pirate, @ship.reload.update_only_pirate
+ end
+
+ def test_should_destroy_existing_when_update_only_is_true_and_id_is_given_and_is_marked_for_destruction
+ Ship.accepts_nested_attributes_for :update_only_pirate, update_only: true, allow_destroy: true
+ @pirate.delete
+ @pirate = @ship.create_update_only_pirate(catchphrase: "Aye")
+
+ @ship.update(update_only_pirate_attributes: { catchphrase: "Arr", id: @pirate.id, _destroy: true })
+
+ assert_raise(ActiveRecord::RecordNotFound) { @pirate.reload }
+
+ Ship.accepts_nested_attributes_for :update_only_pirate, update_only: true, allow_destroy: false
+ end
+end
+
+module NestedAttributesOnACollectionAssociationTests
+ def test_should_define_an_attribute_writer_method_for_the_association
+ assert_respond_to @pirate, association_setter
+ end
+
+ def test_should_raise_an_UnknownAttributeError_for_non_existing_nested_attributes_for_has_many
+ exception = assert_raise ActiveModel::UnknownAttributeError do
+ @pirate.parrots_attributes = [{ peg_leg: true }]
+ end
+ assert_equal "unknown attribute 'peg_leg' for Parrot.", exception.message
+ end
+
+ def test_should_save_only_one_association_on_create
+ pirate = Pirate.create!(
+ :catchphrase => "Arr",
+ association_getter => { "foo" => { name: "Grace OMalley" } })
+
+ assert_equal 1, pirate.reload.send(@association_name).count
+ end
+
+ def test_should_take_a_hash_with_string_keys_and_assign_the_attributes_to_the_associated_models
+ @alternate_params[association_getter].stringify_keys!
+ @pirate.update @alternate_params
+ assert_equal ["Grace OMalley", "Privateers Greed"], [@child_1.reload.name, @child_2.reload.name]
+ end
+
+ def test_should_take_an_array_and_assign_the_attributes_to_the_associated_models
+ @pirate.send(association_setter, @alternate_params[association_getter].values)
+ @pirate.save
+ assert_equal ["Grace OMalley", "Privateers Greed"], [@child_1.reload.name, @child_2.reload.name]
+ end
+
+ def test_should_also_work_with_a_HashWithIndifferentAccess
+ @pirate.send(association_setter, ActiveSupport::HashWithIndifferentAccess.new("foo" => ActiveSupport::HashWithIndifferentAccess.new(id: @child_1.id, name: "Grace OMalley")))
+ @pirate.save
+ assert_equal "Grace OMalley", @child_1.reload.name
+ end
+
+ def test_should_take_a_hash_and_assign_the_attributes_to_the_associated_models
+ @pirate.attributes = @alternate_params
+ assert_equal "Grace OMalley", @pirate.send(@association_name).first.name
+ assert_equal "Privateers Greed", @pirate.send(@association_name).last.name
+ end
+
+ def test_should_not_load_association_when_updating_existing_records
+ @pirate.reload
+ @pirate.send(association_setter, [{ id: @child_1.id, name: "Grace OMalley" }])
+ assert_not_predicate @pirate.send(@association_name), :loaded?
+
+ @pirate.save
+ assert_not_predicate @pirate.send(@association_name), :loaded?
+ assert_equal "Grace OMalley", @child_1.reload.name
+ end
+
+ def test_should_not_overwrite_unsaved_updates_when_loading_association
+ @pirate.reload
+ @pirate.send(association_setter, [{ id: @child_1.id, name: "Grace OMalley" }])
+ assert_equal "Grace OMalley", @pirate.send(@association_name).load_target.find { |r| r.id == @child_1.id }.name
+ end
+
+ def test_should_preserve_order_when_not_overwriting_unsaved_updates
+ @pirate.reload
+ @pirate.send(association_setter, [{ id: @child_1.id, name: "Grace OMalley" }])
+ assert_equal @child_1.id, @pirate.send(@association_name).load_target.first.id
+ end
+
+ def test_should_refresh_saved_records_when_not_overwriting_unsaved_updates
+ @pirate.reload
+ record = @pirate.class.reflect_on_association(@association_name).klass.new(name: "Grace OMalley")
+ @pirate.send(@association_name) << record
+ record.save!
+ @pirate.send(@association_name).last.update!(name: "Polly")
+ assert_equal "Polly", @pirate.send(@association_name).load_target.last.name
+ end
+
+ def test_should_not_remove_scheduled_destroys_when_loading_association
+ @pirate.reload
+ @pirate.send(association_setter, [{ id: @child_1.id, _destroy: "1" }])
+ assert_predicate @pirate.send(@association_name).load_target.find { |r| r.id == @child_1.id }, :marked_for_destruction?
+ end
+
+ def test_should_take_a_hash_with_composite_id_keys_and_assign_the_attributes_to_the_associated_models
+ @child_1.stub(:id, "ABC1X") do
+ @child_2.stub(:id, "ABC2X") do
+ @pirate.attributes = {
+ association_getter => [
+ { id: @child_1.id, name: "Grace OMalley" },
+ { id: @child_2.id, name: "Privateers Greed" }
+ ]
+ }
+
+ assert_equal ["Grace OMalley", "Privateers Greed"], [@child_1.name, @child_2.name]
+ end
+ end
+ end
+
+ def test_should_raise_RecordNotFound_if_an_id_is_given_but_doesnt_return_a_record
+ exception = assert_raise ActiveRecord::RecordNotFound do
+ @pirate.attributes = { association_getter => [{ id: 1234567890 }] }
+ end
+ assert_equal "Couldn't find #{@child_1.class.name} with ID=1234567890 for Pirate with ID=#{@pirate.id}", exception.message
+ end
+
+ def test_should_raise_RecordNotFound_if_an_id_belonging_to_a_different_record_is_given
+ other_pirate = Pirate.create! catchphrase: "Ahoy!"
+ other_child = other_pirate.send(@association_name).create! name: "Buccaneers Servant"
+
+ exception = assert_raise ActiveRecord::RecordNotFound do
+ @pirate.attributes = { association_getter => [{ id: other_child.id }] }
+ end
+ assert_equal "Couldn't find #{@child_1.class.name} with ID=#{other_child.id} for Pirate with ID=#{@pirate.id}", exception.message
+ end
+
+ def test_should_automatically_build_new_associated_models_for_each_entry_in_a_hash_where_the_id_is_missing
+ @pirate.send(@association_name).destroy_all
+ @pirate.reload.attributes = {
+ association_getter => { "foo" => { name: "Grace OMalley" }, "bar" => { name: "Privateers Greed" } }
+ }
+
+ assert_not_predicate @pirate.send(@association_name).first, :persisted?
+ assert_equal "Grace OMalley", @pirate.send(@association_name).first.name
+
+ assert_not_predicate @pirate.send(@association_name).last, :persisted?
+ assert_equal "Privateers Greed", @pirate.send(@association_name).last.name
+ end
+
+ def test_should_not_assign_destroy_key_to_a_record
+ assert_nothing_raised do
+ @pirate.send(association_setter, "foo" => { "_destroy" => "0" })
+ end
+ end
+
+ def test_should_ignore_new_associated_records_with_truthy_destroy_attribute
+ @pirate.send(@association_name).destroy_all
+ @pirate.reload.attributes = {
+ association_getter => {
+ "foo" => { name: "Grace OMalley" },
+ "bar" => { :name => "Privateers Greed", "_destroy" => "1" }
+ }
+ }
+
+ assert_equal 1, @pirate.send(@association_name).length
+ assert_equal "Grace OMalley", @pirate.send(@association_name).first.name
+ end
+
+ def test_should_ignore_new_associated_records_if_a_reject_if_proc_returns_false
+ @alternate_params[association_getter]["baz"] = {}
+ assert_no_difference("@pirate.send(@association_name).count") do
+ @pirate.attributes = @alternate_params
+ end
+ end
+
+ def test_should_sort_the_hash_by_the_keys_before_building_new_associated_models
+ attributes = {}
+ attributes["123726353"] = { name: "Grace OMalley" }
+ attributes["2"] = { name: "Privateers Greed" } # 2 is lower then 123726353
+ @pirate.send(association_setter, attributes)
+
+ assert_equal ["Posideons Killer", "Killer bandita Dionne", "Privateers Greed", "Grace OMalley"].to_set, @pirate.send(@association_name).map(&:name).to_set
+ end
+
+ def test_should_raise_an_argument_error_if_something_else_than_a_hash_is_passed
+ assert_nothing_raised { @pirate.send(association_setter, {}) }
+ assert_nothing_raised { @pirate.send(association_setter, Hash.new) }
+
+ exception = assert_raise ArgumentError do
+ @pirate.send(association_setter, "foo")
+ end
+ assert_equal %{Hash or Array expected for attribute `#{@association_name}`, got String ("foo")}, exception.message
+ end
+
+ def test_should_work_with_update_as_well
+ @pirate.update(catchphrase: "Arr",
+ association_getter => { "foo" => { id: @child_1.id, name: "Grace OMalley" } })
+
+ assert_equal "Grace OMalley", @child_1.reload.name
+ end
+
+ def test_should_update_existing_records_and_add_new_ones_that_have_no_id
+ @alternate_params[association_getter]["baz"] = { name: "Buccaneers Servant" }
+ assert_difference("@pirate.send(@association_name).count", +1) do
+ @pirate.update @alternate_params
+ end
+ assert_equal ["Grace OMalley", "Privateers Greed", "Buccaneers Servant"].to_set, @pirate.reload.send(@association_name).map(&:name).to_set
+ end
+
+ def test_should_be_possible_to_destroy_a_record
+ ["1", 1, "true", true].each do |true_variable|
+ record = @pirate.reload.send(@association_name).create!(name: "Grace OMalley")
+ @pirate.send(association_setter,
+ @alternate_params[association_getter].merge("baz" => { :id => record.id, "_destroy" => true_variable })
+ )
+
+ assert_difference("@pirate.send(@association_name).count", -1) do
+ @pirate.save
+ end
+ end
+ end
+
+ def test_should_not_destroy_the_associated_model_with_a_non_truthy_argument
+ [nil, "", "0", 0, "false", false].each do |false_variable|
+ @alternate_params[association_getter]["foo"]["_destroy"] = false_variable
+ assert_no_difference("@pirate.send(@association_name).count") do
+ @pirate.update(@alternate_params)
+ end
+ end
+ end
+
+ def test_should_not_destroy_the_associated_model_until_the_parent_is_saved
+ assert_no_difference("@pirate.send(@association_name).count") do
+ @pirate.send(association_setter, @alternate_params[association_getter].merge("baz" => { :id => @child_1.id, "_destroy" => true }))
+ end
+ assert_difference("@pirate.send(@association_name).count", -1) { @pirate.save }
+ end
+
+ def test_should_automatically_enable_autosave_on_the_association
+ assert Pirate.reflect_on_association(@association_name).options[:autosave]
+ end
+
+ def test_validate_presence_of_parent_works_with_inverse_of
+ Man.accepts_nested_attributes_for(:interests)
+ assert_equal :man, Man.reflect_on_association(:interests).options[:inverse_of]
+ assert_equal :interests, Interest.reflect_on_association(:man).options[:inverse_of]
+
+ repair_validations(Interest) do
+ Interest.validates_presence_of(:man)
+ assert_difference "Man.count" do
+ assert_difference "Interest.count", 2 do
+ man = Man.create!(name: "John",
+ interests_attributes: [{ topic: "Cars" }, { topic: "Sports" }])
+ assert_equal 2, man.interests.count
+ end
+ end
+ end
+ end
+
+ def test_can_use_symbols_as_object_identifier
+ @pirate.attributes = { parrots_attributes: { foo: { name: "Lovely Day" }, bar: { name: "Blown Away" } } }
+ assert_nothing_raised { @pirate.save! }
+ end
+
+ def test_numeric_column_changes_from_zero_to_no_empty_string
+ Man.accepts_nested_attributes_for(:interests)
+
+ repair_validations(Interest) do
+ Interest.validates_numericality_of(:zine_id)
+ man = Man.create(name: "John")
+ interest = man.interests.create(topic: "bar", zine_id: 0)
+ assert interest.save
+ assert_not man.update(interests_attributes: { id: interest.id, zine_id: "foo" })
+ end
+ end
+
+ private
+
+ def association_setter
+ @association_setter ||= "#{@association_name}_attributes=".to_sym
+ end
+
+ def association_getter
+ @association_getter ||= "#{@association_name}_attributes".to_sym
+ end
+end
+
+class TestNestedAttributesOnAHasManyAssociation < ActiveRecord::TestCase
+ def setup
+ @association_type = :has_many
+ @association_name = :birds
+
+ @pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?")
+ @pirate.birds.create!(name: "Posideons Killer")
+ @pirate.birds.create!(name: "Killer bandita Dionne")
+
+ @child_1, @child_2 = @pirate.birds
+
+ @alternate_params = {
+ birds_attributes: {
+ "foo" => { id: @child_1.id, name: "Grace OMalley" },
+ "bar" => { id: @child_2.id, name: "Privateers Greed" }
+ }
+ }
+ end
+
+ include NestedAttributesOnACollectionAssociationTests
+end
+
+class TestNestedAttributesOnAHasAndBelongsToManyAssociation < ActiveRecord::TestCase
+ def setup
+ @association_type = :has_and_belongs_to_many
+ @association_name = :parrots
+
+ @pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?")
+ @pirate.parrots.create!(name: "Posideons Killer")
+ @pirate.parrots.create!(name: "Killer bandita Dionne")
+
+ @child_1, @child_2 = @pirate.parrots
+
+ @alternate_params = {
+ parrots_attributes: {
+ "foo" => { id: @child_1.id, name: "Grace OMalley" },
+ "bar" => { id: @child_2.id, name: "Privateers Greed" }
+ }
+ }
+ end
+
+ include NestedAttributesOnACollectionAssociationTests
+end
+
+module NestedAttributesLimitTests
+ def teardown
+ Pirate.accepts_nested_attributes_for :parrots, allow_destroy: true, reject_if: proc(&:empty?)
+ end
+
+ def test_limit_with_less_records
+ @pirate.attributes = { parrots_attributes: { "foo" => { name: "Big Big Love" } } }
+ assert_difference("Parrot.count") { @pirate.save! }
+ end
+
+ def test_limit_with_number_exact_records
+ @pirate.attributes = { parrots_attributes: { "foo" => { name: "Lovely Day" }, "bar" => { name: "Blown Away" } } }
+ assert_difference("Parrot.count", 2) { @pirate.save! }
+ end
+
+ def test_limit_with_exceeding_records
+ assert_raises(ActiveRecord::NestedAttributes::TooManyRecords) do
+ @pirate.attributes = { parrots_attributes: { "foo" => { name: "Lovely Day" },
+ "bar" => { name: "Blown Away" },
+ "car" => { name: "The Happening" } } }
+ end
+ end
+end
+
+class TestNestedAttributesLimitNumeric < ActiveRecord::TestCase
+ def setup
+ Pirate.accepts_nested_attributes_for :parrots, limit: 2
+
+ @pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?")
+ end
+
+ include NestedAttributesLimitTests
+end
+
+class TestNestedAttributesLimitSymbol < ActiveRecord::TestCase
+ def setup
+ Pirate.accepts_nested_attributes_for :parrots, limit: :parrots_limit
+
+ @pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?", parrots_limit: 2)
+ end
+
+ include NestedAttributesLimitTests
+end
+
+class TestNestedAttributesLimitProc < ActiveRecord::TestCase
+ def setup
+ Pirate.accepts_nested_attributes_for :parrots, limit: proc { 2 }
+
+ @pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?")
+ end
+
+ include NestedAttributesLimitTests
+end
+
+class TestNestedAttributesWithNonStandardPrimaryKeys < ActiveRecord::TestCase
+ fixtures :owners, :pets
+
+ def setup
+ Owner.accepts_nested_attributes_for :pets, allow_destroy: true
+
+ @owner = owners(:ashley)
+ @pet1, @pet2 = pets(:chew), pets(:mochi)
+
+ @params = {
+ pets_attributes: {
+ "0" => { id: @pet1.id, name: "Foo" },
+ "1" => { id: @pet2.id, name: "Bar" }
+ }
+ }
+ end
+
+ def test_should_update_existing_records_with_non_standard_primary_key
+ @owner.update(@params)
+ assert_equal ["Foo", "Bar"], @owner.pets.map(&:name)
+ end
+
+ def test_attr_accessor_of_child_should_be_value_provided_during_update
+ @owner = owners(:ashley)
+ @pet1 = pets(:chew)
+ attributes = { pets_attributes: { "1" => { id: @pet1.id,
+ name: "Foo2",
+ current_user: "John",
+ _destroy: true } } }
+ @owner.update(attributes)
+ assert_equal "John", Pet.after_destroy_output
+ end
+end
+
+class TestHasOneAutosaveAssociationWhichItselfHasAutosaveAssociations < ActiveRecord::TestCase
+ self.use_transactional_tests = false unless supports_savepoints?
+
+ def setup
+ @pirate = Pirate.create!(catchphrase: "My baby takes tha mornin' train!")
+ @ship = @pirate.create_ship(name: "The good ship Dollypop")
+ @part = @ship.parts.create!(name: "Mast")
+ @trinket = @part.trinkets.create!(name: "Necklace")
+ end
+
+ test "when great-grandchild changed in memory, saving parent should save great-grandchild" do
+ @trinket.name = "changed"
+ @pirate.save
+ assert_equal "changed", @trinket.reload.name
+ end
+
+ test "when great-grandchild changed via attributes, saving parent should save great-grandchild" do
+ @pirate.attributes = { ship_attributes: { id: @ship.id, parts_attributes: [{ id: @part.id, trinkets_attributes: [{ id: @trinket.id, name: "changed" }] }] } }
+ @pirate.save
+ assert_equal "changed", @trinket.reload.name
+ end
+
+ test "when great-grandchild marked_for_destruction via attributes, saving parent should destroy great-grandchild" do
+ @pirate.attributes = { ship_attributes: { id: @ship.id, parts_attributes: [{ id: @part.id, trinkets_attributes: [{ id: @trinket.id, _destroy: true }] }] } }
+ assert_difference("@part.trinkets.count", -1) { @pirate.save }
+ end
+
+ test "when great-grandchild added via attributes, saving parent should create great-grandchild" do
+ @pirate.attributes = { ship_attributes: { id: @ship.id, parts_attributes: [{ id: @part.id, trinkets_attributes: [{ name: "created" }] }] } }
+ assert_difference("@part.trinkets.count", 1) { @pirate.save }
+ end
+
+ test "when extra records exist for associations, validate (which calls nested_records_changed_for_autosave?) should not load them up" do
+ @trinket.name = "changed"
+ Ship.create!(pirate: @pirate, name: "The Black Rock")
+ ShipPart.create!(ship: @ship, name: "Stern")
+ assert_no_queries { @pirate.valid? }
+ end
+end
+
+class TestHasManyAutosaveAssociationWhichItselfHasAutosaveAssociations < ActiveRecord::TestCase
+ self.use_transactional_tests = false unless supports_savepoints?
+
+ def setup
+ @ship = Ship.create!(name: "The good ship Dollypop")
+ @part = @ship.parts.create!(name: "Mast")
+ @trinket = @part.trinkets.create!(name: "Necklace")
+ end
+
+ test "if association is not loaded and association record is saved and then in memory record attributes should be saved" do
+ @ship.parts_attributes = [{ id: @part.id, name: "Deck" }]
+ assert_equal 1, @ship.association(:parts).target.size
+ assert_equal "Deck", @ship.parts[0].name
+ end
+
+ test "if association is not loaded and child doesn't change and I am saving a grandchild then in memory record should be used" do
+ @ship.parts_attributes = [{ id: @part.id, trinkets_attributes: [{ id: @trinket.id, name: "Ruby" }] }]
+ assert_equal 1, @ship.association(:parts).target.size
+ assert_equal "Mast", @ship.parts[0].name
+ assert_no_difference("@ship.parts[0].association(:trinkets).target.size") do
+ @ship.parts[0].association(:trinkets).target.size
+ end
+ assert_equal "Ruby", @ship.parts[0].trinkets[0].name
+ @ship.save
+ assert_equal "Ruby", @ship.parts[0].trinkets[0].name
+ end
+
+ test "when grandchild changed in memory, saving parent should save grandchild" do
+ @trinket.name = "changed"
+ @ship.save
+ assert_equal "changed", @trinket.reload.name
+ end
+
+ test "when grandchild changed via attributes, saving parent should save grandchild" do
+ @ship.attributes = { parts_attributes: [{ id: @part.id, trinkets_attributes: [{ id: @trinket.id, name: "changed" }] }] }
+ @ship.save
+ assert_equal "changed", @trinket.reload.name
+ end
+
+ test "when grandchild marked_for_destruction via attributes, saving parent should destroy grandchild" do
+ @ship.attributes = { parts_attributes: [{ id: @part.id, trinkets_attributes: [{ id: @trinket.id, _destroy: true }] }] }
+ assert_difference("@part.trinkets.count", -1) { @ship.save }
+ end
+
+ test "when grandchild added via attributes, saving parent should create grandchild" do
+ @ship.attributes = { parts_attributes: [{ id: @part.id, trinkets_attributes: [{ name: "created" }] }] }
+ assert_difference("@part.trinkets.count", 1) { @ship.save }
+ end
+
+ test "when extra records exist for associations, validate (which calls nested_records_changed_for_autosave?) should not load them up" do
+ @trinket.name = "changed"
+ Ship.create!(name: "The Black Rock")
+ ShipPart.create!(ship: @ship, name: "Stern")
+ assert_no_queries { @ship.valid? }
+ end
+
+ test "circular references do not perform unnecessary queries" do
+ ship = Ship.new(name: "The Black Rock")
+ part = ship.parts.build(name: "Stern")
+ ship.treasures.build(looter: part)
+
+ assert_queries 3 do
+ ship.save!
+ end
+ end
+
+ test "nested singular associations are validated" do
+ part = ShipPart.new(name: "Stern", ship_attributes: { name: nil })
+
+ assert_not_predicate part, :valid?
+ assert_equal ["Ship name can't be blank"], part.errors.full_messages
+ end
+end
+
+class TestNestedAttributesWithExtend < ActiveRecord::TestCase
+ setup do
+ Pirate.accepts_nested_attributes_for :treasures
+ end
+
+ def test_extend_affects_nested_attributes
+ pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?")
+ pirate.treasures_attributes = [{ id: nil }]
+ assert_equal "from extension", pirate.treasures[0].name
+ end
+end
diff --git a/activerecord/test/cases/nested_attributes_with_callbacks_test.rb b/activerecord/test/cases/nested_attributes_with_callbacks_test.rb
new file mode 100644
index 0000000000..1d26057fdc
--- /dev/null
+++ b/activerecord/test/cases/nested_attributes_with_callbacks_test.rb
@@ -0,0 +1,146 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/pirate"
+require "models/bird"
+
+class NestedAttributesWithCallbacksTest < ActiveRecord::TestCase
+ Pirate.has_many(:birds_with_add_load,
+ class_name: "Bird",
+ before_add: proc { |p, b|
+ @@add_callback_called << b
+ p.birds_with_add_load.to_a
+ })
+ Pirate.has_many(:birds_with_add,
+ class_name: "Bird",
+ before_add: proc { |p, b| @@add_callback_called << b })
+
+ Pirate.accepts_nested_attributes_for(:birds_with_add_load,
+ :birds_with_add,
+ allow_destroy: true)
+
+ def setup
+ @@add_callback_called = []
+ @pirate = Pirate.new.tap do |pirate|
+ pirate.catchphrase = "Don't call me!"
+ pirate.birds_attributes = [{ name: "Bird1" }, { name: "Bird2" }]
+ pirate.save!
+ end
+ @birds = @pirate.birds.to_a
+ end
+
+ def bird_to_update
+ @birds[0]
+ end
+
+ def bird_to_destroy
+ @birds[1]
+ end
+
+ def existing_birds_attributes
+ @birds.map do |bird|
+ bird.attributes.slice("id", "name")
+ end
+ end
+
+ def new_birds
+ @pirate.birds_with_add.to_a - @birds
+ end
+
+ def new_bird_attributes
+ [{ "name" => "New Bird" }]
+ end
+
+ def destroy_bird_attributes
+ [{ "id" => bird_to_destroy.id.to_s, "_destroy" => true }]
+ end
+
+ def update_new_and_destroy_bird_attributes
+ [{ "id" => @birds[0].id.to_s, "name" => "New Name" },
+ { "name" => "New Bird" },
+ { "id" => bird_to_destroy.id.to_s, "_destroy" => true }]
+ end
+
+ # Characterizing when :before_add callback is called
+ test ":before_add called for new bird when not loaded" do
+ assert_not_predicate @pirate.birds_with_add, :loaded?
+ @pirate.birds_with_add_attributes = new_bird_attributes
+ assert_new_bird_with_callback_called
+ end
+
+ test ":before_add called for new bird when loaded" do
+ @pirate.birds_with_add.load_target
+ @pirate.birds_with_add_attributes = new_bird_attributes
+ assert_new_bird_with_callback_called
+ end
+
+ def assert_new_bird_with_callback_called
+ assert_equal(1, new_birds.size)
+ assert_equal(new_birds, @@add_callback_called)
+ end
+
+ test ":before_add not called for identical assignment when not loaded" do
+ assert_not_predicate @pirate.birds_with_add, :loaded?
+ @pirate.birds_with_add_attributes = existing_birds_attributes
+ assert_callbacks_not_called
+ end
+
+ test ":before_add not called for identical assignment when loaded" do
+ @pirate.birds_with_add.load_target
+ @pirate.birds_with_add_attributes = existing_birds_attributes
+ assert_callbacks_not_called
+ end
+
+ test ":before_add not called for destroy assignment when not loaded" do
+ assert_not_predicate @pirate.birds_with_add, :loaded?
+ @pirate.birds_with_add_attributes = destroy_bird_attributes
+ assert_callbacks_not_called
+ end
+
+ test ":before_add not called for deletion assignment when loaded" do
+ @pirate.birds_with_add.load_target
+ @pirate.birds_with_add_attributes = destroy_bird_attributes
+ assert_callbacks_not_called
+ end
+
+ def assert_callbacks_not_called
+ assert_empty new_birds
+ assert_empty @@add_callback_called
+ end
+
+ # Ensuring that the records in the association target are updated,
+ # whether the association is loaded before or not
+ test "Assignment updates records in target when not loaded" do
+ assert_not_predicate @pirate.birds_with_add, :loaded?
+ @pirate.birds_with_add_attributes = update_new_and_destroy_bird_attributes
+ assert_assignment_affects_records_in_target(:birds_with_add)
+ end
+
+ test "Assignment updates records in target when loaded" do
+ @pirate.birds_with_add.load_target
+ @pirate.birds_with_add_attributes = update_new_and_destroy_bird_attributes
+ assert_assignment_affects_records_in_target(:birds_with_add)
+ end
+
+ test("Assignment updates records in target when not loaded" \
+ " and callback loads target") do
+ assert_not_predicate @pirate.birds_with_add_load, :loaded?
+ @pirate.birds_with_add_load_attributes = update_new_and_destroy_bird_attributes
+ assert_assignment_affects_records_in_target(:birds_with_add_load)
+ end
+
+ test("Assignment updates records in target when loaded" \
+ " and callback loads target") do
+ @pirate.birds_with_add_load.load_target
+ @pirate.birds_with_add_load_attributes = update_new_and_destroy_bird_attributes
+ assert_assignment_affects_records_in_target(:birds_with_add_load)
+ end
+
+ def assert_assignment_affects_records_in_target(association_name)
+ association = @pirate.send(association_name)
+ assert association.detect { |b| b == bird_to_update }.name_changed?,
+ "Update record not updated"
+ assert association.detect { |b| b == bird_to_destroy }.marked_for_destruction?,
+ "Destroy record not marked for destruction"
+ end
+end
diff --git a/activerecord/test/cases/null_relation_test.rb b/activerecord/test/cases/null_relation_test.rb
new file mode 100644
index 0000000000..ee96ea1af6
--- /dev/null
+++ b/activerecord/test/cases/null_relation_test.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/developer"
+require "models/comment"
+require "models/post"
+require "models/topic"
+
+class NullRelationTest < ActiveRecord::TestCase
+ fixtures :posts, :comments
+
+ def test_none
+ assert_no_queries do
+ assert_equal [], Developer.none
+ assert_equal [], Developer.all.none
+ end
+ end
+
+ def test_none_chainable
+ Developer.send(:load_schema)
+ assert_no_queries do
+ assert_equal [], Developer.none.where(name: "David")
+ end
+ end
+
+ def test_none_chainable_to_existing_scope_extension_method
+ assert_no_queries do
+ assert_equal 1, Topic.anonymous_extension.none.one
+ end
+ end
+
+ def test_none_chained_to_methods_firing_queries_straight_to_db
+ assert_no_queries do
+ assert_equal [], Developer.none.pluck(:id, :name)
+ assert_equal 0, Developer.none.delete_all
+ assert_equal 0, Developer.none.update_all(name: "David")
+ assert_equal 0, Developer.none.delete(1)
+ assert_equal false, Developer.none.exists?(1)
+ end
+ end
+
+ def test_null_relation_content_size_methods
+ assert_no_queries do
+ assert_equal 0, Developer.none.size
+ assert_equal 0, Developer.none.count
+ assert_equal true, Developer.none.empty?
+ assert_equal true, Developer.none.none?
+ assert_equal false, Developer.none.any?
+ assert_equal false, Developer.none.one?
+ assert_equal false, Developer.none.many?
+ end
+ end
+
+ def test_null_relation_metadata_methods
+ assert_equal "", Developer.none.to_sql
+ assert_equal({}, Developer.none.where_values_hash)
+ end
+
+ def test_null_relation_where_values_hash
+ assert_equal({ "salary" => 100_000 }, Developer.none.where(salary: 100_000).where_values_hash)
+ end
+
+ [:count, :sum].each do |method|
+ define_method "test_null_relation_#{method}" do
+ assert_no_queries do
+ assert_equal 0, Comment.none.public_send(method, :id)
+ assert_equal Hash.new, Comment.none.group(:post_id).public_send(method, :id)
+ end
+ end
+ end
+
+ [:average, :minimum, :maximum].each do |method|
+ define_method "test_null_relation_#{method}" do
+ assert_no_queries do
+ assert_nil Comment.none.public_send(method, :id)
+ assert_equal Hash.new, Comment.none.group(:post_id).public_send(method, :id)
+ end
+ end
+ end
+
+ def test_null_relation_in_where_condition
+ assert_operator Comment.count, :>, 0 # precondition, make sure there are comments.
+ assert_equal 0, Comment.where(post_id: Post.none).count
+ end
+end
diff --git a/activerecord/test/cases/numeric_data_test.rb b/activerecord/test/cases/numeric_data_test.rb
new file mode 100644
index 0000000000..079e664ee4
--- /dev/null
+++ b/activerecord/test/cases/numeric_data_test.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/numeric_data"
+
+class NumericDataTest < ActiveRecord::TestCase
+ def test_big_decimal_conditions
+ m = NumericData.new(
+ bank_balance: 1586.43,
+ big_bank_balance: BigDecimal("1000234000567.95"),
+ world_population: 6000000000,
+ my_house_population: 3
+ )
+ assert m.save
+ assert_equal 0, NumericData.where("bank_balance > ?", 2000.0).count
+ end
+
+ def test_numeric_fields
+ m = NumericData.new(
+ bank_balance: 1586.43,
+ big_bank_balance: BigDecimal("1000234000567.95"),
+ world_population: 2**62,
+ my_house_population: 3
+ )
+ assert m.save
+
+ m1 = NumericData.find_by(
+ bank_balance: 1586.43,
+ big_bank_balance: BigDecimal("1000234000567.95")
+ )
+
+ assert_kind_of Integer, m1.world_population
+ assert_equal 2**62, m1.world_population
+
+ assert_kind_of Integer, m1.my_house_population
+ assert_equal 3, m1.my_house_population
+
+ assert_kind_of BigDecimal, m1.bank_balance
+ assert_equal BigDecimal("1586.43"), m1.bank_balance
+
+ assert_kind_of BigDecimal, m1.big_bank_balance
+ assert_equal BigDecimal("1000234000567.95"), m1.big_bank_balance
+ end
+
+ def test_numeric_fields_with_scale
+ m = NumericData.new(
+ bank_balance: 1586.43122334,
+ big_bank_balance: BigDecimal("234000567.952344"),
+ world_population: 2**62,
+ my_house_population: 3
+ )
+ assert m.save
+
+ m1 = NumericData.find_by(
+ bank_balance: 1586.43122334,
+ big_bank_balance: BigDecimal("234000567.952344")
+ )
+
+ assert_kind_of Integer, m1.world_population
+ assert_equal 2**62, m1.world_population
+
+ assert_kind_of Integer, m1.my_house_population
+ assert_equal 3, m1.my_house_population
+
+ assert_kind_of BigDecimal, m1.bank_balance
+ assert_equal BigDecimal("1586.43"), m1.bank_balance
+
+ assert_kind_of BigDecimal, m1.big_bank_balance
+ assert_equal BigDecimal("234000567.95"), m1.big_bank_balance
+ end
+
+ if current_adapter?(:PostgreSQLAdapter)
+ def test_numeric_fields_with_nan
+ m = NumericData.new(
+ bank_balance: BigDecimal("NaN"),
+ big_bank_balance: BigDecimal("NaN"),
+ world_population: 2**62,
+ my_house_population: 3
+ )
+ assert_predicate m.bank_balance, :nan?
+ assert_predicate m.big_bank_balance, :nan?
+ assert m.save
+
+ m1 = NumericData.find_by(
+ bank_balance: BigDecimal("NaN"),
+ big_bank_balance: BigDecimal("NaN")
+ )
+
+ assert_predicate m1.bank_balance, :nan?
+ assert_predicate m1.big_bank_balance, :nan?
+ end
+ end
+end
diff --git a/activerecord/test/cases/persistence_test.rb b/activerecord/test/cases/persistence_test.rb
new file mode 100644
index 0000000000..4830ff2b5f
--- /dev/null
+++ b/activerecord/test/cases/persistence_test.rb
@@ -0,0 +1,1082 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/aircraft"
+require "models/post"
+require "models/comment"
+require "models/author"
+require "models/topic"
+require "models/reply"
+require "models/category"
+require "models/company"
+require "models/developer"
+require "models/computer"
+require "models/project"
+require "models/minimalistic"
+require "models/parrot"
+require "models/minivan"
+require "models/person"
+require "models/ship"
+require "models/admin"
+require "models/admin/user"
+
+class PersistenceTest < ActiveRecord::TestCase
+ fixtures :topics, :companies, :developers, :accounts, :minimalistics, :authors, :author_addresses, :posts, :minivans
+
+ def test_update_many
+ topic_data = { 1 => { "content" => "1 updated" }, 2 => { "content" => "2 updated" } }
+ updated = Topic.update(topic_data.keys, topic_data.values)
+
+ assert_equal [1, 2], updated.map(&:id)
+ assert_equal "1 updated", Topic.find(1).content
+ assert_equal "2 updated", Topic.find(2).content
+ end
+
+ def test_update_many_with_duplicated_ids
+ updated = Topic.update([1, 1, 2], [
+ { "content" => "1 duplicated" }, { "content" => "1 updated" }, { "content" => "2 updated" }
+ ])
+
+ assert_equal [1, 1, 2], updated.map(&:id)
+ assert_equal "1 updated", Topic.find(1).content
+ assert_equal "2 updated", Topic.find(2).content
+ end
+
+ def test_update_many_with_invalid_id
+ topic_data = { 1 => { "content" => "1 updated" }, 2 => { "content" => "2 updated" }, 99999 => {} }
+
+ assert_raise(ActiveRecord::RecordNotFound) do
+ Topic.update(topic_data.keys, topic_data.values)
+ end
+
+ assert_not_equal "1 updated", Topic.find(1).content
+ assert_not_equal "2 updated", Topic.find(2).content
+ end
+
+ def test_class_level_update_is_affected_by_scoping
+ topic_data = { 1 => { "content" => "1 updated" }, 2 => { "content" => "2 updated" } }
+
+ assert_raise(ActiveRecord::RecordNotFound) do
+ Topic.where("1=0").scoping { Topic.update(topic_data.keys, topic_data.values) }
+ end
+
+ assert_not_equal "1 updated", Topic.find(1).content
+ assert_not_equal "2 updated", Topic.find(2).content
+ end
+
+ def test_delete_all
+ assert Topic.count > 0
+
+ assert_equal Topic.count, Topic.delete_all
+ end
+
+ def test_increment_attribute
+ assert_equal 50, accounts(:signals37).credit_limit
+ accounts(:signals37).increment! :credit_limit
+ assert_equal 51, accounts(:signals37, :reload).credit_limit
+
+ accounts(:signals37).increment(:credit_limit).increment!(:credit_limit)
+ assert_equal 53, accounts(:signals37, :reload).credit_limit
+ end
+
+ def test_increment_nil_attribute
+ assert_nil topics(:first).parent_id
+ topics(:first).increment! :parent_id
+ assert_equal 1, topics(:first).parent_id
+ end
+
+ def test_increment_attribute_by
+ assert_equal 50, accounts(:signals37).credit_limit
+ accounts(:signals37).increment! :credit_limit, 5
+ assert_equal 55, accounts(:signals37, :reload).credit_limit
+
+ accounts(:signals37).increment(:credit_limit, 1).increment!(:credit_limit, 3)
+ assert_equal 59, accounts(:signals37, :reload).credit_limit
+ end
+
+ def test_increment_updates_counter_in_db_using_offset
+ a1 = accounts(:signals37)
+ initial_credit = a1.credit_limit
+ a2 = Account.find(accounts(:signals37).id)
+ a1.increment!(:credit_limit)
+ a2.increment!(:credit_limit)
+ assert_equal initial_credit + 2, a1.reload.credit_limit
+ end
+
+ def test_increment_with_touch_updates_timestamps
+ topic = topics(:first)
+ assert_equal 1, topic.replies_count
+ previously_updated_at = topic.updated_at
+ travel(1.second) do
+ topic.increment!(:replies_count, touch: true)
+ end
+ assert_equal 2, topic.reload.replies_count
+ assert_operator previously_updated_at, :<, topic.updated_at
+ end
+
+ def test_increment_with_touch_an_attribute_updates_timestamps
+ topic = topics(:first)
+ assert_equal 1, topic.replies_count
+ previously_updated_at = topic.updated_at
+ previously_written_on = topic.written_on
+ travel(1.second) do
+ topic.increment!(:replies_count, touch: :written_on)
+ end
+ assert_equal 2, topic.reload.replies_count
+ assert_operator previously_updated_at, :<, topic.updated_at
+ assert_operator previously_written_on, :<, topic.written_on
+ end
+
+ def test_increment_with_no_arg
+ topic = topics(:first)
+ assert_raises(ArgumentError) { topic.increment! }
+ end
+
+ def test_destroy_many
+ clients = Client.find([2, 3])
+
+ assert_difference("Client.count", -2) do
+ destroyed = Client.destroy([2, 3])
+ assert_equal clients, destroyed
+ assert destroyed.all?(&:frozen?), "destroyed clients should be frozen"
+ end
+ end
+
+ def test_destroy_many_with_invalid_id
+ clients = Client.find([2, 3])
+
+ assert_raise(ActiveRecord::RecordNotFound) do
+ Client.destroy([2, 3, 99999])
+ end
+
+ assert_equal clients, Client.find([2, 3])
+ end
+
+ def test_becomes
+ assert_kind_of Reply, topics(:first).becomes(Reply)
+ assert_equal "The First Topic", topics(:first).becomes(Reply).title
+ end
+
+ def test_becomes_after_reload_schema_from_cache
+ Reply.define_attribute_methods
+ Reply.serialize(:content) # invoke reload_schema_from_cache
+ assert_kind_of Reply, topics(:first).becomes(Reply)
+ assert_equal "The First Topic", topics(:first).becomes(Reply).title
+ end
+
+ def test_becomes_includes_errors
+ company = Company.new(name: nil)
+ assert_not_predicate company, :valid?
+ original_errors = company.errors
+ client = company.becomes(Client)
+ assert_equal original_errors.keys, client.errors.keys
+ end
+
+ def test_becomes_errors_base
+ child_class = Class.new(Admin::User) do
+ store_accessor :settings, :foo
+
+ def self.name; "Admin::ChildUser"; end
+ end
+
+ admin = Admin::User.new
+ admin.errors.add :token, :invalid
+ child = admin.becomes(child_class)
+
+ assert_equal [:token], child.errors.keys
+ assert_nothing_raised do
+ child.errors.add :foo, :invalid
+ end
+ end
+
+ def test_duped_becomes_persists_changes_from_the_original
+ original = topics(:first)
+ copy = original.dup.becomes(Reply)
+ copy.save!
+ assert_equal "The First Topic", Topic.find(copy.id).title
+ end
+
+ def test_becomes_wont_break_mutation_tracking
+ topic = topics(:first)
+ reply = topic.becomes(Reply)
+
+ assert_equal 1, topic.id_in_database
+ assert_empty topic.attributes_in_database
+
+ assert_equal 1, reply.id_in_database
+ assert_empty reply.attributes_in_database
+ end
+
+ def test_becomes_includes_changed_attributes
+ company = Company.new(name: "37signals")
+ client = company.becomes(Client)
+ assert_equal "37signals", client.name
+ assert_equal %w{name}, client.changed
+ end
+
+ def test_delete_many
+ original_count = Topic.count
+ Topic.delete(deleting = [1, 2])
+ assert_equal original_count - deleting.size, Topic.count
+ end
+
+ def test_decrement_attribute
+ assert_equal 50, accounts(:signals37).credit_limit
+
+ accounts(:signals37).decrement!(:credit_limit)
+ assert_equal 49, accounts(:signals37, :reload).credit_limit
+
+ accounts(:signals37).decrement(:credit_limit).decrement!(:credit_limit)
+ assert_equal 47, accounts(:signals37, :reload).credit_limit
+ end
+
+ def test_decrement_attribute_by
+ assert_equal 50, accounts(:signals37).credit_limit
+ accounts(:signals37).decrement! :credit_limit, 5
+ assert_equal 45, accounts(:signals37, :reload).credit_limit
+
+ accounts(:signals37).decrement(:credit_limit, 1).decrement!(:credit_limit, 3)
+ assert_equal 41, accounts(:signals37, :reload).credit_limit
+ end
+
+ def test_decrement_with_touch_updates_timestamps
+ topic = topics(:first)
+ assert_equal 1, topic.replies_count
+ previously_updated_at = topic.updated_at
+ travel(1.second) do
+ topic.decrement!(:replies_count, touch: true)
+ end
+ assert_equal 0, topic.reload.replies_count
+ assert_operator previously_updated_at, :<, topic.updated_at
+ end
+
+ def test_decrement_with_touch_an_attribute_updates_timestamps
+ topic = topics(:first)
+ assert_equal 1, topic.replies_count
+ previously_updated_at = topic.updated_at
+ previously_written_on = topic.written_on
+ travel(1.second) do
+ topic.decrement!(:replies_count, touch: :written_on)
+ end
+ assert_equal 0, topic.reload.replies_count
+ assert_operator previously_updated_at, :<, topic.updated_at
+ assert_operator previously_written_on, :<, topic.written_on
+ end
+
+ def test_create
+ topic = Topic.new
+ topic.title = "New Topic"
+ topic.save
+ topic_reloaded = Topic.find(topic.id)
+ assert_equal("New Topic", topic_reloaded.title)
+ end
+
+ def test_save!
+ topic = Topic.new(title: "New Topic")
+ assert topic.save!
+
+ reply = WrongReply.new
+ assert_raise(ActiveRecord::RecordInvalid) { reply.save! }
+ end
+
+ def test_save_null_string_attributes
+ topic = Topic.find(1)
+ topic.attributes = { "title" => "null", "author_name" => "null" }
+ topic.save!
+ topic.reload
+ assert_equal("null", topic.title)
+ assert_equal("null", topic.author_name)
+ end
+
+ def test_save_nil_string_attributes
+ topic = Topic.find(1)
+ topic.title = nil
+ topic.save!
+ topic.reload
+ assert_nil topic.title
+ end
+
+ def test_save_for_record_with_only_primary_key
+ minimalistic = Minimalistic.new
+ assert_nothing_raised { minimalistic.save }
+ end
+
+ def test_save_for_record_with_only_primary_key_that_is_provided
+ assert_nothing_raised { Minimalistic.create!(id: 2) }
+ end
+
+ def test_save_with_duping_of_destroyed_object
+ developer = Developer.first
+ developer.destroy
+ new_developer = developer.dup
+ new_developer.save
+ assert_predicate new_developer, :persisted?
+ assert_not_predicate new_developer, :destroyed?
+ end
+
+ def test_create_many
+ topics = Topic.create([ { "title" => "first" }, { "title" => "second" }])
+ assert_equal 2, topics.size
+ assert_equal "first", topics.first.title
+ end
+
+ def test_create_columns_not_equal_attributes
+ topic = Topic.instantiate(
+ "title" => "Another New Topic",
+ "does_not_exist" => "test"
+ )
+ topic = topic.dup # reset @new_record
+ assert_nothing_raised { topic.save }
+ assert_predicate topic, :persisted?
+ assert_equal "Another New Topic", topic.reload.title
+ end
+
+ def test_create_through_factory_with_block
+ topic = Topic.create("title" => "New Topic") do |t|
+ t.author_name = "David"
+ end
+ assert_equal("New Topic", topic.title)
+ assert_equal("David", topic.author_name)
+ end
+
+ def test_create_many_through_factory_with_block
+ topics = Topic.create([ { "title" => "first" }, { "title" => "second" }]) do |t|
+ t.author_name = "David"
+ end
+ assert_equal 2, topics.size
+ topic1, topic2 = Topic.find(topics[0].id), Topic.find(topics[1].id)
+ assert_equal "first", topic1.title
+ assert_equal "David", topic1.author_name
+ assert_equal "second", topic2.title
+ assert_equal "David", topic2.author_name
+ end
+
+ def test_update_object
+ topic = Topic.new
+ topic.title = "Another New Topic"
+ topic.written_on = "2003-12-12 23:23:00"
+ topic.save
+ topic_reloaded = Topic.find(topic.id)
+ assert_equal("Another New Topic", topic_reloaded.title)
+
+ topic_reloaded.title = "Updated topic"
+ topic_reloaded.save
+
+ topic_reloaded_again = Topic.find(topic.id)
+
+ assert_equal("Updated topic", topic_reloaded_again.title)
+ end
+
+ def test_update_columns_not_equal_attributes
+ topic = Topic.new
+ topic.title = "Still another topic"
+ topic.save
+
+ topic_reloaded = Topic.instantiate(topic.attributes.merge("does_not_exist" => "test"))
+ topic_reloaded.title = "A New Topic"
+ assert_nothing_raised { topic_reloaded.save }
+ assert_predicate topic_reloaded, :persisted?
+ assert_equal "A New Topic", topic_reloaded.reload.title
+ end
+
+ def test_update_for_record_with_only_primary_key
+ minimalistic = minimalistics(:first)
+ assert_nothing_raised { minimalistic.save }
+ end
+
+ def test_update_sti_type
+ assert_instance_of Reply, topics(:second)
+
+ topic = topics(:second).becomes!(Topic)
+ assert_instance_of Topic, topic
+ topic.save!
+ assert_instance_of Topic, Topic.find(topic.id)
+ end
+
+ def test_preserve_original_sti_type
+ reply = topics(:second)
+ assert_equal "Reply", reply.type
+
+ topic = reply.becomes(Topic)
+ assert_equal "Reply", reply.type
+
+ assert_instance_of Topic, topic
+ assert_equal "Reply", topic.type
+ end
+
+ def test_update_sti_subclass_type
+ assert_instance_of Topic, topics(:first)
+
+ reply = topics(:first).becomes!(Reply)
+ assert_instance_of Reply, reply
+ reply.save!
+ assert_instance_of Reply, Reply.find(reply.id)
+ end
+
+ def test_becomes_default_sti_subclass
+ original_type = Topic.columns_hash["type"].default
+ ActiveRecord::Base.connection.change_column_default :topics, :type, "Reply"
+ Topic.reset_column_information
+
+ reply = topics(:second)
+ assert_instance_of Reply, reply
+
+ topic = reply.becomes(Topic)
+ assert_instance_of Topic, topic
+
+ ensure
+ ActiveRecord::Base.connection.change_column_default :topics, :type, original_type
+ Topic.reset_column_information
+ end
+
+ def test_update_after_create
+ klass = Class.new(Topic) do
+ def self.name; "Topic"; end
+ after_create do
+ update_attribute("author_name", "David")
+ end
+ end
+ topic = klass.new
+ topic.title = "Another New Topic"
+ topic.save
+
+ topic_reloaded = Topic.find(topic.id)
+ assert_equal("Another New Topic", topic_reloaded.title)
+ assert_equal("David", topic_reloaded.author_name)
+ end
+
+ def test_update_attribute_does_not_run_sql_if_attribute_is_not_changed
+ topic = Topic.create(title: "Another New Topic")
+ assert_no_queries do
+ assert topic.update_attribute(:title, "Another New Topic")
+ end
+ end
+
+ def test_update_does_not_run_sql_if_record_has_not_changed
+ topic = Topic.create(title: "Another New Topic")
+ assert_no_queries do
+ assert topic.update(title: "Another New Topic")
+ end
+ end
+
+ def test_delete
+ topic = Topic.find(1)
+ assert_equal topic, topic.delete, "topic.delete did not return self"
+ assert topic.frozen?, "topic not frozen after delete"
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.find(topic.id) }
+ end
+
+ def test_delete_doesnt_run_callbacks
+ Topic.find(1).delete
+ assert_not_nil Topic.find(2)
+ end
+
+ def test_delete_isnt_affected_by_scoping
+ topic = Topic.find(1)
+ assert_difference("Topic.count", -1) do
+ Topic.where("1=0").scoping { topic.delete }
+ end
+ end
+
+ def test_destroy
+ topic = Topic.find(1)
+ assert_equal topic, topic.destroy, "topic.destroy did not return self"
+ assert topic.frozen?, "topic not frozen after destroy"
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.find(topic.id) }
+ end
+
+ def test_destroy!
+ topic = Topic.find(1)
+ assert_equal topic, topic.destroy!, "topic.destroy! did not return self"
+ assert topic.frozen?, "topic not frozen after destroy!"
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.find(topic.id) }
+ end
+
+ def test_find_raises_record_not_found_exception
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.find(99999) }
+ end
+
+ def test_update_raises_record_not_found_exception
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.update(99999, approved: true) }
+ end
+
+ def test_destroy_raises_record_not_found_exception
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.destroy(99999) }
+ end
+
+ def test_update_all
+ assert_equal Topic.count, Topic.update_all("content = 'bulk updated!'")
+ assert_equal "bulk updated!", Topic.find(1).content
+ assert_equal "bulk updated!", Topic.find(2).content
+
+ assert_equal Topic.count, Topic.update_all(["content = ?", "bulk updated again!"])
+ assert_equal "bulk updated again!", Topic.find(1).content
+ assert_equal "bulk updated again!", Topic.find(2).content
+
+ assert_equal Topic.count, Topic.update_all(["content = ?", nil])
+ assert_nil Topic.find(1).content
+ end
+
+ def test_update_all_with_hash
+ assert_not_nil Topic.find(1).last_read
+ assert_equal Topic.count, Topic.update_all(content: "bulk updated with hash!", last_read: nil)
+ assert_equal "bulk updated with hash!", Topic.find(1).content
+ assert_equal "bulk updated with hash!", Topic.find(2).content
+ assert_nil Topic.find(1).last_read
+ assert_nil Topic.find(2).last_read
+ end
+
+ def test_delete_new_record
+ client = Client.new(name: "37signals")
+ client.delete
+ assert_predicate client, :frozen?
+
+ assert_not client.save
+ assert_raise(ActiveRecord::RecordNotSaved) { client.save! }
+
+ assert_predicate client, :frozen?
+ assert_raise(RuntimeError) { client.name = "something else" }
+ end
+
+ def test_delete_record_with_associations
+ client = Client.find(3)
+ client.delete
+ assert_predicate client, :frozen?
+ assert_kind_of Firm, client.firm
+
+ assert_not client.save
+ assert_raise(ActiveRecord::RecordNotSaved) { client.save! }
+
+ assert_predicate client, :frozen?
+ assert_raise(RuntimeError) { client.name = "something else" }
+ end
+
+ def test_destroy_new_record
+ client = Client.new(name: "37signals")
+ client.destroy
+ assert_predicate client, :frozen?
+
+ assert_not client.save
+ assert_raise(ActiveRecord::RecordNotSaved) { client.save! }
+
+ assert_predicate client, :frozen?
+ assert_raise(RuntimeError) { client.name = "something else" }
+ end
+
+ def test_destroy_record_with_associations
+ client = Client.find(3)
+ client.destroy
+ assert_predicate client, :frozen?
+ assert_kind_of Firm, client.firm
+
+ assert_not client.save
+ assert_raise(ActiveRecord::RecordNotSaved) { client.save! }
+
+ assert_predicate client, :frozen?
+ assert_raise(RuntimeError) { client.name = "something else" }
+ end
+
+ def test_update_attribute
+ assert_not_predicate Topic.find(1), :approved?
+ Topic.find(1).update_attribute("approved", true)
+ assert_predicate Topic.find(1), :approved?
+
+ Topic.find(1).update_attribute(:approved, false)
+ assert_not_predicate Topic.find(1), :approved?
+
+ Topic.find(1).update_attribute(:change_approved_before_save, true)
+ assert_predicate Topic.find(1), :approved?
+ end
+
+ def test_update_attribute_for_readonly_attribute
+ minivan = Minivan.find("m1")
+ assert_raises(ActiveRecord::ActiveRecordError) { minivan.update_attribute(:color, "black") }
+ end
+
+ def test_update_attribute_with_one_updated
+ t = Topic.first
+ t.update_attribute(:title, "super_title")
+ assert_equal "super_title", t.title
+ assert_not t.changed?, "topic should not have changed"
+ assert_not t.title_changed?, "title should not have changed"
+ assert_nil t.title_change, "title change should be nil"
+
+ t.reload
+ assert_equal "super_title", t.title
+ end
+
+ def test_update_attribute_for_updated_at_on
+ developer = Developer.find(1)
+ prev_month = Time.now.prev_month.change(usec: 0)
+
+ developer.update_attribute(:updated_at, prev_month)
+ assert_equal prev_month, developer.updated_at
+
+ developer.update_attribute(:salary, 80001)
+ assert_not_equal prev_month, developer.updated_at
+
+ developer.reload
+ assert_not_equal prev_month, developer.updated_at
+ end
+
+ def test_update_column
+ topic = Topic.find(1)
+ topic.update_column("approved", true)
+ assert_predicate topic, :approved?
+ topic.reload
+ assert_predicate topic, :approved?
+
+ topic.update_column(:approved, false)
+ assert_not_predicate topic, :approved?
+ topic.reload
+ assert_not_predicate topic, :approved?
+ end
+
+ def test_update_column_should_not_use_setter_method
+ dev = Developer.find(1)
+ dev.instance_eval { def salary=(value); write_attribute(:salary, value * 2); end }
+
+ dev.update_column(:salary, 80000)
+ assert_equal 80000, dev.salary
+
+ dev.reload
+ assert_equal 80000, dev.salary
+ end
+
+ def test_update_column_should_raise_exception_if_new_record
+ topic = Topic.new
+ assert_raises(ActiveRecord::ActiveRecordError) { topic.update_column("approved", false) }
+ end
+
+ def test_update_column_should_not_leave_the_object_dirty
+ topic = Topic.find(1)
+ topic.update_column("content", "--- Have a nice day\n...\n")
+
+ topic.reload
+ topic.update_column(:content, "--- You too\n...\n")
+ assert_equal [], topic.changed
+
+ topic.reload
+ topic.update_column("content", "--- Have a nice day\n...\n")
+ assert_equal [], topic.changed
+ end
+
+ def test_update_column_with_model_having_primary_key_other_than_id
+ minivan = Minivan.find("m1")
+ new_name = "sebavan"
+
+ minivan.update_column(:name, new_name)
+ assert_equal new_name, minivan.name
+ end
+
+ def test_update_column_for_readonly_attribute
+ minivan = Minivan.find("m1")
+ prev_color = minivan.color
+ assert_raises(ActiveRecord::ActiveRecordError) { minivan.update_column(:color, "black") }
+ assert_equal prev_color, minivan.color
+ end
+
+ def test_update_column_should_not_modify_updated_at
+ developer = Developer.find(1)
+ prev_month = Time.now.prev_month.change(usec: 0)
+
+ developer.update_column(:updated_at, prev_month)
+ assert_equal prev_month, developer.updated_at
+
+ developer.update_column(:salary, 80001)
+ assert_equal prev_month, developer.updated_at
+
+ developer.reload
+ assert_equal prev_month.to_i, developer.updated_at.to_i
+ end
+
+ def test_update_column_with_one_changed_and_one_updated
+ t = Topic.order("id").limit(1).first
+ author_name = t.author_name
+ t.author_name = "John"
+ t.update_column(:title, "super_title")
+ assert_equal "John", t.author_name
+ assert_equal "super_title", t.title
+ assert t.changed?, "topic should have changed"
+ assert t.author_name_changed?, "author_name should have changed"
+
+ t.reload
+ assert_equal author_name, t.author_name
+ assert_equal "super_title", t.title
+ end
+
+ def test_update_column_with_default_scope
+ developer = DeveloperCalledDavid.first
+ developer.name = "John"
+ developer.save!
+
+ assert developer.update_column(:name, "Will"), "did not update record due to default scope"
+ end
+
+ def test_update_columns
+ topic = Topic.find(1)
+ topic.update_columns("approved" => true, title: "Sebastian Topic")
+ assert_predicate topic, :approved?
+ assert_equal "Sebastian Topic", topic.title
+ topic.reload
+ assert_predicate topic, :approved?
+ assert_equal "Sebastian Topic", topic.title
+ end
+
+ def test_update_columns_should_not_use_setter_method
+ dev = Developer.find(1)
+ dev.instance_eval { def salary=(value); write_attribute(:salary, value * 2); end }
+
+ dev.update_columns(salary: 80000)
+ assert_equal 80000, dev.salary
+
+ dev.reload
+ assert_equal 80000, dev.salary
+ end
+
+ def test_update_columns_should_raise_exception_if_new_record
+ topic = Topic.new
+ assert_raises(ActiveRecord::ActiveRecordError) { topic.update_columns(approved: false) }
+ end
+
+ def test_update_columns_should_not_leave_the_object_dirty
+ topic = Topic.find(1)
+ topic.update("content" => "--- Have a nice day\n...\n", :author_name => "Jose")
+
+ topic.reload
+ topic.update_columns(content: "--- You too\n...\n", "author_name" => "Sebastian")
+ assert_equal [], topic.changed
+
+ topic.reload
+ topic.update_columns(content: "--- Have a nice day\n...\n", author_name: "Jose")
+ assert_equal [], topic.changed
+ end
+
+ def test_update_columns_with_model_having_primary_key_other_than_id
+ minivan = Minivan.find("m1")
+ new_name = "sebavan"
+
+ minivan.update_columns(name: new_name)
+ assert_equal new_name, minivan.name
+ end
+
+ def test_update_columns_with_one_readonly_attribute
+ minivan = Minivan.find("m1")
+ prev_color = minivan.color
+ prev_name = minivan.name
+ assert_raises(ActiveRecord::ActiveRecordError) { minivan.update_columns(name: "My old minivan", color: "black") }
+ assert_equal prev_color, minivan.color
+ assert_equal prev_name, minivan.name
+
+ minivan.reload
+ assert_equal prev_color, minivan.color
+ assert_equal prev_name, minivan.name
+ end
+
+ def test_update_columns_should_not_modify_updated_at
+ developer = Developer.find(1)
+ prev_month = Time.now.prev_month.change(usec: 0)
+
+ developer.update_columns(updated_at: prev_month)
+ assert_equal prev_month, developer.updated_at
+
+ developer.update_columns(salary: 80000)
+ assert_equal prev_month, developer.updated_at
+ assert_equal 80000, developer.salary
+
+ developer.reload
+ assert_equal prev_month.to_i, developer.updated_at.to_i
+ assert_equal 80000, developer.salary
+ end
+
+ def test_update_columns_with_one_changed_and_one_updated
+ t = Topic.order("id").limit(1).first
+ author_name = t.author_name
+ t.author_name = "John"
+ t.update_columns(title: "super_title")
+ assert_equal "John", t.author_name
+ assert_equal "super_title", t.title
+ assert t.changed?, "topic should have changed"
+ assert t.author_name_changed?, "author_name should have changed"
+
+ t.reload
+ assert_equal author_name, t.author_name
+ assert_equal "super_title", t.title
+ end
+
+ def test_update_columns_changing_id
+ topic = Topic.find(1)
+ topic.update_columns(id: 123)
+ assert_equal 123, topic.id
+ topic.reload
+ assert_equal 123, topic.id
+ end
+
+ def test_update_columns_returns_boolean
+ topic = Topic.find(1)
+ assert_equal true, topic.update_columns(title: "New title")
+ end
+
+ def test_update_columns_with_default_scope
+ developer = DeveloperCalledDavid.first
+ developer.name = "John"
+ developer.save!
+
+ assert developer.update_columns(name: "Will"), "did not update record due to default scope"
+ end
+
+ def test_update
+ topic = Topic.find(1)
+ assert_not_predicate topic, :approved?
+ assert_equal "The First Topic", topic.title
+
+ topic.update("approved" => true, "title" => "The First Topic Updated")
+ topic.reload
+ assert_predicate topic, :approved?
+ assert_equal "The First Topic Updated", topic.title
+
+ topic.update(approved: false, title: "The First Topic")
+ topic.reload
+ assert_not_predicate topic, :approved?
+ assert_equal "The First Topic", topic.title
+
+ error = assert_raise(ActiveRecord::RecordNotUnique, ActiveRecord::StatementInvalid) do
+ topic.update(id: 3, title: "Hm is it possible?")
+ end
+ assert_not_nil error.cause
+ assert_not_equal "Hm is it possible?", Topic.find(3).title
+
+ topic.update(id: 1234)
+ assert_nothing_raised { topic.reload }
+ assert_equal topic.title, Topic.find(1234).title
+ end
+
+ def test_update_attributes
+ topic = Topic.find(1)
+ assert_deprecated do
+ topic.update_attributes("title" => "The First Topic Updated")
+ end
+ end
+
+ def test_update_parameters
+ topic = Topic.find(1)
+ assert_nothing_raised do
+ topic.update({})
+ end
+
+ assert_raises(ArgumentError) do
+ topic.update(nil)
+ end
+ end
+
+ def test_update!
+ Reply.validates_presence_of(:title)
+ reply = Reply.find(2)
+ assert_equal "The Second Topic of the day", reply.title
+ assert_equal "Have a nice day", reply.content
+
+ reply.update!("title" => "The Second Topic of the day updated", "content" => "Have a nice evening")
+ reply.reload
+ assert_equal "The Second Topic of the day updated", reply.title
+ assert_equal "Have a nice evening", reply.content
+
+ reply.update!(title: "The Second Topic of the day", content: "Have a nice day")
+ reply.reload
+ assert_equal "The Second Topic of the day", reply.title
+ assert_equal "Have a nice day", reply.content
+
+ assert_raise(ActiveRecord::RecordInvalid) { reply.update!(title: nil, content: "Have a nice evening") }
+ ensure
+ Reply.clear_validators!
+ end
+
+ def test_update_attributes!
+ reply = Reply.find(2)
+ assert_deprecated do
+ reply.update_attributes!("title" => "The Second Topic of the day updated")
+ end
+ end
+
+ def test_destroyed_returns_boolean
+ developer = Developer.first
+ assert_equal false, developer.destroyed?
+ developer.destroy
+ assert_equal true, developer.destroyed?
+
+ developer = Developer.last
+ assert_equal false, developer.destroyed?
+ developer.delete
+ assert_equal true, developer.destroyed?
+ end
+
+ def test_persisted_returns_boolean
+ developer = Developer.new(name: "Jose")
+ assert_equal false, developer.persisted?
+ developer.save!
+ assert_equal true, developer.persisted?
+
+ developer = Developer.first
+ assert_equal true, developer.persisted?
+ developer.destroy
+ assert_equal false, developer.persisted?
+
+ developer = Developer.last
+ assert_equal true, developer.persisted?
+ developer.delete
+ assert_equal false, developer.persisted?
+ end
+
+ def test_class_level_destroy
+ should_be_destroyed_reply = Reply.create("title" => "hello", "content" => "world")
+ Topic.find(1).replies << should_be_destroyed_reply
+
+ topic = Topic.destroy(1)
+ assert_predicate topic, :destroyed?
+
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.find(1) }
+ assert_raise(ActiveRecord::RecordNotFound) { Reply.find(should_be_destroyed_reply.id) }
+ end
+
+ def test_class_level_destroy_is_affected_by_scoping
+ should_not_be_destroyed_reply = Reply.create("title" => "hello", "content" => "world")
+ Topic.find(1).replies << should_not_be_destroyed_reply
+
+ assert_raise(ActiveRecord::RecordNotFound) do
+ Topic.where("1=0").scoping { Topic.destroy(1) }
+ end
+
+ assert_nothing_raised { Topic.find(1) }
+ assert_nothing_raised { Reply.find(should_not_be_destroyed_reply.id) }
+ end
+
+ def test_class_level_delete
+ should_not_be_destroyed_reply = Reply.create("title" => "hello", "content" => "world")
+ Topic.find(1).replies << should_not_be_destroyed_reply
+
+ Topic.delete(1)
+ assert_raise(ActiveRecord::RecordNotFound) { Topic.find(1) }
+ assert_nothing_raised { Reply.find(should_not_be_destroyed_reply.id) }
+ end
+
+ def test_class_level_delete_is_affected_by_scoping
+ should_not_be_destroyed_reply = Reply.create("title" => "hello", "content" => "world")
+ Topic.find(1).replies << should_not_be_destroyed_reply
+
+ Topic.where("1=0").scoping { Topic.delete(1) }
+ assert_nothing_raised { Topic.find(1) }
+ assert_nothing_raised { Reply.find(should_not_be_destroyed_reply.id) }
+ end
+
+ def test_create_with_custom_timestamps
+ custom_datetime = 1.hour.ago.beginning_of_day
+
+ %w(created_at created_on updated_at updated_on).each do |attribute|
+ parrot = LiveParrot.create(:name => "colombian", attribute => custom_datetime)
+ assert_equal custom_datetime, parrot[attribute]
+ end
+ end
+
+ def test_persist_inherited_class_with_different_table_name
+ minimalistic_aircrafts = Class.new(Minimalistic) do
+ self.table_name = "aircraft"
+ end
+
+ assert_difference "Aircraft.count", 1 do
+ aircraft = minimalistic_aircrafts.create(name: "Wright Flyer")
+ aircraft.name = "Wright Glider"
+ aircraft.save
+ end
+
+ assert_equal "Wright Glider", Aircraft.last.name
+ end
+
+ def test_instantiate_creates_a_new_instance
+ post = Post.instantiate("title" => "appropriate documentation", "type" => "SpecialPost")
+ assert_equal "appropriate documentation", post.title
+ assert_instance_of SpecialPost, post
+
+ # body was not initialized
+ assert_raises ActiveModel::MissingAttributeError do
+ post.body
+ end
+ end
+
+ def test_reload_removes_custom_selects
+ post = Post.select("posts.*, 1 as wibble").last!
+
+ assert_equal 1, post[:wibble]
+ assert_nil post.reload[:wibble]
+ end
+
+ def test_find_via_reload
+ post = Post.new
+
+ assert_predicate post, :new_record?
+
+ post.id = 1
+ post.reload
+
+ assert_equal "Welcome to the weblog", post.title
+ assert_not_predicate post, :new_record?
+ end
+
+ def test_reload_via_querycache
+ ActiveRecord::Base.connection.enable_query_cache!
+ ActiveRecord::Base.connection.clear_query_cache
+ assert ActiveRecord::Base.connection.query_cache_enabled, "cache should be on"
+ parrot = Parrot.create(name: "Shane")
+
+ # populate the cache with the SELECT result
+ found_parrot = Parrot.find(parrot.id)
+ assert_equal parrot.id, found_parrot.id
+
+ # Manually update the 'name' attribute in the DB directly
+ assert_equal 1, ActiveRecord::Base.connection.query_cache.length
+ ActiveRecord::Base.uncached do
+ found_parrot.name = "Mary"
+ found_parrot.save
+ end
+
+ # Now reload, and verify that it gets the DB version, and not the querycache version
+ found_parrot.reload
+ assert_equal "Mary", found_parrot.name
+
+ found_parrot = Parrot.find(parrot.id)
+ assert_equal "Mary", found_parrot.name
+ ensure
+ ActiveRecord::Base.connection.disable_query_cache!
+ end
+
+ def test_save_touch_false
+ parrot = Parrot.create!(
+ name: "Bob",
+ created_at: 1.day.ago,
+ updated_at: 1.day.ago)
+
+ created_at = parrot.created_at
+ updated_at = parrot.updated_at
+
+ parrot.name = "Barb"
+ parrot.save!(touch: false)
+ assert_equal parrot.created_at, created_at
+ assert_equal parrot.updated_at, updated_at
+ end
+
+ def test_reset_column_information_resets_children
+ child_class = Class.new(Topic)
+ child_class.new # force schema to load
+
+ ActiveRecord::Base.connection.add_column(:topics, :foo, :string)
+ Topic.reset_column_information
+
+ # this should redefine attribute methods
+ child_class.new
+
+ assert child_class.instance_methods.include?(:foo)
+ assert child_class.instance_methods.include?(:foo_changed?)
+ assert_equal "bar", child_class.new(foo: :bar).foo
+ ensure
+ ActiveRecord::Base.connection.remove_column(:topics, :foo)
+ Topic.reset_column_information
+ end
+end
diff --git a/activerecord/test/cases/pooled_connections_test.rb b/activerecord/test/cases/pooled_connections_test.rb
new file mode 100644
index 0000000000..080aeb0989
--- /dev/null
+++ b/activerecord/test/cases/pooled_connections_test.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/project"
+require "timeout"
+
+class PooledConnectionsTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ def setup
+ @per_test_teardown = []
+ @connection = ActiveRecord::Base.remove_connection
+ end
+
+ teardown do
+ ActiveRecord::Base.clear_all_connections!
+ ActiveRecord::Base.establish_connection(@connection)
+ @per_test_teardown.each(&:call)
+ end
+
+ # Will deadlock due to lack of Monitor timeouts in 1.9
+ def checkout_checkin_connections(pool_size, threads)
+ ActiveRecord::Base.establish_connection(@connection.merge(pool: pool_size, checkout_timeout: 0.5))
+ @connection_count = 0
+ @timed_out = 0
+ threads.times do
+ Thread.new do
+ conn = ActiveRecord::Base.connection_pool.checkout
+ sleep 0.1
+ ActiveRecord::Base.connection_pool.checkin conn
+ @connection_count += 1
+ rescue ActiveRecord::ConnectionTimeoutError
+ @timed_out += 1
+ end.join
+ end
+ end
+
+ def checkout_checkin_connections_loop(pool_size, loops)
+ ActiveRecord::Base.establish_connection(@connection.merge(pool: pool_size, checkout_timeout: 0.5))
+ @connection_count = 0
+ @timed_out = 0
+ loops.times do
+ conn = ActiveRecord::Base.connection_pool.checkout
+ ActiveRecord::Base.connection_pool.checkin conn
+ @connection_count += 1
+ ActiveRecord::Base.connection.data_sources
+ rescue ActiveRecord::ConnectionTimeoutError
+ @timed_out += 1
+ end
+ end
+
+ def test_pooled_connection_checkin_one
+ checkout_checkin_connections 1, 2
+ assert_equal 2, @connection_count
+ assert_equal 0, @timed_out
+ assert_equal 1, ActiveRecord::Base.connection_pool.connections.size
+ end
+
+ def test_pooled_connection_checkin_two
+ checkout_checkin_connections_loop 2, 3
+ assert_equal 3, @connection_count
+ assert_equal 0, @timed_out
+ assert_equal 2, ActiveRecord::Base.connection_pool.connections.size
+ end
+
+ def test_pooled_connection_remove
+ ActiveRecord::Base.establish_connection(@connection.merge(pool: 2, checkout_timeout: 0.5))
+ old_connection = ActiveRecord::Base.connection
+ extra_connection = ActiveRecord::Base.connection_pool.checkout
+ ActiveRecord::Base.connection_pool.remove(extra_connection)
+ assert_equal ActiveRecord::Base.connection, old_connection
+ end
+
+ private
+
+ def add_record(name)
+ ActiveRecord::Base.connection_pool.with_connection { Project.create! name: name }
+ end
+end unless in_memory_db?
diff --git a/activerecord/test/cases/primary_keys_test.rb b/activerecord/test/cases/primary_keys_test.rb
new file mode 100644
index 0000000000..4759d3b6b2
--- /dev/null
+++ b/activerecord/test/cases/primary_keys_test.rb
@@ -0,0 +1,473 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+require "models/topic"
+require "models/reply"
+require "models/subscriber"
+require "models/movie"
+require "models/keyboard"
+require "models/mixed_case_monkey"
+require "models/dashboard"
+require "models/non_primary_key"
+
+class PrimaryKeysTest < ActiveRecord::TestCase
+ fixtures :topics, :subscribers, :movies, :mixed_case_monkeys
+
+ def test_to_key_with_default_primary_key
+ topic = Topic.new
+ assert_nil topic.to_key
+ topic = Topic.find(1)
+ assert_equal [1], topic.to_key
+ end
+
+ def test_to_key_with_customized_primary_key
+ keyboard = Keyboard.new
+ assert_nil keyboard.to_key
+ keyboard.save
+ assert_equal keyboard.to_key, [keyboard.id]
+ end
+
+ def test_read_attribute_with_custom_primary_key
+ keyboard = Keyboard.create!
+ assert_equal keyboard.key_number, keyboard.read_attribute(:id)
+ end
+
+ def test_to_key_with_primary_key_after_destroy
+ topic = Topic.find(1)
+ topic.destroy
+ assert_equal [1], topic.to_key
+ end
+
+ def test_integer_key
+ topic = Topic.find(1)
+ assert_equal(topics(:first).author_name, topic.author_name)
+ topic = Topic.find(2)
+ assert_equal(topics(:second).author_name, topic.author_name)
+
+ topic = Topic.new
+ topic.title = "New Topic"
+ assert_nil topic.id
+ topic.save!
+ id = topic.id
+
+ topicReloaded = Topic.find(id)
+ assert_equal("New Topic", topicReloaded.title)
+ end
+
+ def test_customized_primary_key_auto_assigns_on_save
+ Keyboard.delete_all
+ keyboard = Keyboard.new(name: "HHKB")
+ keyboard.save!
+ assert_equal keyboard.id, Keyboard.find_by_name("HHKB").id
+ end
+
+ def test_customized_primary_key_can_be_get_before_saving
+ keyboard = Keyboard.new
+ assert_nil keyboard.id
+ assert_nil keyboard.key_number
+ end
+
+ def test_customized_string_primary_key_settable_before_save
+ subscriber = Subscriber.new
+ subscriber.id = "webster123"
+ assert_equal "webster123", subscriber.id
+ assert_equal "webster123", subscriber.nick
+ end
+
+ def test_update_with_non_primary_key_id_column
+ subscriber = Subscriber.first
+ subscriber.update(update_count: 1)
+ subscriber.reload
+ assert_equal 1, subscriber.update_count
+ end
+
+ def test_update_columns_with_non_primary_key_id_column
+ subscriber = Subscriber.first
+ subscriber.update_columns(id: 1)
+ assert_not_equal 1, subscriber.nick
+ end
+
+ def test_string_key
+ subscriber = Subscriber.find(subscribers(:first).nick)
+ assert_equal(subscribers(:first).name, subscriber.name)
+ subscriber = Subscriber.find(subscribers(:second).nick)
+ assert_equal(subscribers(:second).name, subscriber.name)
+
+ subscriber = Subscriber.new
+ subscriber.id = "jdoe"
+ assert_equal("jdoe", subscriber.id)
+ subscriber.name = "John Doe"
+ subscriber.save!
+ assert_equal("jdoe", subscriber.id)
+
+ subscriberReloaded = Subscriber.find("jdoe")
+ assert_equal("John Doe", subscriberReloaded.name)
+ end
+
+ def test_id_column_that_is_not_primary_key
+ NonPrimaryKey.create!(id: 100)
+ actual = NonPrimaryKey.find_by(id: 100)
+ assert_match %r{<NonPrimaryKey id: 100}, actual.inspect
+ end
+
+ def test_find_with_more_than_one_string_key
+ assert_equal 2, Subscriber.find(subscribers(:first).nick, subscribers(:second).nick).length
+ end
+
+ def test_primary_key_prefix
+ old_primary_key_prefix_type = ActiveRecord::Base.primary_key_prefix_type
+ ActiveRecord::Base.primary_key_prefix_type = :table_name
+ Topic.reset_primary_key
+ assert_equal "topicid", Topic.primary_key
+
+ ActiveRecord::Base.primary_key_prefix_type = :table_name_with_underscore
+ Topic.reset_primary_key
+ assert_equal "topic_id", Topic.primary_key
+
+ ActiveRecord::Base.primary_key_prefix_type = nil
+ Topic.reset_primary_key
+ assert_equal "id", Topic.primary_key
+ ensure
+ ActiveRecord::Base.primary_key_prefix_type = old_primary_key_prefix_type
+ end
+
+ def test_delete_should_quote_pkey
+ assert_nothing_raised { MixedCaseMonkey.delete(1) }
+ end
+
+ def test_update_counters_should_quote_pkey_and_quote_counter_columns
+ assert_nothing_raised { MixedCaseMonkey.update_counters(1, fleaCount: 99) }
+ end
+
+ def test_find_with_one_id_should_quote_pkey
+ assert_nothing_raised { MixedCaseMonkey.find(1) }
+ end
+
+ def test_find_with_multiple_ids_should_quote_pkey
+ assert_nothing_raised { MixedCaseMonkey.find([1, 2]) }
+ end
+
+ def test_instance_update_should_quote_pkey
+ assert_nothing_raised { MixedCaseMonkey.find(1).save }
+ end
+
+ def test_instance_destroy_should_quote_pkey
+ assert_nothing_raised { MixedCaseMonkey.find(1).destroy }
+ end
+
+ def test_primary_key_returns_value_if_it_exists
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "developers"
+ end
+
+ assert_equal "id", klass.primary_key
+ end
+
+ def test_primary_key_returns_nil_if_it_does_not_exist
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "developers_projects"
+ end
+
+ assert_nil klass.primary_key
+ end
+
+ def test_quoted_primary_key_after_set_primary_key
+ k = Class.new(ActiveRecord::Base)
+ assert_equal k.connection.quote_column_name("id"), k.quoted_primary_key
+ k.primary_key = "foo"
+ assert_equal k.connection.quote_column_name("foo"), k.quoted_primary_key
+ end
+
+ def test_auto_detect_primary_key_from_schema
+ MixedCaseMonkey.reset_primary_key
+ assert_equal "monkeyID", MixedCaseMonkey.primary_key
+ end
+
+ def test_primary_key_update_with_custom_key_name
+ dashboard = Dashboard.create!(dashboard_id: "1")
+ dashboard.id = "2"
+ dashboard.save!
+
+ dashboard = Dashboard.first
+ assert_equal "2", dashboard.id
+ end
+
+ def test_create_without_primary_key_no_extra_query
+ skip if current_adapter?(:OracleAdapter)
+
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "dashboards"
+ end
+ klass.create! # warmup schema cache
+ assert_queries(3, ignore_none: true) { klass.create! }
+ end
+
+ if current_adapter?(:PostgreSQLAdapter)
+ def test_serial_with_quoted_sequence_name
+ column = MixedCaseMonkey.columns_hash[MixedCaseMonkey.primary_key]
+ assert_equal "nextval('\"mixed_case_monkeys_monkeyID_seq\"'::regclass)", column.default_function
+ assert_predicate column, :serial?
+ end
+
+ def test_serial_with_unquoted_sequence_name
+ column = Topic.columns_hash[Topic.primary_key]
+ assert_equal "nextval('topics_id_seq'::regclass)", column.default_function
+ assert_predicate column, :serial?
+ end
+ end
+end
+
+class PrimaryKeyWithNoConnectionTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ unless in_memory_db?
+ def test_set_primary_key_with_no_connection
+ connection = ActiveRecord::Base.remove_connection
+
+ model = Class.new(ActiveRecord::Base)
+ model.primary_key = "foo"
+
+ assert_equal "foo", model.primary_key
+
+ ActiveRecord::Base.establish_connection(connection)
+
+ assert_equal "foo", model.primary_key
+ end
+ end
+end
+
+class PrimaryKeyWithAutoIncrementTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ class AutoIncrement < ActiveRecord::Base
+ end
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ end
+
+ def teardown
+ @connection.drop_table(:auto_increments, if_exists: true)
+ end
+
+ def test_primary_key_with_integer
+ @connection.create_table(:auto_increments, id: :integer, force: true)
+ assert_auto_incremented
+ end
+
+ def test_primary_key_with_bigint
+ @connection.create_table(:auto_increments, id: :bigint, force: true)
+ assert_auto_incremented
+ end
+
+ private
+ def assert_auto_incremented
+ record1 = AutoIncrement.create!
+ assert_not_nil record1.id
+
+ record1.destroy
+
+ record2 = AutoIncrement.create!
+ assert_not_nil record2.id
+ assert_operator record2.id, :>, record1.id
+ end
+end
+
+class PrimaryKeyAnyTypeTest < ActiveRecord::TestCase
+ include SchemaDumpingHelper
+
+ self.use_transactional_tests = false
+
+ class Barcode < ActiveRecord::Base
+ end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table(:barcodes, primary_key: "code", id: :string, limit: 42, force: true)
+ end
+
+ teardown do
+ @connection.drop_table(:barcodes, if_exists: true)
+ end
+
+ def test_any_type_primary_key
+ assert_equal "code", Barcode.primary_key
+
+ column = Barcode.column_for_attribute(Barcode.primary_key)
+ assert_not column.null
+ assert_equal :string, column.type
+ assert_equal 42, column.limit
+ ensure
+ Barcode.reset_column_information
+ end
+
+ test "schema dump primary key includes type and options" do
+ schema = dump_table_schema "barcodes"
+ assert_match %r{create_table "barcodes", primary_key: "code", id: :string, limit: 42}, schema
+ assert_no_match %r{t\.index \["code"\]}, schema
+ end
+
+ if current_adapter?(:Mysql2Adapter) && subsecond_precision_supported?
+ test "schema typed primary key column" do
+ @connection.create_table(:scheduled_logs, id: :timestamp, precision: 6, force: true)
+ schema = dump_table_schema("scheduled_logs")
+ assert_match %r/create_table "scheduled_logs", id: :timestamp, precision: 6/, schema
+ end
+ end
+end
+
+class CompositePrimaryKeyTest < ActiveRecord::TestCase
+ include SchemaDumpingHelper
+
+ self.use_transactional_tests = false
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ @connection.schema_cache.clear!
+ @connection.create_table(:uber_barcodes, primary_key: ["region", "code"], force: true) do |t|
+ t.string :region
+ t.integer :code
+ end
+ @connection.create_table(:barcodes_reverse, primary_key: ["code", "region"], force: true) do |t|
+ t.string :region
+ t.integer :code
+ end
+ @connection.create_table(:travels, primary_key: ["from", "to"], force: true) do |t|
+ t.string :from
+ t.string :to
+ end
+ end
+
+ def teardown
+ @connection.drop_table :uber_barcodes, if_exists: true
+ @connection.drop_table :barcodes_reverse, if_exists: true
+ @connection.drop_table :travels, if_exists: true
+ end
+
+ def test_composite_primary_key
+ assert_equal ["region", "code"], @connection.primary_keys("uber_barcodes")
+ end
+
+ def test_composite_primary_key_with_reserved_words
+ assert_equal ["from", "to"], @connection.primary_keys("travels")
+ end
+
+ def test_composite_primary_key_out_of_order
+ assert_equal ["code", "region"], @connection.primary_keys("barcodes_reverse")
+ end
+
+ def test_primary_key_issues_warning
+ model = Class.new(ActiveRecord::Base) do
+ def self.table_name
+ "uber_barcodes"
+ end
+ end
+ warning = capture(:stderr) do
+ assert_nil model.primary_key
+ end
+ assert_match(/WARNING: Active Record does not support composite primary key\./, warning)
+ end
+
+ def test_collectly_dump_composite_primary_key
+ schema = dump_table_schema "uber_barcodes"
+ assert_match %r{create_table "uber_barcodes", primary_key: \["region", "code"\]}, schema
+ end
+
+ def test_dumping_composite_primary_key_out_of_order
+ schema = dump_table_schema "barcodes_reverse"
+ assert_match %r{create_table "barcodes_reverse", primary_key: \["code", "region"\]}, schema
+ end
+end
+
+class PrimaryKeyIntegerNilDefaultTest < ActiveRecord::TestCase
+ include SchemaDumpingHelper
+
+ self.use_transactional_tests = false
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ end
+
+ def teardown
+ @connection.drop_table :int_defaults, if_exists: true
+ end
+
+ def test_schema_dump_primary_key_integer_with_default_nil
+ skip if current_adapter?(:SQLite3Adapter)
+ @connection.create_table(:int_defaults, id: :integer, default: nil, force: true)
+ schema = dump_table_schema "int_defaults"
+ assert_match %r{create_table "int_defaults", id: :integer, default: nil}, schema
+ end
+
+ def test_schema_dump_primary_key_bigint_with_default_nil
+ @connection.create_table(:int_defaults, id: :bigint, default: nil, force: true)
+ schema = dump_table_schema "int_defaults"
+ assert_match %r{create_table "int_defaults", id: :bigint, default: nil}, schema
+ end
+end
+
+if current_adapter?(:PostgreSQLAdapter, :Mysql2Adapter)
+ class PrimaryKeyIntegerTest < ActiveRecord::TestCase
+ include SchemaDumpingHelper
+
+ self.use_transactional_tests = false
+
+ class Widget < ActiveRecord::Base
+ end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @pk_type = current_adapter?(:PostgreSQLAdapter) ? :serial : :integer
+ end
+
+ teardown do
+ @connection.drop_table :widgets, if_exists: true
+ end
+
+ test "primary key column type with serial/integer" do
+ @connection.create_table(:widgets, id: @pk_type, force: true)
+ column = @connection.columns(:widgets).find { |c| c.name == "id" }
+ assert_equal :integer, column.type
+ assert_not_predicate column, :bigint?
+ end
+
+ test "primary key with serial/integer are automatically numbered" do
+ @connection.create_table(:widgets, id: @pk_type, force: true)
+ widget = Widget.create!
+ assert_not_nil widget.id
+ end
+
+ test "schema dump primary key with serial/integer" do
+ @connection.create_table(:widgets, id: @pk_type, force: true)
+ schema = dump_table_schema "widgets"
+ assert_match %r{create_table "widgets", id: :#{@pk_type}, }, schema
+ end
+
+ if current_adapter?(:Mysql2Adapter)
+ test "primary key column type with options" do
+ @connection.create_table(:widgets, id: :primary_key, limit: 4, unsigned: true, force: true)
+ column = @connection.columns(:widgets).find { |c| c.name == "id" }
+ assert_predicate column, :auto_increment?
+ assert_equal :integer, column.type
+ assert_not_predicate column, :bigint?
+ assert_predicate column, :unsigned?
+
+ schema = dump_table_schema "widgets"
+ assert_match %r{create_table "widgets", id: :integer, unsigned: true, }, schema
+ end
+
+ test "bigint primary key with unsigned" do
+ @connection.create_table(:widgets, id: :bigint, unsigned: true, force: true)
+ column = @connection.columns(:widgets).find { |c| c.name == "id" }
+ assert_predicate column, :auto_increment?
+ assert_equal :integer, column.type
+ assert_predicate column, :bigint?
+ assert_predicate column, :unsigned?
+
+ schema = dump_table_schema "widgets"
+ assert_match %r{create_table "widgets", id: :bigint, unsigned: true, }, schema
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/query_cache_test.rb b/activerecord/test/cases/query_cache_test.rb
new file mode 100644
index 0000000000..04bbc7d136
--- /dev/null
+++ b/activerecord/test/cases/query_cache_test.rb
@@ -0,0 +1,634 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+require "models/task"
+require "models/category"
+require "models/post"
+require "rack"
+
+class QueryCacheTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ fixtures :tasks, :topics, :categories, :posts, :categories_posts
+
+ class ShouldNotHaveExceptionsLogger < ActiveRecord::LogSubscriber
+ attr_reader :logger, :events
+
+ def initialize
+ super
+ @logger = ::Logger.new File::NULL
+ @exception = false
+ @events = []
+ end
+
+ def exception?
+ @exception
+ end
+
+ def sql(event)
+ @events << event
+ super
+ rescue
+ @exception = true
+ end
+ end
+
+ def teardown
+ Task.connection.clear_query_cache
+ ActiveRecord::Base.connection.disable_query_cache!
+ super
+ end
+
+ def test_exceptional_middleware_clears_and_disables_cache_on_error
+ assert_cache :off
+
+ mw = middleware { |env|
+ Task.find 1
+ Task.find 1
+ query_cache = ActiveRecord::Base.connection.query_cache
+ assert_equal 1, query_cache.length, query_cache.keys
+ raise "lol borked"
+ }
+ assert_raises(RuntimeError) { mw.call({}) }
+
+ assert_cache :off
+ end
+
+ def test_query_cache_is_applied_to_connections_in_all_handlers
+ ActiveRecord::Base.connection_handlers = {
+ writing: ActiveRecord::Base.default_connection_handler,
+ reading: ActiveRecord::ConnectionAdapters::ConnectionHandler.new
+ }
+
+ ActiveRecord::Base.connected_to(role: :reading) do
+ ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations["arunit"])
+ end
+
+ mw = middleware { |env|
+ ro_conn = ActiveRecord::Base.connection_handlers[:reading].connection_pool_list.first.connection
+ assert_predicate ActiveRecord::Base.connection, :query_cache_enabled
+ assert_predicate ro_conn, :query_cache_enabled
+ }
+
+ mw.call({})
+ ensure
+ ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.default_connection_handler }
+ end
+
+ def test_query_cache_across_threads
+ with_temporary_connection_pool do
+ if in_memory_db?
+ # Separate connections to an in-memory database create an entirely new database,
+ # with an empty schema etc, so we just stub out this schema on the fly.
+ ActiveRecord::Base.connection_pool.with_connection do |connection|
+ connection.create_table :tasks do |t|
+ t.datetime :starting
+ t.datetime :ending
+ end
+ end
+ ActiveRecord::FixtureSet.create_fixtures(self.class.fixture_path, ["tasks"], {}, ActiveRecord::Base)
+ end
+
+ ActiveRecord::Base.connection_pool.connections.each do |conn|
+ assert_cache :off, conn
+ end
+
+ assert_not_predicate ActiveRecord::Base.connection, :nil?
+ assert_cache :off
+
+ middleware {
+ assert_cache :clean
+
+ Task.find 1
+ assert_cache :dirty
+
+ thread_1_connection = ActiveRecord::Base.connection
+ ActiveRecord::Base.clear_active_connections!
+ assert_cache :off, thread_1_connection
+
+ started = Concurrent::Event.new
+ checked = Concurrent::Event.new
+
+ thread_2_connection = nil
+ thread = Thread.new {
+ thread_2_connection = ActiveRecord::Base.connection
+
+ assert_equal thread_2_connection, thread_1_connection
+ assert_cache :off
+
+ middleware {
+ assert_cache :clean
+
+ Task.find 1
+ assert_cache :dirty
+
+ started.set
+ checked.wait
+
+ ActiveRecord::Base.clear_active_connections!
+ }.call({})
+ }
+
+ started.wait
+
+ thread_1_connection = ActiveRecord::Base.connection
+ assert_not_equal thread_1_connection, thread_2_connection
+ assert_cache :dirty, thread_2_connection
+ checked.set
+ thread.join
+
+ assert_cache :off, thread_2_connection
+ }.call({})
+
+ ActiveRecord::Base.connection_pool.connections.each do |conn|
+ assert_cache :off, conn
+ end
+ ensure
+ ActiveRecord::Base.connection_pool.disconnect!
+ end
+ end
+
+ def test_middleware_delegates
+ called = false
+ mw = middleware { |env|
+ called = true
+ [200, {}, nil]
+ }
+ mw.call({})
+ assert called, "middleware should delegate"
+ end
+
+ def test_middleware_caches
+ mw = middleware { |env|
+ Task.find 1
+ Task.find 1
+ query_cache = ActiveRecord::Base.connection.query_cache
+ assert_equal 1, query_cache.length, query_cache.keys
+ [200, {}, nil]
+ }
+ mw.call({})
+ end
+
+ def test_cache_enabled_during_call
+ assert_cache :off
+
+ mw = middleware { |env|
+ assert_cache :clean
+ [200, {}, nil]
+ }
+ mw.call({})
+ end
+
+ def test_cache_passing_a_relation
+ post = Post.first
+ Post.cache do
+ query = post.categories.select(:post_id)
+ assert Post.connection.select_all(query).is_a?(ActiveRecord::Result)
+ end
+ end
+
+ def test_find_queries
+ assert_queries(2) { Task.find(1); Task.find(1) }
+ end
+
+ def test_find_queries_with_cache
+ Task.cache do
+ assert_queries(1) { Task.find(1); Task.find(1) }
+ end
+ end
+
+ def test_find_queries_with_cache_multi_record
+ Task.cache do
+ assert_queries(2) { Task.find(1); Task.find(1); Task.find(2) }
+ end
+ end
+
+ def test_find_queries_with_multi_cache_blocks
+ Task.cache do
+ Task.cache do
+ assert_queries(2) { Task.find(1); Task.find(2) }
+ end
+ assert_no_queries { Task.find(1); Task.find(1); Task.find(2) }
+ end
+ end
+
+ def test_count_queries_with_cache
+ Task.cache do
+ assert_queries(1) { Task.count; Task.count }
+ end
+ end
+
+ def test_exists_queries_with_cache
+ Post.cache do
+ assert_queries(1) { Post.exists?; Post.exists? }
+ end
+ end
+
+ def test_select_all_with_cache
+ Post.cache do
+ assert_queries(1) do
+ 2.times { Post.connection.select_all(Post.all) }
+ end
+ end
+ end
+
+ def test_select_one_with_cache
+ Post.cache do
+ assert_queries(1) do
+ 2.times { Post.connection.select_one(Post.all) }
+ end
+ end
+ end
+
+ def test_select_value_with_cache
+ Post.cache do
+ assert_queries(1) do
+ 2.times { Post.connection.select_value(Post.all) }
+ end
+ end
+ end
+
+ def test_select_values_with_cache
+ Post.cache do
+ assert_queries(1) do
+ 2.times { Post.connection.select_values(Post.all) }
+ end
+ end
+ end
+
+ def test_select_rows_with_cache
+ Post.cache do
+ assert_queries(1) do
+ 2.times { Post.connection.select_rows(Post.all) }
+ end
+ end
+ end
+
+ def test_query_cache_dups_results_correctly
+ Task.cache do
+ now = Time.now.utc
+ task = Task.find 1
+ assert_not_equal now, task.starting
+ task.starting = now
+ task.reload
+ assert_not_equal now, task.starting
+ end
+ end
+
+ def test_cache_notifications_can_be_overridden
+ logger = ShouldNotHaveExceptionsLogger.new
+ subscriber = ActiveSupport::Notifications.subscribe "sql.active_record", logger
+
+ connection = ActiveRecord::Base.connection.dup
+
+ def connection.cache_notification_info(sql, name, binds)
+ super.merge(neat: true)
+ end
+
+ connection.cache do
+ connection.select_all "select 1"
+ connection.select_all "select 1"
+ end
+
+ assert_equal true, logger.events.last.payload[:neat]
+ ensure
+ ActiveSupport::Notifications.unsubscribe subscriber
+ end
+
+ def test_cache_does_not_raise_exceptions
+ logger = ShouldNotHaveExceptionsLogger.new
+ subscriber = ActiveSupport::Notifications.subscribe "sql.active_record", logger
+
+ ActiveRecord::Base.cache do
+ assert_queries(1) { Task.find(1); Task.find(1) }
+ end
+
+ assert_not_predicate logger, :exception?
+ ensure
+ ActiveSupport::Notifications.unsubscribe subscriber
+ end
+
+ def test_query_cache_does_not_allow_sql_key_mutation
+ subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |_, _, _, _, payload|
+ payload[:sql].downcase!
+ end
+
+ assert_raises FrozenError do
+ ActiveRecord::Base.cache do
+ assert_queries(1) { Task.find(1); Task.find(1) }
+ end
+ end
+ ensure
+ ActiveSupport::Notifications.unsubscribe subscriber
+ end
+
+ def test_cache_is_flat
+ Task.cache do
+ assert_queries(1) { Topic.find(1); Topic.find(1); }
+ end
+
+ ActiveRecord::Base.cache do
+ assert_queries(1) { Task.find(1); Task.find(1) }
+ end
+ end
+
+ def test_cache_does_not_wrap_results_in_arrays
+ Task.cache do
+ if current_adapter?(:SQLite3Adapter, :Mysql2Adapter, :PostgreSQLAdapter, :OracleAdapter)
+ assert_equal 2, Task.connection.select_value("SELECT count(*) AS count_all FROM tasks")
+ else
+ assert_instance_of String, Task.connection.select_value("SELECT count(*) AS count_all FROM tasks")
+ end
+ end
+ end
+
+ def test_cache_is_ignored_for_locked_relations
+ task = Task.find 1
+
+ Task.cache do
+ assert_queries(2) { task.lock!; task.lock! }
+ end
+ end
+
+ def test_cache_is_available_when_connection_is_connected
+ conf = ActiveRecord::Base.configurations
+
+ ActiveRecord::Base.configurations = {}
+ Task.cache do
+ assert_queries(1) { Task.find(1); Task.find(1) }
+ end
+ ensure
+ ActiveRecord::Base.configurations = conf
+ end
+
+ def test_cache_is_available_when_using_a_not_connected_connection
+ skip "In-Memory DB can't test for using a not connected connection" if in_memory_db?
+ with_temporary_connection_pool do
+ spec_name = Task.connection_specification_name
+ conf = ActiveRecord::Base.configurations["arunit"].merge("name" => "test2")
+ ActiveRecord::Base.connection_handler.establish_connection(conf)
+ Task.connection_specification_name = "test2"
+ assert_not_predicate Task, :connected?
+
+ Task.cache do
+ assert_queries(1) { Task.find(1); Task.find(1) }
+ ensure
+ ActiveRecord::Base.connection_handler.remove_connection(Task.connection_specification_name)
+ Task.connection_specification_name = spec_name
+ end
+ end
+ end
+
+ def test_query_cache_executes_new_queries_within_block
+ ActiveRecord::Base.connection.enable_query_cache!
+
+ # Warm up the cache by running the query
+ assert_queries(1) do
+ assert_equal 0, Post.where(title: "test").to_a.count
+ end
+
+ # Check that if the same query is run again, no queries are executed
+ assert_no_queries do
+ assert_equal 0, Post.where(title: "test").to_a.count
+ end
+
+ ActiveRecord::Base.connection.uncached do
+ # Check that new query is executed, avoiding the cache
+ assert_queries(1) do
+ assert_equal 0, Post.where(title: "test").to_a.count
+ end
+ end
+ end
+
+ def test_query_cache_doesnt_leak_cached_results_of_rolled_back_queries
+ ActiveRecord::Base.connection.enable_query_cache!
+ post = Post.first
+
+ Post.transaction do
+ post.update(title: "rollback")
+ assert_equal 1, Post.where(title: "rollback").to_a.count
+ raise ActiveRecord::Rollback
+ end
+
+ assert_equal 0, Post.where(title: "rollback").to_a.count
+
+ ActiveRecord::Base.connection.uncached do
+ assert_equal 0, Post.where(title: "rollback").to_a.count
+ end
+
+ begin
+ Post.transaction do
+ post.update(title: "rollback")
+ assert_equal 1, Post.where(title: "rollback").to_a.count
+ raise "broken"
+ end
+ rescue Exception
+ end
+
+ assert_equal 0, Post.where(title: "rollback").to_a.count
+
+ ActiveRecord::Base.connection.uncached do
+ assert_equal 0, Post.where(title: "rollback").to_a.count
+ end
+ end
+
+ def test_query_cached_even_when_types_are_reset
+ Task.cache do
+ # Warm the cache
+ Task.find(1)
+
+ # Preload the type cache again (so we don't have those queries issued during our assertions)
+ Task.connection.send(:reload_type_map)
+
+ # Clear places where type information is cached
+ Task.reset_column_information
+ Task.initialize_find_by_cache
+ Task.define_attribute_methods
+
+ assert_no_queries do
+ Task.find(1)
+ end
+ end
+ end
+
+ def test_query_cache_does_not_establish_connection_if_unconnected
+ with_temporary_connection_pool do
+ ActiveRecord::Base.clear_active_connections!
+ assert_not ActiveRecord::Base.connection_handler.active_connections? # sanity check
+
+ middleware {
+ assert_not ActiveRecord::Base.connection_handler.active_connections?, "QueryCache forced ActiveRecord::Base to establish a connection in setup"
+ }.call({})
+
+ assert_not ActiveRecord::Base.connection_handler.active_connections?, "QueryCache forced ActiveRecord::Base to establish a connection in cleanup"
+ end
+ end
+
+ def test_query_cache_is_enabled_on_connections_established_after_middleware_runs
+ with_temporary_connection_pool do
+ ActiveRecord::Base.clear_active_connections!
+ assert_not ActiveRecord::Base.connection_handler.active_connections? # sanity check
+
+ middleware {
+ assert_predicate ActiveRecord::Base.connection, :query_cache_enabled
+ }.call({})
+ assert_not_predicate ActiveRecord::Base.connection, :query_cache_enabled
+ end
+ end
+
+ def test_query_caching_is_local_to_the_current_thread
+ with_temporary_connection_pool do
+ ActiveRecord::Base.clear_active_connections!
+
+ middleware {
+ assert ActiveRecord::Base.connection_pool.query_cache_enabled
+ assert ActiveRecord::Base.connection.query_cache_enabled
+
+ Thread.new {
+ assert_not ActiveRecord::Base.connection_pool.query_cache_enabled
+ assert_not ActiveRecord::Base.connection.query_cache_enabled
+ }.join
+ }.call({})
+ end
+ end
+
+ def test_query_cache_is_enabled_on_all_connection_pools
+ middleware {
+ ActiveRecord::Base.connection_handler.connection_pool_list.each do |pool|
+ assert pool.query_cache_enabled
+ assert pool.connection.query_cache_enabled
+ end
+ }.call({})
+ end
+
+ private
+
+ def with_temporary_connection_pool
+ old_pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool(ActiveRecord::Base.connection_specification_name)
+ new_pool = ActiveRecord::ConnectionAdapters::ConnectionPool.new ActiveRecord::Base.connection_pool.spec
+ ActiveRecord::Base.connection_handler.send(:owner_to_pool)["primary"] = new_pool
+
+ yield
+ ensure
+ ActiveRecord::Base.connection_handler.send(:owner_to_pool)["primary"] = old_pool
+ end
+
+ def middleware(&app)
+ executor = Class.new(ActiveSupport::Executor)
+ ActiveRecord::QueryCache.install_executor_hooks executor
+ lambda { |env| executor.wrap { app.call(env) } }
+ end
+
+ def assert_cache(state, connection = ActiveRecord::Base.connection)
+ case state
+ when :off
+ assert_not connection.query_cache_enabled, "cache should be off"
+ assert connection.query_cache.empty?, "cache should be empty"
+ when :clean
+ assert connection.query_cache_enabled, "cache should be on"
+ assert connection.query_cache.empty?, "cache should be empty"
+ when :dirty
+ assert connection.query_cache_enabled, "cache should be on"
+ assert_not connection.query_cache.empty?, "cache should be dirty"
+ else
+ raise "unknown state"
+ end
+ end
+end
+
+class QueryCacheExpiryTest < ActiveRecord::TestCase
+ fixtures :tasks, :posts, :categories, :categories_posts
+
+ def teardown
+ Task.connection.clear_query_cache
+ end
+
+ def test_cache_gets_cleared_after_migration
+ # warm the cache
+ Post.find(1)
+
+ # change the column definition
+ Post.connection.change_column :posts, :title, :string, limit: 80
+ assert_nothing_raised { Post.find(1) }
+
+ # restore the old definition
+ Post.connection.change_column :posts, :title, :string
+ end
+
+ def test_find
+ assert_called(Task.connection, :clear_query_cache) do
+ assert_not Task.connection.query_cache_enabled
+ Task.cache do
+ assert Task.connection.query_cache_enabled
+ Task.find(1)
+
+ Task.uncached do
+ assert_not Task.connection.query_cache_enabled
+ Task.find(1)
+ end
+
+ assert Task.connection.query_cache_enabled
+ end
+ assert_not Task.connection.query_cache_enabled
+ end
+ end
+
+ def test_update
+ assert_called(Task.connection, :clear_query_cache, times: 2) do
+ Task.cache do
+ task = Task.find(1)
+ task.starting = Time.now.utc
+ task.save!
+ end
+ end
+ end
+
+ def test_destroy
+ assert_called(Task.connection, :clear_query_cache, times: 2) do
+ Task.cache do
+ Task.find(1).destroy
+ end
+ end
+ end
+
+ def test_insert
+ assert_called(ActiveRecord::Base.connection, :clear_query_cache, times: 2) do
+ Task.cache do
+ Task.create!
+ end
+ end
+ end
+
+ def test_cache_is_expired_by_habtm_update
+ assert_called(ActiveRecord::Base.connection, :clear_query_cache, times: 2) do
+ ActiveRecord::Base.cache do
+ c = Category.first
+ p = Post.first
+ p.categories << c
+ end
+ end
+ end
+
+ def test_cache_is_expired_by_habtm_delete
+ assert_called(ActiveRecord::Base.connection, :clear_query_cache, times: 2) do
+ ActiveRecord::Base.cache do
+ p = Post.find(1)
+ assert_predicate p.categories, :any?
+ p.categories.delete_all
+ end
+ end
+ end
+
+ test "threads use the same connection" do
+ @connection_1 = ActiveRecord::Base.connection.object_id
+
+ thread_a = Thread.new do
+ @connection_2 = ActiveRecord::Base.connection.object_id
+ end
+
+ thread_a.join
+
+ assert_equal @connection_1, @connection_2
+ end
+end
diff --git a/activerecord/test/cases/quoting_test.rb b/activerecord/test/cases/quoting_test.rb
new file mode 100644
index 0000000000..723fccc8d9
--- /dev/null
+++ b/activerecord/test/cases/quoting_test.rb
@@ -0,0 +1,297 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class QuotingTest < ActiveRecord::TestCase
+ def setup
+ @quoter = Class.new { include Quoting }.new
+ end
+
+ def test_quoted_true
+ assert_equal "TRUE", @quoter.quoted_true
+ end
+
+ def test_quoted_false
+ assert_equal "FALSE", @quoter.quoted_false
+ end
+
+ def test_quote_column_name
+ assert_equal "foo", @quoter.quote_column_name("foo")
+ end
+
+ def test_quote_table_name
+ assert_equal "foo", @quoter.quote_table_name("foo")
+ end
+
+ def test_quote_table_name_calls_quote_column_name
+ @quoter.extend(Module.new {
+ def quote_column_name(string)
+ "lol"
+ end
+ })
+ assert_equal "lol", @quoter.quote_table_name("foo")
+ end
+
+ def test_quote_string
+ assert_equal "''", @quoter.quote_string("'")
+ assert_equal "\\\\", @quoter.quote_string("\\")
+ assert_equal "hi''i", @quoter.quote_string("hi'i")
+ assert_equal "hi\\\\i", @quoter.quote_string("hi\\i")
+ end
+
+ def test_quoted_date
+ t = Date.today
+ assert_equal t.to_s(:db), @quoter.quoted_date(t)
+ end
+
+ def test_quoted_timestamp_utc
+ with_timezone_config default: :utc do
+ t = Time.now.change(usec: 0)
+ assert_equal t.getutc.to_s(:db), @quoter.quoted_date(t)
+ end
+ end
+
+ def test_quoted_timestamp_local
+ with_timezone_config default: :local do
+ t = Time.now.change(usec: 0)
+ assert_equal t.getlocal.to_s(:db), @quoter.quoted_date(t)
+ end
+ end
+
+ def test_quoted_timestamp_crazy
+ with_timezone_config default: :asdfasdf do
+ t = Time.now.change(usec: 0)
+ assert_equal t.getlocal.to_s(:db), @quoter.quoted_date(t)
+ end
+ end
+
+ def test_quoted_time_utc
+ with_timezone_config default: :utc do
+ t = Time.now.change(usec: 0)
+
+ expected = t.change(year: 2000, month: 1, day: 1)
+ expected = expected.getutc.to_s(:db).slice(11..-1)
+
+ assert_equal expected, @quoter.quoted_time(t)
+ end
+ end
+
+ def test_quoted_time_local
+ with_timezone_config default: :local do
+ t = Time.now.change(usec: 0)
+
+ expected = t.change(year: 2000, month: 1, day: 1)
+ expected = expected.getlocal.to_s(:db).sub("2000-01-01 ", "")
+
+ assert_equal expected, @quoter.quoted_time(t)
+ end
+ end
+
+ def test_quoted_time_dst_utc
+ with_env_tz "America/New_York" do
+ with_timezone_config default: :utc do
+ t = Time.new(2000, 7, 1, 0, 0, 0, "+04:30")
+
+ expected = t.change(year: 2000, month: 1, day: 1)
+ expected = expected.getutc.to_s(:db).slice(11..-1)
+
+ assert_equal expected, @quoter.quoted_time(t)
+ end
+ end
+ end
+
+ def test_quoted_time_dst_local
+ with_env_tz "America/New_York" do
+ with_timezone_config default: :local do
+ t = Time.new(2000, 7, 1, 0, 0, 0, "+04:30")
+
+ expected = t.change(year: 2000, month: 1, day: 1)
+ expected = expected.getlocal.to_s(:db).slice(11..-1)
+
+ assert_equal expected, @quoter.quoted_time(t)
+ end
+ end
+ end
+
+ def test_quoted_time_crazy
+ with_timezone_config default: :asdfasdf do
+ t = Time.now.change(usec: 0)
+
+ expected = t.change(year: 2000, month: 1, day: 1)
+ expected = expected.getlocal.to_s(:db).sub("2000-01-01 ", "")
+
+ assert_equal expected, @quoter.quoted_time(t)
+ end
+ end
+
+ def test_quoted_datetime_utc
+ with_timezone_config default: :utc do
+ t = Time.now.change(usec: 0).to_datetime
+ assert_equal t.getutc.to_s(:db), @quoter.quoted_date(t)
+ end
+ end
+
+ ###
+ # DateTime doesn't define getlocal, so make sure it does nothing
+ def test_quoted_datetime_local
+ with_timezone_config default: :local do
+ t = Time.now.change(usec: 0).to_datetime
+ assert_equal t.to_s(:db), @quoter.quoted_date(t)
+ end
+ end
+
+ def test_quote_nil
+ assert_equal "NULL", @quoter.quote(nil)
+ end
+
+ def test_quote_true
+ assert_equal @quoter.quoted_true, @quoter.quote(true)
+ end
+
+ def test_quote_false
+ assert_equal @quoter.quoted_false, @quoter.quote(false)
+ end
+
+ def test_quote_float
+ float = 1.2
+ assert_equal float.to_s, @quoter.quote(float)
+ end
+
+ def test_quote_integer
+ integer = 1
+ assert_equal integer.to_s, @quoter.quote(integer)
+ end
+
+ def test_quote_bignum
+ bignum = 1 << 100
+ assert_equal bignum.to_s, @quoter.quote(bignum)
+ end
+
+ def test_quote_bigdecimal
+ bigdec = BigDecimal((1 << 100).to_s)
+ assert_equal bigdec.to_s("F"), @quoter.quote(bigdec)
+ end
+
+ def test_dates_and_times
+ @quoter.extend(Module.new { def quoted_date(value) "lol" end })
+ assert_equal "'lol'", @quoter.quote(Date.today)
+ assert_equal "'lol'", @quoter.quote(Time.now)
+ assert_equal "'lol'", @quoter.quote(DateTime.now)
+ end
+
+ def test_quoting_classes
+ assert_equal "'Object'", @quoter.quote(Object)
+ end
+
+ def test_crazy_object
+ crazy = Object.new
+ e = assert_raises(TypeError) do
+ @quoter.quote(crazy)
+ end
+ assert_equal "can't quote Object", e.message
+ end
+
+ def test_quote_string_no_column
+ assert_equal "'lo\\\\l'", @quoter.quote('lo\l')
+ end
+
+ def test_quote_as_mb_chars_no_column
+ string = ActiveSupport::Multibyte::Chars.new('lo\l')
+ assert_equal "'lo\\\\l'", @quoter.quote(string)
+ end
+
+ def test_quote_duration
+ assert_equal "1800", @quoter.quote(30.minutes)
+ end
+ end
+
+ class TypeCastingTest < ActiveRecord::TestCase
+ def setup
+ @conn = ActiveRecord::Base.connection
+ end
+
+ def test_type_cast_symbol
+ assert_equal "foo", @conn.type_cast(:foo)
+ end
+
+ def test_type_cast_date
+ date = Date.today
+ if current_adapter?(:Mysql2Adapter)
+ expected = date
+ else
+ expected = @conn.quoted_date(date)
+ end
+ assert_equal expected, @conn.type_cast(date)
+ end
+
+ def test_type_cast_time
+ time = Time.now
+ if current_adapter?(:Mysql2Adapter)
+ expected = time
+ else
+ expected = @conn.quoted_date(time)
+ end
+ assert_equal expected, @conn.type_cast(time)
+ end
+
+ def test_type_cast_numeric
+ assert_equal 10, @conn.type_cast(10)
+ assert_equal 2.2, @conn.type_cast(2.2)
+ end
+
+ def test_type_cast_nil
+ assert_nil @conn.type_cast(nil)
+ end
+
+ def test_type_cast_unknown_should_raise_error
+ obj = Class.new.new
+ assert_raise(TypeError) { @conn.type_cast(obj) }
+ end
+ end
+
+ class QuoteBooleanTest < ActiveRecord::TestCase
+ def setup
+ @connection = ActiveRecord::Base.connection
+ end
+
+ def test_quote_returns_frozen_string
+ assert_predicate @connection.quote(true), :frozen?
+ assert_predicate @connection.quote(false), :frozen?
+ end
+
+ def test_type_cast_returns_frozen_value
+ assert_predicate @connection.type_cast(true), :frozen?
+ assert_predicate @connection.type_cast(false), :frozen?
+ end
+ end
+
+ if subsecond_precision_supported?
+ class QuoteARBaseTest < ActiveRecord::TestCase
+ class DatetimePrimaryKey < ActiveRecord::Base
+ end
+
+ def setup
+ @time = ::Time.utc(2017, 2, 14, 12, 34, 56, 789999)
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table :datetime_primary_keys, id: :datetime, precision: 3, force: true
+ end
+
+ def teardown
+ @connection.drop_table :datetime_primary_keys, if_exists: true
+ end
+
+ def test_quote_ar_object
+ value = DatetimePrimaryKey.new(id: @time)
+ assert_equal "'2017-02-14 12:34:56.789000'", @connection.quote(value)
+ end
+
+ def test_type_cast_ar_object
+ value = DatetimePrimaryKey.new(id: @time)
+ assert_equal @connection.type_cast(value.id), @connection.type_cast(value)
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/readonly_test.rb b/activerecord/test/cases/readonly_test.rb
new file mode 100644
index 0000000000..059fa76132
--- /dev/null
+++ b/activerecord/test/cases/readonly_test.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/author"
+require "models/post"
+require "models/comment"
+require "models/developer"
+require "models/computer"
+require "models/project"
+require "models/reader"
+require "models/person"
+require "models/ship"
+
+class ReadOnlyTest < ActiveRecord::TestCase
+ fixtures :authors, :author_addresses, :posts, :comments, :developers, :projects, :developers_projects, :people, :readers
+
+ def test_cant_save_readonly_record
+ dev = Developer.find(1)
+ assert_not_predicate dev, :readonly?
+
+ dev.readonly!
+ assert_predicate dev, :readonly?
+
+ assert_nothing_raised do
+ dev.name = "Luscious forbidden fruit."
+ assert_not dev.save
+ dev.name = "Forbidden."
+ end
+
+ e = assert_raise(ActiveRecord::ReadOnlyRecord) { dev.save }
+ assert_equal "Developer is marked as readonly", e.message
+
+ e = assert_raise(ActiveRecord::ReadOnlyRecord) { dev.save! }
+ assert_equal "Developer is marked as readonly", e.message
+
+ e = assert_raise(ActiveRecord::ReadOnlyRecord) { dev.destroy }
+ assert_equal "Developer is marked as readonly", e.message
+ end
+
+ def test_find_with_readonly_option
+ Developer.all.each { |d| assert_not d.readonly? }
+ Developer.readonly(false).each { |d| assert_not d.readonly? }
+ Developer.readonly(true).each { |d| assert d.readonly? }
+ Developer.readonly.each { |d| assert d.readonly? }
+ end
+
+ def test_find_with_joins_option_does_not_imply_readonly
+ Developer.joins(" ").each { |d| assert_not d.readonly? }
+ Developer.joins(" ").readonly(true).each { |d| assert d.readonly? }
+
+ Developer.joins(", projects").each { |d| assert_not d.readonly? }
+ Developer.joins(", projects").readonly(true).each { |d| assert d.readonly? }
+ end
+
+ def test_has_many_find_readonly
+ post = Post.find(1)
+ assert_not_empty post.comments
+ assert_not post.comments.any?(&:readonly?)
+ assert_not post.comments.to_a.any?(&:readonly?)
+ assert post.comments.readonly(true).all?(&:readonly?)
+ end
+
+ def test_has_many_with_through_is_not_implicitly_marked_readonly
+ assert people = Post.find(1).people
+ assert_not people.any?(&:readonly?)
+ end
+
+ def test_has_many_with_through_is_not_implicitly_marked_readonly_while_finding_by_id
+ assert_not_predicate posts(:welcome).people.find(1), :readonly?
+ end
+
+ def test_has_many_with_through_is_not_implicitly_marked_readonly_while_finding_first
+ assert_not_predicate posts(:welcome).people.first, :readonly?
+ end
+
+ def test_has_many_with_through_is_not_implicitly_marked_readonly_while_finding_last
+ assert_not_predicate posts(:welcome).people.last, :readonly?
+ end
+
+ def test_readonly_scoping
+ Post.where("1=1").scoping do
+ assert_not_predicate Post.find(1), :readonly?
+ assert_predicate Post.readonly(true).find(1), :readonly?
+ assert_not_predicate Post.readonly(false).find(1), :readonly?
+ end
+
+ Post.joins(" ").scoping do
+ assert_not_predicate Post.find(1), :readonly?
+ assert_predicate Post.readonly.find(1), :readonly?
+ assert_not_predicate Post.readonly(false).find(1), :readonly?
+ end
+
+ # Oracle barfs on this because the join includes unqualified and
+ # conflicting column names
+ unless current_adapter?(:OracleAdapter)
+ Post.joins(", developers").scoping do
+ assert_not_predicate Post.find(1), :readonly?
+ assert_predicate Post.readonly.find(1), :readonly?
+ assert_not_predicate Post.readonly(false).find(1), :readonly?
+ end
+ end
+
+ Post.readonly(true).scoping do
+ assert_predicate Post.find(1), :readonly?
+ assert_predicate Post.readonly.find(1), :readonly?
+ assert_not_predicate Post.readonly(false).find(1), :readonly?
+ end
+ end
+
+ def test_association_collection_method_missing_scoping_not_readonly
+ developer = Developer.find(1)
+ project = Post.find(1)
+
+ assert_not_predicate developer.projects.all_as_method.first, :readonly?
+ assert_not_predicate developer.projects.all_as_scope.first, :readonly?
+
+ assert_not_predicate project.comments.all_as_method.first, :readonly?
+ assert_not_predicate project.comments.all_as_scope.first, :readonly?
+ end
+end
diff --git a/activerecord/test/cases/reaper_test.rb b/activerecord/test/cases/reaper_test.rb
new file mode 100644
index 0000000000..b630f782bc
--- /dev/null
+++ b/activerecord/test/cases/reaper_test.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class ReaperTest < ActiveRecord::TestCase
+ attr_reader :pool
+
+ def setup
+ super
+ @pool = ConnectionPool.new ActiveRecord::Base.connection_pool.spec
+ end
+
+ teardown do
+ @pool.connections.each(&:close)
+ end
+
+ class FakePool
+ attr_reader :reaped
+ attr_reader :flushed
+
+ def initialize
+ @reaped = false
+ end
+
+ def reap
+ @reaped = true
+ end
+
+ def flush
+ @flushed = true
+ end
+ end
+
+ # A reaper with nil time should never reap connections
+ def test_nil_time
+ fp = FakePool.new
+ assert_not fp.reaped
+ reaper = ConnectionPool::Reaper.new(fp, nil)
+ reaper.run
+ assert_not fp.reaped
+ end
+
+ def test_some_time
+ fp = FakePool.new
+ assert_not fp.reaped
+
+ reaper = ConnectionPool::Reaper.new(fp, 0.0001)
+ reaper.run
+ until fp.reaped
+ Thread.pass
+ end
+ assert fp.reaped
+ assert fp.flushed
+ end
+
+ def test_pool_has_reaper
+ assert pool.reaper
+ end
+
+ def test_reaping_frequency_configuration
+ spec = ActiveRecord::Base.connection_pool.spec.dup
+ spec.config[:reaping_frequency] = "10.01"
+ pool = ConnectionPool.new spec
+ assert_equal 10.01, pool.reaper.frequency
+ end
+
+ def test_connection_pool_starts_reaper
+ spec = ActiveRecord::Base.connection_pool.spec.dup
+ spec.config[:reaping_frequency] = "0.0001"
+
+ pool = ConnectionPool.new spec
+
+ conn = nil
+ child = Thread.new do
+ conn = pool.checkout
+ Thread.stop
+ end
+ Thread.pass while conn.nil?
+
+ assert_predicate conn, :in_use?
+
+ child.terminate
+
+ while conn.in_use?
+ Thread.pass
+ end
+ assert_not_predicate conn, :in_use?
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/reflection_test.rb b/activerecord/test/cases/reflection_test.rb
new file mode 100644
index 0000000000..abadafbad4
--- /dev/null
+++ b/activerecord/test/cases/reflection_test.rb
@@ -0,0 +1,518 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+require "models/customer"
+require "models/company"
+require "models/company_in_module"
+require "models/ship"
+require "models/pirate"
+require "models/price_estimate"
+require "models/essay"
+require "models/author"
+require "models/organization"
+require "models/post"
+require "models/tagging"
+require "models/category"
+require "models/book"
+require "models/subscriber"
+require "models/subscription"
+require "models/tag"
+require "models/sponsor"
+require "models/edge"
+require "models/hotel"
+require "models/chef"
+require "models/department"
+require "models/cake_designer"
+require "models/drink_designer"
+require "models/recipe"
+
+class ReflectionTest < ActiveRecord::TestCase
+ include ActiveRecord::Reflection
+
+ fixtures :topics, :customers, :companies, :subscribers, :price_estimates
+
+ def setup
+ @first = Topic.find(1)
+ end
+
+ def test_human_name
+ assert_equal "Price estimate", PriceEstimate.model_name.human
+ assert_equal "Subscriber", Subscriber.model_name.human
+ end
+
+ def test_read_attribute_names
+ assert_equal(
+ %w( id title author_name author_email_address bonus_time written_on last_read content important group approved replies_count unique_replies_count parent_id parent_title type created_at updated_at ).sort,
+ @first.attribute_names.sort
+ )
+ end
+
+ def test_columns
+ assert_equal 18, Topic.columns.length
+ end
+
+ def test_columns_are_returned_in_the_order_they_were_declared
+ column_names = Topic.columns.map(&:name)
+ assert_equal %w(id title author_name author_email_address written_on bonus_time last_read content important approved replies_count unique_replies_count parent_id parent_title type group created_at updated_at), column_names
+ end
+
+ def test_content_columns
+ content_columns = Topic.content_columns
+ content_column_names = content_columns.map(&:name)
+ assert_equal 13, content_columns.length
+ assert_equal %w(title author_name author_email_address written_on bonus_time last_read content important group approved parent_title created_at updated_at).sort, content_column_names.sort
+ end
+
+ def test_column_string_type_and_limit
+ assert_equal :string, @first.column_for_attribute("title").type
+ assert_equal :string, @first.column_for_attribute(:title).type
+ assert_equal :string, @first.type_for_attribute("title").type
+ assert_equal :string, @first.type_for_attribute(:title).type
+ assert_equal 250, @first.column_for_attribute("title").limit
+ end
+
+ def test_column_null_not_null
+ subscriber = Subscriber.first
+ assert subscriber.column_for_attribute("name").null
+ assert_not subscriber.column_for_attribute("nick").null
+ end
+
+ def test_human_name_for_column
+ assert_equal "Author name", @first.column_for_attribute("author_name").human_name
+ end
+
+ def test_integer_columns
+ assert_equal :integer, @first.column_for_attribute("id").type
+ assert_equal :integer, @first.column_for_attribute(:id).type
+ assert_equal :integer, @first.type_for_attribute("id").type
+ assert_equal :integer, @first.type_for_attribute(:id).type
+ end
+
+ def test_non_existent_columns_return_null_object
+ column = @first.column_for_attribute("attribute_that_doesnt_exist")
+ assert_instance_of ActiveRecord::ConnectionAdapters::NullColumn, column
+ assert_equal "attribute_that_doesnt_exist", column.name
+ assert_nil column.sql_type
+ assert_nil column.type
+
+ column = @first.column_for_attribute(:attribute_that_doesnt_exist)
+ assert_instance_of ActiveRecord::ConnectionAdapters::NullColumn, column
+ end
+
+ def test_non_existent_types_are_identity_types
+ type = @first.type_for_attribute("attribute_that_doesnt_exist")
+ object = Object.new
+
+ assert_equal object, type.deserialize(object)
+ assert_equal object, type.cast(object)
+ assert_equal object, type.serialize(object)
+
+ type = @first.type_for_attribute(:attribute_that_doesnt_exist)
+ assert_equal object, type.deserialize(object)
+ assert_equal object, type.cast(object)
+ assert_equal object, type.serialize(object)
+ end
+
+ def test_reflection_klass_for_nested_class_name
+ reflection = ActiveRecord::Reflection.create(
+ :has_many,
+ nil,
+ nil,
+ { class_name: "MyApplication::Business::Company" },
+ Customer
+ )
+ assert_nothing_raised do
+ assert_equal MyApplication::Business::Company, reflection.klass
+ end
+ end
+
+ def test_irregular_reflection_class_name
+ ActiveSupport::Inflector.inflections do |inflect|
+ inflect.irregular "plural_irregular", "plurales_irregulares"
+ end
+ reflection = ActiveRecord::Reflection.create(:has_many, "plurales_irregulares", nil, {}, ActiveRecord::Base)
+ assert_equal "PluralIrregular", reflection.class_name
+ end
+
+ def test_aggregation_reflection
+ reflection_for_address = AggregateReflection.new(
+ :address, nil, { mapping: [ %w(address_street street), %w(address_city city), %w(address_country country) ] }, Customer
+ )
+
+ reflection_for_balance = AggregateReflection.new(
+ :balance, nil, { class_name: "Money", mapping: %w(balance amount) }, Customer
+ )
+
+ reflection_for_gps_location = AggregateReflection.new(
+ :gps_location, nil, {}, Customer
+ )
+
+ assert_includes Customer.reflect_on_all_aggregations, reflection_for_gps_location
+ assert_includes Customer.reflect_on_all_aggregations, reflection_for_balance
+ assert_includes Customer.reflect_on_all_aggregations, reflection_for_address
+
+ assert_equal reflection_for_address, Customer.reflect_on_aggregation(:address)
+
+ assert_equal Address, Customer.reflect_on_aggregation(:address).klass
+
+ assert_equal Money, Customer.reflect_on_aggregation(:balance).klass
+ end
+
+ def test_reflect_on_all_autosave_associations
+ expected = Pirate.reflect_on_all_associations.select { |r| r.options[:autosave] }
+ received = Pirate.reflect_on_all_autosave_associations
+
+ assert_not_empty received
+ assert_not_equal Pirate.reflect_on_all_associations.length, received.length
+ assert_equal expected, received
+ end
+
+ def test_has_many_reflection
+ reflection_for_clients = ActiveRecord::Reflection.create(:has_many, :clients, nil, { order: "id", dependent: :destroy }, Firm)
+
+ assert_equal reflection_for_clients, Firm.reflect_on_association(:clients)
+
+ assert_equal Client, Firm.reflect_on_association(:clients).klass
+ assert_equal "companies", Firm.reflect_on_association(:clients).table_name
+
+ assert_equal Client, Firm.reflect_on_association(:clients_of_firm).klass
+ assert_equal "companies", Firm.reflect_on_association(:clients_of_firm).table_name
+ end
+
+ def test_has_one_reflection
+ reflection_for_account = ActiveRecord::Reflection.create(:has_one, :account, nil, { foreign_key: "firm_id", dependent: :destroy }, Firm)
+ assert_equal reflection_for_account, Firm.reflect_on_association(:account)
+
+ assert_equal Account, Firm.reflect_on_association(:account).klass
+ assert_equal "accounts", Firm.reflect_on_association(:account).table_name
+ end
+
+ def test_belongs_to_inferred_foreign_key_from_assoc_name
+ Company.belongs_to :foo
+ assert_equal "foo_id", Company.reflect_on_association(:foo).foreign_key
+ Company.belongs_to :bar, class_name: "Xyzzy"
+ assert_equal "bar_id", Company.reflect_on_association(:bar).foreign_key
+ Company.belongs_to :baz, class_name: "Xyzzy", foreign_key: "xyzzy_id"
+ assert_equal "xyzzy_id", Company.reflect_on_association(:baz).foreign_key
+ end
+
+ def test_association_reflection_in_modules
+ ActiveRecord::Base.store_full_sti_class = false
+
+ assert_reflection MyApplication::Business::Firm,
+ :clients_of_firm,
+ klass: MyApplication::Business::Client,
+ class_name: "Client",
+ table_name: "companies"
+
+ assert_reflection MyApplication::Billing::Account,
+ :firm,
+ klass: MyApplication::Business::Firm,
+ class_name: "MyApplication::Business::Firm",
+ table_name: "companies"
+
+ assert_reflection MyApplication::Billing::Account,
+ :qualified_billing_firm,
+ klass: MyApplication::Billing::Firm,
+ class_name: "MyApplication::Billing::Firm",
+ table_name: "companies"
+
+ assert_reflection MyApplication::Billing::Account,
+ :unqualified_billing_firm,
+ klass: MyApplication::Billing::Firm,
+ class_name: "Firm",
+ table_name: "companies"
+
+ assert_reflection MyApplication::Billing::Account,
+ :nested_qualified_billing_firm,
+ klass: MyApplication::Billing::Nested::Firm,
+ class_name: "MyApplication::Billing::Nested::Firm",
+ table_name: "companies"
+
+ assert_reflection MyApplication::Billing::Account,
+ :nested_unqualified_billing_firm,
+ klass: MyApplication::Billing::Nested::Firm,
+ class_name: "Nested::Firm",
+ table_name: "companies"
+ ensure
+ ActiveRecord::Base.store_full_sti_class = true
+ end
+
+ def test_reflection_should_not_raise_error_when_compared_to_other_object
+ assert_not_equal Object.new, Firm._reflections["clients"]
+ end
+
+ def test_reflections_should_return_keys_as_strings
+ assert Category.reflections.keys.all? { |key| key.is_a? String }, "Model.reflections is expected to return string for keys"
+ end
+
+ def test_has_and_belongs_to_many_reflection
+ assert_equal :has_and_belongs_to_many, Category.reflections["posts"].macro
+ assert_equal :posts, Category.reflect_on_all_associations(:has_and_belongs_to_many).first.name
+ end
+
+ def test_has_many_through_reflection
+ assert_kind_of ThroughReflection, Subscriber.reflect_on_association(:books)
+ end
+
+ def test_chain
+ expected = [
+ Organization.reflect_on_association(:author_essay_categories),
+ Author.reflect_on_association(:essays),
+ Organization.reflect_on_association(:authors)
+ ]
+ actual = Organization.reflect_on_association(:author_essay_categories).chain
+
+ assert_equal expected, actual
+ end
+
+ def test_scope_chain_does_not_interfere_with_hmt_with_polymorphic_case
+ hotel = Hotel.create!
+ department = hotel.departments.create!
+ department.chefs.create!(employable: CakeDesigner.create!)
+ department.chefs.create!(employable: DrinkDesigner.create!)
+
+ assert_equal 1, hotel.cake_designers.size
+ assert_equal 1, hotel.cake_designers.count
+ assert_equal 1, hotel.drink_designers.size
+ assert_equal 1, hotel.drink_designers.count
+ assert_equal 2, hotel.chefs.size
+ assert_equal 2, hotel.chefs.count
+ end
+
+ def test_scope_chain_does_not_interfere_with_hmt_with_polymorphic_case_and_sti
+ hotel = Hotel.create!
+ hotel.mocktail_designers << MocktailDesigner.create!
+
+ assert_equal 1, hotel.mocktail_designers.size
+ assert_equal 1, hotel.mocktail_designers.count
+ assert_equal 1, hotel.chef_lists.size
+ assert_equal 1, hotel.chef_lists.count
+
+ hotel.mocktail_designers = []
+
+ assert_equal 0, hotel.mocktail_designers.size
+ assert_equal 0, hotel.mocktail_designers.count
+ assert_equal 0, hotel.chef_lists.size
+ assert_equal 0, hotel.chef_lists.count
+ end
+
+ def test_scope_chain_of_polymorphic_association_does_not_leak_into_other_hmt_associations
+ hotel = Hotel.create!
+ department = hotel.departments.create!
+ drink = department.chefs.create!(employable: DrinkDesigner.create!)
+ Recipe.create!(chef_id: drink.id, hotel_id: hotel.id)
+
+ expected_sql = capture_sql { hotel.recipes.to_a }
+
+ Hotel.reflect_on_association(:recipes).clear_association_scope_cache
+ hotel.reload
+ hotel.drink_designers.to_a
+ loaded_sql = capture_sql { hotel.recipes.to_a }
+
+ assert_equal expected_sql, loaded_sql
+ end
+
+ def test_nested?
+ assert_not_predicate Author.reflect_on_association(:comments), :nested?
+ assert_predicate Author.reflect_on_association(:tags), :nested?
+
+ # Only goes :through once, but the through_reflection is a has_and_belongs_to_many, so this is
+ # a nested through association
+ assert_predicate Category.reflect_on_association(:post_comments), :nested?
+ end
+
+ def test_association_primary_key
+ # Normal association
+ assert_equal "id", Author.reflect_on_association(:posts).association_primary_key.to_s
+ assert_equal "name", Author.reflect_on_association(:essay).association_primary_key.to_s
+ assert_equal "name", Essay.reflect_on_association(:writer).association_primary_key.to_s
+
+ # Through association (uses the :primary_key option from the source reflection)
+ assert_equal "nick", Author.reflect_on_association(:subscribers).association_primary_key.to_s
+ assert_equal "name", Author.reflect_on_association(:essay_category).association_primary_key.to_s
+ assert_equal "custom_primary_key", Author.reflect_on_association(:tags_with_primary_key).association_primary_key.to_s # nested
+ end
+
+ def test_association_primary_key_raises_when_missing_primary_key
+ reflection = ActiveRecord::Reflection.create(:has_many, :edge, nil, {}, Author)
+ assert_raises(ActiveRecord::UnknownPrimaryKey) { reflection.association_primary_key }
+
+ through = Class.new(ActiveRecord::Reflection::ThroughReflection) {
+ define_method(:source_reflection) { reflection }
+ }.new(reflection)
+ assert_raises(ActiveRecord::UnknownPrimaryKey) { through.association_primary_key }
+ end
+
+ def test_active_record_primary_key
+ assert_equal "nick", Subscriber.reflect_on_association(:subscriptions).active_record_primary_key.to_s
+ assert_equal "name", Author.reflect_on_association(:essay).active_record_primary_key.to_s
+ end
+
+ def test_active_record_primary_key_raises_when_missing_primary_key
+ reflection = ActiveRecord::Reflection.create(:has_many, :author, nil, {}, Edge)
+ assert_raises(ActiveRecord::UnknownPrimaryKey) { reflection.active_record_primary_key }
+ end
+
+ def test_type
+ assert_equal "taggable_type", Post.reflect_on_association(:taggings).type.to_s
+ assert_equal "imageable_class", Post.reflect_on_association(:images).type.to_s
+ assert_nil Post.reflect_on_association(:readers).type
+ end
+
+ def test_foreign_type
+ assert_equal "sponsorable_type", Sponsor.reflect_on_association(:sponsorable).foreign_type.to_s
+ assert_equal "sponsorable_type", Sponsor.reflect_on_association(:thing).foreign_type.to_s
+ assert_nil Sponsor.reflect_on_association(:sponsor_club).foreign_type
+ end
+
+ def test_collection_association
+ assert_predicate Pirate.reflect_on_association(:birds), :collection?
+ assert_predicate Pirate.reflect_on_association(:parrots), :collection?
+
+ assert_not_predicate Pirate.reflect_on_association(:ship), :collection?
+ assert_not_predicate Ship.reflect_on_association(:pirate), :collection?
+ end
+
+ def test_default_association_validation
+ assert_predicate ActiveRecord::Reflection.create(:has_many, :clients, nil, {}, Firm), :validate?
+
+ assert_not_predicate ActiveRecord::Reflection.create(:has_one, :client, nil, {}, Firm), :validate?
+ assert_not_predicate ActiveRecord::Reflection.create(:belongs_to, :client, nil, {}, Firm), :validate?
+ end
+
+ def test_always_validate_association_if_explicit
+ assert_predicate ActiveRecord::Reflection.create(:has_one, :client, nil, { validate: true }, Firm), :validate?
+ assert_predicate ActiveRecord::Reflection.create(:belongs_to, :client, nil, { validate: true }, Firm), :validate?
+ assert_predicate ActiveRecord::Reflection.create(:has_many, :clients, nil, { validate: true }, Firm), :validate?
+ end
+
+ def test_validate_association_if_autosave
+ assert_predicate ActiveRecord::Reflection.create(:has_one, :client, nil, { autosave: true }, Firm), :validate?
+ assert_predicate ActiveRecord::Reflection.create(:belongs_to, :client, nil, { autosave: true }, Firm), :validate?
+ assert_predicate ActiveRecord::Reflection.create(:has_many, :clients, nil, { autosave: true }, Firm), :validate?
+ end
+
+ def test_never_validate_association_if_explicit
+ assert_not_predicate ActiveRecord::Reflection.create(:has_one, :client, nil, { autosave: true, validate: false }, Firm), :validate?
+ assert_not_predicate ActiveRecord::Reflection.create(:belongs_to, :client, nil, { autosave: true, validate: false }, Firm), :validate?
+ assert_not_predicate ActiveRecord::Reflection.create(:has_many, :clients, nil, { autosave: true, validate: false }, Firm), :validate?
+ end
+
+ def test_foreign_key
+ assert_equal "author_id", Author.reflect_on_association(:posts).foreign_key.to_s
+ assert_equal "category_id", Post.reflect_on_association(:categorizations).foreign_key.to_s
+ end
+
+ def test_symbol_for_class_name
+ assert_equal Client, Firm.reflect_on_association(:unsorted_clients_with_symbol).klass
+ end
+
+ def test_class_for_class_name
+ error = assert_raises(ArgumentError) do
+ ActiveRecord::Reflection.create(:has_many, :clients, nil, { class_name: Client }, Firm)
+ end
+ assert_equal "A class was passed to `:class_name` but we are expecting a string.", error.message
+ end
+
+ def test_join_table
+ category = Struct.new(:table_name, :pluralize_table_names).new("categories", true)
+ product = Struct.new(:table_name, :pluralize_table_names).new("products", true)
+
+ reflection = ActiveRecord::Reflection.create(:has_many, :categories, nil, {}, product)
+ reflection.stub(:klass, category) do
+ assert_equal "categories_products", reflection.join_table
+ end
+
+ reflection = ActiveRecord::Reflection.create(:has_many, :products, nil, {}, category)
+ reflection.stub(:klass, product) do
+ assert_equal "categories_products", reflection.join_table
+ end
+ end
+
+ def test_join_table_with_common_prefix
+ category = Struct.new(:table_name, :pluralize_table_names).new("catalog_categories", true)
+ product = Struct.new(:table_name, :pluralize_table_names).new("catalog_products", true)
+
+ reflection = ActiveRecord::Reflection.create(:has_many, :categories, nil, {}, product)
+ reflection.stub(:klass, category) do
+ assert_equal "catalog_categories_products", reflection.join_table
+ end
+
+ reflection = ActiveRecord::Reflection.create(:has_many, :products, nil, {}, category)
+ reflection.stub(:klass, product) do
+ assert_equal "catalog_categories_products", reflection.join_table
+ end
+ end
+
+ def test_join_table_with_different_prefix
+ category = Struct.new(:table_name, :pluralize_table_names).new("catalog_categories", true)
+ page = Struct.new(:table_name, :pluralize_table_names).new("content_pages", true)
+
+ reflection = ActiveRecord::Reflection.create(:has_many, :categories, nil, {}, page)
+ reflection.stub(:klass, category) do
+ assert_equal "catalog_categories_content_pages", reflection.join_table
+ end
+
+ reflection = ActiveRecord::Reflection.create(:has_many, :pages, nil, {}, category)
+ reflection.stub(:klass, page) do
+ assert_equal "catalog_categories_content_pages", reflection.join_table
+ end
+ end
+
+ def test_join_table_can_be_overridden
+ category = Struct.new(:table_name, :pluralize_table_names).new("categories", true)
+ product = Struct.new(:table_name, :pluralize_table_names).new("products", true)
+
+ reflection = ActiveRecord::Reflection.create(:has_many, :categories, nil, { join_table: "product_categories" }, product)
+ reflection.stub(:klass, category) do
+ assert_equal "product_categories", reflection.join_table
+ end
+
+ reflection = ActiveRecord::Reflection.create(:has_many, :products, nil, { join_table: "product_categories" }, category)
+ reflection.stub(:klass, product) do
+ assert_equal "product_categories", reflection.join_table
+ end
+ end
+
+ def test_includes_accepts_symbols
+ hotel = Hotel.create!
+ department = hotel.departments.create!
+ department.chefs.create!
+
+ assert_nothing_raised do
+ assert_equal department.chefs, Hotel.includes([departments: :chefs]).first.chefs
+ end
+ end
+
+ def test_includes_accepts_strings
+ hotel = Hotel.create!
+ department = hotel.departments.create!
+ department.chefs.create!
+
+ assert_nothing_raised do
+ assert_equal department.chefs, Hotel.includes(["departments" => "chefs"]).first.chefs
+ end
+ end
+
+ def test_reflect_on_association_accepts_symbols
+ assert_nothing_raised do
+ assert_equal Hotel.reflect_on_association(:departments).name, :departments
+ end
+ end
+
+ def test_reflect_on_association_accepts_strings
+ assert_nothing_raised do
+ assert_equal Hotel.reflect_on_association("departments").name, :departments
+ end
+ end
+
+ private
+ def assert_reflection(klass, association, options)
+ assert reflection = klass.reflect_on_association(association)
+ options.each do |method, value|
+ assert_equal(value, reflection.send(method))
+ end
+ end
+end
diff --git a/activerecord/test/cases/relation/delegation_test.rb b/activerecord/test/cases/relation/delegation_test.rb
new file mode 100644
index 0000000000..a8030c2d64
--- /dev/null
+++ b/activerecord/test/cases/relation/delegation_test.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+require "models/comment"
+
+module ActiveRecord
+ module ArrayDelegationTests
+ ARRAY_DELEGATES = [
+ :+, :-, :|, :&, :[], :shuffle,
+ :all?, :collect, :compact, :detect, :each, :each_cons, :each_with_index,
+ :exclude?, :find_all, :flat_map, :group_by, :include?, :length,
+ :map, :none?, :one?, :partition, :reject, :reverse, :rotate,
+ :sample, :second, :sort, :sort_by, :slice, :third, :index, :rindex,
+ :to_ary, :to_set, :to_xml, :to_yaml, :join,
+ :in_groups, :in_groups_of, :to_sentence, :to_formatted_s, :as_json
+ ]
+
+ ARRAY_DELEGATES.each do |method|
+ define_method "test_delegates_#{method}_to_Array" do
+ assert_respond_to target, method
+ end
+ end
+ end
+
+ module DeprecatedArelDelegationTests
+ AREL_METHODS = [
+ :with, :orders, :froms, :project, :projections, :taken, :constraints, :exists, :locked, :where_sql,
+ :ast, :source, :join_sources, :to_dot, :create_insert, :create_true, :create_false
+ ]
+
+ def test_deprecate_arel_delegation
+ AREL_METHODS.each do |method|
+ assert_deprecated { target.public_send(method) }
+ assert_deprecated { target.public_send(method) }
+ end
+ end
+ end
+
+ class DelegationAssociationTest < ActiveRecord::TestCase
+ include ArrayDelegationTests
+ include DeprecatedArelDelegationTests
+
+ def target
+ Post.new.comments
+ end
+ end
+
+ class DelegationRelationTest < ActiveRecord::TestCase
+ include ArrayDelegationTests
+ include DeprecatedArelDelegationTests
+
+ def target
+ Comment.all
+ end
+ end
+
+ class QueryingMethodsDelegationTest < ActiveRecord::TestCase
+ QUERYING_METHODS = [
+ :find, :take, :take!, :first, :first!, :last, :last!, :exists?, :any?, :many?, :none?, :one?,
+ :second, :second!, :third, :third!, :fourth, :fourth!, :fifth, :fifth!, :forty_two, :forty_two!, :third_to_last, :third_to_last!, :second_to_last, :second_to_last!,
+ :first_or_create, :first_or_create!, :first_or_initialize,
+ :find_or_create_by, :find_or_create_by!, :create_or_find_by, :create_or_find_by!, :find_or_initialize_by,
+ :find_by, :find_by!,
+ :destroy_all, :delete_all, :update_all,
+ :find_each, :find_in_batches, :in_batches,
+ :select, :group, :order, :except, :reorder, :limit, :offset, :joins, :left_joins, :left_outer_joins, :or,
+ :where, :rewhere, :preload, :eager_load, :includes, :from, :lock, :readonly, :extending,
+ :having, :create_with, :distinct, :references, :none, :unscope, :merge,
+ :count, :average, :minimum, :maximum, :sum, :calculate,
+ :pluck, :pick, :ids,
+ ]
+
+ def test_delegate_querying_methods
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "posts"
+ end
+
+ QUERYING_METHODS.each do |method|
+ assert_respond_to klass.all, method
+ assert_respond_to klass, method
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/relation/delete_all_test.rb b/activerecord/test/cases/relation/delete_all_test.rb
new file mode 100644
index 0000000000..446d7621ea
--- /dev/null
+++ b/activerecord/test/cases/relation/delete_all_test.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/author"
+require "models/post"
+require "models/pet"
+require "models/toy"
+
+class DeleteAllTest < ActiveRecord::TestCase
+ fixtures :authors, :author_addresses, :posts, :pets, :toys
+
+ def test_destroy_all
+ davids = Author.where(name: "David")
+
+ # Force load
+ assert_equal [authors(:david)], davids.to_a
+ assert_predicate davids, :loaded?
+
+ assert_difference("Author.count", -1) do
+ destroyed = davids.destroy_all
+ assert_equal [authors(:david)], destroyed
+ assert_predicate destroyed.first, :frozen?
+ end
+
+ assert_equal [], davids.to_a
+ assert_predicate davids, :loaded?
+ end
+
+ def test_delete_all
+ davids = Author.where(name: "David")
+
+ assert_difference("Author.count", -1) { davids.delete_all }
+ assert_not_predicate davids, :loaded?
+ end
+
+ def test_delete_all_loaded
+ davids = Author.where(name: "David")
+
+ # Force load
+ assert_equal [authors(:david)], davids.to_a
+ assert_predicate davids, :loaded?
+
+ assert_difference("Author.count", -1) { davids.delete_all }
+
+ assert_equal [], davids.to_a
+ assert_predicate davids, :loaded?
+ end
+
+ def test_delete_all_with_unpermitted_relation_raises_error
+ assert_raises(ActiveRecord::ActiveRecordError) { Author.distinct.delete_all }
+ assert_raises(ActiveRecord::ActiveRecordError) { Author.group(:name).delete_all }
+ assert_raises(ActiveRecord::ActiveRecordError) { Author.having("SUM(id) < 3").delete_all }
+ end
+
+ def test_delete_all_with_joins_and_where_part_is_hash
+ pets = Pet.joins(:toys).where(toys: { name: "Bone" })
+
+ assert_equal true, pets.exists?
+ assert_equal pets.count, pets.delete_all
+ end
+
+ def test_delete_all_with_joins_and_where_part_is_not_hash
+ pets = Pet.joins(:toys).where("toys.name = ?", "Bone")
+
+ assert_equal true, pets.exists?
+ assert_equal pets.count, pets.delete_all
+ end
+
+ def test_delete_all_with_left_joins
+ pets = Pet.left_joins(:toys).where(toys: { name: "Bone" })
+
+ assert_equal true, pets.exists?
+ assert_equal pets.count, pets.delete_all
+ end
+
+ def test_delete_all_with_includes
+ pets = Pet.includes(:toys).where(toys: { name: "Bone" })
+
+ assert_equal true, pets.exists?
+ assert_equal pets.count, pets.delete_all
+ end
+
+ unless current_adapter?(:OracleAdapter)
+ def test_delete_all_with_order_and_limit_deletes_subset_only
+ author = authors(:david)
+ limited_posts = Post.where(author: author).order(:id).limit(1)
+ assert_equal 1, limited_posts.size
+ assert_equal 2, limited_posts.limit(2).size
+ assert_equal 1, limited_posts.delete_all
+ assert_raise(ActiveRecord::RecordNotFound) { posts(:welcome) }
+ assert posts(:thinking)
+ end
+
+ def test_delete_all_with_order_and_limit_and_offset_deletes_subset_only
+ author = authors(:david)
+ limited_posts = Post.where(author: author).order(:id).limit(1).offset(1)
+ assert_equal 1, limited_posts.size
+ assert_equal 2, limited_posts.limit(2).size
+ assert_equal 1, limited_posts.delete_all
+ assert_raise(ActiveRecord::RecordNotFound) { posts(:thinking) }
+ assert posts(:welcome)
+ end
+ end
+end
diff --git a/activerecord/test/cases/relation/merging_test.rb b/activerecord/test/cases/relation/merging_test.rb
new file mode 100644
index 0000000000..224e4f39a8
--- /dev/null
+++ b/activerecord/test/cases/relation/merging_test.rb
@@ -0,0 +1,181 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/author"
+require "models/comment"
+require "models/developer"
+require "models/computer"
+require "models/post"
+require "models/project"
+require "models/rating"
+
+class RelationMergingTest < ActiveRecord::TestCase
+ fixtures :developers, :comments, :authors, :author_addresses, :posts
+
+ def test_relation_merging
+ devs = Developer.where("salary >= 80000").merge(Developer.limit(2)).merge(Developer.order("id ASC").where("id < 3"))
+ assert_equal [developers(:david), developers(:jamis)], devs.to_a
+
+ dev_with_count = Developer.limit(1).merge(Developer.order("id DESC")).merge(Developer.select("developers.*"))
+ assert_equal [developers(:poor_jamis)], dev_with_count.to_a
+ end
+
+ def test_relation_to_sql
+ post = Post.first
+ sql = post.comments.to_sql
+ assert_match(/.?post_id.? = #{post.id}\z/i, sql)
+ end
+
+ def test_relation_merging_with_arel_equalities_keeps_last_equality
+ devs = Developer.where(Developer.arel_table[:salary].eq(80000)).merge(
+ Developer.where(Developer.arel_table[:salary].eq(9000))
+ )
+ assert_equal [developers(:poor_jamis)], devs.to_a
+ end
+
+ def test_relation_merging_with_arel_equalities_keeps_last_equality_with_non_attribute_left_hand
+ salary_attr = Developer.arel_table[:salary]
+ devs = Developer.where(
+ Arel::Nodes::NamedFunction.new("abs", [salary_attr]).eq(80000)
+ ).merge(
+ Developer.where(
+ Arel::Nodes::NamedFunction.new("abs", [salary_attr]).eq(9000)
+ )
+ )
+ assert_equal [developers(:poor_jamis)], devs.to_a
+ end
+
+ def test_relation_merging_with_eager_load
+ relations = []
+ relations << Post.order("comments.id DESC").merge(Post.eager_load(:last_comment)).merge(Post.all)
+ relations << Post.eager_load(:last_comment).merge(Post.order("comments.id DESC")).merge(Post.all)
+
+ relations.each do |posts|
+ post = posts.find { |p| p.id == 1 }
+ assert_equal Post.find(1).last_comment, post.last_comment
+ end
+ end
+
+ def test_relation_merging_with_locks
+ devs = Developer.lock.where("salary >= 80000").order("id DESC").merge(Developer.limit(2))
+ assert_predicate devs, :locked?
+ end
+
+ def test_relation_merging_with_preload
+ [Post.all.merge(Post.preload(:author)), Post.preload(:author).merge(Post.all)].each do |posts|
+ assert_queries(2) { assert posts.first.author }
+ end
+ end
+
+ def test_relation_merging_with_joins
+ comments = Comment.joins(:post).where(body: "Thank you for the welcome").merge(Post.where(body: "Such a lovely day"))
+ assert_equal 1, comments.count
+ end
+
+ def test_relation_merging_with_left_outer_joins
+ comments = Comment.joins(:post).where(body: "Thank you for the welcome").merge(Post.left_outer_joins(:author).where(body: "Such a lovely day"))
+
+ assert_equal 1, comments.count
+ end
+
+ def test_relation_merging_with_skip_query_cache
+ assert_equal Post.all.merge(Post.all.skip_query_cache!).skip_query_cache_value, true
+ end
+
+ def test_relation_merging_with_association
+ assert_queries(2) do # one for loading post, and another one merged query
+ post = Post.where(body: "Such a lovely day").first
+ comments = Comment.where(body: "Thank you for the welcome").merge(post.comments)
+ assert_equal 1, comments.count
+ end
+ end
+
+ test "merge collapses wheres from the LHS only" do
+ left = Post.where(title: "omg").where(comments_count: 1)
+ right = Post.where(title: "wtf").where(title: "bbq")
+
+ merged = left.merge(right)
+
+ assert_not_includes merged.to_sql, "omg"
+ assert_includes merged.to_sql, "wtf"
+ assert_includes merged.to_sql, "bbq"
+ end
+
+ def test_merging_reorders_bind_params
+ post = Post.first
+ right = Post.where(id: 1)
+ left = Post.where(title: post.title)
+
+ merged = left.merge(right)
+ assert_equal post, merged.first
+ end
+
+ def test_merging_compares_symbols_and_strings_as_equal
+ post = PostThatLoadsCommentsInAnAfterSaveHook.create!(title: "First Post", body: "Blah blah blah.")
+ assert_equal "First comment!", post.comments.where(body: "First comment!").first_or_create.body
+ end
+
+ def test_merging_with_from_clause
+ relation = Post.all
+ assert_empty relation.from_clause
+ relation = relation.merge(Post.from("posts"))
+ assert_not_empty relation.from_clause
+ end
+
+ def test_merging_with_from_clause_on_different_class
+ assert Comment.joins(:post).merge(Post.from("posts")).first
+ end
+
+ def test_merging_with_order_with_binds
+ relation = Post.all.merge(Post.order([Arel.sql("title LIKE ?"), "%suffix"]))
+ assert_equal ["title LIKE '%suffix'"], relation.order_values
+ end
+
+ def test_merging_with_order_without_binds
+ relation = Post.all.merge(Post.order(Arel.sql("title LIKE '%?'")))
+ assert_equal ["title LIKE '%?'"], relation.order_values
+ end
+end
+
+class MergingDifferentRelationsTest < ActiveRecord::TestCase
+ fixtures :posts, :authors, :author_addresses, :developers
+
+ test "merging where relations" do
+ hello_by_bob = Post.where(body: "hello").joins(:author).
+ merge(Author.where(name: "Bob")).order("posts.id").pluck("posts.id")
+
+ assert_equal [posts(:misc_by_bob).id,
+ posts(:other_by_bob).id], hello_by_bob
+ end
+
+ test "merging order relations" do
+ posts_by_author_name = Post.limit(3).joins(:author).
+ merge(Author.order(:name)).pluck("authors.name")
+
+ assert_equal ["Bob", "Bob", "David"], posts_by_author_name
+
+ posts_by_author_name = Post.limit(3).joins(:author).
+ merge(Author.order("name")).pluck("authors.name")
+
+ assert_equal ["Bob", "Bob", "David"], posts_by_author_name
+ end
+
+ test "merging order relations (using a hash argument)" do
+ posts_by_author_name = Post.limit(4).joins(:author).
+ merge(Author.order(name: :desc)).pluck("authors.name")
+
+ assert_equal ["Mary", "Mary", "Mary", "David"], posts_by_author_name
+ end
+
+ test "relation merging (using a proc argument)" do
+ dev = Developer.where(name: "Jamis").first
+
+ comment_1 = dev.comments.create!(body: "I'm Jamis", post: Post.first)
+ rating_1 = comment_1.ratings.create!
+
+ comment_2 = dev.comments.create!(body: "I'm John", post: Post.first)
+ comment_2.ratings.create!
+
+ assert_equal dev.ratings, [rating_1]
+ end
+end
diff --git a/activerecord/test/cases/relation/mutation_test.rb b/activerecord/test/cases/relation/mutation_test.rb
new file mode 100644
index 0000000000..f82ecd4449
--- /dev/null
+++ b/activerecord/test/cases/relation/mutation_test.rb
@@ -0,0 +1,150 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+
+module ActiveRecord
+ class RelationMutationTest < ActiveRecord::TestCase
+ (Relation::MULTI_VALUE_METHODS - [:references, :extending, :order, :unscope, :select]).each do |method|
+ test "##{method}!" do
+ assert relation.public_send("#{method}!", :foo).equal?(relation)
+ assert_equal [:foo], relation.public_send("#{method}_values")
+ end
+ end
+
+ test "#_select!" do
+ assert relation._select!(:foo).equal?(relation)
+ assert_equal [:foo], relation.select_values
+ end
+
+ test "#order!" do
+ assert relation.order!("name ASC").equal?(relation)
+ assert_equal ["name ASC"], relation.order_values
+ end
+
+ test "#order! with symbol prepends the table name" do
+ assert relation.order!(:name).equal?(relation)
+ node = relation.order_values.first
+ assert_predicate node, :ascending?
+ assert_equal :name, node.expr.name
+ assert_equal "posts", node.expr.relation.name
+ end
+
+ test "#order! on non-string does not attempt regexp match for references" do
+ obj = Object.new
+ assert_not_called(obj, :=~) do
+ assert relation.order!(obj)
+ assert_equal [obj], relation.order_values
+ end
+ end
+
+ test "#references!" do
+ assert relation.references!(:foo).equal?(relation)
+ assert_includes relation.references_values, "foo"
+ end
+
+ test "extending!" do
+ mod, mod2 = Module.new, Module.new
+
+ assert relation.extending!(mod).equal?(relation)
+ assert_equal [mod], relation.extending_values
+ assert relation.is_a?(mod)
+
+ relation.extending!(mod2)
+ assert_equal [mod, mod2], relation.extending_values
+ end
+
+ test "extending! with empty args" do
+ relation.extending!
+ assert_equal [], relation.extending_values
+ end
+
+ (Relation::SINGLE_VALUE_METHODS - [:lock, :reordering, :reverse_order, :create_with, :skip_query_cache]).each do |method|
+ test "##{method}!" do
+ assert relation.public_send("#{method}!", :foo).equal?(relation)
+ assert_equal :foo, relation.public_send("#{method}_value")
+ end
+ end
+
+ test "#from!" do
+ assert relation.from!("foo").equal?(relation)
+ assert_equal "foo", relation.from_clause.value
+ end
+
+ test "#lock!" do
+ assert relation.lock!("foo").equal?(relation)
+ assert_equal "foo", relation.lock_value
+ end
+
+ test "#reorder!" do
+ @relation = relation.order("foo")
+
+ assert relation.reorder!("bar").equal?(relation)
+ assert_equal ["bar"], relation.order_values
+ assert relation.reordering_value
+ end
+
+ test "#reorder! with symbol prepends the table name" do
+ assert relation.reorder!(:name).equal?(relation)
+ node = relation.order_values.first
+
+ assert_predicate node, :ascending?
+ assert_equal :name, node.expr.name
+ assert_equal "posts", node.expr.relation.name
+ end
+
+ test "reverse_order!" do
+ @relation = Post.order("title ASC, comments_count DESC")
+
+ relation.reverse_order!
+
+ assert_equal "title DESC", relation.order_values.first
+ assert_equal "comments_count ASC", relation.order_values.last
+
+ relation.reverse_order!
+
+ assert_equal "title ASC", relation.order_values.first
+ assert_equal "comments_count DESC", relation.order_values.last
+ end
+
+ test "create_with!" do
+ assert relation.create_with!(foo: "bar").equal?(relation)
+ assert_equal({ foo: "bar" }, relation.create_with_value)
+ end
+
+ test "merge!" do
+ assert relation.merge!(select: :foo).equal?(relation)
+ assert_equal [:foo], relation.select_values
+ end
+
+ test "merge with a proc" do
+ assert_equal [:foo], relation.merge(-> { select(:foo) }).select_values
+ end
+
+ test "none!" do
+ assert relation.none!.equal?(relation)
+ assert_equal [NullRelation], relation.extending_values
+ assert relation.is_a?(NullRelation)
+ end
+
+ test "distinct!" do
+ relation.distinct! :foo
+ assert_equal :foo, relation.distinct_value
+ end
+
+ test "skip_query_cache!" do
+ relation.skip_query_cache!
+ assert relation.skip_query_cache_value
+ end
+
+ test "skip_preloading!" do
+ relation.skip_preloading!
+ assert relation.skip_preloading_value
+ end
+
+ private
+ def relation
+ @relation ||= Relation.new(FakeKlass)
+ end
+ end
+end
diff --git a/activerecord/test/cases/relation/or_test.rb b/activerecord/test/cases/relation/or_test.rb
new file mode 100644
index 0000000000..065819e0f1
--- /dev/null
+++ b/activerecord/test/cases/relation/or_test.rb
@@ -0,0 +1,137 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/author"
+require "models/categorization"
+require "models/post"
+
+module ActiveRecord
+ class OrTest < ActiveRecord::TestCase
+ fixtures :posts
+ fixtures :authors, :author_addresses
+
+ def test_or_with_relation
+ expected = Post.where("id = 1 or id = 2").to_a
+ assert_equal expected, Post.where("id = 1").or(Post.where("id = 2")).to_a
+ end
+
+ def test_or_identity
+ expected = Post.where("id = 1").to_a
+ assert_equal expected, Post.where("id = 1").or(Post.where("id = 1")).to_a
+ end
+
+ def test_or_with_null_left
+ expected = Post.where("id = 1").to_a
+ assert_equal expected, Post.none.or(Post.where("id = 1")).to_a
+ end
+
+ def test_or_with_null_right
+ expected = Post.where("id = 1").to_a
+ assert_equal expected, Post.where("id = 1").or(Post.none).to_a
+ end
+
+ def test_or_with_bind_params
+ assert_equal Post.find([1, 2]).sort_by(&:id), Post.where(id: 1).or(Post.where(id: 2)).sort_by(&:id)
+ end
+
+ def test_or_with_null_both
+ expected = Post.none.to_a
+ assert_equal expected, Post.none.or(Post.none).to_a
+ end
+
+ def test_or_without_left_where
+ expected = Post.all
+ assert_equal expected, Post.or(Post.where("id = 1")).to_a
+ end
+
+ def test_or_without_right_where
+ expected = Post.all
+ assert_equal expected, Post.where("id = 1").or(Post.all).to_a
+ end
+
+ def test_or_preserves_other_querying_methods
+ expected = Post.where("id = 1 or id = 2 or id = 3").order("body asc").to_a
+ partial = Post.order("body asc")
+ assert_equal expected, partial.where("id = 1").or(partial.where(id: [2, 3])).to_a
+ assert_equal expected, Post.order("body asc").where("id = 1").or(Post.order("body asc").where(id: [2, 3])).to_a
+ end
+
+ def test_or_with_incompatible_relations
+ error = assert_raises ArgumentError do
+ Post.order("body asc").where("id = 1").or(Post.order("id desc").where(id: [2, 3])).to_a
+ end
+
+ assert_equal "Relation passed to #or must be structurally compatible. Incompatible values: [:order]", error.message
+ end
+
+ def test_or_with_unscope_where
+ expected = Post.where("id = 1 or id = 2")
+ partial = Post.where("id = 1 and id != 2")
+ assert_equal expected, partial.or(partial.unscope(:where).where("id = 2")).to_a
+ end
+
+ def test_or_with_unscope_where_column
+ expected = Post.where("id = 1 or id = 2")
+ partial = Post.where(id: 1).where.not(id: 2)
+ assert_equal expected, partial.or(partial.unscope(where: :id).where("id = 2")).to_a
+ end
+
+ def test_or_with_unscope_order
+ expected = Post.where("id = 1 or id = 2")
+ assert_equal expected, Post.order("body asc").where("id = 1").unscope(:order).or(Post.where("id = 2")).to_a
+ end
+
+ def test_or_with_incompatible_unscope
+ error = assert_raises ArgumentError do
+ Post.order("body asc").where("id = 1").or(Post.order("body asc").where("id = 2").unscope(:order)).to_a
+ end
+
+ assert_equal "Relation passed to #or must be structurally compatible. Incompatible values: [:order]", error.message
+ end
+
+ def test_or_when_grouping
+ groups = Post.where("id < 10").group("body").select("body, COUNT(*) AS c")
+ expected = groups.having("COUNT(*) > 1 OR body like 'Such%'").to_a.map { |o| [o.body, o.c] }
+ assert_equal expected, groups.having("COUNT(*) > 1").or(groups.having("body like 'Such%'")).to_a.map { |o| [o.body, o.c] }
+ end
+
+ def test_or_with_named_scope
+ expected = Post.where("id = 1 or body LIKE '\%a\%'").to_a
+ assert_equal expected, Post.where("id = 1").or(Post.containing_the_letter_a)
+ end
+
+ def test_or_inside_named_scope
+ expected = Post.where("body LIKE '\%a\%' OR title LIKE ?", "%'%").order("id DESC").to_a
+ assert_equal expected, Post.order(id: :desc).typographically_interesting
+ end
+
+ def test_or_on_loaded_relation
+ expected = Post.where("id = 1 or id = 2").to_a
+ p = Post.where("id = 1")
+ p.load
+ assert_equal true, p.loaded?
+ assert_equal expected, p.or(Post.where("id = 2")).to_a
+ end
+
+ def test_or_with_non_relation_object_raises_error
+ assert_raises ArgumentError do
+ Post.where(id: [1, 2, 3]).or(title: "Rails")
+ end
+ end
+
+ def test_or_with_references_inequality
+ joined = Post.includes(:author)
+ actual = joined.where(authors: { id: 1 })
+ .or(joined.where(title: "I don't have any comments"))
+ expected = Author.find(1).posts + Post.where(title: "I don't have any comments")
+ assert_equal expected.sort_by(&:id), actual.sort_by(&:id)
+ end
+
+ def test_or_with_scope_on_association
+ author = Author.first
+ assert_nothing_raised do
+ author.top_posts.or(author.other_top_posts)
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/relation/predicate_builder_test.rb b/activerecord/test/cases/relation/predicate_builder_test.rb
new file mode 100644
index 0000000000..b432330deb
--- /dev/null
+++ b/activerecord/test/cases/relation/predicate_builder_test.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+
+module ActiveRecord
+ class PredicateBuilderTest < ActiveRecord::TestCase
+ def test_registering_new_handlers
+ Topic.predicate_builder.register_handler(Regexp, proc do |column, value|
+ Arel::Nodes::InfixOperation.new("~", column, Arel.sql(value.source))
+ end)
+
+ assert_match %r{["`]topics["`]\.["`]title["`] ~ rails}i, Topic.where(title: /rails/).to_sql
+ ensure
+ Topic.reset_column_information
+ end
+ end
+end
diff --git a/activerecord/test/cases/relation/record_fetch_warning_test.rb b/activerecord/test/cases/relation/record_fetch_warning_test.rb
new file mode 100644
index 0000000000..22d32d75bc
--- /dev/null
+++ b/activerecord/test/cases/relation/record_fetch_warning_test.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+require "active_record/relation/record_fetch_warning"
+
+module ActiveRecord
+ class RecordFetchWarningTest < ActiveRecord::TestCase
+ fixtures :posts
+
+ def setup
+ @original_logger = ActiveRecord::Base.logger
+ @original_warn_on_records_fetched_greater_than = ActiveRecord::Base.warn_on_records_fetched_greater_than
+ @log = StringIO.new
+ end
+
+ def teardown
+ ActiveRecord::Base.logger = @original_logger
+ ActiveRecord::Base.warn_on_records_fetched_greater_than = @original_warn_on_records_fetched_greater_than
+ end
+
+ def test_warn_on_records_fetched_greater_than_allowed_limit
+ ActiveRecord::Base.logger = ActiveSupport::Logger.new(@log)
+ ActiveRecord::Base.logger.level = Logger::WARN
+ ActiveRecord::Base.warn_on_records_fetched_greater_than = 1
+
+ Post.all.to_a
+
+ assert_match(/Query fetched/, @log.string)
+ end
+
+ def test_does_not_warn_on_records_fetched_less_than_allowed_limit
+ ActiveRecord::Base.logger = ActiveSupport::Logger.new(@log)
+ ActiveRecord::Base.logger.level = Logger::WARN
+ ActiveRecord::Base.warn_on_records_fetched_greater_than = 100
+
+ Post.all.to_a
+
+ assert_no_match(/Query fetched/, @log.string)
+ end
+ end
+end
diff --git a/activerecord/test/cases/relation/select_test.rb b/activerecord/test/cases/relation/select_test.rb
new file mode 100644
index 0000000000..dec8a6925d
--- /dev/null
+++ b/activerecord/test/cases/relation/select_test.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+
+module ActiveRecord
+ class SelectTest < ActiveRecord::TestCase
+ fixtures :posts
+
+ def test_select_with_nil_argument
+ expected = Post.select(:title).to_sql
+ assert_equal expected, Post.select(nil).select(:title).to_sql
+ end
+ end
+end
diff --git a/activerecord/test/cases/relation/update_all_test.rb b/activerecord/test/cases/relation/update_all_test.rb
new file mode 100644
index 0000000000..bb6912148c
--- /dev/null
+++ b/activerecord/test/cases/relation/update_all_test.rb
@@ -0,0 +1,237 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/author"
+require "models/category"
+require "models/comment"
+require "models/computer"
+require "models/developer"
+require "models/post"
+require "models/person"
+require "models/pet"
+require "models/toy"
+require "models/topic"
+require "models/tag"
+require "models/tagging"
+require "models/warehouse_thing"
+
+class UpdateAllTest < ActiveRecord::TestCase
+ fixtures :authors, :author_addresses, :comments, :developers, :posts, :people, :pets, :toys, :tags, :taggings, "warehouse-things"
+
+ class TopicWithCallbacks < ActiveRecord::Base
+ self.table_name = :topics
+ cattr_accessor :topic_count
+ before_update { |topic| topic.author_name = "David" if topic.author_name.blank? }
+ after_update { |topic| topic.class.topic_count = topic.class.count }
+ end
+
+ def test_update_all_with_scope
+ tag = Tag.first
+ Post.tagged_with(tag.id).update_all(title: "rofl")
+ posts = Post.tagged_with(tag.id).all.to_a
+ assert_operator posts.length, :>, 0
+ posts.each { |post| assert_equal "rofl", post.title }
+ end
+
+ def test_update_all_with_non_standard_table_name
+ assert_equal 1, WarehouseThing.where(id: 1).update_all(["value = ?", 0])
+ assert_equal 0, WarehouseThing.find(1).value
+ end
+
+ def test_update_all_with_blank_argument
+ assert_raises(ArgumentError) { Comment.update_all({}) }
+ end
+
+ def test_update_all_with_joins
+ pets = Pet.joins(:toys).where(toys: { name: "Bone" })
+
+ assert_equal true, pets.exists?
+ assert_equal pets.count, pets.update_all(name: "Bob")
+ end
+
+ def test_update_all_with_left_joins
+ pets = Pet.left_joins(:toys).where(toys: { name: "Bone" })
+
+ assert_equal true, pets.exists?
+ assert_equal pets.count, pets.update_all(name: "Bob")
+ end
+
+ def test_update_all_with_includes
+ pets = Pet.includes(:toys).where(toys: { name: "Bone" })
+
+ assert_equal true, pets.exists?
+ assert_equal pets.count, pets.update_all(name: "Bob")
+ end
+
+ def test_update_all_with_joins_and_limit
+ comments = Comment.joins(:post).where("posts.id" => posts(:welcome).id).limit(1)
+ assert_equal 1, comments.update_all(post_id: posts(:thinking).id)
+ assert_equal posts(:thinking), comments(:greetings).post
+ end
+
+ def test_update_all_with_joins_and_limit_and_order
+ comments = Comment.joins(:post).where("posts.id" => posts(:welcome).id).order("comments.id").limit(1)
+ assert_equal 1, comments.update_all(post_id: posts(:thinking).id)
+ assert_equal posts(:thinking), comments(:greetings).post
+ assert_equal posts(:welcome), comments(:more_greetings).post
+ end
+
+ def test_update_all_with_joins_and_offset
+ all_comments = Comment.joins(:post).where("posts.id" => posts(:welcome).id)
+ count = all_comments.count
+ comments = all_comments.offset(1)
+
+ assert_equal count - 1, comments.update_all(post_id: posts(:thinking).id)
+ end
+
+ def test_update_all_with_joins_and_offset_and_order
+ all_comments = Comment.joins(:post).where("posts.id" => posts(:welcome).id).order("posts.id", "comments.id")
+ count = all_comments.count
+ comments = all_comments.offset(1)
+
+ assert_equal count - 1, comments.update_all(post_id: posts(:thinking).id)
+ assert_equal posts(:thinking), comments(:more_greetings).post
+ assert_equal posts(:welcome), comments(:greetings).post
+ end
+
+ def test_update_counters_with_joins
+ assert_nil pets(:parrot).integer
+
+ Pet.joins(:toys).where(toys: { name: "Bone" }).update_counters(integer: 1)
+
+ assert_equal 1, pets(:parrot).reload.integer
+ end
+
+ def test_touch_all_updates_records_timestamps
+ david = developers(:david)
+ david_previously_updated_at = david.updated_at
+ jamis = developers(:jamis)
+ jamis_previously_updated_at = jamis.updated_at
+ Developer.where(name: "David").touch_all
+
+ assert_not_equal david_previously_updated_at, david.reload.updated_at
+ assert_equal jamis_previously_updated_at, jamis.reload.updated_at
+ end
+
+ def test_touch_all_with_custom_timestamp
+ developer = developers(:david)
+ previously_created_at = developer.created_at
+ previously_updated_at = developer.updated_at
+ Developer.where(name: "David").touch_all(:created_at)
+ developer.reload
+
+ assert_not_equal previously_created_at, developer.created_at
+ assert_not_equal previously_updated_at, developer.updated_at
+ end
+
+ def test_touch_all_with_given_time
+ developer = developers(:david)
+ previously_created_at = developer.created_at
+ previously_updated_at = developer.updated_at
+ new_time = Time.utc(2015, 2, 16, 4, 54, 0)
+ Developer.where(name: "David").touch_all(:created_at, time: new_time)
+ developer.reload
+
+ assert_not_equal previously_created_at, developer.created_at
+ assert_not_equal previously_updated_at, developer.updated_at
+ assert_equal new_time, developer.created_at
+ assert_equal new_time, developer.updated_at
+ end
+
+ def test_touch_all_updates_locking_column
+ person = people(:david)
+
+ assert_difference -> { person.reload.lock_version }, +1 do
+ Person.where(first_name: "David").touch_all
+ end
+ end
+
+ def test_update_on_relation
+ topic1 = TopicWithCallbacks.create! title: "arel", author_name: nil
+ topic2 = TopicWithCallbacks.create! title: "activerecord", author_name: nil
+ topics = TopicWithCallbacks.where(id: [topic1.id, topic2.id])
+ topics.update(title: "adequaterecord")
+
+ assert_equal TopicWithCallbacks.count, TopicWithCallbacks.topic_count
+
+ assert_equal "adequaterecord", topic1.reload.title
+ assert_equal "adequaterecord", topic2.reload.title
+ # Testing that the before_update callbacks have run
+ assert_equal "David", topic1.reload.author_name
+ assert_equal "David", topic2.reload.author_name
+ end
+
+ def test_update_with_ids_on_relation
+ topic1 = TopicWithCallbacks.create!(title: "arel", author_name: nil)
+ topic2 = TopicWithCallbacks.create!(title: "activerecord", author_name: nil)
+ topics = TopicWithCallbacks.none
+ topics.update(
+ [topic1.id, topic2.id],
+ [{ title: "adequaterecord" }, { title: "adequaterecord" }]
+ )
+
+ assert_equal TopicWithCallbacks.count, TopicWithCallbacks.topic_count
+
+ assert_equal "adequaterecord", topic1.reload.title
+ assert_equal "adequaterecord", topic2.reload.title
+ # Testing that the before_update callbacks have run
+ assert_equal "David", topic1.reload.author_name
+ assert_equal "David", topic2.reload.author_name
+ end
+
+ def test_update_on_relation_passing_active_record_object_is_not_permitted
+ topic = Topic.create!(title: "Foo", author_name: nil)
+ assert_raises(ArgumentError) do
+ Topic.where(id: topic.id).update(topic, title: "Bar")
+ end
+ end
+
+ # Oracle UPDATE does not support ORDER BY
+ unless current_adapter?(:OracleAdapter)
+ def test_update_all_ignores_order_without_limit_from_association
+ author = authors(:david)
+ assert_nothing_raised do
+ assert_equal author.posts_with_comments_and_categories.length, author.posts_with_comments_and_categories.update_all([ "body = ?", "bulk update!" ])
+ end
+ end
+
+ def test_update_all_doesnt_ignore_order
+ assert_equal authors(:david).id + 1, authors(:mary).id # make sure there is going to be a duplicate PK error
+ test_update_with_order_succeeds = lambda do |order|
+ Author.order(order).update_all("id = id + 1")
+ rescue ActiveRecord::ActiveRecordError
+ false
+ end
+
+ if test_update_with_order_succeeds.call("id DESC")
+ # test that this wasn't a fluke and using an incorrect order results in an exception
+ assert_not test_update_with_order_succeeds.call("id ASC")
+ else
+ # test that we're failing because the current Arel's engine doesn't support UPDATE ORDER BY queries is using subselects instead
+ assert_sql(/\AUPDATE .+ \(SELECT .* ORDER BY id DESC\)\z/i) do
+ test_update_with_order_succeeds.call("id DESC")
+ end
+ end
+ end
+
+ def test_update_all_with_order_and_limit_updates_subset_only
+ author = authors(:david)
+ limited_posts = author.posts_sorted_by_id_limited
+ assert_equal 1, limited_posts.size
+ assert_equal 2, limited_posts.limit(2).size
+ assert_equal 1, limited_posts.update_all([ "body = ?", "bulk update!" ])
+ assert_equal "bulk update!", posts(:welcome).body
+ assert_not_equal "bulk update!", posts(:thinking).body
+ end
+
+ def test_update_all_with_order_and_limit_and_offset_updates_subset_only
+ author = authors(:david)
+ limited_posts = author.posts_sorted_by_id_limited.offset(1)
+ assert_equal 1, limited_posts.size
+ assert_equal 2, limited_posts.limit(2).size
+ assert_equal 1, limited_posts.update_all([ "body = ?", "bulk update!" ])
+ assert_equal "bulk update!", posts(:thinking).body
+ assert_not_equal "bulk update!", posts(:welcome).body
+ end
+ end
+end
diff --git a/activerecord/test/cases/relation/where_chain_test.rb b/activerecord/test/cases/relation/where_chain_test.rb
new file mode 100644
index 0000000000..a68eb2b446
--- /dev/null
+++ b/activerecord/test/cases/relation/where_chain_test.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+require "models/comment"
+
+module ActiveRecord
+ class WhereChainTest < ActiveRecord::TestCase
+ fixtures :posts
+
+ def setup
+ super
+ @name = "title"
+ end
+
+ def test_not_inverts_where_clause
+ relation = Post.where.not(title: "hello")
+ expected_where_clause = Post.where(title: "hello").where_clause.invert
+
+ assert_equal expected_where_clause, relation.where_clause
+ end
+
+ def test_not_with_nil
+ assert_raise ArgumentError do
+ Post.where.not(nil)
+ end
+ end
+
+ def test_association_not_eq
+ expected = Comment.arel_table[@name].not_eq(Arel::Nodes::BindParam.new(1))
+ relation = Post.joins(:comments).where.not(comments: { title: "hello" })
+ assert_equal(expected.to_sql, relation.where_clause.ast.to_sql)
+ end
+
+ def test_not_eq_with_preceding_where
+ relation = Post.where(title: "hello").where.not(title: "world")
+ expected_where_clause =
+ Post.where(title: "hello").where_clause +
+ Post.where(title: "world").where_clause.invert
+
+ assert_equal expected_where_clause, relation.where_clause
+ end
+
+ def test_not_eq_with_succeeding_where
+ relation = Post.where.not(title: "hello").where(title: "world")
+ expected_where_clause =
+ Post.where(title: "hello").where_clause.invert +
+ Post.where(title: "world").where_clause
+
+ assert_equal expected_where_clause, relation.where_clause
+ end
+
+ def test_chaining_multiple
+ relation = Post.where.not(author_id: [1, 2]).where.not(title: "ruby on rails")
+ expected_where_clause =
+ Post.where(author_id: [1, 2]).where_clause.invert +
+ Post.where(title: "ruby on rails").where_clause.invert
+
+ assert_equal expected_where_clause, relation.where_clause
+ end
+
+ def test_rewhere_with_one_condition
+ relation = Post.where(title: "hello").where(title: "world").rewhere(title: "alone")
+ expected = Post.where(title: "alone")
+
+ assert_equal expected.where_clause, relation.where_clause
+ end
+
+ def test_rewhere_with_multiple_overwriting_conditions
+ relation = Post.where(title: "hello").where(body: "world").rewhere(title: "alone", body: "again")
+ expected = Post.where(title: "alone", body: "again")
+
+ assert_equal expected.where_clause, relation.where_clause
+ end
+
+ def test_rewhere_with_one_overwriting_condition_and_one_unrelated
+ relation = Post.where(title: "hello").where(body: "world").rewhere(title: "alone")
+ expected = Post.where(body: "world", title: "alone")
+
+ assert_equal expected.where_clause, relation.where_clause
+ end
+
+ def test_rewhere_with_range
+ relation = Post.where(comments_count: 1..3).rewhere(comments_count: 3..5)
+
+ assert_equal Post.where(comments_count: 3..5), relation
+ end
+
+ def test_rewhere_with_infinite_upper_bound_range
+ relation = Post.where(comments_count: 1..Float::INFINITY).rewhere(comments_count: 3..5)
+
+ assert_equal Post.where(comments_count: 3..5), relation
+ end
+
+ def test_rewhere_with_infinite_lower_bound_range
+ relation = Post.where(comments_count: -Float::INFINITY..1).rewhere(comments_count: 3..5)
+
+ assert_equal Post.where(comments_count: 3..5), relation
+ end
+
+ def test_rewhere_with_infinite_range
+ relation = Post.where(comments_count: -Float::INFINITY..Float::INFINITY).rewhere(comments_count: 3..5)
+
+ assert_equal Post.where(comments_count: 3..5), relation
+ end
+ end
+end
diff --git a/activerecord/test/cases/relation/where_clause_test.rb b/activerecord/test/cases/relation/where_clause_test.rb
new file mode 100644
index 0000000000..0b06cec40b
--- /dev/null
+++ b/activerecord/test/cases/relation/where_clause_test.rb
@@ -0,0 +1,245 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class ActiveRecord::Relation
+ class WhereClauseTest < ActiveRecord::TestCase
+ test "+ combines two where clauses" do
+ first_clause = WhereClause.new([table["id"].eq(bind_param(1))])
+ second_clause = WhereClause.new([table["name"].eq(bind_param("Sean"))])
+ combined = WhereClause.new(
+ [table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Sean"))],
+ )
+
+ assert_equal combined, first_clause + second_clause
+ end
+
+ test "+ is associative, but not commutative" do
+ a = WhereClause.new(["a"])
+ b = WhereClause.new(["b"])
+ c = WhereClause.new(["c"])
+
+ assert_equal a + (b + c), (a + b) + c
+ assert_not_equal a + b, b + a
+ end
+
+ test "an empty where clause is the identity value for +" do
+ clause = WhereClause.new([table["id"].eq(bind_param(1))])
+
+ assert_equal clause, clause + WhereClause.empty
+ end
+
+ test "merge combines two where clauses" do
+ a = WhereClause.new([table["id"].eq(1)])
+ b = WhereClause.new([table["name"].eq("Sean")])
+ expected = WhereClause.new([table["id"].eq(1), table["name"].eq("Sean")])
+
+ assert_equal expected, a.merge(b)
+ end
+
+ test "merge keeps the right side, when two equality clauses reference the same column" do
+ a = WhereClause.new([table["id"].eq(1), table["name"].eq("Sean")])
+ b = WhereClause.new([table["name"].eq("Jim")])
+ expected = WhereClause.new([table["id"].eq(1), table["name"].eq("Jim")])
+
+ assert_equal expected, a.merge(b)
+ end
+
+ test "merge removes bind parameters matching overlapping equality clauses" do
+ a = WhereClause.new(
+ [table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Sean"))],
+ )
+ b = WhereClause.new(
+ [table["name"].eq(bind_param("Jim"))],
+ )
+ expected = WhereClause.new(
+ [table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Jim"))],
+ )
+
+ assert_equal expected, a.merge(b)
+ end
+
+ test "merge allows for columns with the same name from different tables" do
+ table2 = Arel::Table.new("table2")
+ a = WhereClause.new(
+ [table["id"].eq(bind_param(1)), table2["id"].eq(bind_param(2))],
+ )
+ b = WhereClause.new(
+ [table["id"].eq(bind_param(3))],
+ )
+ expected = WhereClause.new(
+ [table2["id"].eq(bind_param(2)), table["id"].eq(bind_param(3))],
+ )
+
+ assert_equal expected, a.merge(b)
+ end
+
+ test "a clause knows if it is empty" do
+ assert_empty WhereClause.empty
+ assert_not_empty WhereClause.new(["anything"])
+ end
+
+ test "invert cannot handle nil" do
+ where_clause = WhereClause.new([nil])
+
+ assert_raises ArgumentError do
+ where_clause.invert
+ end
+ end
+
+ test "invert replaces each part of the predicate with its inverse" do
+ random_object = Object.new
+ original = WhereClause.new([
+ table["id"].in([1, 2, 3]),
+ table["id"].eq(1),
+ table["id"].is_not_distinct_from(1),
+ table["id"].is_distinct_from(2),
+ "sql literal",
+ random_object
+ ])
+ expected = WhereClause.new([
+ table["id"].not_in([1, 2, 3]),
+ table["id"].not_eq(1),
+ table["id"].is_distinct_from(1),
+ table["id"].is_not_distinct_from(2),
+ Arel::Nodes::Not.new(Arel::Nodes::SqlLiteral.new("sql literal")),
+ Arel::Nodes::Not.new(random_object)
+ ])
+
+ assert_equal expected, original.invert
+ end
+
+ test "except removes binary predicates referencing a given column" do
+ where_clause = WhereClause.new([
+ table["id"].in([1, 2, 3]),
+ table["name"].eq(bind_param("Sean")),
+ table["age"].gteq(bind_param(30)),
+ ])
+ expected = WhereClause.new([table["age"].gteq(bind_param(30))])
+
+ assert_equal expected, where_clause.except("id", "name")
+ end
+
+ test "except jumps over unhandled binds (like with OR) correctly" do
+ wcs = (0..9).map do |i|
+ WhereClause.new([table["id#{i}"].eq(bind_param(i))])
+ end
+
+ wc = wcs[0] + wcs[1] + wcs[2].or(wcs[3]) + wcs[4] + wcs[5] + wcs[6].or(wcs[7]) + wcs[8] + wcs[9]
+
+ expected = wcs[0] + wcs[2].or(wcs[3]) + wcs[5] + wcs[6].or(wcs[7]) + wcs[9]
+ actual = wc.except("id1", "id2", "id4", "id7", "id8")
+
+ assert_equal expected, actual
+ end
+
+ test "ast groups its predicates with AND" do
+ predicates = [
+ table["id"].in([1, 2, 3]),
+ table["name"].eq(bind_param(nil)),
+ ]
+ where_clause = WhereClause.new(predicates)
+ expected = Arel::Nodes::And.new(predicates)
+
+ assert_equal expected, where_clause.ast
+ end
+
+ test "ast wraps any SQL literals in parenthesis" do
+ random_object = Object.new
+ where_clause = WhereClause.new([
+ table["id"].in([1, 2, 3]),
+ "foo = bar",
+ random_object,
+ ])
+ expected = Arel::Nodes::And.new([
+ table["id"].in([1, 2, 3]),
+ Arel::Nodes::Grouping.new(Arel.sql("foo = bar")),
+ random_object,
+ ])
+
+ assert_equal expected, where_clause.ast
+ end
+
+ test "ast removes any empty strings" do
+ where_clause = WhereClause.new([table["id"].in([1, 2, 3])])
+ where_clause_with_empty = WhereClause.new([table["id"].in([1, 2, 3]), ""])
+
+ assert_equal where_clause.ast, where_clause_with_empty.ast
+ end
+
+ test "or joins the two clauses using OR" do
+ where_clause = WhereClause.new([table["id"].eq(bind_param(1))])
+ other_clause = WhereClause.new([table["name"].eq(bind_param("Sean"))])
+ expected_ast =
+ Arel::Nodes::Grouping.new(
+ Arel::Nodes::Or.new(table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Sean")))
+ )
+
+ assert_equal expected_ast.to_sql, where_clause.or(other_clause).ast.to_sql
+ end
+
+ test "or returns an empty where clause when either side is empty" do
+ where_clause = WhereClause.new([table["id"].eq(bind_param(1))])
+
+ assert_equal WhereClause.empty, where_clause.or(WhereClause.empty)
+ assert_equal WhereClause.empty, WhereClause.empty.or(where_clause)
+ end
+
+ test "or places common conditions before the OR" do
+ a = WhereClause.new(
+ [table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Sean"))],
+ )
+ b = WhereClause.new(
+ [table["id"].eq(bind_param(1)), table["hair_color"].eq(bind_param("black"))],
+ )
+
+ common = WhereClause.new(
+ [table["id"].eq(bind_param(1))],
+ )
+
+ or_clause = WhereClause.new([table["name"].eq(bind_param("Sean"))])
+ .or(WhereClause.new([table["hair_color"].eq(bind_param("black"))]))
+
+ assert_equal common + or_clause, a.or(b)
+ end
+
+ test "or can detect identical or as being a common condition" do
+ common_or = WhereClause.new([table["name"].eq(bind_param("Sean"))])
+ .or(WhereClause.new([table["hair_color"].eq(bind_param("black"))]))
+
+ a = common_or + WhereClause.new([table["id"].eq(bind_param(1))])
+ b = common_or + WhereClause.new([table["foo"].eq(bind_param("bar"))])
+
+ new_or = WhereClause.new([table["id"].eq(bind_param(1))])
+ .or(WhereClause.new([table["foo"].eq(bind_param("bar"))]))
+
+ assert_equal common_or + new_or, a.or(b)
+ end
+
+ test "or will use only common conditions if one side only has common conditions" do
+ only_common = WhereClause.new([
+ table["id"].eq(bind_param(1)),
+ "foo = bar",
+ ])
+
+ common_with_extra = WhereClause.new([
+ table["id"].eq(bind_param(1)),
+ "foo = bar",
+ table["extra"].eq(bind_param("pluto")),
+ ])
+
+ assert_equal only_common, only_common.or(common_with_extra)
+ assert_equal only_common, common_with_extra.or(only_common)
+ end
+
+ private
+
+ def table
+ Arel::Table.new("table")
+ end
+
+ def bind_param(value)
+ Arel::Nodes::BindParam.new(value)
+ end
+ end
+end
diff --git a/activerecord/test/cases/relation/where_test.rb b/activerecord/test/cases/relation/where_test.rb
new file mode 100644
index 0000000000..99797528b2
--- /dev/null
+++ b/activerecord/test/cases/relation/where_test.rb
@@ -0,0 +1,366 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/author"
+require "models/binary"
+require "models/cake_designer"
+require "models/car"
+require "models/chef"
+require "models/post"
+require "models/comment"
+require "models/edge"
+require "models/essay"
+require "models/price_estimate"
+require "models/topic"
+require "models/treasure"
+require "models/vertex"
+
+module ActiveRecord
+ class WhereTest < ActiveRecord::TestCase
+ fixtures :posts, :edges, :authors, :author_addresses, :binaries, :essays, :cars, :treasures, :price_estimates, :topics
+
+ def test_where_copies_bind_params
+ author = authors(:david)
+ posts = author.posts.where("posts.id != 1")
+ joined = Post.where(id: posts)
+
+ assert_operator joined.length, :>, 0
+
+ joined.each { |post|
+ assert_equal author, post.author
+ assert_not_equal 1, post.id
+ }
+ end
+
+ def test_where_copies_bind_params_in_the_right_order
+ author = authors(:david)
+ posts = author.posts.where.not(id: 1)
+ joined = Post.where(id: posts, title: posts.first.title)
+
+ assert_equal joined, [posts.first]
+ end
+
+ def test_where_copies_arel_bind_params
+ chef = Chef.create!
+ CakeDesigner.create!(chef: chef)
+
+ cake_designers = CakeDesigner.joins(:chef).where(chefs: { id: chef.id })
+ chefs = Chef.where(employable: cake_designers)
+
+ assert_equal [chef], chefs.to_a
+ end
+
+ def test_where_with_casted_value_is_nil
+ assert_equal 4, Topic.where(last_read: "").count
+ end
+
+ def test_rewhere_on_root
+ assert_equal posts(:welcome), Post.rewhere(title: "Welcome to the weblog").first
+ end
+
+ def test_belongs_to_shallow_where
+ author = Author.new
+ author.id = 1
+
+ assert_equal Post.where(author_id: 1).to_sql, Post.where(author: author).to_sql
+ end
+
+ def test_belongs_to_nil_where
+ assert_equal Post.where(author_id: nil).to_sql, Post.where(author: nil).to_sql
+ end
+
+ def test_belongs_to_array_value_where
+ assert_equal Post.where(author_id: [1, 2]).to_sql, Post.where(author: [1, 2]).to_sql
+ end
+
+ def test_belongs_to_nested_relation_where
+ expected = Post.where(author_id: Author.where(id: [1, 2])).to_sql
+ actual = Post.where(author: Author.where(id: [1, 2])).to_sql
+
+ assert_equal expected, actual
+ end
+
+ def test_belongs_to_nested_where
+ parent = Comment.new
+ parent.id = 1
+
+ expected = Post.where(comments: { parent_id: 1 }).joins(:comments)
+ actual = Post.where(comments: { parent: parent }).joins(:comments)
+
+ assert_equal expected.to_sql, actual.to_sql
+ end
+
+ def test_belongs_to_nested_where_with_relation
+ author = authors(:david)
+
+ expected = Author.where(id: author).joins(:posts)
+ actual = Author.where(posts: { author_id: Author.where(id: author.id) }).joins(:posts)
+
+ assert_equal expected.to_a, actual.to_a
+ end
+
+ def test_polymorphic_shallow_where
+ treasure = Treasure.new
+ treasure.id = 1
+
+ expected = PriceEstimate.where(estimate_of_type: "Treasure", estimate_of_id: 1)
+ actual = PriceEstimate.where(estimate_of: treasure)
+
+ assert_equal expected.to_sql, actual.to_sql
+ end
+
+ def test_polymorphic_shallow_where_not
+ treasure = treasures(:sapphire)
+
+ expected = [price_estimates(:diamond), price_estimates(:honda)]
+ actual = PriceEstimate.where.not(estimate_of: treasure)
+
+ assert_equal expected.sort_by(&:id), actual.sort_by(&:id)
+ end
+
+ def test_polymorphic_nested_array_where
+ treasure = Treasure.new
+ treasure.id = 1
+ hidden = HiddenTreasure.new
+ hidden.id = 2
+
+ expected = PriceEstimate.where(estimate_of_type: "Treasure", estimate_of_id: [treasure, hidden])
+ actual = PriceEstimate.where(estimate_of: [treasure, hidden])
+
+ assert_equal expected.to_sql, actual.to_sql
+ end
+
+ def test_polymorphic_nested_array_where_not
+ treasure = treasures(:diamond)
+ car = cars(:honda)
+
+ expected = [price_estimates(:sapphire_1), price_estimates(:sapphire_2)]
+ actual = PriceEstimate.where.not(estimate_of: [treasure, car])
+
+ assert_equal expected.sort_by(&:id), actual.sort_by(&:id)
+ end
+
+ def test_polymorphic_array_where_multiple_types
+ treasure_1 = treasures(:diamond)
+ treasure_2 = treasures(:sapphire)
+ car = cars(:honda)
+
+ expected = [price_estimates(:diamond), price_estimates(:sapphire_1), price_estimates(:sapphire_2), price_estimates(:honda)].sort
+ actual = PriceEstimate.where(estimate_of: [treasure_1, treasure_2, car]).to_a.sort
+
+ assert_equal expected, actual
+ end
+
+ def test_polymorphic_nested_relation_where
+ expected = PriceEstimate.where(estimate_of_type: "Treasure", estimate_of_id: Treasure.where(id: [1, 2]))
+ actual = PriceEstimate.where(estimate_of: Treasure.where(id: [1, 2]))
+
+ assert_equal expected.to_sql, actual.to_sql
+ end
+
+ def test_polymorphic_sti_shallow_where
+ treasure = HiddenTreasure.new
+ treasure.id = 1
+
+ expected = PriceEstimate.where(estimate_of_type: "Treasure", estimate_of_id: 1)
+ actual = PriceEstimate.where(estimate_of: treasure)
+
+ assert_equal expected.to_sql, actual.to_sql
+ end
+
+ def test_polymorphic_nested_where
+ thing = Post.new
+ thing.id = 1
+
+ expected = Treasure.where(price_estimates: { thing_type: "Post", thing_id: 1 }).joins(:price_estimates)
+ actual = Treasure.where(price_estimates: { thing: thing }).joins(:price_estimates)
+
+ assert_equal expected.to_sql, actual.to_sql
+ end
+
+ def test_polymorphic_sti_nested_where
+ treasure = HiddenTreasure.new
+ treasure.id = 1
+
+ expected = Treasure.where(price_estimates: { estimate_of_type: "Treasure", estimate_of_id: 1 }).joins(:price_estimates)
+ actual = Treasure.where(price_estimates: { estimate_of: treasure }).joins(:price_estimates)
+
+ assert_equal expected.to_sql, actual.to_sql
+ end
+
+ def test_decorated_polymorphic_where
+ treasure_decorator = Struct.new(:model) do
+ def self.method_missing(method, *args, &block)
+ Treasure.send(method, *args, &block)
+ end
+
+ def is_a?(klass)
+ model.is_a?(klass)
+ end
+
+ def method_missing(method, *args, &block)
+ model.send(method, *args, &block)
+ end
+ end
+
+ treasure = Treasure.new
+ treasure.id = 1
+ decorated_treasure = treasure_decorator.new(treasure)
+
+ expected = PriceEstimate.where(estimate_of_type: "Treasure", estimate_of_id: 1)
+ actual = PriceEstimate.where(estimate_of: decorated_treasure)
+
+ assert_equal expected.to_sql, actual.to_sql
+ end
+
+ def test_aliased_attribute
+ expected = Topic.where(heading: "The First Topic")
+ actual = Topic.where(title: "The First Topic")
+
+ assert_equal expected.to_sql, actual.to_sql
+ end
+
+ def test_where_error
+ assert_nothing_raised do
+ Post.where(id: { "posts.author_id" => 10 }).first
+ end
+ end
+
+ def test_where_with_table_name
+ post = Post.first
+ assert_equal post, Post.where(posts: { "id" => post.id }).first
+ end
+
+ def test_where_with_table_name_and_empty_hash
+ assert_equal 0, Post.where(posts: {}).count
+ end
+
+ def test_where_with_table_name_and_empty_array
+ assert_equal 0, Post.where(id: []).count
+ end
+
+ def test_where_with_empty_hash_and_no_foreign_key
+ assert_equal 0, Edge.where(sink: {}).count
+ end
+
+ def test_where_with_blank_conditions
+ [[], {}, nil, ""].each do |blank|
+ assert_equal 4, Edge.where(blank).order("sink_id").to_a.size
+ end
+ end
+
+ def test_where_with_integer_for_string_column
+ count = Post.where(title: 0).count
+ assert_equal 0, count
+ end
+
+ def test_where_with_float_for_string_column
+ count = Post.where(title: 0.0).count
+ assert_equal 0, count
+ end
+
+ def test_where_with_boolean_for_string_column
+ count = Post.where(title: false).count
+ assert_equal 0, count
+ end
+
+ def test_where_with_decimal_for_string_column
+ count = Post.where(title: BigDecimal(0)).count
+ assert_equal 0, count
+ end
+
+ def test_where_with_duration_for_string_column
+ count = Post.where(title: 0.seconds).count
+ assert_equal 0, count
+ end
+
+ def test_where_with_integer_for_binary_column
+ count = Binary.where(data: 0).count
+ assert_equal 0, count
+ end
+
+ def test_where_on_association_with_custom_primary_key
+ author = authors(:david)
+ essay = Essay.where(writer: author).first
+
+ assert_equal essays(:david_modest_proposal), essay
+ end
+
+ def test_where_on_association_with_custom_primary_key_with_relation
+ author = authors(:david)
+ essay = Essay.where(writer: Author.where(id: author.id)).first
+
+ assert_equal essays(:david_modest_proposal), essay
+ end
+
+ def test_where_on_association_with_relation_performs_subselect_not_two_queries
+ author = authors(:david)
+
+ assert_queries(1) do
+ Essay.where(writer: Author.where(id: author.id)).to_a
+ end
+ end
+
+ def test_where_on_association_with_custom_primary_key_with_array_of_base
+ author = authors(:david)
+ essay = Essay.where(writer: [author]).first
+
+ assert_equal essays(:david_modest_proposal), essay
+ end
+
+ def test_where_on_association_with_custom_primary_key_with_array_of_ids
+ essay = Essay.where(writer: ["David"]).first
+
+ assert_equal essays(:david_modest_proposal), essay
+ end
+
+ def test_where_with_relation_on_has_many_association
+ essay = essays(:david_modest_proposal)
+ author = Author.where(essays: Essay.where(id: essay.id)).first
+
+ assert_equal authors(:david), author
+ end
+
+ def test_where_with_relation_on_has_one_association
+ author = authors(:david)
+ author_address = AuthorAddress.where(author: Author.where(id: author.id)).first
+ assert_equal author_addresses(:david_address), author_address
+ end
+
+
+ def test_where_on_association_with_select_relation
+ essay = Essay.where(author: Author.where(name: "David").select(:name)).take
+ assert_equal essays(:david_modest_proposal), essay
+ end
+
+ def test_where_with_strong_parameters
+ protected_params = Class.new do
+ attr_reader :permitted
+ alias :permitted? :permitted
+
+ def initialize(parameters)
+ @parameters = parameters
+ @permitted = false
+ end
+
+ def to_h
+ @parameters
+ end
+
+ def permit!
+ @permitted = true
+ self
+ end
+ end
+
+ author = authors(:david)
+ params = protected_params.new(name: author.name)
+ assert_raises(ActiveModel::ForbiddenAttributesError) { Author.where(params) }
+ assert_equal author, Author.where(params.permit!).first
+ end
+
+ def test_where_with_unsupported_arguments
+ assert_raises(ArgumentError) { Author.where(42) }
+ end
+ end
+end
diff --git a/activerecord/test/cases/relation_test.rb b/activerecord/test/cases/relation_test.rb
new file mode 100644
index 0000000000..68161f6a84
--- /dev/null
+++ b/activerecord/test/cases/relation_test.rb
@@ -0,0 +1,372 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+require "models/comment"
+require "models/author"
+require "models/rating"
+require "models/categorization"
+
+module ActiveRecord
+ class RelationTest < ActiveRecord::TestCase
+ fixtures :posts, :comments, :authors, :author_addresses, :ratings, :categorizations
+
+ def test_construction
+ relation = Relation.new(FakeKlass, table: :b)
+ assert_equal FakeKlass, relation.klass
+ assert_equal :b, relation.table
+ assert_not relation.loaded, "relation is not loaded"
+ end
+
+ def test_responds_to_model_and_returns_klass
+ relation = Relation.new(FakeKlass)
+ assert_equal FakeKlass, relation.model
+ end
+
+ def test_initialize_single_values
+ relation = Relation.new(FakeKlass)
+ (Relation::SINGLE_VALUE_METHODS - [:create_with]).each do |method|
+ assert_nil relation.send("#{method}_value"), method.to_s
+ end
+ value = relation.create_with_value
+ assert_equal({}, value)
+ assert_predicate value, :frozen?
+ end
+
+ def test_multi_value_initialize
+ relation = Relation.new(FakeKlass)
+ Relation::MULTI_VALUE_METHODS.each do |method|
+ values = relation.send("#{method}_values")
+ assert_equal [], values, method.to_s
+ assert_predicate values, :frozen?, method.to_s
+ end
+ end
+
+ def test_extensions
+ relation = Relation.new(FakeKlass)
+ assert_equal [], relation.extensions
+ end
+
+ def test_empty_where_values_hash
+ relation = Relation.new(FakeKlass)
+ assert_equal({}, relation.where_values_hash)
+ end
+
+ def test_has_values
+ relation = Relation.new(Post)
+ relation.where!(id: 10)
+ assert_equal({ "id" => 10 }, relation.where_values_hash)
+ end
+
+ def test_values_wrong_table
+ relation = Relation.new(Post)
+ relation.where! Comment.arel_table[:id].eq(10)
+ assert_equal({}, relation.where_values_hash)
+ end
+
+ def test_tree_is_not_traversed
+ relation = Relation.new(Post)
+ left = relation.table[:id].eq(10)
+ right = relation.table[:id].eq(10)
+ combine = left.or(right)
+ relation.where! combine
+ assert_equal({}, relation.where_values_hash)
+ end
+
+ def test_scope_for_create
+ relation = Relation.new(FakeKlass)
+ assert_equal({}, relation.scope_for_create)
+ end
+
+ def test_create_with_value
+ relation = Relation.new(Post)
+ relation.create_with_value = { hello: "world" }
+ assert_equal({ "hello" => "world" }, relation.scope_for_create)
+ end
+
+ def test_create_with_value_with_wheres
+ relation = Relation.new(Post)
+ assert_equal({}, relation.scope_for_create)
+
+ relation.where!(id: 10)
+ assert_equal({ "id" => 10 }, relation.scope_for_create)
+
+ relation.create_with_value = { hello: "world" }
+ assert_equal({ "hello" => "world", "id" => 10 }, relation.scope_for_create)
+ end
+
+ def test_empty_scope
+ relation = Relation.new(Post)
+ assert_predicate relation, :empty_scope?
+
+ relation.merge!(relation)
+ assert_predicate relation, :empty_scope?
+ end
+
+ def test_bad_constants_raise_errors
+ assert_raises(NameError) do
+ ActiveRecord::Relation::HelloWorld
+ end
+ end
+
+ def test_empty_eager_loading?
+ relation = Relation.new(FakeKlass)
+ assert_not_predicate relation, :eager_loading?
+ end
+
+ def test_eager_load_values
+ relation = Relation.new(FakeKlass)
+ relation.eager_load! :b
+ assert_predicate relation, :eager_loading?
+ end
+
+ def test_references_values
+ relation = Relation.new(FakeKlass)
+ assert_equal [], relation.references_values
+ relation = relation.references(:foo).references(:omg, :lol)
+ assert_equal ["foo", "omg", "lol"], relation.references_values
+ end
+
+ def test_references_values_dont_duplicate
+ relation = Relation.new(FakeKlass)
+ relation = relation.references(:foo).references(:foo)
+ assert_equal ["foo"], relation.references_values
+ end
+
+ test "merging a hash into a relation" do
+ relation = Relation.new(Post)
+ relation = relation.merge where: { name: :lol }, readonly: true
+
+ assert_equal({ "name" => :lol }, relation.where_clause.to_h)
+ assert_equal true, relation.readonly_value
+ end
+
+ test "merging an empty hash into a relation" do
+ assert_equal Relation::WhereClause.empty, Relation.new(FakeKlass).merge({}).where_clause
+ end
+
+ test "merging a hash with unknown keys raises" do
+ assert_raises(ArgumentError) { Relation::HashMerger.new(nil, omg: "lol") }
+ end
+
+ test "merging nil or false raises" do
+ relation = Relation.new(FakeKlass)
+
+ e = assert_raises(ArgumentError) do
+ relation = relation.merge nil
+ end
+
+ assert_equal "invalid argument: nil.", e.message
+
+ e = assert_raises(ArgumentError) do
+ relation = relation.merge false
+ end
+
+ assert_equal "invalid argument: false.", e.message
+ end
+
+ test "#values returns a dup of the values" do
+ relation = Relation.new(Post).where!(name: :foo)
+ values = relation.values
+
+ values[:where] = nil
+ assert_not_nil relation.where_clause
+ end
+
+ test "relations can be created with a values hash" do
+ relation = Relation.new(FakeKlass, values: { select: [:foo] })
+ assert_equal [:foo], relation.select_values
+ end
+
+ test "merging a hash interpolates conditions" do
+ klass = Class.new(FakeKlass) do
+ def self.sanitize_sql(args)
+ raise unless args == ["foo = ?", "bar"]
+ "foo = bar"
+ end
+ end
+
+ relation = Relation.new(klass)
+ relation.merge!(where: ["foo = ?", "bar"])
+ assert_equal Relation::WhereClause.new(["foo = bar"]), relation.where_clause
+ end
+
+ def test_merging_readonly_false
+ relation = Relation.new(FakeKlass)
+ readonly_false_relation = relation.readonly(false)
+ # test merging in both directions
+ assert_equal false, relation.merge(readonly_false_relation).readonly_value
+ assert_equal false, readonly_false_relation.merge(relation).readonly_value
+ end
+
+ def test_relation_merging_with_merged_joins_as_symbols
+ special_comments_with_ratings = SpecialComment.joins(:ratings)
+ posts_with_special_comments_with_ratings = Post.group("posts.id").joins(:special_comments).merge(special_comments_with_ratings)
+ assert_equal({ 4 => 2 }, authors(:david).posts.merge(posts_with_special_comments_with_ratings).count)
+ end
+
+ def test_relation_merging_with_merged_symbol_joins_keeps_inner_joins
+ queries = capture_sql { Author.joins(:posts).merge(Post.joins(:comments)).to_a }
+
+ nb_inner_join = queries.sum { |sql| sql.scan(/INNER\s+JOIN/i).size }
+ assert_equal 2, nb_inner_join, "Wrong amount of INNER JOIN in query"
+ assert queries.none? { |sql| /LEFT\s+(OUTER)?\s+JOIN/i.match?(sql) }, "Shouldn't have any LEFT JOIN in query"
+ end
+
+ def test_relation_merging_with_merged_symbol_joins_has_correct_size_and_count
+ # Has one entry per comment
+ merged_authors_with_commented_posts_relation = Author.joins(:posts).merge(Post.joins(:comments))
+
+ post_ids_with_author = Post.joins(:author).pluck(:id)
+ manual_comments_on_post_that_have_author = Comment.where(post_id: post_ids_with_author).pluck(:id)
+
+ assert_equal manual_comments_on_post_that_have_author.size, merged_authors_with_commented_posts_relation.count
+ assert_equal manual_comments_on_post_that_have_author.size, merged_authors_with_commented_posts_relation.to_a.size
+ end
+
+ def test_relation_merging_with_merged_symbol_joins_is_aliased
+ categorizations_with_authors = Categorization.joins(:author)
+ queries = capture_sql { Post.joins(:author, :categorizations).merge(Author.select(:id)).merge(categorizations_with_authors).to_a }
+
+ nb_inner_join = queries.sum { |sql| sql.scan(/INNER\s+JOIN/i).size }
+ assert_equal 3, nb_inner_join, "Wrong amount of INNER JOIN in query"
+
+ # using `\W` as the column separator
+ assert queries.any? { |sql| %r[INNER\s+JOIN\s+#{Regexp.escape(Author.quoted_table_name)}\s+\Wauthors_categorizations\W]i.match?(sql) }, "Should be aliasing the child INNER JOINs in query"
+ end
+
+ def test_relation_with_merged_joins_aliased_works
+ categorizations_with_authors = Categorization.joins(:author)
+ posts_with_joins_and_merges = Post.joins(:author, :categorizations)
+ .merge(Author.select(:id)).merge(categorizations_with_authors)
+
+ author_with_posts = Author.joins(:posts).ids
+ categorizations_with_author = Categorization.joins(:author).ids
+ posts_with_author_and_categorizations = Post.joins(:categorizations).where(author_id: author_with_posts, categorizations: { id: categorizations_with_author }).ids
+
+ assert_equal posts_with_author_and_categorizations.size, posts_with_joins_and_merges.count
+ assert_equal posts_with_author_and_categorizations.size, posts_with_joins_and_merges.to_a.size
+ end
+
+ def test_relation_merging_with_joins_as_join_dependency_pick_proper_parent
+ post = Post.create!(title: "haha", body: "huhu")
+ comment = post.comments.create!(body: "hu")
+ 3.times { comment.ratings.create! }
+
+ relation = Post.joins(:comments).merge Comment.joins(:ratings)
+
+ assert_equal 3, relation.where(id: post.id).pluck(:id).size
+ end
+
+ def test_merge_raises_with_invalid_argument
+ assert_raises ArgumentError do
+ relation = Relation.new(FakeKlass)
+ relation.merge(true)
+ end
+ end
+
+ def test_respond_to_for_non_selected_element
+ post = Post.select(:title).first
+ assert_not_respond_to post, :body, "post should not respond_to?(:body) since invoking it raises exception"
+
+ silence_warnings { post = Post.select("'title' as post_title").first }
+ assert_not_respond_to post, :title, "post should not respond_to?(:body) since invoking it raises exception"
+ end
+
+ def test_select_quotes_when_using_from_clause
+ skip_if_sqlite3_version_includes_quoting_bug
+ quoted_join = ActiveRecord::Base.connection.quote_table_name("join")
+ selected = Post.select(:join).from(Post.select("id as #{quoted_join}")).map(&:join)
+ assert_equal Post.pluck(:id), selected
+ end
+
+ def test_selecting_aliased_attribute_quotes_column_name_when_from_is_used
+ skip_if_sqlite3_version_includes_quoting_bug
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = :test_with_keyword_column_name
+ alias_attribute :description, :desc
+ end
+ klass.create!(description: "foo")
+
+ assert_equal ["foo"], klass.select(:description).from(klass.all).map(&:desc)
+ end
+
+ def test_relation_merging_with_merged_joins_as_strings
+ join_string = "LEFT OUTER JOIN #{Rating.quoted_table_name} ON #{SpecialComment.quoted_table_name}.id = #{Rating.quoted_table_name}.comment_id"
+ special_comments_with_ratings = SpecialComment.joins join_string
+ posts_with_special_comments_with_ratings = Post.group("posts.id").joins(:special_comments).merge(special_comments_with_ratings)
+ assert_equal({ 2 => 1, 4 => 3, 5 => 1 }, authors(:david).posts.merge(posts_with_special_comments_with_ratings).count)
+ end
+
+ def test_relation_merging_keeps_joining_order
+ authors = Author.where(id: 1)
+ posts = Post.joins(:author).merge(authors)
+ comments = Comment.joins(:post).merge(posts)
+ ratings = Rating.joins(:comment).merge(comments)
+
+ assert_equal 3, ratings.count
+ end
+
+ class EnsureRoundTripTypeCasting < ActiveRecord::Type::Value
+ def type
+ :string
+ end
+
+ def cast(value)
+ raise value unless value == "value from user"
+ "cast value"
+ end
+
+ def deserialize(value)
+ raise value unless value == "type cast for database"
+ "type cast from database"
+ end
+
+ def serialize(value)
+ raise value unless value == "cast value"
+ "type cast for database"
+ end
+ end
+
+ class UpdateAllTestModel < ActiveRecord::Base
+ self.table_name = "posts"
+
+ attribute :body, EnsureRoundTripTypeCasting.new
+ end
+
+ def test_update_all_goes_through_normal_type_casting
+ UpdateAllTestModel.update_all(body: "value from user", type: nil) # No STI
+
+ assert_equal "type cast from database", UpdateAllTestModel.first.body
+ end
+
+ def test_skip_preloading_after_arel_has_been_generated
+ assert_nothing_raised do
+ relation = Comment.all
+ relation.arel
+ relation.skip_preloading!
+ end
+ end
+
+ private
+
+ def skip_if_sqlite3_version_includes_quoting_bug
+ if sqlite3_version_includes_quoting_bug?
+ skip <<-ERROR.squish
+ You are using an outdated version of SQLite3 which has a bug in
+ quoted column names. Please update SQLite3 and rebuild the sqlite3
+ ruby gem
+ ERROR
+ end
+ end
+
+ def sqlite3_version_includes_quoting_bug?
+ if current_adapter?(:SQLite3Adapter)
+ selected_quoted_column_names = ActiveRecord::Base.connection.exec_query(
+ 'SELECT "join" FROM (SELECT id AS "join" FROM posts) subquery'
+ ).columns
+ ["join"] != selected_quoted_column_names
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb
new file mode 100644
index 0000000000..756eeca35f
--- /dev/null
+++ b/activerecord/test/cases/relations_test.rb
@@ -0,0 +1,1931 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/tag"
+require "models/tagging"
+require "models/post"
+require "models/topic"
+require "models/comment"
+require "models/author"
+require "models/entrant"
+require "models/developer"
+require "models/project"
+require "models/person"
+require "models/computer"
+require "models/reply"
+require "models/company"
+require "models/bird"
+require "models/car"
+require "models/engine"
+require "models/tyre"
+require "models/minivan"
+require "models/possession"
+require "models/reader"
+require "models/category"
+require "models/categorization"
+require "models/edge"
+require "models/subscriber"
+
+class RelationTest < ActiveRecord::TestCase
+ fixtures :authors, :author_addresses, :topics, :entrants, :developers, :people, :companies, :developers_projects, :accounts, :categories, :categorizations, :categories_posts, :posts, :comments, :tags, :taggings, :cars, :minivans
+
+ def test_do_not_double_quote_string_id
+ van = Minivan.last
+ assert van
+ assert_equal van.id, Minivan.where(minivan_id: van).to_a.first.minivan_id
+ end
+
+ def test_do_not_double_quote_string_id_with_array
+ van = Minivan.last
+ assert van
+ assert_equal van, Minivan.where(minivan_id: [van]).to_a.first
+ end
+
+ def test_two_scopes_with_includes_should_not_drop_any_include
+ # heat habtm cache
+ car = Car.incl_engines.incl_tyres.first
+ car.tyres.length
+ car.engines.length
+
+ car = Car.incl_engines.incl_tyres.first
+ assert_no_queries { car.tyres.length }
+ assert_no_queries { car.engines.length }
+ end
+
+ def test_dynamic_finder
+ x = Post.where("author_id = ?", 1)
+ assert_respond_to x.klass, :find_by_id
+ end
+
+ def test_multivalue_where
+ posts = Post.where("author_id = ? AND id = ?", 1, 1)
+ assert_equal 1, posts.to_a.size
+ end
+
+ def test_scoped
+ topics = Topic.all
+ assert_kind_of ActiveRecord::Relation, topics
+ assert_equal 5, topics.size
+ end
+
+ def test_to_json
+ assert_nothing_raised { Bird.all.to_json }
+ assert_nothing_raised { Bird.all.to_a.to_json }
+ end
+
+ def test_to_yaml
+ assert_nothing_raised { Bird.all.to_yaml }
+ assert_nothing_raised { Bird.all.to_a.to_yaml }
+ end
+
+ def test_to_xml
+ assert_nothing_raised { Bird.all.to_xml }
+ assert_nothing_raised { Bird.all.to_a.to_xml }
+ end
+
+ def test_scoped_all
+ topics = Topic.all.to_a
+ assert_kind_of Array, topics
+ assert_no_queries { assert_equal 5, topics.size }
+ end
+
+ def test_loaded_all
+ topics = Topic.all
+
+ assert_queries(1) do
+ 2.times { assert_equal 5, topics.to_a.size }
+ end
+
+ assert_predicate topics, :loaded?
+ end
+
+ def test_scoped_first
+ topics = Topic.all.order("id ASC")
+
+ assert_queries(1) do
+ 2.times { assert_equal "The First Topic", topics.first.title }
+ end
+
+ assert_not_predicate topics, :loaded?
+ end
+
+ def test_loaded_first
+ topics = Topic.all.order("id ASC")
+ topics.load # force load
+
+ assert_no_queries do
+ assert_equal "The First Topic", topics.first.title
+ end
+
+ assert_predicate topics, :loaded?
+ end
+
+ def test_loaded_first_with_limit
+ topics = Topic.all.order("id ASC")
+ topics.load # force load
+
+ assert_no_queries do
+ assert_equal ["The First Topic",
+ "The Second Topic of the day"], topics.first(2).map(&:title)
+ end
+
+ assert_predicate topics, :loaded?
+ end
+
+ def test_first_get_more_than_available
+ topics = Topic.all.order("id ASC")
+ unloaded_first = topics.first(10)
+ topics.load # force load
+
+ assert_no_queries do
+ loaded_first = topics.first(10)
+ assert_equal unloaded_first, loaded_first
+ end
+ end
+
+ def test_reload
+ topics = Topic.all
+
+ assert_queries(1) do
+ 2.times { topics.to_a }
+ end
+
+ assert_predicate topics, :loaded?
+
+ original_size = topics.to_a.size
+ Topic.create! title: "fake"
+
+ assert_queries(1) { topics.reload }
+ assert_equal original_size + 1, topics.size
+ assert_predicate topics, :loaded?
+ end
+
+ def test_finding_with_subquery
+ relation = Topic.where(approved: true)
+ assert_equal relation.to_a, Topic.select("*").from(relation).to_a
+ assert_equal relation.to_a, Topic.select("subquery.*").from(relation).to_a
+ assert_equal relation.to_a, Topic.select("a.*").from(relation, :a).to_a
+ end
+
+ def test_finding_with_subquery_with_binds
+ relation = Post.first.comments
+ assert_equal relation.to_a, Comment.select("*").from(relation).to_a
+ assert_equal relation.to_a, Comment.select("subquery.*").from(relation).to_a
+ assert_equal relation.to_a, Comment.select("a.*").from(relation, :a).to_a
+ end
+
+ def test_finding_with_subquery_without_select_does_not_change_the_select
+ relation = Topic.where(approved: true)
+ assert_raises(ActiveRecord::StatementInvalid) do
+ Topic.from(relation).to_a
+ end
+ end
+
+ def test_select_with_subquery_in_from_does_not_use_original_table_name
+ relation = Comment.group(:type).select("COUNT(post_id) AS post_count, type")
+ subquery = Comment.from(relation).select("type", "post_count")
+ assert_equal(relation.map(&:post_count).sort, subquery.map(&:post_count).sort)
+ end
+
+ def test_group_with_subquery_in_from_does_not_use_original_table_name
+ relation = Comment.group(:type).select("COUNT(post_id) AS post_count,type")
+ subquery = Comment.from(relation).group("type").average("post_count")
+ assert_equal(relation.map(&:post_count).sort, subquery.values.sort)
+ end
+
+ def test_finding_with_subquery_with_eager_loading_in_from
+ relation = Comment.includes(:post).where("posts.type": "Post")
+ assert_equal relation.to_a, Comment.select("*").from(relation).to_a
+ assert_equal relation.to_a, Comment.select("subquery.*").from(relation).to_a
+ assert_equal relation.to_a, Comment.select("a.*").from(relation, :a).to_a
+ end
+
+ def test_finding_with_subquery_with_eager_loading_in_where
+ relation = Comment.includes(:post).where("posts.type": "Post")
+ assert_equal relation.sort_by(&:id), Comment.where(id: relation).sort_by(&:id)
+ end
+
+ def test_finding_with_conditions
+ assert_equal ["David"], Author.where(name: "David").map(&:name)
+ assert_equal ["Mary"], Author.where(["name = ?", "Mary"]).map(&:name)
+ assert_equal ["Mary"], Author.where("name = ?", "Mary").map(&:name)
+ end
+
+ def test_finding_with_order
+ topics = Topic.order("id")
+ assert_equal 5, topics.to_a.size
+ assert_equal topics(:first).title, topics.first.title
+ end
+
+ def test_finding_with_arel_order
+ topics = Topic.order(Topic.arel_table[:id].asc)
+ assert_equal 5, topics.to_a.size
+ assert_equal topics(:first).title, topics.first.title
+ end
+
+ def test_finding_with_assoc_order
+ topics = Topic.order(id: :desc)
+ assert_equal 5, topics.to_a.size
+ assert_equal topics(:fifth).title, topics.first.title
+ end
+
+ def test_finding_with_arel_assoc_order
+ topics = Topic.order(Arel.sql("id") => :desc)
+ assert_equal 5, topics.to_a.size
+ assert_equal topics(:fifth).title, topics.first.title
+ end
+
+ def test_finding_with_reversed_assoc_order
+ topics = Topic.order(id: :asc).reverse_order
+ assert_equal 5, topics.to_a.size
+ assert_equal topics(:fifth).title, topics.first.title
+ end
+
+ def test_finding_with_reversed_arel_assoc_order
+ topics = Topic.order(Arel.sql("id") => :asc).reverse_order
+ assert_equal 5, topics.to_a.size
+ assert_equal topics(:fifth).title, topics.first.title
+ end
+
+ def test_reverse_order_with_function
+ topics = Topic.order(Arel.sql("length(title)")).reverse_order
+ assert_equal topics(:second).title, topics.first.title
+ end
+
+ def test_reverse_arel_assoc_order_with_function
+ topics = Topic.order(Arel.sql("length(title)") => :asc).reverse_order
+ assert_equal topics(:second).title, topics.first.title
+ end
+
+ def test_reverse_order_with_function_other_predicates
+ topics = Topic.order(Arel.sql("author_name, length(title), id")).reverse_order
+ assert_equal topics(:second).title, topics.first.title
+ topics = Topic.order(Arel.sql("length(author_name), id, length(title)")).reverse_order
+ assert_equal topics(:fifth).title, topics.first.title
+ end
+
+ def test_reverse_order_with_multiargument_function
+ assert_raises(ActiveRecord::IrreversibleOrderError) do
+ Topic.order(Arel.sql("concat(author_name, title)")).reverse_order
+ end
+ assert_raises(ActiveRecord::IrreversibleOrderError) do
+ Topic.order(Arel.sql("concat(lower(author_name), title)")).reverse_order
+ end
+ assert_raises(ActiveRecord::IrreversibleOrderError) do
+ Topic.order(Arel.sql("concat(author_name, lower(title))")).reverse_order
+ end
+ assert_raises(ActiveRecord::IrreversibleOrderError) do
+ Topic.order(Arel.sql("concat(lower(author_name), title, length(title)")).reverse_order
+ end
+ end
+
+ def test_reverse_arel_assoc_order_with_multiargument_function
+ assert_nothing_raised do
+ Topic.order(Arel.sql("REPLACE(title, '', '')") => :asc).reverse_order
+ end
+ end
+
+ def test_reverse_order_with_nulls_first_or_last
+ assert_raises(ActiveRecord::IrreversibleOrderError) do
+ Topic.order(Arel.sql("title NULLS FIRST")).reverse_order
+ end
+ assert_raises(ActiveRecord::IrreversibleOrderError) do
+ Topic.order(Arel.sql("title nulls last")).reverse_order
+ end
+ end
+
+ def test_default_reverse_order_on_table_without_primary_key
+ assert_raises(ActiveRecord::IrreversibleOrderError) do
+ Edge.all.reverse_order
+ end
+ end
+
+ def test_order_with_hash_and_symbol_generates_the_same_sql
+ assert_equal Topic.order(:id).to_sql, Topic.order(id: :asc).to_sql
+ end
+
+ def test_finding_with_desc_order_with_string
+ topics = Topic.order(id: "desc")
+ assert_equal 5, topics.to_a.size
+ assert_equal [topics(:fifth), topics(:fourth), topics(:third), topics(:second), topics(:first)], topics.to_a
+ end
+
+ def test_finding_with_asc_order_with_string
+ topics = Topic.order(id: "asc")
+ assert_equal 5, topics.to_a.size
+ assert_equal [topics(:first), topics(:second), topics(:third), topics(:fourth), topics(:fifth)], topics.to_a
+ end
+
+ def test_support_upper_and_lower_case_directions
+ assert_includes Topic.order(id: "ASC").to_sql, "ASC"
+ assert_includes Topic.order(id: "asc").to_sql, "ASC"
+ assert_includes Topic.order(id: :ASC).to_sql, "ASC"
+ assert_includes Topic.order(id: :asc).to_sql, "ASC"
+
+ assert_includes Topic.order(id: "DESC").to_sql, "DESC"
+ assert_includes Topic.order(id: "desc").to_sql, "DESC"
+ assert_includes Topic.order(id: :DESC).to_sql, "DESC"
+ assert_includes Topic.order(id: :desc).to_sql, "DESC"
+ end
+
+ def test_raising_exception_on_invalid_hash_params
+ e = assert_raise(ArgumentError) { Topic.order(:name, "id DESC", id: :asfsdf) }
+ assert_equal 'Direction "asfsdf" is invalid. Valid directions are: [:asc, :desc, :ASC, :DESC, "asc", "desc", "ASC", "DESC"]', e.message
+ end
+
+ def test_finding_last_with_arel_order
+ topics = Topic.order(Topic.arel_table[:id].asc)
+ assert_equal topics(:fifth).title, topics.last.title
+ end
+
+ def test_finding_with_order_concatenated
+ topics = Topic.order("author_name").order("title")
+ assert_equal 5, topics.to_a.size
+ assert_equal topics(:fourth).title, topics.first.title
+ end
+
+ def test_finding_with_order_by_aliased_attributes
+ topics = Topic.order(:heading)
+ assert_equal 5, topics.to_a.size
+ assert_equal topics(:fifth).title, topics.first.title
+ end
+
+ def test_finding_with_assoc_order_by_aliased_attributes
+ topics = Topic.order(heading: :desc)
+ assert_equal 5, topics.to_a.size
+ assert_equal topics(:third).title, topics.first.title
+ end
+
+ def test_finding_with_reorder
+ topics = Topic.order("author_name").order("title").reorder("id").to_a
+ topics_titles = topics.map(&:title)
+ assert_equal ["The First Topic", "The Second Topic of the day", "The Third Topic of the day", "The Fourth Topic of the day", "The Fifth Topic of the day"], topics_titles
+ end
+
+ def test_finding_with_reorder_by_aliased_attributes
+ topics = Topic.order("author_name").reorder(:heading)
+ assert_equal 5, topics.to_a.size
+ assert_equal topics(:fifth).title, topics.first.title
+ end
+
+ def test_finding_with_assoc_reorder_by_aliased_attributes
+ topics = Topic.order("author_name").reorder(heading: :desc)
+ assert_equal 5, topics.to_a.size
+ assert_equal topics(:third).title, topics.first.title
+ end
+
+ def test_finding_with_order_and_take
+ entrants = Entrant.order("id ASC").limit(2).to_a
+
+ assert_equal 2, entrants.size
+ assert_equal entrants(:first).name, entrants.first.name
+ end
+
+ def test_finding_with_cross_table_order_and_limit
+ tags = Tag.includes(:taggings).
+ order("tags.name asc", "taggings.taggable_id asc", Arel.sql("REPLACE('abc', taggings.taggable_type, taggings.taggable_type)")).
+ limit(1).to_a
+ assert_equal 1, tags.length
+ end
+
+ def test_finding_with_complex_order_and_limit
+ tags = Tag.includes(:taggings).references(:taggings).order(Arel.sql("REPLACE('abc', taggings.taggable_type, taggings.taggable_type)")).limit(1).to_a
+ assert_equal 1, tags.length
+ end
+
+ def test_finding_with_complex_order
+ tags = Tag.includes(:taggings).references(:taggings).order(Arel.sql("REPLACE('abc', taggings.taggable_type, taggings.taggable_type)")).to_a
+ assert_equal 3, tags.length
+ end
+
+ def test_finding_with_sanitized_order
+ query = Tag.order([Arel.sql("field(id, ?)"), [1, 3, 2]]).to_sql
+ assert_match(/field\(id, 1,3,2\)/, query)
+
+ query = Tag.order([Arel.sql("field(id, ?)"), []]).to_sql
+ assert_match(/field\(id, NULL\)/, query)
+
+ query = Tag.order([Arel.sql("field(id, ?)"), nil]).to_sql
+ assert_match(/field\(id, NULL\)/, query)
+ end
+
+ def test_finding_with_order_limit_and_offset
+ entrants = Entrant.order("id ASC").limit(2).offset(1)
+
+ assert_equal 2, entrants.to_a.size
+ assert_equal entrants(:second).name, entrants.first.name
+
+ entrants = Entrant.order("id ASC").limit(2).offset(2)
+ assert_equal 1, entrants.to_a.size
+ assert_equal entrants(:third).name, entrants.first.name
+ end
+
+ def test_finding_with_group
+ developers = Developer.group("salary").select("salary").to_a
+ assert_equal 4, developers.size
+ assert_equal 4, developers.map(&:salary).uniq.size
+ end
+
+ def test_select_with_block
+ even_ids = Developer.all.select { |d| d.id % 2 == 0 }.map(&:id)
+ assert_equal [2, 4, 6, 8, 10], even_ids.sort
+ end
+
+ def test_joins_with_nil_argument
+ assert_nothing_raised { DependentFirm.joins(nil).first }
+ end
+
+ def test_finding_with_hash_conditions_on_joined_table
+ firms = DependentFirm.joins(:account).where(name: "RailsCore", accounts: { credit_limit: 55..60 }).to_a
+ assert_equal 1, firms.size
+ assert_equal companies(:rails_core), firms.first
+ end
+
+ def test_find_all_with_join
+ developers_on_project_one = Developer.joins("LEFT JOIN developers_projects ON developers.id = developers_projects.developer_id").
+ where("project_id=1").to_a
+
+ assert_equal 3, developers_on_project_one.length
+ developer_names = developers_on_project_one.map(&:name)
+ assert_includes developer_names, "David"
+ assert_includes developer_names, "Jamis"
+ end
+
+ def test_find_on_hash_conditions
+ assert_equal Topic.all.merge!(where: { approved: false }).to_a, Topic.where(approved: false).to_a
+ end
+
+ def test_joins_with_string_array
+ person_with_reader_and_post = Post.joins([
+ "INNER JOIN categorizations ON categorizations.post_id = posts.id",
+ "INNER JOIN categories ON categories.id = categorizations.category_id AND categories.type = 'SpecialCategory'"
+ ]
+ ).to_a
+ assert_equal 1, person_with_reader_and_post.size
+ end
+
+ def test_no_arguments_to_query_methods_raise_errors
+ assert_raises(ArgumentError) { Topic.references() }
+ assert_raises(ArgumentError) { Topic.includes() }
+ assert_raises(ArgumentError) { Topic.preload() }
+ assert_raises(ArgumentError) { Topic.group() }
+ assert_raises(ArgumentError) { Topic.reorder() }
+ end
+
+ def test_blank_like_arguments_to_query_methods_dont_raise_errors
+ assert_nothing_raised { Topic.references([]) }
+ assert_nothing_raised { Topic.includes([]) }
+ assert_nothing_raised { Topic.preload([]) }
+ assert_nothing_raised { Topic.group([]) }
+ assert_nothing_raised { Topic.reorder([]) }
+ end
+
+ def test_respond_to_delegates_to_arel
+ relation = Topic.all
+ fake_arel = Struct.new(:responds) {
+ def respond_to?(method, access = false)
+ responds << [method, access]
+ end
+ }.new []
+
+ relation.extend(Module.new { attr_accessor :arel })
+ relation.arel = fake_arel
+
+ relation.respond_to?(:matching_attributes)
+ assert_equal [:matching_attributes, false], fake_arel.responds.first
+ end
+
+ def test_respond_to_dynamic_finders
+ relation = Topic.all
+
+ ["find_by_title", "find_by_title_and_author_name"].each do |method|
+ assert_respond_to relation, method
+ end
+ end
+
+ def test_respond_to_class_methods_and_scopes
+ assert_respond_to Topic.all, :by_lifo
+ end
+
+ def test_find_with_readonly_option
+ Developer.all.each { |d| assert_not d.readonly? }
+ Developer.all.readonly.each { |d| assert d.readonly? }
+ end
+
+ def test_eager_association_loading_of_stis_with_multiple_references
+ authors = Author.eager_load(posts: { special_comments: { post: [ :special_comments, :very_special_comment ] } }).
+ order("comments.body, very_special_comments_posts.body").where("posts.id = 4").to_a
+
+ assert_equal [authors(:david)], authors
+ assert_no_queries do
+ authors.first.posts.first.special_comments.first.post.special_comments
+ authors.first.posts.first.special_comments.first.post.very_special_comment
+ end
+ end
+
+ def test_find_with_preloaded_associations
+ assert_queries(2) do
+ posts = Post.preload(:comments).order("posts.id")
+ assert posts.first.comments.first
+ end
+
+ assert_queries(2) do
+ posts = Post.preload(:comments).order("posts.id")
+ assert posts.first.comments.first
+ end
+
+ assert_queries(2) do
+ posts = Post.preload(:author).order("posts.id")
+ assert posts.first.author
+ end
+
+ assert_queries(2) do
+ posts = Post.preload(:author).order("posts.id")
+ assert posts.first.author
+ end
+
+ assert_queries(3) do
+ posts = Post.preload(:author, :comments).order("posts.id")
+ assert posts.first.author
+ assert posts.first.comments.first
+ end
+ end
+
+ def test_preload_applies_to_all_chained_preloaded_scopes
+ assert_queries(3) do
+ post = Post.with_comments.with_tags.first
+ assert post
+ end
+ end
+
+ def test_find_with_included_associations
+ assert_queries(2) do
+ posts = Post.includes(:comments).order("posts.id")
+ assert posts.first.comments.first
+ end
+
+ assert_queries(2) do
+ posts = Post.all.includes(:comments).order("posts.id")
+ assert posts.first.comments.first
+ end
+
+ assert_queries(2) do
+ posts = Post.includes(:author).order("posts.id")
+ assert posts.first.author
+ end
+
+ assert_queries(3) do
+ posts = Post.includes(:author, :comments).order("posts.id")
+ assert posts.first.author
+ assert posts.first.comments.first
+ end
+ end
+
+ def test_default_scoping_finder_methods
+ developers = DeveloperCalledDavid.order("id").map(&:id).sort
+ assert_equal Developer.where(name: "David").map(&:id).sort, developers
+ end
+
+ def test_includes_with_select
+ query = Post.select("comments_count AS ranking").order("ranking").includes(:comments)
+ .where(comments: { id: 1 })
+
+ assert_equal ["comments_count AS ranking"], query.select_values
+ assert_equal 1, query.to_a.size
+ end
+
+ def test_preloading_with_associations_and_merges
+ post = Post.create! title: "Uhuu", body: "body"
+ reader = Reader.create! post_id: post.id, person_id: 1
+ comment = Comment.create! post_id: post.id, body: "body"
+
+ assert_not_respond_to comment, :readers
+
+ post_rel = Post.preload(:readers).joins(:readers).where(title: "Uhuu")
+ result_comment = Comment.joins(:post).merge(post_rel).to_a.first
+ assert_equal comment, result_comment
+
+ assert_no_queries do
+ assert_equal post, result_comment.post
+ assert_equal [reader], result_comment.post.readers.to_a
+ end
+
+ post_rel = Post.includes(:readers).where(title: "Uhuu")
+ result_comment = Comment.joins(:post).merge(post_rel).first
+ assert_equal comment, result_comment
+
+ assert_no_queries do
+ assert_equal post, result_comment.post
+ assert_equal [reader], result_comment.post.readers.to_a
+ end
+ end
+
+ def test_preloading_with_associations_default_scopes_and_merges
+ post = Post.create! title: "Uhuu", body: "body"
+ reader = Reader.create! post_id: post.id, person_id: 1
+
+ post_rel = PostWithPreloadDefaultScope.preload(:readers).joins(:readers).where(title: "Uhuu")
+ result_post = PostWithPreloadDefaultScope.all.merge(post_rel).to_a.first
+
+ assert_no_queries do
+ assert_equal [reader], result_post.readers.to_a
+ end
+
+ post_rel = PostWithIncludesDefaultScope.includes(:readers).where(title: "Uhuu")
+ result_post = PostWithIncludesDefaultScope.all.merge(post_rel).to_a.first
+
+ assert_no_queries do
+ assert_equal [reader], result_post.readers.to_a
+ end
+ end
+
+ def test_loading_with_one_association
+ posts = Post.preload(:comments)
+ post = posts.find { |p| p.id == 1 }
+ assert_equal 2, post.comments.size
+ assert_includes post.comments, comments(:greetings)
+
+ post = Post.where("posts.title = 'Welcome to the weblog'").preload(:comments).first
+ assert_equal 2, post.comments.size
+ assert_includes post.comments, comments(:greetings)
+
+ posts = Post.preload(:last_comment)
+ post = posts.find { |p| p.id == 1 }
+ assert_equal Post.find(1).last_comment, post.last_comment
+ end
+
+ def test_to_sql_on_eager_join
+ expected = assert_sql {
+ Post.eager_load(:last_comment).order("comments.id DESC").to_a
+ }.first
+ actual = Post.eager_load(:last_comment).order("comments.id DESC").to_sql
+ assert_equal expected, actual
+ end
+
+ def test_to_sql_on_scoped_proxy
+ auth = Author.first
+ Post.where("1=1").written_by(auth)
+ assert_not auth.posts.to_sql.include?("1=1")
+ end
+
+ def test_loading_with_one_association_with_non_preload
+ posts = Post.eager_load(:last_comment).order("comments.id DESC")
+ post = posts.find { |p| p.id == 1 }
+ assert_equal Post.find(1).last_comment, post.last_comment
+ end
+
+ def test_dynamic_find_by_attributes
+ david = authors(:david)
+ author = Author.preload(:taggings).find_by_id(david.id)
+ expected_taggings = taggings(:welcome_general, :thinking_general)
+
+ assert_no_queries do
+ assert_equal expected_taggings, author.taggings.uniq.sort_by(&:id)
+ end
+
+ authors = Author.all
+ assert_equal david, authors.find_by_id_and_name(david.id, david.name)
+ assert_equal david, authors.find_by_id_and_name!(david.id, david.name)
+ end
+
+ def test_dynamic_find_by_attributes_bang
+ author = Author.all.find_by_id!(authors(:david).id)
+ assert_equal "David", author.name
+
+ assert_raises(ActiveRecord::RecordNotFound) { Author.all.find_by_id_and_name!(20, "invalid") }
+ end
+
+ def test_find_id
+ authors = Author.all
+
+ david = authors.find(authors(:david).id)
+ assert_equal "David", david.name
+
+ assert_raises(ActiveRecord::RecordNotFound) { authors.where(name: "lifo").find("42") }
+ end
+
+ def test_find_ids
+ authors = Author.order("id ASC")
+
+ results = authors.find(authors(:david).id, authors(:mary).id)
+ assert_kind_of Array, results
+ assert_equal 2, results.size
+ assert_equal "David", results[0].name
+ assert_equal "Mary", results[1].name
+ assert_equal results, authors.find([authors(:david).id, authors(:mary).id])
+
+ assert_raises(ActiveRecord::RecordNotFound) { authors.where(name: "lifo").find(authors(:david).id, "42") }
+ assert_raises(ActiveRecord::RecordNotFound) { authors.find(["42", 43]) }
+ end
+
+ def test_find_in_empty_array
+ authors = Author.all.where(id: [])
+ assert_predicate authors.to_a, :blank?
+ end
+
+ def test_where_with_ar_object
+ author = Author.first
+ authors = Author.all.where(id: author)
+ assert_equal 1, authors.to_a.length
+ end
+
+ def test_find_with_list_of_ar
+ author = Author.first
+ authors = Author.find([author.id])
+ assert_equal author, authors.first
+ end
+
+ def test_find_by_id_with_list_of_ar
+ author = Author.first
+ authors = Author.find_by_id([author])
+ assert_equal author, authors
+ end
+
+ def test_find_all_using_where_twice_should_or_the_relation
+ david = authors(:david)
+ relation = Author.unscoped
+ relation = relation.where(name: david.name)
+ relation = relation.where(name: "Santiago")
+ relation = relation.where(id: david.id)
+ assert_equal [], relation.to_a
+ end
+
+ def test_multi_where_ands_queries
+ relation = Author.unscoped
+ david = authors(:david)
+ sql = relation.where(name: david.name).where(name: "Santiago").to_sql
+ assert_match("AND", sql)
+ end
+
+ def test_find_all_with_multiple_should_use_and
+ david = authors(:david)
+ relation = [
+ { name: david.name },
+ { name: "Santiago" },
+ { name: "tenderlove" },
+ ].inject(Author.unscoped) do |memo, param|
+ memo.where(param)
+ end
+ assert_equal [], relation.to_a
+ end
+
+ def test_typecasting_where_with_array
+ ids = Author.pluck(:id)
+ slugs = ids.map { |id| "#{id}-as-a-slug" }
+
+ assert_equal Author.all.to_a, Author.where(id: slugs).to_a
+ end
+
+ def test_find_all_using_where_with_relation
+ david = authors(:david)
+ assert_queries(1) {
+ relation = Author.where(id: Author.where(id: david.id))
+ assert_equal [david], relation.to_a
+ }
+
+ assert_queries(1) {
+ relation = Author.where("id in (?)", Author.where(id: david).select(:id))
+ assert_equal [david], relation.to_a
+ }
+
+ assert_queries(1) do
+ relation = Author.where("id in (:author_ids)", author_ids: Author.where(id: david).select(:id))
+ assert_equal [david], relation.to_a
+ end
+ end
+
+ def test_find_all_using_where_with_relation_with_bound_values
+ david = authors(:david)
+ davids_posts = david.posts.order(:id).to_a
+
+ assert_queries(1) do
+ relation = Post.where(id: david.posts.select(:id))
+ assert_equal davids_posts, relation.order(:id).to_a
+ end
+
+ assert_queries(1) do
+ relation = Post.where("id in (?)", david.posts.select(:id))
+ assert_equal davids_posts, relation.order(:id).to_a, "should process Relation as bind variables"
+ end
+
+ assert_queries(1) do
+ relation = Post.where("id in (:post_ids)", post_ids: david.posts.select(:id))
+ assert_equal davids_posts, relation.order(:id).to_a, "should process Relation as named bind variables"
+ end
+ end
+
+ def test_find_all_using_where_with_relation_and_alternate_primary_key
+ cool_first = minivans(:cool_first)
+ assert_queries(1) {
+ relation = Minivan.where(minivan_id: Minivan.where(name: cool_first.name))
+ assert_equal [cool_first], relation.to_a
+ }
+ end
+
+ def test_find_all_using_where_with_relation_does_not_alter_select_values
+ david = authors(:david)
+
+ subquery = Author.where(id: david.id)
+
+ assert_queries(1) {
+ relation = Author.where(id: subquery)
+ assert_equal [david], relation.to_a
+ }
+
+ assert_equal 0, subquery.select_values.size
+ end
+
+ def test_find_all_using_where_with_relation_with_joins
+ david = authors(:david)
+ assert_queries(1) {
+ relation = Author.where(id: Author.joins(:posts).where(id: david.id))
+ assert_equal [david], relation.to_a
+ }
+ end
+
+ def test_find_all_using_where_with_relation_with_select_to_build_subquery
+ david = authors(:david)
+ assert_queries(1) {
+ relation = Author.where(name: Author.where(id: david.id).select(:name))
+ assert_equal [david], relation.to_a
+ }
+ end
+
+ def test_last
+ authors = Author.all
+ assert_equal authors(:bob), authors.last
+ end
+
+ def test_select_with_aggregates
+ posts = Post.select(:title, :body)
+
+ assert_equal 11, posts.count(:all)
+ assert_equal 11, posts.size
+ assert_predicate posts, :any?
+ assert_predicate posts, :many?
+ assert_not_empty posts
+ end
+
+ def test_select_takes_a_variable_list_of_args
+ david = developers(:david)
+
+ developer = Developer.where(id: david.id).select(:name, :salary).first
+ assert_equal david.name, developer.name
+ assert_equal david.salary, developer.salary
+ end
+
+ def test_select_takes_an_aliased_attribute
+ first = topics(:first)
+
+ topic = Topic.where(id: first.id).select(:heading).first
+ assert_equal first.heading, topic.heading
+ end
+
+ def test_select_argument_error
+ assert_raises(ArgumentError) { Developer.select }
+ end
+
+ def test_count
+ posts = Post.all
+
+ assert_equal 11, posts.count
+ assert_equal 11, posts.count(:all)
+ assert_equal 11, posts.count(:id)
+
+ assert_equal 3, posts.where("comments_count > 1").count
+ assert_equal 6, posts.where(comments_count: 0).count
+ end
+
+ def test_count_with_block
+ posts = Post.all
+ assert_equal 8, posts.count { |p| p.comments_count.even? }
+ end
+
+ def test_count_on_association_relation
+ author = Author.last
+ another_author = Author.first
+ posts = Post.where(author_id: author.id)
+
+ assert_equal author.posts.where(author_id: author.id).size, posts.count
+
+ assert_equal 0, author.posts.where(author_id: another_author.id).size
+ assert_empty author.posts.where(author_id: another_author.id)
+ end
+
+ def test_count_with_distinct
+ posts = Post.all
+
+ assert_equal 4, posts.distinct(true).count(:comments_count)
+ assert_equal 11, posts.distinct(false).count(:comments_count)
+
+ assert_equal 4, posts.distinct(true).select(:comments_count).count
+ assert_equal 11, posts.distinct(false).select(:comments_count).count
+ end
+
+ def test_size_with_distinct
+ posts = Post.distinct.select(:author_id, :comments_count)
+ assert_queries(1) { assert_equal 8, posts.size }
+ assert_queries(1) { assert_equal 8, posts.load.size }
+ end
+
+ def test_size_with_eager_loading_and_custom_order
+ posts = Post.includes(:comments).order("comments.id")
+ assert_queries(1) { assert_equal 11, posts.size }
+ assert_queries(1) { assert_equal 11, posts.load.size }
+ end
+
+ def test_size_with_eager_loading_and_custom_order_and_distinct
+ posts = Post.includes(:comments).order("comments.id").distinct
+ assert_queries(1) { assert_equal 11, posts.size }
+ assert_queries(1) { assert_equal 11, posts.load.size }
+ end
+
+ def test_count_explicit_columns
+ Post.update_all(comments_count: nil)
+ posts = Post.all
+
+ assert_equal [0], posts.select("comments_count").where("id is not null").group("id").order("id").count.values.uniq
+ assert_equal 0, posts.where("id is not null").select("comments_count").count
+
+ assert_equal 11, posts.select("comments_count").count("id")
+ assert_equal 0, posts.select("comments_count").count
+ assert_equal 0, posts.count(:comments_count)
+ assert_equal 0, posts.count("comments_count")
+ end
+
+ def test_multiple_selects
+ post = Post.all.select("comments_count").select("title").order("id ASC").first
+ assert_equal "Welcome to the weblog", post.title
+ assert_equal 2, post.comments_count
+ end
+
+ def test_size
+ posts = Post.all
+
+ assert_queries(1) { assert_equal 11, posts.size }
+ assert_not_predicate posts, :loaded?
+
+ best_posts = posts.where(comments_count: 0)
+ best_posts.load # force load
+ assert_no_queries { assert_equal 6, best_posts.size }
+ end
+
+ def test_size_with_limit
+ posts = Post.limit(10)
+
+ assert_queries(1) { assert_equal 10, posts.size }
+ assert_not_predicate posts, :loaded?
+
+ best_posts = posts.where(comments_count: 0)
+ best_posts.load # force load
+ assert_no_queries { assert_equal 6, best_posts.size }
+ end
+
+ def test_size_with_zero_limit
+ posts = Post.limit(0)
+
+ assert_no_queries { assert_equal 0, posts.size }
+ assert_not_predicate posts, :loaded?
+
+ posts.load # force load
+ assert_no_queries { assert_equal 0, posts.size }
+ end
+
+ def test_empty_with_zero_limit
+ posts = Post.limit(0)
+
+ assert_no_queries { assert_equal true, posts.empty? }
+ assert_not_predicate posts, :loaded?
+ end
+
+ def test_count_complex_chained_relations
+ posts = Post.select("comments_count").where("id is not null").group("author_id").where("comments_count > 0")
+
+ expected = { 1 => 4, 2 => 1 }
+ assert_equal expected, posts.count
+ end
+
+ def test_empty
+ posts = Post.all
+
+ assert_queries(1) { assert_equal false, posts.empty? }
+ assert_not_predicate posts, :loaded?
+
+ no_posts = posts.where(title: "")
+ assert_queries(1) { assert_equal true, no_posts.empty? }
+ assert_not_predicate no_posts, :loaded?
+
+ best_posts = posts.where(comments_count: 0)
+ best_posts.load # force load
+ assert_no_queries { assert_equal false, best_posts.empty? }
+ end
+
+ def test_empty_complex_chained_relations
+ posts = Post.select("comments_count").where("id is not null").group("author_id").where("comments_count > 0")
+
+ assert_queries(1) { assert_equal false, posts.empty? }
+ assert_not_predicate posts, :loaded?
+
+ no_posts = posts.where(title: "")
+ assert_queries(1) { assert_equal true, no_posts.empty? }
+ assert_not_predicate no_posts, :loaded?
+ end
+
+ def test_any
+ posts = Post.all
+
+ # This test was failing when run on its own (as opposed to running the entire suite).
+ # The second line in the assert_queries block was causing visit_Arel_Attributes_Attribute
+ # in Arel::Visitors::ToSql to trigger a SHOW TABLES query. Running that line here causes
+ # the SHOW TABLES result to be cached so we don't have to do it again in the block.
+ #
+ # This is obviously a rubbish fix but it's the best I can come up with for now...
+ posts.where(id: nil).any?
+
+ assert_queries(3) do
+ assert posts.any? # Uses COUNT()
+ assert_not_predicate posts.where(id: nil), :any?
+
+ assert posts.any? { |p| p.id > 0 }
+ assert_not posts.any? { |p| p.id <= 0 }
+ end
+
+ assert_predicate posts, :loaded?
+ end
+
+ def test_many
+ posts = Post.all
+
+ assert_queries(2) do
+ assert posts.many? # Uses COUNT()
+ assert posts.many? { |p| p.id > 0 }
+ assert_not posts.many? { |p| p.id < 2 }
+ end
+
+ assert_predicate posts, :loaded?
+ end
+
+ def test_many_with_limits
+ posts = Post.all
+
+ assert_predicate posts, :many?
+ assert_not_predicate posts.limit(1), :many?
+ end
+
+ def test_none?
+ posts = Post.all
+ assert_queries(1) do
+ assert_not posts.none? # Uses COUNT()
+ end
+
+ assert_not_predicate posts, :loaded?
+
+ assert_queries(1) do
+ assert posts.none? { |p| p.id < 0 }
+ assert_not posts.none? { |p| p.id == 1 }
+ end
+
+ assert_predicate posts, :loaded?
+ end
+
+ def test_one
+ posts = Post.all
+ assert_queries(1) do
+ assert_not posts.one? # Uses COUNT()
+ end
+
+ assert_not_predicate posts, :loaded?
+
+ assert_queries(1) do
+ assert_not posts.one? { |p| p.id < 3 }
+ assert posts.one? { |p| p.id == 1 }
+ end
+
+ assert_predicate posts, :loaded?
+ end
+
+ def test_to_a_should_dup_target
+ posts = Post.all
+
+ original_size = posts.size
+ removed = posts.to_a.pop
+
+ assert_equal original_size, posts.size
+ assert_includes posts.to_a, removed
+ end
+
+ def test_build
+ posts = Post.all
+
+ post = posts.new
+ assert_kind_of Post, post
+ end
+
+ def test_scoped_build
+ posts = Post.where(title: "You told a lie")
+
+ post = posts.new
+ assert_kind_of Post, post
+ assert_equal "You told a lie", post.title
+ end
+
+ def test_create
+ birds = Bird.all
+
+ sparrow = birds.create
+ assert_kind_of Bird, sparrow
+ assert_not_predicate sparrow, :persisted?
+
+ hen = birds.where(name: "hen").create
+ assert_predicate hen, :persisted?
+ assert_equal "hen", hen.name
+ end
+
+ def test_create_bang
+ birds = Bird.all
+
+ assert_raises(ActiveRecord::RecordInvalid) { birds.create! }
+
+ hen = birds.where(name: "hen").create!
+ assert_kind_of Bird, hen
+ assert_predicate hen, :persisted?
+ assert_equal "hen", hen.name
+ end
+
+ def test_create_with_polymorphic_association
+ author = authors(:david)
+ post = posts(:welcome)
+ comment = Comment.where(post: post, author: author).create!(body: "hello")
+
+ assert_equal author, comment.author
+ assert_equal post, comment.post
+ end
+
+ def test_first_or_create
+ parrot = Bird.where(color: "green").first_or_create(name: "parrot")
+ assert_kind_of Bird, parrot
+ assert_predicate parrot, :persisted?
+ assert_equal "parrot", parrot.name
+ assert_equal "green", parrot.color
+
+ same_parrot = Bird.where(color: "green").first_or_create(name: "parakeet")
+ assert_kind_of Bird, same_parrot
+ assert_predicate same_parrot, :persisted?
+ assert_equal parrot, same_parrot
+ end
+
+ def test_first_or_create_with_no_parameters
+ parrot = Bird.where(color: "green").first_or_create
+ assert_kind_of Bird, parrot
+ assert_not_predicate parrot, :persisted?
+ assert_equal "green", parrot.color
+ end
+
+ def test_first_or_create_with_block
+ parrot = Bird.where(color: "green").first_or_create { |bird| bird.name = "parrot" }
+ assert_kind_of Bird, parrot
+ assert_predicate parrot, :persisted?
+ assert_equal "green", parrot.color
+ assert_equal "parrot", parrot.name
+
+ same_parrot = Bird.where(color: "green").first_or_create { |bird| bird.name = "parakeet" }
+ assert_equal parrot, same_parrot
+ end
+
+ def test_first_or_create_with_array
+ several_green_birds = Bird.where(color: "green").first_or_create([{ name: "parrot" }, { name: "parakeet" }])
+ assert_kind_of Array, several_green_birds
+ several_green_birds.each { |bird| assert bird.persisted? }
+
+ same_parrot = Bird.where(color: "green").first_or_create([{ name: "hummingbird" }, { name: "macaw" }])
+ assert_kind_of Bird, same_parrot
+ assert_equal several_green_birds.first, same_parrot
+ end
+
+ def test_first_or_create_bang_with_valid_options
+ parrot = Bird.where(color: "green").first_or_create!(name: "parrot")
+ assert_kind_of Bird, parrot
+ assert_predicate parrot, :persisted?
+ assert_equal "parrot", parrot.name
+ assert_equal "green", parrot.color
+
+ same_parrot = Bird.where(color: "green").first_or_create!(name: "parakeet")
+ assert_kind_of Bird, same_parrot
+ assert_predicate same_parrot, :persisted?
+ assert_equal parrot, same_parrot
+ end
+
+ def test_first_or_create_bang_with_invalid_options
+ assert_raises(ActiveRecord::RecordInvalid) { Bird.where(color: "green").first_or_create!(pirate_id: 1) }
+ end
+
+ def test_first_or_create_bang_with_no_parameters
+ assert_raises(ActiveRecord::RecordInvalid) { Bird.where(color: "green").first_or_create! }
+ end
+
+ def test_first_or_create_bang_with_valid_block
+ parrot = Bird.where(color: "green").first_or_create! { |bird| bird.name = "parrot" }
+ assert_kind_of Bird, parrot
+ assert_predicate parrot, :persisted?
+ assert_equal "green", parrot.color
+ assert_equal "parrot", parrot.name
+
+ same_parrot = Bird.where(color: "green").first_or_create! { |bird| bird.name = "parakeet" }
+ assert_equal parrot, same_parrot
+ end
+
+ def test_first_or_create_bang_with_invalid_block
+ assert_raise(ActiveRecord::RecordInvalid) do
+ Bird.where(color: "green").first_or_create! { |bird| bird.pirate_id = 1 }
+ end
+ end
+
+ def test_first_or_create_with_valid_array
+ several_green_birds = Bird.where(color: "green").first_or_create!([{ name: "parrot" }, { name: "parakeet" }])
+ assert_kind_of Array, several_green_birds
+ several_green_birds.each { |bird| assert bird.persisted? }
+
+ same_parrot = Bird.where(color: "green").first_or_create!([{ name: "hummingbird" }, { name: "macaw" }])
+ assert_kind_of Bird, same_parrot
+ assert_equal several_green_birds.first, same_parrot
+ end
+
+ def test_first_or_create_with_invalid_array
+ assert_raises(ActiveRecord::RecordInvalid) { Bird.where(color: "green").first_or_create!([ { name: "parrot" }, { pirate_id: 1 } ]) }
+ end
+
+ def test_first_or_initialize
+ parrot = Bird.where(color: "green").first_or_initialize(name: "parrot")
+ assert_kind_of Bird, parrot
+ assert_not_predicate parrot, :persisted?
+ assert_predicate parrot, :valid?
+ assert_predicate parrot, :new_record?
+ assert_equal "parrot", parrot.name
+ assert_equal "green", parrot.color
+ end
+
+ def test_first_or_initialize_with_no_parameters
+ parrot = Bird.where(color: "green").first_or_initialize
+ assert_kind_of Bird, parrot
+ assert_not_predicate parrot, :persisted?
+ assert_not_predicate parrot, :valid?
+ assert_predicate parrot, :new_record?
+ assert_equal "green", parrot.color
+ end
+
+ def test_first_or_initialize_with_block
+ parrot = Bird.where(color: "green").first_or_initialize { |bird| bird.name = "parrot" }
+ assert_kind_of Bird, parrot
+ assert_not_predicate parrot, :persisted?
+ assert_predicate parrot, :valid?
+ assert_predicate parrot, :new_record?
+ assert_equal "green", parrot.color
+ assert_equal "parrot", parrot.name
+ end
+
+ def test_find_or_create_by
+ assert_nil Bird.find_by(name: "bob")
+
+ bird = Bird.find_or_create_by(name: "bob")
+ assert_predicate bird, :persisted?
+
+ assert_equal bird, Bird.find_or_create_by(name: "bob")
+ end
+
+ def test_find_or_create_by_with_create_with
+ assert_nil Bird.find_by(name: "bob")
+
+ bird = Bird.create_with(color: "green").find_or_create_by(name: "bob")
+ assert_predicate bird, :persisted?
+ assert_equal "green", bird.color
+
+ assert_equal bird, Bird.create_with(color: "blue").find_or_create_by(name: "bob")
+ end
+
+ def test_find_or_create_by!
+ assert_raises(ActiveRecord::RecordInvalid) { Bird.find_or_create_by!(color: "green") }
+ end
+
+ def test_create_or_find_by
+ assert_nil Subscriber.find_by(nick: "bob")
+
+ subscriber = Subscriber.create!(nick: "bob")
+
+ assert_equal subscriber, Subscriber.create_or_find_by(nick: "bob")
+ assert_not_equal subscriber, Subscriber.create_or_find_by(nick: "cat")
+ end
+
+ def test_create_or_find_by_should_not_raise_due_to_validation_errors
+ assert_nothing_raised do
+ bird = Bird.create_or_find_by(color: "green")
+ assert_predicate bird, :invalid?
+ end
+ end
+
+ def test_create_or_find_by_with_non_unique_attributes
+ Subscriber.create!(nick: "bob", name: "the builder")
+
+ assert_raises(ActiveRecord::RecordNotFound) do
+ Subscriber.create_or_find_by(nick: "bob", name: "the cat")
+ end
+ end
+
+ def test_create_or_find_by_within_transaction
+ assert_nil Subscriber.find_by(nick: "bob")
+
+ subscriber = Subscriber.create!(nick: "bob")
+
+ Subscriber.transaction do
+ assert_equal subscriber, Subscriber.create_or_find_by(nick: "bob")
+ assert_not_equal subscriber, Subscriber.create_or_find_by(nick: "cat")
+ end
+ end
+
+ def test_create_or_find_by_with_bang
+ assert_nil Subscriber.find_by(nick: "bob")
+
+ subscriber = Subscriber.create!(nick: "bob")
+
+ assert_equal subscriber, Subscriber.create_or_find_by!(nick: "bob")
+ assert_not_equal subscriber, Subscriber.create_or_find_by!(nick: "cat")
+ end
+
+ def test_create_or_find_by_with_bang_should_raise_due_to_validation_errors
+ assert_raises(ActiveRecord::RecordInvalid) { Bird.create_or_find_by!(color: "green") }
+ end
+
+ def test_create_or_find_by_with_bang_with_non_unique_attributes
+ Subscriber.create!(nick: "bob", name: "the builder")
+
+ assert_raises(ActiveRecord::RecordNotFound) do
+ Subscriber.create_or_find_by!(nick: "bob", name: "the cat")
+ end
+ end
+
+ def test_create_or_find_by_with_bang_within_transaction
+ assert_nil Subscriber.find_by(nick: "bob")
+
+ subscriber = Subscriber.create!(nick: "bob")
+
+ Subscriber.transaction do
+ assert_equal subscriber, Subscriber.create_or_find_by!(nick: "bob")
+ assert_not_equal subscriber, Subscriber.create_or_find_by!(nick: "cat")
+ end
+ end
+
+ def test_find_or_initialize_by
+ assert_nil Bird.find_by(name: "bob")
+
+ bird = Bird.find_or_initialize_by(name: "bob")
+ assert_predicate bird, :new_record?
+ bird.save!
+
+ assert_equal bird, Bird.find_or_initialize_by(name: "bob")
+ end
+
+ def test_explicit_create_with
+ hens = Bird.where(name: "hen")
+ assert_equal "hen", hens.new.name
+
+ hens = hens.create_with(name: "cock")
+ assert_equal "cock", hens.new.name
+ end
+
+ def test_create_with_nested_attributes
+ assert_difference("Project.count", 1) do
+ developers = Developer.where(name: "Aaron")
+ developers = developers.create_with(
+ projects_attributes: [{ name: "p1" }]
+ )
+ developers.create!
+ end
+ end
+
+ def test_except
+ relation = Post.where(author_id: 1).order("id ASC").limit(1)
+ assert_equal [posts(:welcome)], relation.to_a
+
+ author_posts = relation.except(:order, :limit)
+ assert_equal Post.where(author_id: 1).to_a, author_posts.to_a
+
+ all_posts = relation.except(:where, :order, :limit)
+ assert_equal Post.all, all_posts
+ end
+
+ def test_only
+ relation = Post.where(author_id: 1).order("id ASC").limit(1)
+ assert_equal [posts(:welcome)], relation.to_a
+
+ author_posts = relation.only(:where)
+ assert_equal Post.where(author_id: 1).to_a, author_posts.to_a
+
+ all_posts = relation.only(:limit)
+ assert_equal Post.limit(1).to_a, all_posts.to_a
+ end
+
+ def test_anonymous_extension
+ relation = Post.where(author_id: 1).order("id ASC").extending do
+ def author
+ "lifo"
+ end
+ end
+
+ assert_equal "lifo", relation.author
+ assert_equal "lifo", relation.limit(1).author
+ end
+
+ def test_named_extension
+ relation = Post.where(author_id: 1).order("id ASC").extending(Post::NamedExtension)
+ assert_equal "lifo", relation.author
+ assert_equal "lifo", relation.limit(1).author
+ end
+
+ def test_order_by_relation_attribute
+ assert_equal Post.order(Post.arel_table[:title]).to_a, Post.order("title").to_a
+ end
+
+ def test_default_scope_order_with_scope_order
+ assert_equal "zyke", CoolCar.order_using_new_style.limit(1).first.name
+ assert_equal "zyke", FastCar.order_using_new_style.limit(1).first.name
+ end
+
+ def test_order_using_scoping
+ car1 = CoolCar.order("id DESC").scoping do
+ CoolCar.all.merge!(order: "id asc").first
+ end
+ assert_equal "zyke", car1.name
+
+ car2 = FastCar.order("id DESC").scoping do
+ FastCar.all.merge!(order: "id asc").first
+ end
+ assert_equal "zyke", car2.name
+ end
+
+ def test_unscoped_block_style
+ assert_equal "honda", CoolCar.unscoped { CoolCar.order_using_new_style.limit(1).first.name }
+ assert_equal "honda", FastCar.unscoped { FastCar.order_using_new_style.limit(1).first.name }
+ end
+
+ def test_intersection_with_array
+ relation = Author.where(name: "David")
+ rails_author = relation.first
+
+ assert_equal [rails_author], [rails_author] & relation
+ assert_equal [rails_author], relation & [rails_author]
+ end
+
+ def test_primary_key
+ assert_equal "id", Post.all.primary_key
+ end
+
+ def test_ordering_with_extra_spaces
+ assert_equal authors(:david), Author.order("id DESC , name DESC").last
+ end
+
+ def test_distinct
+ tag1 = Tag.create(name: "Foo")
+ tag2 = Tag.create(name: "Foo")
+
+ query = Tag.select(:name).where(id: [tag1.id, tag2.id])
+
+ assert_equal ["Foo", "Foo"], query.map(&:name)
+ assert_sql(/DISTINCT/) do
+ assert_equal ["Foo"], query.distinct.map(&:name)
+ end
+ assert_sql(/DISTINCT/) do
+ assert_equal ["Foo"], query.distinct(true).map(&:name)
+ end
+ assert_equal ["Foo", "Foo"], query.distinct(true).distinct(false).map(&:name)
+ end
+
+ def test_doesnt_add_having_values_if_options_are_blank
+ scope = Post.having("")
+ assert_empty scope.having_clause
+
+ scope = Post.having([])
+ assert_empty scope.having_clause
+ end
+
+ def test_having_with_binds_for_both_where_and_having
+ post = Post.first
+ having_then_where = Post.having(id: post.id).where(title: post.title).group(:id)
+ where_then_having = Post.where(title: post.title).having(id: post.id).group(:id)
+
+ assert_equal [post], having_then_where
+ assert_equal [post], where_then_having
+ end
+
+ def test_multiple_where_and_having_clauses
+ post = Post.first
+ having_then_where = Post.having(id: post.id).where(title: post.title)
+ .having(id: post.id).where(title: post.title).group(:id)
+
+ assert_equal [post], having_then_where
+ end
+
+ def test_grouping_by_column_with_reserved_name
+ assert_equal [], Possession.select(:where).group(:where).to_a
+ end
+
+ def test_references_triggers_eager_loading
+ scope = Post.includes(:comments)
+ assert_not_predicate scope, :eager_loading?
+ assert_predicate scope.references(:comments), :eager_loading?
+ end
+
+ def test_references_doesnt_trigger_eager_loading_if_reference_not_included
+ scope = Post.references(:comments)
+ assert_not_predicate scope, :eager_loading?
+ end
+
+ def test_automatically_added_where_references
+ scope = Post.where(comments: { body: "Bla" })
+ assert_equal ["comments"], scope.references_values
+
+ scope = Post.where("comments.body" => "Bla")
+ assert_equal ["comments"], scope.references_values
+ end
+
+ def test_automatically_added_where_not_references
+ scope = Post.where.not(comments: { body: "Bla" })
+ assert_equal ["comments"], scope.references_values
+
+ scope = Post.where.not("comments.body" => "Bla")
+ assert_equal ["comments"], scope.references_values
+ end
+
+ def test_automatically_added_having_references
+ scope = Post.having(comments: { body: "Bla" })
+ assert_equal ["comments"], scope.references_values
+
+ scope = Post.having("comments.body" => "Bla")
+ assert_equal ["comments"], scope.references_values
+ end
+
+ def test_automatically_added_order_references
+ scope = Post.order("comments.body")
+ assert_equal ["comments"], scope.references_values
+
+ scope = Post.order(Arel.sql("#{Comment.quoted_table_name}.#{Comment.quoted_primary_key}"))
+ if current_adapter?(:OracleAdapter)
+ assert_equal ["COMMENTS"], scope.references_values
+ else
+ assert_equal ["comments"], scope.references_values
+ end
+
+ scope = Post.order("comments.body", "yaks.body")
+ assert_equal ["comments", "yaks"], scope.references_values
+
+ # Don't infer yaks, let's not go down that road again...
+ scope = Post.order("comments.body, yaks.body")
+ assert_equal ["comments"], scope.references_values
+
+ scope = Post.order("comments.body asc")
+ assert_equal ["comments"], scope.references_values
+
+ scope = Post.order(Arel.sql("foo(comments.body)"))
+ assert_equal [], scope.references_values
+ end
+
+ def test_automatically_added_reorder_references
+ scope = Post.reorder("comments.body")
+ assert_equal %w(comments), scope.references_values
+
+ scope = Post.reorder(Arel.sql("#{Comment.quoted_table_name}.#{Comment.quoted_primary_key}"))
+ if current_adapter?(:OracleAdapter)
+ assert_equal ["COMMENTS"], scope.references_values
+ else
+ assert_equal ["comments"], scope.references_values
+ end
+
+ scope = Post.reorder("comments.body", "yaks.body")
+ assert_equal %w(comments yaks), scope.references_values
+
+ # Don't infer yaks, let's not go down that road again...
+ scope = Post.reorder("comments.body, yaks.body")
+ assert_equal %w(comments), scope.references_values
+
+ scope = Post.reorder("comments.body asc")
+ assert_equal %w(comments), scope.references_values
+
+ scope = Post.reorder(Arel.sql("foo(comments.body)"))
+ assert_equal [], scope.references_values
+ end
+
+ def test_order_with_reorder_nil_removes_the_order
+ relation = Post.order(:title).reorder(nil)
+
+ assert_nil relation.order_values.first
+ end
+
+ def test_reverse_order_with_reorder_nil_removes_the_order
+ relation = Post.order(:title).reverse_order.reorder(nil)
+
+ assert_nil relation.order_values.first
+ end
+
+ def test_presence
+ topics = Topic.all
+
+ # the first query is triggered because there are no topics yet.
+ assert_queries(1) { assert topics.present? }
+
+ # checking if there are topics is used before you actually display them,
+ # thus it shouldn't invoke an extra count query.
+ assert_no_queries { assert topics.present? }
+ assert_no_queries { assert_not topics.blank? }
+
+ # shows count of topics and loops after loading the query should not trigger extra queries either.
+ assert_no_queries { topics.size }
+ assert_no_queries { topics.length }
+ assert_no_queries { topics.each }
+
+ # count always trigger the COUNT query.
+ assert_queries(1) { topics.count }
+
+ assert_predicate topics, :loaded?
+ end
+
+ test "find_by with hash conditions returns the first matching record" do
+ assert_equal posts(:eager_other), Post.order(:id).find_by(author_id: 2)
+ end
+
+ test "find_by with non-hash conditions returns the first matching record" do
+ assert_equal posts(:eager_other), Post.order(:id).find_by("author_id = 2")
+ end
+
+ test "find_by with multi-arg conditions returns the first matching record" do
+ assert_equal posts(:eager_other), Post.order(:id).find_by("author_id = ?", 2)
+ end
+
+ test "find_by returns nil if the record is missing" do
+ assert_nil Post.all.find_by("1 = 0")
+ end
+
+ test "find_by doesn't have implicit ordering" do
+ assert_sql(/^((?!ORDER).)*$/) { Post.all.find_by(author_id: 2) }
+ end
+
+ test "find_by requires at least one argument" do
+ assert_raises(ArgumentError) { Post.all.find_by }
+ end
+
+ test "find_by! with hash conditions returns the first matching record" do
+ assert_equal posts(:eager_other), Post.order(:id).find_by!(author_id: 2)
+ end
+
+ test "find_by! with non-hash conditions returns the first matching record" do
+ assert_equal posts(:eager_other), Post.order(:id).find_by!("author_id = 2")
+ end
+
+ test "find_by! with multi-arg conditions returns the first matching record" do
+ assert_equal posts(:eager_other), Post.order(:id).find_by!("author_id = ?", 2)
+ end
+
+ test "find_by! doesn't have implicit ordering" do
+ assert_sql(/^((?!ORDER).)*$/) { Post.all.find_by!(author_id: 2) }
+ end
+
+ test "find_by! raises RecordNotFound if the record is missing" do
+ assert_raises(ActiveRecord::RecordNotFound) do
+ Post.all.find_by!("1 = 0")
+ end
+ end
+
+ test "find_by! requires at least one argument" do
+ assert_raises(ArgumentError) { Post.all.find_by! }
+ end
+
+ test "loaded relations cannot be mutated by multi value methods" do
+ relation = Post.all
+ relation.to_a
+
+ assert_raises(ActiveRecord::ImmutableRelation) do
+ relation.where! "foo"
+ end
+ end
+
+ test "loaded relations cannot be mutated by single value methods" do
+ relation = Post.all
+ relation.to_a
+
+ assert_raises(ActiveRecord::ImmutableRelation) do
+ relation.limit! 5
+ end
+ end
+
+ test "loaded relations cannot be mutated by merge!" do
+ relation = Post.all
+ relation.to_a
+
+ assert_raises(ActiveRecord::ImmutableRelation) do
+ relation.merge! where: "foo"
+ end
+ end
+
+ test "loaded relations cannot be mutated by extending!" do
+ relation = Post.all
+ relation.to_a
+
+ assert_raises(ActiveRecord::ImmutableRelation) do
+ relation.extending! Module.new
+ end
+ end
+
+ test "relations with cached arel can't be mutated [internal API]" do
+ relation = Post.all
+ relation.arel
+
+ assert_raises(ActiveRecord::ImmutableRelation) { relation.limit!(5) }
+ assert_raises(ActiveRecord::ImmutableRelation) { relation.where!("1 = 2") }
+ end
+
+ test "relations show the records in #inspect" do
+ relation = Post.limit(2)
+ assert_equal "#<ActiveRecord::Relation [#{Post.limit(2).map(&:inspect).join(', ')}]>", relation.inspect
+ end
+
+ test "relations limit the records in #inspect at 10" do
+ relation = Post.limit(11)
+ assert_equal "#<ActiveRecord::Relation [#{Post.limit(10).map(&:inspect).join(', ')}, ...]>", relation.inspect
+ end
+
+ test "relations don't load all records in #inspect" do
+ assert_sql(/LIMIT|ROWNUM <=|FETCH FIRST/) do
+ Post.all.inspect
+ end
+ end
+
+ test "already-loaded relations don't perform a new query in #inspect" do
+ relation = Post.limit(2)
+ relation.to_a
+
+ expected = "#<ActiveRecord::Relation [#{Post.limit(2).map(&:inspect).join(', ')}]>"
+
+ assert_no_queries do
+ assert_equal expected, relation.inspect
+ end
+ end
+
+ test "using a custom table affects the wheres" do
+ post = posts(:welcome)
+
+ assert_equal post, custom_post_relation.where!(title: post.title).take
+ end
+
+ test "using a custom table with joins affects the joins" do
+ post = posts(:welcome)
+
+ assert_equal post, custom_post_relation.joins(:author).where!(title: post.title).take
+ end
+
+ test "arel_attribute respects a custom table" do
+ assert_equal [posts(:sti_comments)], custom_post_relation.ranked_by_comments.limit_by(1).to_a
+ end
+
+ test "alias_tracker respects a custom table" do
+ assert_equal posts(:welcome), custom_post_relation("categories_posts").joins(:categories).first
+ end
+
+ test "#load" do
+ relation = Post.all
+ assert_queries(1) do
+ assert_equal relation, relation.load
+ end
+ assert_no_queries { relation.to_a }
+ end
+
+ test "group with select and includes" do
+ authors_count = Post.select("author_id, COUNT(author_id) AS num_posts").
+ group("author_id").order("author_id").includes(:author).to_a
+
+ assert_no_queries do
+ result = authors_count.map do |post|
+ [post.num_posts, post.author.try(:name)]
+ end
+
+ expected = [[1, nil], [5, "David"], [3, "Mary"], [2, "Bob"]]
+ assert_equal expected, result
+ end
+ end
+
+ test "joins with select" do
+ posts = Post.joins(:author).select("id", "authors.author_address_id").order("posts.id").limit(3)
+ assert_equal [1, 2, 4], posts.map(&:id)
+ assert_equal [1, 1, 1], posts.map(&:author_address_id)
+ end
+
+ test "delegations do not leak to other classes" do
+ Topic.all.by_lifo
+ assert Topic.all.class.method_defined?(:by_lifo)
+ assert_not_respond_to Post.all, :by_lifo
+ end
+
+ def test_unscope_with_subquery
+ p1 = Post.where(id: 1)
+ p2 = Post.where(id: 2)
+
+ assert_not_equal p1, p2
+
+ comments = Comment.where(post: p1).unscope(where: :post_id).where(post: p2)
+
+ assert_not_equal p1.first.comments, comments
+ assert_equal p2.first.comments, comments
+ end
+
+ def test_unscope_specific_where_value
+ posts = Post.where(title: "Welcome to the weblog", body: "Such a lovely day")
+
+ assert_equal 1, posts.count
+ assert_equal 1, posts.unscope(where: :title).count
+ assert_equal 1, posts.unscope(where: :body).count
+ end
+
+ def test_locked_should_not_build_arel
+ posts = Post.locked
+ assert_predicate posts, :locked?
+ assert_nothing_raised { posts.lock!(false) }
+ end
+
+ def test_relation_join_method
+ assert_equal "Thank you for the welcome,Thank you again for the welcome", Post.first.comments.join(",")
+ end
+
+ def test_relation_with_private_kernel_method
+ accounts = Account.all
+ assert_equal [accounts(:signals37)], accounts.open
+ assert_equal [accounts(:signals37)], accounts.available
+
+ sub_accounts = SubAccount.all
+ assert_equal [accounts(:signals37)], sub_accounts.open
+ assert_equal [accounts(:signals37)], sub_accounts.available
+ end
+
+ test "#skip_query_cache!" do
+ Post.cache do
+ assert_queries(1) do
+ Post.all.load
+ Post.all.load
+ end
+
+ assert_queries(2) do
+ Post.all.skip_query_cache!.load
+ Post.all.skip_query_cache!.load
+ end
+ end
+ end
+
+ test "#skip_query_cache! with an eager load" do
+ Post.cache do
+ assert_queries(1) do
+ Post.eager_load(:comments).load
+ Post.eager_load(:comments).load
+ end
+
+ assert_queries(2) do
+ Post.eager_load(:comments).skip_query_cache!.load
+ Post.eager_load(:comments).skip_query_cache!.load
+ end
+ end
+ end
+
+ test "#skip_query_cache! with a preload" do
+ Post.cache do
+ assert_queries(2) do
+ Post.preload(:comments).load
+ Post.preload(:comments).load
+ end
+
+ assert_queries(4) do
+ Post.preload(:comments).skip_query_cache!.load
+ Post.preload(:comments).skip_query_cache!.load
+ end
+ end
+ end
+
+ test "#where with set" do
+ david = authors(:david)
+ mary = authors(:mary)
+
+ authors = Author.where(name: ["David", "Mary"].to_set)
+ assert_equal [david, mary], authors
+ end
+
+ test "#where with empty set" do
+ authors = Author.where(name: Set.new)
+ assert_empty authors
+ end
+
+ private
+ def custom_post_relation(alias_name = "omg_posts")
+ table_alias = Post.arel_table.alias(alias_name)
+ table_metadata = ActiveRecord::TableMetadata.new(Post, table_alias)
+ predicate_builder = ActiveRecord::PredicateBuilder.new(table_metadata)
+
+ ActiveRecord::Relation.create(
+ Post,
+ table: table_alias,
+ predicate_builder: predicate_builder
+ )
+ end
+end
diff --git a/activerecord/test/cases/reload_models_test.rb b/activerecord/test/cases/reload_models_test.rb
new file mode 100644
index 0000000000..72f4bfaf6d
--- /dev/null
+++ b/activerecord/test/cases/reload_models_test.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/owner"
+require "models/pet"
+
+class ReloadModelsTest < ActiveRecord::TestCase
+ include ActiveSupport::Testing::Isolation
+
+ fixtures :pets, :owners
+
+ def test_has_one_with_reload
+ pet = Pet.find_by_name("parrot")
+ pet.owner = Owner.find_by_name("ashley")
+
+ # Reload the class Owner, simulating auto-reloading of model classes in a
+ # development environment. Note that meanwhile the class Pet is not
+ # reloaded, simulating a class that is present in a plugin.
+ Object.class_eval { remove_const :Owner }
+ Kernel.load(File.expand_path("../models/owner.rb", __dir__))
+
+ pet = Pet.find_by_name("parrot")
+ pet.owner = Owner.find_by_name("ashley")
+ assert_equal pet.owner, Owner.find_by_name("ashley")
+ end
+end unless in_memory_db?
diff --git a/activerecord/test/cases/reserved_word_test.rb b/activerecord/test/cases/reserved_word_test.rb
new file mode 100644
index 0000000000..e32605fd11
--- /dev/null
+++ b/activerecord/test/cases/reserved_word_test.rb
@@ -0,0 +1,141 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class ReservedWordTest < ActiveRecord::TestCase
+ self.use_instantiated_fixtures = true
+ self.use_transactional_tests = false
+
+ class Group < ActiveRecord::Base
+ Group.table_name = "group"
+ belongs_to :select
+ has_one :values
+ end
+
+ class Select < ActiveRecord::Base
+ Select.table_name = "select"
+ has_many :groups
+ end
+
+ class Values < ActiveRecord::Base
+ Values.table_name = "values"
+ end
+
+ class Distinct < ActiveRecord::Base
+ Distinct.table_name = "distinct"
+ has_and_belongs_to_many :selects
+ has_many :values, through: :groups
+ end
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table :select, force: true
+ @connection.create_table :distinct, force: true
+ @connection.create_table :distinct_select, id: false, force: true do |t|
+ t.belongs_to :distinct
+ t.belongs_to :select
+ end
+ @connection.create_table :group, force: true do |t|
+ t.string :order
+ t.belongs_to :select
+ end
+ @connection.create_table :values, primary_key: :as, force: true do |t|
+ t.belongs_to :group
+ end
+ end
+
+ def teardown
+ @connection.drop_table :select, if_exists: true
+ @connection.drop_table :distinct, if_exists: true
+ @connection.drop_table :distinct_select, if_exists: true
+ @connection.drop_table :group, if_exists: true
+ @connection.drop_table :values, if_exists: true
+ @connection.drop_table :order, if_exists: true
+ end
+
+ def test_create_tables
+ assert_not @connection.table_exists?(:order)
+
+ @connection.create_table :order do |t|
+ t.string :group
+ end
+
+ assert @connection.table_exists?(:order)
+ end
+
+ def test_rename_tables
+ assert_nothing_raised { @connection.rename_table(:group, :order) }
+ end
+
+ def test_change_columns
+ assert_nothing_raised { @connection.change_column_default(:group, :order, "whatever") }
+ assert_nothing_raised { @connection.change_column("group", "order", :text, default: nil) }
+ assert_nothing_raised { @connection.rename_column(:group, :order, :values) }
+ end
+
+ def test_introspect
+ assert_equal ["id", "order", "select_id"], @connection.columns(:group).map(&:name).sort
+ assert_equal ["index_group_on_select_id"], @connection.indexes(:group).map(&:name).sort
+ end
+
+ def test_activerecord_model
+ x = Group.new
+ x.order = "x"
+ x.save!
+ x.order = "y"
+ x.save!
+ assert_equal x, Group.find_by_order("y")
+ assert_equal x, Group.find(x.id)
+ end
+
+ def test_delete_all_with_subselect
+ create_test_fixtures :values
+ assert_equal 1, Values.order(:as).limit(1).offset(1).delete_all
+ assert_raise(ActiveRecord::RecordNotFound) { Values.find(2) }
+ assert Values.find(1)
+ end
+
+ def test_has_one_associations
+ create_test_fixtures :group, :values
+ v = Group.find(1).values
+ assert_equal 2, v.id
+ end
+
+ def test_belongs_to_associations
+ create_test_fixtures :select, :group
+ gs = Select.find(2).groups
+ assert_equal 2, gs.length
+ assert_equal [2, 3], gs.collect(&:id).sort
+ end
+
+ def test_has_and_belongs_to_many
+ create_test_fixtures :select, :distinct, :distinct_select
+ s = Distinct.find(1).selects
+ assert_equal 2, s.length
+ assert_equal [1, 2], s.collect(&:id).sort
+ end
+
+ def test_activerecord_introspection
+ assert_predicate Group, :table_exists?
+ assert_equal ["id", "order", "select_id"], Group.columns.map(&:name).sort
+ end
+
+ def test_calculations_work_with_reserved_words
+ create_test_fixtures :group
+ assert_equal 3, Group.count
+ end
+
+ def test_associations_work_with_reserved_words
+ create_test_fixtures :select, :group
+ selects = Select.all.merge!(includes: [:groups]).to_a
+ assert_no_queries do
+ selects.each { |select| select.groups }
+ end
+ end
+
+ private
+ # custom fixture loader, uses FixtureSet#create_fixtures and appends base_path to the current file's path
+ def create_test_fixtures(*fixture_names)
+ ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT + "/reserved_words", fixture_names)
+ end
+end
diff --git a/activerecord/test/cases/result_test.rb b/activerecord/test/cases/result_test.rb
new file mode 100644
index 0000000000..825aee2423
--- /dev/null
+++ b/activerecord/test/cases/result_test.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ class ResultTest < ActiveRecord::TestCase
+ def result
+ Result.new(["col_1", "col_2"], [
+ ["row 1 col 1", "row 1 col 2"],
+ ["row 2 col 1", "row 2 col 2"],
+ ["row 3 col 1", "row 3 col 2"],
+ ])
+ end
+
+ test "includes_column?" do
+ assert result.includes_column?("col_1")
+ assert_not result.includes_column?("foo")
+ end
+
+ test "length" do
+ assert_equal 3, result.length
+ end
+
+ test "to_a returns row_hashes" do
+ assert_equal [
+ { "col_1" => "row 1 col 1", "col_2" => "row 1 col 2" },
+ { "col_1" => "row 2 col 1", "col_2" => "row 2 col 2" },
+ { "col_1" => "row 3 col 1", "col_2" => "row 3 col 2" },
+ ], result.to_a
+ end
+
+ test "to_hash (deprecated) returns row_hashes" do
+ assert_deprecated do
+ assert_equal [
+ { "col_1" => "row 1 col 1", "col_2" => "row 1 col 2" },
+ { "col_1" => "row 2 col 1", "col_2" => "row 2 col 2" },
+ { "col_1" => "row 3 col 1", "col_2" => "row 3 col 2" },
+ ], result.to_hash
+ end
+ end
+
+ test "first returns first row as a hash" do
+ assert_equal(
+ { "col_1" => "row 1 col 1", "col_2" => "row 1 col 2" }, result.first)
+ end
+
+ test "last returns last row as a hash" do
+ assert_equal(
+ { "col_1" => "row 3 col 1", "col_2" => "row 3 col 2" }, result.last)
+ end
+
+ test "each with block returns row hashes" do
+ result.each do |row|
+ assert_equal ["col_1", "col_2"], row.keys
+ end
+ end
+
+ test "each without block returns an enumerator" do
+ result.each.with_index do |row, index|
+ assert_equal ["col_1", "col_2"], row.keys
+ assert_kind_of Integer, index
+ end
+ end
+
+ test "each without block returns a sized enumerator" do
+ assert_equal 3, result.each.size
+ end
+
+ test "cast_values returns rows after type casting" do
+ values = [["1.1", "2.2"], ["3.3", "4.4"]]
+ columns = ["col1", "col2"]
+ types = { "col1" => Type::Integer.new, "col2" => Type::Float.new }
+ result = Result.new(columns, values, types)
+
+ assert_equal [[1, 2.2], [3, 4.4]], result.cast_values
+ end
+
+ test "cast_values uses identity type for unknown types" do
+ values = [["1.1", "2.2"], ["3.3", "4.4"]]
+ columns = ["col1", "col2"]
+ types = { "col1" => Type::Integer.new }
+ result = Result.new(columns, values, types)
+
+ assert_equal [[1, "2.2"], [3, "4.4"]], result.cast_values
+ end
+
+ test "cast_values returns single dimensional array if single column" do
+ values = [["1.1"], ["3.3"]]
+ columns = ["col1"]
+ types = { "col1" => Type::Integer.new }
+ result = Result.new(columns, values, types)
+
+ assert_equal [1, 3], result.cast_values
+ end
+
+ test "cast_values can receive types to use instead" do
+ values = [["1.1", "2.2"], ["3.3", "4.4"]]
+ columns = ["col1", "col2"]
+ types = { "col1" => Type::Integer.new, "col2" => Type::Float.new }
+ result = Result.new(columns, values, types)
+
+ assert_equal [[1.1, 2.2], [3.3, 4.4]], result.cast_values("col1" => Type::Float.new)
+ end
+ end
+end
diff --git a/activerecord/test/cases/sanitize_test.rb b/activerecord/test/cases/sanitize_test.rb
new file mode 100644
index 0000000000..778cf86ac3
--- /dev/null
+++ b/activerecord/test/cases/sanitize_test.rb
@@ -0,0 +1,185 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/binary"
+require "models/author"
+require "models/post"
+require "models/customer"
+
+class SanitizeTest < ActiveRecord::TestCase
+ def setup
+ end
+
+ def test_sanitize_sql_array_handles_string_interpolation
+ quoted_bambi = ActiveRecord::Base.connection.quote_string("Bambi")
+ assert_equal "name='#{quoted_bambi}'", Binary.sanitize_sql_array(["name='%s'", "Bambi"])
+ assert_equal "name='#{quoted_bambi}'", Binary.sanitize_sql_array(["name='%s'", "Bambi".mb_chars])
+ quoted_bambi_and_thumper = ActiveRecord::Base.connection.quote_string("Bambi\nand\nThumper")
+ assert_equal "name='#{quoted_bambi_and_thumper}'", Binary.sanitize_sql_array(["name='%s'", "Bambi\nand\nThumper"])
+ assert_equal "name='#{quoted_bambi_and_thumper}'", Binary.sanitize_sql_array(["name='%s'", "Bambi\nand\nThumper".mb_chars])
+ end
+
+ def test_sanitize_sql_array_handles_bind_variables
+ quoted_bambi = ActiveRecord::Base.connection.quote("Bambi")
+ assert_equal "name=#{quoted_bambi}", Binary.sanitize_sql_array(["name=?", "Bambi"])
+ assert_equal "name=#{quoted_bambi}", Binary.sanitize_sql_array(["name=?", "Bambi".mb_chars])
+ quoted_bambi_and_thumper = ActiveRecord::Base.connection.quote("Bambi\nand\nThumper")
+ assert_equal "name=#{quoted_bambi_and_thumper}", Binary.sanitize_sql_array(["name=?", "Bambi\nand\nThumper"])
+ assert_equal "name=#{quoted_bambi_and_thumper}", Binary.sanitize_sql_array(["name=?", "Bambi\nand\nThumper".mb_chars])
+ end
+
+ def test_sanitize_sql_array_handles_named_bind_variables
+ quoted_bambi = ActiveRecord::Base.connection.quote("Bambi")
+ assert_equal "name=#{quoted_bambi}", Binary.sanitize_sql_array(["name=:name", name: "Bambi"])
+ assert_equal "name=#{quoted_bambi} AND id=1", Binary.sanitize_sql_array(["name=:name AND id=:id", name: "Bambi", id: 1])
+
+ quoted_bambi_and_thumper = ActiveRecord::Base.connection.quote("Bambi\nand\nThumper")
+ assert_equal "name=#{quoted_bambi_and_thumper}", Binary.sanitize_sql_array(["name=:name", name: "Bambi\nand\nThumper"])
+ assert_equal "name=#{quoted_bambi_and_thumper} AND name2=#{quoted_bambi_and_thumper}", Binary.sanitize_sql_array(["name=:name AND name2=:name", name: "Bambi\nand\nThumper"])
+ end
+
+ def test_sanitize_sql_array_handles_relations
+ david = Author.create!(name: "David")
+ david_posts = david.posts.select(:id)
+
+ sub_query_pattern = /\(\bselect\b.*?\bwhere\b.*?\)/i
+
+ select_author_sql = Post.sanitize_sql_array(["id in (?)", david_posts])
+ assert_match(sub_query_pattern, select_author_sql, "should sanitize `Relation` as subquery for bind variables")
+
+ select_author_sql = Post.sanitize_sql_array(["id in (:post_ids)", post_ids: david_posts])
+ assert_match(sub_query_pattern, select_author_sql, "should sanitize `Relation` as subquery for named bind variables")
+ end
+
+ def test_sanitize_sql_array_handles_empty_statement
+ select_author_sql = Post.sanitize_sql_array([""])
+ assert_equal("", select_author_sql)
+ end
+
+ def test_sanitize_sql_like
+ assert_equal '100\%', Binary.sanitize_sql_like("100%")
+ assert_equal 'snake\_cased\_string', Binary.sanitize_sql_like("snake_cased_string")
+ assert_equal 'C:\\\\Programs\\\\MsPaint', Binary.sanitize_sql_like('C:\\Programs\\MsPaint')
+ assert_equal "normal string 42", Binary.sanitize_sql_like("normal string 42")
+ end
+
+ def test_sanitize_sql_like_with_custom_escape_character
+ assert_equal "100!%", Binary.sanitize_sql_like("100%", "!")
+ assert_equal "snake!_cased!_string", Binary.sanitize_sql_like("snake_cased_string", "!")
+ assert_equal "great!!", Binary.sanitize_sql_like("great!", "!")
+ assert_equal 'C:\\Programs\\MsPaint', Binary.sanitize_sql_like('C:\\Programs\\MsPaint', "!")
+ assert_equal "normal string 42", Binary.sanitize_sql_like("normal string 42", "!")
+ end
+
+ def test_sanitize_sql_like_example_use_case
+ searchable_post = Class.new(Post) do
+ def self.search_as_method(term)
+ where("title LIKE ?", sanitize_sql_like(term, "!"))
+ end
+
+ scope :search_as_scope, -> (term) {
+ where("title LIKE ?", sanitize_sql_like(term, "!"))
+ }
+ end
+
+ assert_sql(/LIKE '20!% !_reduction!_!!'/) do
+ searchable_post.search_as_method("20% _reduction_!").to_a
+ end
+
+ assert_sql(/LIKE '20!% !_reduction!_!!'/) do
+ searchable_post.search_as_scope("20% _reduction_!").to_a
+ end
+ end
+
+ def test_bind_arity
+ assert_nothing_raised { bind "" }
+ assert_raise(ActiveRecord::PreparedStatementInvalid) { bind "", 1 }
+
+ assert_raise(ActiveRecord::PreparedStatementInvalid) { bind "?" }
+ assert_nothing_raised { bind "?", 1 }
+ assert_raise(ActiveRecord::PreparedStatementInvalid) { bind "?", 1, 1 }
+ end
+
+ def test_named_bind_variables
+ assert_equal "1", bind(":a", a: 1) # ' ruby-mode
+ assert_equal "1 1", bind(":a :a", a: 1) # ' ruby-mode
+
+ assert_nothing_raised { bind("'+00:00'", foo: "bar") }
+ end
+
+ def test_named_bind_arity
+ assert_nothing_raised { bind "name = :name", name: "37signals" }
+ assert_nothing_raised { bind "name = :name", name: "37signals", id: 1 }
+ assert_raise(ActiveRecord::PreparedStatementInvalid) { bind "name = :name", id: 1 }
+ end
+
+ class SimpleEnumerable
+ include Enumerable
+
+ def initialize(ary)
+ @ary = ary
+ end
+
+ def each(&b)
+ @ary.each(&b)
+ end
+ end
+
+ def test_bind_enumerable
+ quoted_abc = %(#{ActiveRecord::Base.connection.quote('a')},#{ActiveRecord::Base.connection.quote('b')},#{ActiveRecord::Base.connection.quote('c')})
+
+ assert_equal "1,2,3", bind("?", [1, 2, 3])
+ assert_equal quoted_abc, bind("?", %w(a b c))
+
+ assert_equal "1,2,3", bind(":a", a: [1, 2, 3])
+ assert_equal quoted_abc, bind(":a", a: %w(a b c)) # '
+
+ assert_equal "1,2,3", bind("?", SimpleEnumerable.new([1, 2, 3]))
+ assert_equal quoted_abc, bind("?", SimpleEnumerable.new(%w(a b c)))
+
+ assert_equal "1,2,3", bind(":a", a: SimpleEnumerable.new([1, 2, 3]))
+ assert_equal quoted_abc, bind(":a", a: SimpleEnumerable.new(%w(a b c))) # '
+ end
+
+ def test_bind_empty_enumerable
+ quoted_nil = ActiveRecord::Base.connection.quote(nil)
+ assert_equal quoted_nil, bind("?", [])
+ assert_equal " in (#{quoted_nil})", bind(" in (?)", [])
+ assert_equal "foo in (#{quoted_nil})", bind("foo in (?)", [])
+ end
+
+ def test_bind_empty_string
+ quoted_empty = ActiveRecord::Base.connection.quote("")
+ assert_equal quoted_empty, bind("?", "")
+ end
+
+ def test_bind_chars
+ quoted_bambi = ActiveRecord::Base.connection.quote("Bambi")
+ quoted_bambi_and_thumper = ActiveRecord::Base.connection.quote("Bambi\nand\nThumper")
+ assert_equal "name=#{quoted_bambi}", bind("name=?", "Bambi")
+ assert_equal "name=#{quoted_bambi_and_thumper}", bind("name=?", "Bambi\nand\nThumper")
+ assert_equal "name=#{quoted_bambi}", bind("name=?", "Bambi".mb_chars)
+ assert_equal "name=#{quoted_bambi_and_thumper}", bind("name=?", "Bambi\nand\nThumper".mb_chars)
+ end
+
+ def test_named_bind_with_postgresql_type_casts
+ l = Proc.new { bind(":a::integer '2009-01-01'::date", a: "10") }
+ assert_nothing_raised(&l)
+ assert_equal "#{ActiveRecord::Base.connection.quote('10')}::integer '2009-01-01'::date", l.call
+ end
+
+ def test_deprecated_expand_hash_conditions_for_aggregates
+ assert_deprecated do
+ assert_equal({ "balance" => 50 }, Customer.send(:expand_hash_conditions_for_aggregates, balance: Money.new(50)))
+ end
+ end
+
+ private
+ def bind(statement, *vars)
+ if vars.first.is_a?(Hash)
+ ActiveRecord::Base.send(:replace_named_bind_variables, statement, vars.first)
+ else
+ ActiveRecord::Base.send(:replace_bind_variables, statement, vars)
+ end
+ end
+end
diff --git a/activerecord/test/cases/schema_dumper_test.rb b/activerecord/test/cases/schema_dumper_test.rb
new file mode 100644
index 0000000000..dda3efa47c
--- /dev/null
+++ b/activerecord/test/cases/schema_dumper_test.rb
@@ -0,0 +1,521 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+class SchemaDumperTest < ActiveRecord::TestCase
+ include SchemaDumpingHelper
+ self.use_transactional_tests = false
+
+ setup do
+ ActiveRecord::SchemaMigration.create_table
+ end
+
+ def standard_dump
+ @@standard_dump ||= perform_schema_dump
+ end
+
+ def perform_schema_dump
+ dump_all_table_schema []
+ end
+
+ def test_dump_schema_information_with_empty_versions
+ ActiveRecord::SchemaMigration.delete_all
+ schema_info = ActiveRecord::Base.connection.dump_schema_information
+ assert_no_match(/INSERT INTO/, schema_info)
+ end
+
+ def test_dump_schema_information_outputs_lexically_ordered_versions
+ versions = %w{ 20100101010101 20100201010101 20100301010101 }
+ versions.reverse_each do |v|
+ ActiveRecord::SchemaMigration.create!(version: v)
+ end
+
+ schema_info = ActiveRecord::Base.connection.dump_schema_information
+ assert_match(/20100201010101.*20100301010101/m, schema_info)
+ ensure
+ ActiveRecord::SchemaMigration.delete_all
+ end
+
+ def test_schema_dump
+ output = standard_dump
+ assert_match %r{create_table "accounts"}, output
+ assert_match %r{create_table "authors"}, output
+ assert_no_match %r{(?<=, ) do \|t\|}, output
+ assert_no_match %r{create_table "schema_migrations"}, output
+ assert_no_match %r{create_table "ar_internal_metadata"}, output
+ end
+
+ def test_schema_dump_uses_force_cascade_on_create_table
+ output = dump_table_schema "authors"
+ assert_match %r{create_table "authors",.* force: :cascade}, output
+ end
+
+ def test_schema_dump_excludes_sqlite_sequence
+ output = standard_dump
+ assert_no_match %r{create_table "sqlite_sequence"}, output
+ end
+
+ def test_schema_dump_includes_camelcase_table_name
+ output = standard_dump
+ assert_match %r{create_table "CamelCase"}, output
+ end
+
+ def assert_no_line_up(lines, pattern)
+ return assert(true) if lines.empty?
+ matches = lines.map { |line| line.match(pattern) }
+ matches.compact!
+ return assert(true) if matches.empty?
+ line_matches = lines.map { |line| [line, line.match(pattern)] }.select { |line, match| match }
+ assert line_matches.all? { |line, match|
+ start = match.offset(0).first
+ line[start - 2..start - 1] == ", "
+ }
+ end
+
+ def column_definition_lines(output = standard_dump)
+ output.scan(/^( *)create_table.*?\n(.*?)^\1end/m).map { |m| m.last.split(/\n/) }
+ end
+
+ def test_types_no_line_up
+ column_definition_lines.each do |column_set|
+ next if column_set.empty?
+
+ assert column_set.all? { |column| !column.match(/\bt\.\w+\s{2,}/) }
+ end
+ end
+
+ def test_arguments_no_line_up
+ column_definition_lines.each do |column_set|
+ assert_no_line_up(column_set, /default: /)
+ assert_no_line_up(column_set, /limit: /)
+ assert_no_line_up(column_set, /null: /)
+ end
+ end
+
+ def test_no_dump_errors
+ output = standard_dump
+ assert_no_match %r{\# Could not dump table}, output
+ end
+
+ def test_schema_dump_includes_not_null_columns
+ output = dump_all_table_schema([/^[^r]/])
+ assert_match %r{null: false}, output
+ end
+
+ def test_schema_dump_includes_limit_constraint_for_integer_columns
+ output = dump_all_table_schema([/^(?!integer_limits)/])
+
+ assert_match %r{"c_int_without_limit"(?!.*limit)}, output
+
+ if current_adapter?(:PostgreSQLAdapter)
+ assert_match %r{c_int_1.*limit: 2}, output
+ assert_match %r{c_int_2.*limit: 2}, output
+
+ # int 3 is 4 bytes in postgresql
+ assert_match %r{"c_int_3"(?!.*limit)}, output
+ assert_match %r{"c_int_4"(?!.*limit)}, output
+ elsif current_adapter?(:Mysql2Adapter)
+ assert_match %r{c_int_1.*limit: 1}, output
+ assert_match %r{c_int_2.*limit: 2}, output
+ assert_match %r{c_int_3.*limit: 3}, output
+
+ assert_match %r{"c_int_4"(?!.*limit)}, output
+ elsif current_adapter?(:SQLite3Adapter)
+ assert_match %r{c_int_1.*limit: 1}, output
+ assert_match %r{c_int_2.*limit: 2}, output
+ assert_match %r{c_int_3.*limit: 3}, output
+ assert_match %r{c_int_4.*limit: 4}, output
+ end
+
+ if current_adapter?(:SQLite3Adapter, :OracleAdapter)
+ assert_match %r{c_int_5.*limit: 5}, output
+ assert_match %r{c_int_6.*limit: 6}, output
+ assert_match %r{c_int_7.*limit: 7}, output
+ assert_match %r{c_int_8.*limit: 8}, output
+ else
+ assert_match %r{t\.bigint\s+"c_int_5"$}, output
+ assert_match %r{t\.bigint\s+"c_int_6"$}, output
+ assert_match %r{t\.bigint\s+"c_int_7"$}, output
+ assert_match %r{t\.bigint\s+"c_int_8"$}, output
+ end
+ end
+
+ def test_schema_dump_with_string_ignored_table
+ output = dump_all_table_schema(["accounts"])
+ assert_no_match %r{create_table "accounts"}, output
+ assert_match %r{create_table "authors"}, output
+ assert_no_match %r{create_table "schema_migrations"}, output
+ assert_no_match %r{create_table "ar_internal_metadata"}, output
+ end
+
+ def test_schema_dump_with_regexp_ignored_table
+ output = dump_all_table_schema([/^account/])
+ assert_no_match %r{create_table "accounts"}, output
+ assert_match %r{create_table "authors"}, output
+ assert_no_match %r{create_table "schema_migrations"}, output
+ assert_no_match %r{create_table "ar_internal_metadata"}, output
+ end
+
+ def test_schema_dumps_index_columns_in_right_order
+ index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*company_index/).first.strip
+ if current_adapter?(:Mysql2Adapter)
+ if ActiveRecord::Base.connection.supports_index_sort_order?
+ assert_equal 't.index ["firm_id", "type", "rating"], name: "company_index", length: { type: 10 }, order: { rating: :desc }', index_definition
+ else
+ assert_equal 't.index ["firm_id", "type", "rating"], name: "company_index", length: { type: 10 }', index_definition
+ end
+ elsif ActiveRecord::Base.connection.supports_index_sort_order?
+ assert_equal 't.index ["firm_id", "type", "rating"], name: "company_index", order: { rating: :desc }', index_definition
+ else
+ assert_equal 't.index ["firm_id", "type", "rating"], name: "company_index"', index_definition
+ end
+ end
+
+ def test_schema_dumps_partial_indices
+ index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*company_partial_index/).first.strip
+ if ActiveRecord::Base.connection.supports_partial_index?
+ assert_equal 't.index ["firm_id", "type"], name: "company_partial_index", where: "(rating > 10)"', index_definition
+ else
+ assert_equal 't.index ["firm_id", "type"], name: "company_partial_index"', index_definition
+ end
+ end
+
+ def test_schema_dumps_index_sort_order
+ index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*_name_and_rating/).first.strip
+ if ActiveRecord::Base.connection.supports_index_sort_order?
+ assert_equal 't.index ["name", "rating"], name: "index_companies_on_name_and_rating", order: :desc', index_definition
+ else
+ assert_equal 't.index ["name", "rating"], name: "index_companies_on_name_and_rating"', index_definition
+ end
+ end
+
+ def test_schema_dumps_index_length
+ index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*_name_and_description/).first.strip
+ if current_adapter?(:Mysql2Adapter)
+ assert_equal 't.index ["name", "description"], name: "index_companies_on_name_and_description", length: 10', index_definition
+ else
+ assert_equal 't.index ["name", "description"], name: "index_companies_on_name_and_description"', index_definition
+ end
+ end
+
+ def test_schema_dump_should_honor_nonstandard_primary_keys
+ output = standard_dump
+ match = output.match(%r{create_table "movies"(.*)do})
+ assert_not_nil(match, "nonstandardpk table not found")
+ assert_match %r(primary_key: "movieid"), match[1], "non-standard primary key not preserved"
+ end
+
+ def test_schema_dump_should_use_false_as_default
+ output = dump_table_schema "booleans"
+ assert_match %r{t\.boolean\s+"has_fun",.+default: false}, output
+ end
+
+ def test_schema_dump_does_not_include_limit_for_text_field
+ output = dump_table_schema "admin_users"
+ assert_match %r{t\.text\s+"params"$}, output
+ end
+
+ def test_schema_dump_does_not_include_limit_for_binary_field
+ output = dump_table_schema "binaries"
+ assert_match %r{t\.binary\s+"data"$}, output
+ end
+
+ def test_schema_dump_does_not_include_limit_for_float_field
+ output = dump_table_schema "numeric_data"
+ assert_match %r{t\.float\s+"temperature"$}, output
+ end
+
+ if ActiveRecord::Base.connection.supports_expression_index?
+ def test_schema_dump_expression_indices
+ index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*company_expression_index/).first.strip
+ index_definition.sub!(/, name: "company_expression_index"\z/, "")
+
+ if current_adapter?(:PostgreSQLAdapter)
+ assert_match %r{CASE.+lower\(\(name\)::text\).+END\) DESC"\z}i, index_definition
+ elsif current_adapter?(:Mysql2Adapter)
+ assert_match %r{CASE.+lower\(`name`\).+END\) DESC"\z}i, index_definition
+ elsif current_adapter?(:SQLite3Adapter)
+ assert_match %r{CASE.+lower\(name\).+END\) DESC"\z}i, index_definition
+ else
+ assert false
+ end
+ end
+ end
+
+ if current_adapter?(:Mysql2Adapter)
+ def test_schema_dump_includes_length_for_mysql_binary_fields
+ output = standard_dump
+ assert_match %r{t\.binary\s+"var_binary",\s+limit: 255$}, output
+ assert_match %r{t\.binary\s+"var_binary_large",\s+limit: 4095$}, output
+ end
+
+ def test_schema_dump_includes_length_for_mysql_blob_and_text_fields
+ output = standard_dump
+ assert_match %r{t\.blob\s+"tiny_blob",\s+limit: 255$}, output
+ assert_match %r{t\.binary\s+"normal_blob"$}, output
+ assert_match %r{t\.binary\s+"medium_blob",\s+limit: 16777215$}, output
+ assert_match %r{t\.binary\s+"long_blob",\s+limit: 4294967295$}, output
+ assert_match %r{t\.text\s+"tiny_text",\s+limit: 255$}, output
+ assert_match %r{t\.text\s+"normal_text"$}, output
+ assert_match %r{t\.text\s+"medium_text",\s+limit: 16777215$}, output
+ assert_match %r{t\.text\s+"long_text",\s+limit: 4294967295$}, output
+ end
+
+ def test_schema_does_not_include_limit_for_emulated_mysql_boolean_fields
+ output = standard_dump
+ assert_no_match %r{t\.boolean\s+"has_fun",.+limit: 1}, output
+ end
+
+ def test_schema_dumps_index_type
+ output = dump_table_schema "key_tests"
+ assert_match %r{t\.index \["awesome"\], name: "index_key_tests_on_awesome", type: :fulltext$}, output
+ assert_match %r{t\.index \["pizza"\], name: "index_key_tests_on_pizza"$}, output
+ end
+ end
+
+ def test_schema_dump_includes_decimal_options
+ output = dump_all_table_schema([/^[^n]/])
+ assert_match %r{precision: 3,[[:space:]]+scale: 2,[[:space:]]+default: "2\.78"}, output
+ end
+
+ if current_adapter?(:PostgreSQLAdapter)
+ def test_schema_dump_includes_bigint_default
+ output = dump_table_schema "defaults"
+ assert_match %r{t\.bigint\s+"bigint_default",\s+default: 0}, output
+ end
+
+ def test_schema_dump_includes_limit_on_array_type
+ output = dump_table_schema "bigint_array"
+ assert_match %r{t\.bigint\s+"big_int_data_points\",\s+array: true}, output
+ end
+
+ def test_schema_dump_allows_array_of_decimal_defaults
+ output = dump_table_schema "bigint_array"
+ assert_match %r{t\.decimal\s+"decimal_array_default",\s+default: \["1.23", "3.45"\],\s+array: true}, output
+ end
+
+ def test_schema_dump_interval_type
+ output = dump_table_schema "postgresql_times"
+ assert_match %r{t\.interval\s+"time_interval"$}, output
+ assert_match %r{t\.interval\s+"scaled_time_interval",\s+precision: 6$}, output
+ end
+
+ def test_schema_dump_oid_type
+ output = dump_table_schema "postgresql_oids"
+ assert_match %r{t\.oid\s+"obj_id"$}, output
+ end
+
+ def test_schema_dump_includes_extensions
+ connection = ActiveRecord::Base.connection
+
+ connection.stub(:extensions, ["hstore"]) do
+ output = perform_schema_dump
+ assert_match "# These are extensions that must be enabled", output
+ assert_match %r{enable_extension "hstore"}, output
+ end
+
+ connection.stub(:extensions, []) do
+ output = perform_schema_dump
+ assert_no_match "# These are extensions that must be enabled", output
+ assert_no_match %r{enable_extension}, output
+ end
+ end
+
+ def test_schema_dump_includes_extensions_in_alphabetic_order
+ connection = ActiveRecord::Base.connection
+
+ connection.stub(:extensions, ["hstore", "uuid-ossp", "xml2"]) do
+ output = perform_schema_dump
+ enabled_extensions = output.scan(%r{enable_extension "(.+)"}).flatten
+ assert_equal ["hstore", "uuid-ossp", "xml2"], enabled_extensions
+ end
+
+ connection.stub(:extensions, ["uuid-ossp", "xml2", "hstore"]) do
+ output = perform_schema_dump
+ enabled_extensions = output.scan(%r{enable_extension "(.+)"}).flatten
+ assert_equal ["hstore", "uuid-ossp", "xml2"], enabled_extensions
+ end
+ end
+ end
+
+ def test_schema_dump_keeps_large_precision_integer_columns_as_decimal
+ output = standard_dump
+ # Oracle supports precision up to 38 and it identifies decimals with scale 0 as integers
+ if current_adapter?(:OracleAdapter)
+ assert_match %r{t\.integer\s+"atoms_in_universe",\s+precision: 38}, output
+ elsif current_adapter?(:FbAdapter)
+ assert_match %r{t\.integer\s+"atoms_in_universe",\s+precision: 18}, output
+ else
+ assert_match %r{t\.decimal\s+"atoms_in_universe",\s+precision: 55}, output
+ end
+ end
+
+ def test_schema_dump_keeps_id_column_when_id_is_false_and_id_column_added
+ output = standard_dump
+ match = output.match(%r{create_table "goofy_string_id"(.*)do.*\n(.*)\n})
+ assert_not_nil(match, "goofy_string_id table not found")
+ assert_match %r(id: false), match[1], "no table id not preserved"
+ assert_match %r{t\.string\s+"id",.*?null: false$}, match[2], "non-primary key id column not preserved"
+ end
+
+ def test_schema_dump_keeps_id_false_when_id_is_false_and_unique_not_null_column_added
+ output = standard_dump
+ assert_match %r{create_table "string_key_objects", id: false}, output
+ end
+
+ if ActiveRecord::Base.connection.supports_foreign_keys?
+ def test_foreign_keys_are_dumped_at_the_bottom_to_circumvent_dependency_issues
+ output = standard_dump
+ assert_match(/^\s+add_foreign_key "fk_test_has_fk"[^\n]+\n\s+add_foreign_key "lessons_students"/, output)
+ end
+
+ def test_do_not_dump_foreign_keys_for_ignored_tables
+ output = dump_table_schema "authors"
+ assert_equal ["authors"], output.scan(/^\s*add_foreign_key "([^"]+)".+$/).flatten
+ end
+ end
+
+ class CreateDogMigration < ActiveRecord::Migration::Current
+ def up
+ create_table("dog_owners") do |t|
+ end
+
+ create_table("dogs") do |t|
+ t.column :name, :string
+ t.references :owner
+ t.index [:name]
+ t.foreign_key :dog_owners, column: "owner_id"
+ end
+ end
+ def down
+ drop_table("dogs")
+ drop_table("dog_owners")
+ end
+ end
+
+ def test_schema_dump_with_table_name_prefix_and_suffix
+ original, $stdout = $stdout, StringIO.new
+ ActiveRecord::Base.table_name_prefix = "foo_"
+ ActiveRecord::Base.table_name_suffix = "_bar"
+
+ migration = CreateDogMigration.new
+ migration.migrate(:up)
+
+ output = perform_schema_dump
+ assert_no_match %r{create_table "foo_.+_bar"}, output
+ assert_no_match %r{add_index "foo_.+_bar"}, output
+ assert_no_match %r{create_table "schema_migrations"}, output
+ assert_no_match %r{create_table "ar_internal_metadata"}, output
+
+ if ActiveRecord::Base.connection.supports_foreign_keys?
+ assert_no_match %r{add_foreign_key "foo_.+_bar"}, output
+ assert_no_match %r{add_foreign_key "[^"]+", "foo_.+_bar"}, output
+ end
+ ensure
+ migration.migrate(:down)
+
+ ActiveRecord::Base.table_name_suffix = ActiveRecord::Base.table_name_prefix = ""
+ $stdout = original
+ end
+
+ def test_schema_dump_with_table_name_prefix_and_suffix_regexp_escape
+ original, $stdout = $stdout, StringIO.new
+ ActiveRecord::Base.table_name_prefix = "foo$"
+ ActiveRecord::Base.table_name_suffix = "$bar"
+
+ migration = CreateDogMigration.new
+ migration.migrate(:up)
+
+ output = perform_schema_dump
+ assert_no_match %r{create_table "foo\$.+\$bar"}, output
+ assert_no_match %r{add_index "foo\$.+\$bar"}, output
+ assert_no_match %r{create_table "schema_migrations"}, output
+ assert_no_match %r{create_table "ar_internal_metadata"}, output
+
+ if ActiveRecord::Base.connection.supports_foreign_keys?
+ assert_no_match %r{add_foreign_key "foo\$.+\$bar"}, output
+ assert_no_match %r{add_foreign_key "[^"]+", "foo\$.+\$bar"}, output
+ end
+ ensure
+ migration.migrate(:down)
+
+ ActiveRecord::Base.table_name_suffix = ActiveRecord::Base.table_name_prefix = ""
+ $stdout = original
+ end
+
+ def test_schema_dump_with_table_name_prefix_and_ignoring_tables
+ original, $stdout = $stdout, StringIO.new
+
+ create_cat_migration = Class.new(ActiveRecord::Migration::Current) do
+ def change
+ create_table("cats") do |t|
+ end
+ create_table("omg_cats") do |t|
+ end
+ end
+ end
+
+ original_table_name_prefix = ActiveRecord::Base.table_name_prefix
+ original_schema_dumper_ignore_tables = ActiveRecord::SchemaDumper.ignore_tables
+ ActiveRecord::Base.table_name_prefix = "omg_"
+ ActiveRecord::SchemaDumper.ignore_tables = ["cats"]
+ migration = create_cat_migration.new
+ migration.migrate(:up)
+
+ stream = StringIO.new
+ output = ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, stream).string
+
+ assert_match %r{create_table "omg_cats"}, output
+ assert_no_match %r{create_table "cats"}, output
+ ensure
+ migration.migrate(:down)
+ ActiveRecord::Base.table_name_prefix = original_table_name_prefix
+ ActiveRecord::SchemaDumper.ignore_tables = original_schema_dumper_ignore_tables
+
+ $stdout = original
+ end
+end
+
+class SchemaDumperDefaultsTest < ActiveRecord::TestCase
+ include SchemaDumpingHelper
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table :dump_defaults, force: true do |t|
+ t.string :string_with_default, default: "Hello!"
+ t.date :date_with_default, default: "2014-06-05"
+ t.datetime :datetime_with_default, default: "2014-06-05 07:17:04"
+ t.time :time_with_default, default: "07:17:04"
+ t.decimal :decimal_with_default, default: "1234567890.0123456789", precision: 20, scale: 10
+ end
+
+ if current_adapter?(:PostgreSQLAdapter)
+ @connection.create_table :infinity_defaults, force: true do |t|
+ t.float :float_with_inf_default, default: Float::INFINITY
+ t.float :float_with_nan_default, default: Float::NAN
+ end
+ end
+ end
+
+ teardown do
+ @connection.drop_table "dump_defaults", if_exists: true
+ end
+
+ def test_schema_dump_defaults_with_universally_supported_types
+ output = dump_table_schema("dump_defaults")
+
+ assert_match %r{t\.string\s+"string_with_default",.*?default: "Hello!"}, output
+ assert_match %r{t\.date\s+"date_with_default",\s+default: "2014-06-05"}, output
+ assert_match %r{t\.datetime\s+"datetime_with_default",\s+default: "2014-06-05 07:17:04"}, output
+ assert_match %r{t\.time\s+"time_with_default",\s+default: "2000-01-01 07:17:04"}, output
+ assert_match %r{t\.decimal\s+"decimal_with_default",\s+precision: 20,\s+scale: 10,\s+default: "1234567890.0123456789"}, output
+ end
+
+ def test_schema_dump_with_float_column_infinity_default
+ skip unless current_adapter?(:PostgreSQLAdapter)
+ output = dump_table_schema("infinity_defaults")
+ assert_match %r{t\.float\s+"float_with_inf_default",\s+default: ::Float::INFINITY}, output
+ assert_match %r{t\.float\s+"float_with_nan_default",\s+default: ::Float::NAN}, output
+ end
+end
diff --git a/activerecord/test/cases/schema_loading_test.rb b/activerecord/test/cases/schema_loading_test.rb
new file mode 100644
index 0000000000..f539156466
--- /dev/null
+++ b/activerecord/test/cases/schema_loading_test.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module SchemaLoadCounter
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ attr_accessor :load_schema_calls
+
+ def load_schema!
+ self.load_schema_calls ||= 0
+ self.load_schema_calls += 1
+ super
+ end
+ end
+end
+
+class SchemaLoadingTest < ActiveRecord::TestCase
+ def test_basic_model_is_loaded_once
+ klass = define_model
+ klass.new
+ assert_equal 1, klass.load_schema_calls
+ end
+
+ def test_model_with_custom_lock_is_loaded_once
+ klass = define_model do |c|
+ c.table_name = :lock_without_defaults_cust
+ c.locking_column = :custom_lock_version
+ end
+ klass.new
+ assert_equal 1, klass.load_schema_calls
+ end
+
+ def test_model_with_changed_custom_lock_is_loaded_twice
+ klass = define_model do |c|
+ c.table_name = :lock_without_defaults_cust
+ end
+ klass.new
+ klass.locking_column = :custom_lock_version
+ klass.new
+ assert_equal 2, klass.load_schema_calls
+ end
+
+ private
+
+ def define_model
+ Class.new(ActiveRecord::Base) do
+ include SchemaLoadCounter
+ self.table_name = :lock_without_defaults
+ yield self if block_given?
+ end
+ end
+end
diff --git a/activerecord/test/cases/scoping/default_scoping_test.rb b/activerecord/test/cases/scoping/default_scoping_test.rb
new file mode 100644
index 0000000000..6281712df6
--- /dev/null
+++ b/activerecord/test/cases/scoping/default_scoping_test.rb
@@ -0,0 +1,575 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+require "models/comment"
+require "models/developer"
+require "models/project"
+require "models/computer"
+require "models/vehicle"
+require "models/cat"
+require "concurrent/atomic/cyclic_barrier"
+
+class DefaultScopingTest < ActiveRecord::TestCase
+ fixtures :developers, :posts, :comments
+
+ def test_default_scope
+ expected = Developer.all.merge!(order: "salary DESC").to_a.collect(&:salary)
+ received = DeveloperOrderedBySalary.all.collect(&:salary)
+ assert_equal expected, received
+ end
+
+ def test_default_scope_as_class_method
+ assert_equal [developers(:david).becomes(ClassMethodDeveloperCalledDavid)], ClassMethodDeveloperCalledDavid.all
+ end
+
+ def test_default_scope_as_class_method_referencing_scope
+ assert_equal [developers(:david).becomes(ClassMethodReferencingScopeDeveloperCalledDavid)], ClassMethodReferencingScopeDeveloperCalledDavid.all
+ end
+
+ def test_default_scope_as_block_referencing_scope
+ assert_equal [developers(:david).becomes(LazyBlockReferencingScopeDeveloperCalledDavid)], LazyBlockReferencingScopeDeveloperCalledDavid.all
+ end
+
+ def test_default_scope_with_lambda
+ assert_equal [developers(:david).becomes(LazyLambdaDeveloperCalledDavid)], LazyLambdaDeveloperCalledDavid.all
+ end
+
+ def test_default_scope_with_block
+ assert_equal [developers(:david).becomes(LazyBlockDeveloperCalledDavid)], LazyBlockDeveloperCalledDavid.all
+ end
+
+ def test_default_scope_with_callable
+ assert_equal [developers(:david).becomes(CallableDeveloperCalledDavid)], CallableDeveloperCalledDavid.all
+ end
+
+ def test_default_scope_is_unscoped_on_find
+ assert_equal 1, DeveloperCalledDavid.count
+ assert_equal 11, DeveloperCalledDavid.unscoped.count
+ end
+
+ def test_default_scope_is_unscoped_on_create
+ assert_nil DeveloperCalledJamis.unscoped.create!.name
+ end
+
+ def test_default_scope_with_conditions_string
+ assert_equal Developer.where(name: "David").map(&:id).sort, DeveloperCalledDavid.all.map(&:id).sort
+ assert_nil DeveloperCalledDavid.create!.name
+ end
+
+ def test_default_scope_with_conditions_hash
+ assert_equal Developer.where(name: "Jamis").map(&:id).sort, DeveloperCalledJamis.all.map(&:id).sort
+ assert_equal "Jamis", DeveloperCalledJamis.create!.name
+ end
+
+ def test_default_scope_with_inheritance
+ wheres = InheritedPoorDeveloperCalledJamis.all.where_values_hash
+ assert_equal "Jamis", wheres["name"]
+ assert_equal 50000, wheres["salary"]
+ end
+
+ def test_default_scope_with_module_includes
+ wheres = ModuleIncludedPoorDeveloperCalledJamis.all.where_values_hash
+ assert_equal "Jamis", wheres["name"]
+ assert_equal 50000, wheres["salary"]
+ end
+
+ def test_default_scope_with_multiple_calls
+ wheres = MultiplePoorDeveloperCalledJamis.all.where_values_hash
+ assert_equal "Jamis", wheres["name"]
+ assert_equal 50000, wheres["salary"]
+ end
+
+ def test_scope_overwrites_default
+ expected = Developer.all.merge!(order: "salary DESC, name DESC").to_a.collect(&:name)
+ received = DeveloperOrderedBySalary.by_name.to_a.collect(&:name)
+ assert_equal expected, received
+ end
+
+ def test_reorder_overrides_default_scope_order
+ expected = Developer.order("name DESC").collect(&:name)
+ received = DeveloperOrderedBySalary.reorder("name DESC").collect(&:name)
+ assert_equal expected, received
+ end
+
+ def test_order_after_reorder_combines_orders
+ expected = Developer.order("name DESC, id DESC").collect { |dev| [dev.name, dev.id] }
+ received = Developer.order("name ASC").reorder("name DESC").order("id DESC").collect { |dev| [dev.name, dev.id] }
+ assert_equal expected, received
+ end
+
+ def test_unscope_overrides_default_scope
+ expected = Developer.all.collect { |dev| [dev.name, dev.id] }
+ received = DeveloperCalledJamis.unscope(:where).collect { |dev| [dev.name, dev.id] }
+ assert_equal expected, received
+ end
+
+ def test_unscope_after_reordering_and_combining
+ expected = Developer.order("id DESC, name DESC").collect { |dev| [dev.name, dev.id] }
+ received = DeveloperOrderedBySalary.reorder("name DESC").unscope(:order).order("id DESC, name DESC").collect { |dev| [dev.name, dev.id] }
+ assert_equal expected, received
+
+ expected_2 = Developer.all.collect { |dev| [dev.name, dev.id] }
+ received_2 = Developer.order("id DESC, name DESC").unscope(:order).collect { |dev| [dev.name, dev.id] }
+ assert_equal expected_2, received_2
+
+ expected_3 = Developer.all.collect { |dev| [dev.name, dev.id] }
+ received_3 = Developer.reorder("name DESC").unscope(:order).collect { |dev| [dev.name, dev.id] }
+ assert_equal expected_3, received_3
+ end
+
+ def test_unscope_with_where_attributes
+ expected = Developer.order("salary DESC").collect(&:name)
+ received = DeveloperOrderedBySalary.where(name: "David").unscope(where: :name).collect(&:name)
+ assert_equal expected.sort, received.sort
+
+ expected_2 = Developer.order("salary DESC").collect(&:name)
+ received_2 = DeveloperOrderedBySalary.select("id").where("name" => "Jamis").unscope({ where: :name }, :select).collect(&:name)
+ assert_equal expected_2.sort, received_2.sort
+
+ expected_3 = Developer.order("salary DESC").collect(&:name)
+ received_3 = DeveloperOrderedBySalary.select("id").where("name" => "Jamis").unscope(:select, :where).collect(&:name)
+ assert_equal expected_3.sort, received_3.sort
+
+ expected_4 = Developer.order("salary DESC").collect(&:name)
+ received_4 = DeveloperOrderedBySalary.where.not("name" => "Jamis").unscope(where: :name).collect(&:name)
+ assert_equal expected_4.sort, received_4.sort
+
+ expected_5 = Developer.order("salary DESC").collect(&:name)
+ received_5 = DeveloperOrderedBySalary.where.not("name" => ["Jamis", "David"]).unscope(where: :name).collect(&:name)
+ assert_equal expected_5.sort, received_5.sort
+
+ expected_6 = Developer.order("salary DESC").collect(&:name)
+ received_6 = DeveloperOrderedBySalary.where(Developer.arel_table["name"].eq("David")).unscope(where: :name).collect(&:name)
+ assert_equal expected_6.sort, received_6.sort
+
+ expected_7 = Developer.order("salary DESC").collect(&:name)
+ received_7 = DeveloperOrderedBySalary.where(Developer.arel_table[:name].eq("David")).unscope(where: :name).collect(&:name)
+ assert_equal expected_7.sort, received_7.sort
+ end
+
+ def test_unscope_comparison_where_clauses
+ # unscoped for WHERE (`developers`.`id` <= 2)
+ expected = Developer.order("salary DESC").collect(&:name)
+ received = DeveloperOrderedBySalary.where(id: -Float::INFINITY..2).unscope(where: :id).collect { |dev| dev.name }
+ assert_equal expected.sort, received.sort
+
+ # unscoped for WHERE (`developers`.`id` < 2)
+ expected = Developer.order("salary DESC").collect(&:name)
+ received = DeveloperOrderedBySalary.where(id: -Float::INFINITY...2).unscope(where: :id).collect { |dev| dev.name }
+ assert_equal expected.sort, received.sort
+ end
+
+ def test_unscope_multiple_where_clauses
+ expected = Developer.order("salary DESC").collect(&:name)
+ received = DeveloperOrderedBySalary.where(name: "Jamis").where(id: 1).unscope(where: [:name, :id]).collect(&:name)
+ assert_equal expected.sort, received.sort
+ end
+
+ def test_unscope_string_where_clauses_involved
+ dev_relation = Developer.order("salary DESC").where("created_at > ?", 1.year.ago)
+ expected = dev_relation.collect(&:name)
+
+ dev_ordered_relation = DeveloperOrderedBySalary.where(name: "Jamis").where("created_at > ?", 1.year.ago)
+ received = dev_ordered_relation.unscope(where: [:name]).collect(&:name)
+
+ assert_equal expected.sort, received.sort
+ end
+
+ def test_unscope_with_grouping_attributes
+ expected = Developer.order("salary DESC").collect(&:name)
+ received = DeveloperOrderedBySalary.group(:name).unscope(:group).collect(&:name)
+ assert_equal expected.sort, received.sort
+
+ expected_2 = Developer.order("salary DESC").collect(&:name)
+ received_2 = DeveloperOrderedBySalary.group("name").unscope(:group).collect(&:name)
+ assert_equal expected_2.sort, received_2.sort
+ end
+
+ def test_unscope_with_limit_in_query
+ expected = Developer.order("salary DESC").collect(&:name)
+ received = DeveloperOrderedBySalary.limit(1).unscope(:limit).collect(&:name)
+ assert_equal expected.sort, received.sort
+ end
+
+ def test_order_to_unscope_reordering
+ scope = DeveloperOrderedBySalary.order("salary DESC, name ASC").reverse_order.unscope(:order)
+ assert_no_match(/order/i, scope.to_sql)
+ end
+
+ def test_unscope_reverse_order
+ expected = Developer.all.collect(&:name)
+ received = Developer.order("salary DESC").reverse_order.unscope(:order).collect(&:name)
+ assert_equal expected, received
+ end
+
+ def test_unscope_select
+ expected = Developer.order("salary ASC").collect(&:name)
+ received = Developer.order("salary DESC").reverse_order.select(:name).unscope(:select).collect(&:name)
+ assert_equal expected, received
+
+ expected_2 = Developer.all.collect(&:id)
+ received_2 = Developer.select(:name).unscope(:select).collect(&:id)
+ assert_equal expected_2, received_2
+ end
+
+ def test_unscope_offset
+ expected = Developer.all.collect(&:name)
+ received = Developer.offset(5).unscope(:offset).collect(&:name)
+ assert_equal expected, received
+ end
+
+ def test_unscope_joins_and_select_on_developers_projects
+ expected = Developer.all.collect(&:name)
+ received = Developer.joins("JOIN developers_projects ON id = developer_id").select(:id).unscope(:joins, :select).collect(&:name)
+ assert_equal expected, received
+ end
+
+ def test_unscope_left_outer_joins
+ expected = Developer.all.collect(&:name)
+ received = Developer.left_outer_joins(:projects).select(:id).unscope(:left_outer_joins, :select).collect(&:name)
+ assert_equal expected, received
+ end
+
+ def test_unscope_left_joins
+ expected = Developer.all.collect(&:name)
+ received = Developer.left_joins(:projects).select(:id).unscope(:left_joins, :select).collect(&:name)
+ assert_equal expected, received
+ end
+
+ def test_unscope_includes
+ expected = Developer.all.collect(&:name)
+ received = Developer.includes(:projects).select(:id).unscope(:includes, :select).collect(&:name)
+ assert_equal expected, received
+ end
+
+ def test_unscope_having
+ expected = DeveloperOrderedBySalary.all.collect(&:name)
+ received = DeveloperOrderedBySalary.having("name IN ('Jamis', 'David')").unscope(:having).collect(&:name)
+ assert_equal expected, received
+ end
+
+ def test_unscope_and_scope
+ developer_klass = Class.new(Developer) do
+ scope :by_name, -> name { unscope(where: :name).where(name: name) }
+ end
+
+ expected = developer_klass.where(name: "Jamis").collect { |dev| [dev.name, dev.id] }
+ received = developer_klass.where(name: "David").by_name("Jamis").collect { |dev| [dev.name, dev.id] }
+ assert_equal expected, received
+ end
+
+ def test_unscope_errors_with_invalid_value
+ assert_raises(ArgumentError) do
+ Developer.includes(:projects).where(name: "Jamis").unscope(:stupidly_incorrect_value)
+ end
+
+ assert_raises(ArgumentError) do
+ Developer.all.unscope(:includes, :select, :some_broken_value)
+ end
+
+ assert_raises(ArgumentError) do
+ Developer.order("name DESC").reverse_order.unscope(:reverse_order)
+ end
+
+ assert_raises(ArgumentError) do
+ Developer.order("name DESC").where(name: "Jamis").unscope()
+ end
+ end
+
+ def test_unscope_errors_with_non_where_hash_keys
+ assert_raises(ArgumentError) do
+ Developer.where(name: "Jamis").limit(4).unscope(limit: 4)
+ end
+
+ assert_raises(ArgumentError) do
+ Developer.where(name: "Jamis").unscope("where" => :name)
+ end
+ end
+
+ def test_unscope_errors_with_non_symbol_or_hash_arguments
+ assert_raises(ArgumentError) do
+ Developer.where(name: "Jamis").limit(3).unscope("limit")
+ end
+
+ assert_raises(ArgumentError) do
+ Developer.select("id").unscope("select")
+ end
+
+ assert_raises(ArgumentError) do
+ Developer.select("id").unscope(5)
+ end
+ end
+
+ def test_unscope_merging
+ merged = Developer.where(name: "Jamis").merge(Developer.unscope(:where))
+ assert_empty merged.where_clause
+ assert_not_empty merged.where(name: "Jon").where_clause
+ end
+
+ def test_order_in_default_scope_should_not_prevail
+ expected = Developer.all.merge!(order: "salary desc").to_a.collect(&:salary)
+ received = DeveloperOrderedBySalary.all.merge!(order: "salary").to_a.collect(&:salary)
+ assert_equal expected, received
+ end
+
+ def test_create_attribute_overwrites_default_scoping
+ assert_equal "David", PoorDeveloperCalledJamis.create!(name: "David").name
+ assert_equal 200000, PoorDeveloperCalledJamis.create!(name: "David", salary: 200000).salary
+ end
+
+ def test_create_attribute_overwrites_default_values
+ assert_nil PoorDeveloperCalledJamis.create!(salary: nil).salary
+ assert_equal 50000, PoorDeveloperCalledJamis.create!(name: "David").salary
+ end
+
+ def test_default_scope_attribute
+ jamis = PoorDeveloperCalledJamis.new(name: "David")
+ assert_equal 50000, jamis.salary
+ end
+
+ def test_where_attribute
+ aaron = PoorDeveloperCalledJamis.where(salary: 20).new(name: "Aaron")
+ assert_equal 20, aaron.salary
+ assert_equal "Aaron", aaron.name
+ end
+
+ def test_where_attribute_merge
+ aaron = PoorDeveloperCalledJamis.where(name: "foo").new(name: "Aaron")
+ assert_equal "Aaron", aaron.name
+ end
+
+ def test_scope_composed_by_limit_and_then_offset_is_equal_to_scope_composed_by_offset_and_then_limit
+ posts_limit_offset = Post.limit(3).offset(2)
+ posts_offset_limit = Post.offset(2).limit(3)
+ assert_equal posts_limit_offset, posts_offset_limit
+ end
+
+ def test_create_with_merge
+ aaron = PoorDeveloperCalledJamis.create_with(name: "foo", salary: 20).merge(
+ PoorDeveloperCalledJamis.create_with(name: "Aaron")).new
+ assert_equal 20, aaron.salary
+ assert_equal "Aaron", aaron.name
+
+ aaron = PoorDeveloperCalledJamis.create_with(name: "foo", salary: 20).
+ create_with(name: "Aaron").new
+ assert_equal 20, aaron.salary
+ assert_equal "Aaron", aaron.name
+ end
+
+ def test_create_with_using_both_string_and_symbol
+ jamis = PoorDeveloperCalledJamis.create_with(name: "foo").create_with("name" => "Aaron").new
+ assert_equal "Aaron", jamis.name
+ end
+
+ def test_create_with_reset
+ jamis = PoorDeveloperCalledJamis.create_with(name: "Aaron").create_with(nil).new
+ assert_equal "Jamis", jamis.name
+ end
+
+ def test_create_with_takes_precedence_over_where
+ developer = Developer.where(name: nil).create_with(name: "Aaron").new
+ assert_equal "Aaron", developer.name
+ end
+
+ def test_create_with_nested_attributes
+ assert_difference("Project.count", 1) do
+ Developer.create_with(
+ projects_attributes: [{ name: "p1" }]
+ ).scoping do
+ Developer.create!(name: "Aaron")
+ end
+ end
+ end
+
+ # FIXME: I don't know if this is *desired* behavior, but it is *today's*
+ # behavior.
+ def test_create_with_empty_hash_will_not_reset
+ jamis = PoorDeveloperCalledJamis.create_with(name: "Aaron").create_with({}).new
+ assert_equal "Aaron", jamis.name
+ end
+
+ def test_unscoped_with_named_scope_should_not_have_default_scope
+ assert_equal [DeveloperCalledJamis.find(developers(:poor_jamis).id)], DeveloperCalledJamis.poor
+
+ assert_includes DeveloperCalledJamis.unscoped.poor, developers(:david).becomes(DeveloperCalledJamis)
+
+ assert_equal 11, DeveloperCalledJamis.unscoped.length
+ assert_equal 1, DeveloperCalledJamis.poor.length
+ assert_equal 10, DeveloperCalledJamis.unscoped.poor.length
+ assert_equal 10, DeveloperCalledJamis.unscoped { DeveloperCalledJamis.poor }.length
+ end
+
+ def test_default_scope_with_joins
+ assert_equal Comment.where(post_id: SpecialPostWithDefaultScope.pluck(:id)).count,
+ Comment.joins(:special_post_with_default_scope).count
+ assert_equal Comment.where(post_id: Post.pluck(:id)).count,
+ Comment.joins(:post).count
+ end
+
+ def test_joins_not_affected_by_scope_other_than_default_or_unscoped
+ without_scope_on_post = Comment.joins(:post).to_a
+ with_scope_on_post = nil
+ Post.where(id: [1, 5, 6]).scoping do
+ with_scope_on_post = Comment.joins(:post).to_a
+ end
+
+ assert_equal with_scope_on_post, without_scope_on_post
+ end
+
+ def test_unscoped_with_joins_should_not_have_default_scope
+ assert_equal SpecialPostWithDefaultScope.unscoped { Comment.joins(:special_post_with_default_scope).to_a },
+ Comment.joins(:post).to_a
+ end
+
+ def test_sti_association_with_unscoped_not_affected_by_default_scope
+ post = posts(:thinking)
+ comments = [comments(:does_it_hurt)]
+
+ post.special_comments.update_all(deleted_at: Time.now)
+
+ assert_raises(ActiveRecord::RecordNotFound) { Post.joins(:special_comments).find(post.id) }
+ assert_equal [], post.special_comments
+
+ SpecialComment.unscoped do
+ assert_equal post, Post.joins(:special_comments).find(post.id)
+ assert_equal comments, Post.joins(:special_comments).find(post.id).special_comments
+ assert_equal comments, Post.eager_load(:special_comments).find(post.id).special_comments
+ assert_equal comments, Post.includes(:special_comments).find(post.id).special_comments
+ assert_equal comments, Post.preload(:special_comments).find(post.id).special_comments
+ end
+ end
+
+ def test_default_scope_select_ignored_by_aggregations
+ assert_equal DeveloperWithSelect.all.to_a.count, DeveloperWithSelect.count
+ end
+
+ def test_default_scope_select_ignored_by_grouped_aggregations
+ assert_equal Hash[Developer.all.group_by(&:salary).map { |s, d| [s, d.count] }],
+ DeveloperWithSelect.group(:salary).count
+ end
+
+ def test_default_scope_order_ignored_by_aggregations
+ assert_equal DeveloperOrderedBySalary.all.count, DeveloperOrderedBySalary.count
+ end
+
+ def test_default_scope_find_last
+ assert DeveloperOrderedBySalary.count > 1, "need more than one row for test"
+
+ lowest_salary_dev = DeveloperOrderedBySalary.find(developers(:poor_jamis).id)
+ assert_equal lowest_salary_dev, DeveloperOrderedBySalary.last
+ end
+
+ def test_default_scope_include_with_count
+ d = DeveloperWithIncludes.create!
+ d.audit_logs.create! message: "foo"
+
+ assert_equal 1, DeveloperWithIncludes.where(audit_logs: { message: "foo" }).count
+ end
+
+ def test_default_scope_with_references_works_through_collection_association
+ post = PostWithCommentWithDefaultScopeReferencesAssociation.create!(title: "Hello World", body: "Here we go.")
+ comment = post.comment_with_default_scope_references_associations.create!(body: "Great post.", developer_id: Developer.first.id)
+ assert_equal comment, post.comment_with_default_scope_references_associations.to_a.first
+ end
+
+ def test_default_scope_with_references_works_through_association
+ post = PostWithCommentWithDefaultScopeReferencesAssociation.create!(title: "Hello World", body: "Here we go.")
+ comment = post.comment_with_default_scope_references_associations.create!(body: "Great post.", developer_id: Developer.first.id)
+ assert_equal comment, post.first_comment
+ end
+
+ def test_default_scope_with_references_works_with_find_by
+ post = PostWithCommentWithDefaultScopeReferencesAssociation.create!(title: "Hello World", body: "Here we go.")
+ comment = post.comment_with_default_scope_references_associations.create!(body: "Great post.", developer_id: Developer.first.id)
+ assert_equal comment, CommentWithDefaultScopeReferencesAssociation.find_by(id: comment.id)
+ end
+
+ test "additional conditions are ANDed with the default scope" do
+ scope = DeveloperCalledJamis.where(name: "David")
+ assert_equal 2, scope.where_clause.ast.children.length
+ assert_equal [], scope.to_a
+ end
+
+ test "additional conditions in a scope are ANDed with the default scope" do
+ scope = DeveloperCalledJamis.david
+ assert_equal 2, scope.where_clause.ast.children.length
+ assert_equal [], scope.to_a
+ end
+
+ test "a scope can remove the condition from the default scope" do
+ scope = DeveloperCalledJamis.david2
+ assert_equal 1, scope.where_clause.ast.children.length
+ assert_equal Developer.where(name: "David").map(&:id), scope.map(&:id)
+ end
+
+ def test_with_abstract_class_where_clause_should_not_be_duplicated
+ scope = Bus.all
+ assert_equal scope.where_clause.ast.children.length, 1
+ end
+
+ def test_sti_conditions_are_not_carried_in_default_scope
+ ConditionalStiPost.create! body: ""
+ SubConditionalStiPost.create! body: ""
+ SubConditionalStiPost.create! title: "Hello world", body: ""
+
+ assert_equal 2, ConditionalStiPost.count
+ assert_equal 2, ConditionalStiPost.all.to_a.size
+ assert_equal 3, ConditionalStiPost.unscope(where: :title).to_a.size
+
+ assert_equal 1, SubConditionalStiPost.count
+ assert_equal 1, SubConditionalStiPost.all.to_a.size
+ assert_equal 2, SubConditionalStiPost.unscope(where: :title).to_a.size
+ end
+
+ def test_with_abstract_class_scope_should_be_executed_in_correct_context
+ vegetarian_pattern, gender_pattern = if current_adapter?(:Mysql2Adapter)
+ [/`lions`.`is_vegetarian`/, /`lions`.`gender`/]
+ elsif current_adapter?(:OracleAdapter)
+ [/"LIONS"."IS_VEGETARIAN"/, /"LIONS"."GENDER"/]
+ else
+ [/"lions"."is_vegetarian"/, /"lions"."gender"/]
+ end
+
+ assert_match vegetarian_pattern, Lion.all.to_sql
+ assert_match gender_pattern, Lion.female.to_sql
+ end
+end
+
+class DefaultScopingWithThreadTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ def test_default_scoping_with_threads
+ 2.times do
+ Thread.new {
+ assert_includes DeveloperOrderedBySalary.all.to_sql, "salary DESC"
+ DeveloperOrderedBySalary.connection.close
+ }.join
+ end
+ end
+
+ def test_default_scope_is_threadsafe
+ 2.times { ThreadsafeDeveloper.unscoped.create! }
+
+ threads = []
+ assert_not_equal 1, ThreadsafeDeveloper.unscoped.count
+
+ barrier_1 = Concurrent::CyclicBarrier.new(2)
+ barrier_2 = Concurrent::CyclicBarrier.new(2)
+
+ threads << Thread.new do
+ Thread.current[:default_scope_delay] = -> { barrier_1.wait; barrier_2.wait }
+ assert_equal 1, ThreadsafeDeveloper.all.to_a.count
+ ThreadsafeDeveloper.connection.close
+ end
+ threads << Thread.new do
+ Thread.current[:default_scope_delay] = -> { barrier_2.wait }
+ barrier_1.wait
+ assert_equal 1, ThreadsafeDeveloper.all.to_a.count
+ ThreadsafeDeveloper.connection.close
+ end
+ threads.each(&:join)
+ ensure
+ ThreadsafeDeveloper.unscoped.destroy_all
+ end
+end unless in_memory_db?
diff --git a/activerecord/test/cases/scoping/named_scoping_test.rb b/activerecord/test/cases/scoping/named_scoping_test.rb
new file mode 100644
index 0000000000..f707951a16
--- /dev/null
+++ b/activerecord/test/cases/scoping/named_scoping_test.rb
@@ -0,0 +1,601 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+require "models/topic"
+require "models/comment"
+require "models/reply"
+require "models/author"
+require "models/developer"
+require "models/computer"
+
+class NamedScopingTest < ActiveRecord::TestCase
+ fixtures :posts, :authors, :topics, :comments, :author_addresses
+
+ def test_implements_enumerable
+ assert_not_empty Topic.all
+
+ assert_equal Topic.all.to_a, Topic.base
+ assert_equal Topic.all.to_a, Topic.base.to_a
+ assert_equal Topic.first, Topic.base.first
+ assert_equal Topic.all.to_a, Topic.base.map { |i| i }
+ end
+
+ def test_found_items_are_cached
+ all_posts = Topic.base
+
+ assert_queries(1) do
+ all_posts.collect { true }
+ all_posts.collect { true }
+ end
+ end
+
+ def test_reload_expires_cache_of_found_items
+ all_posts = Topic.base
+ all_posts.to_a
+
+ new_post = Topic.create!
+ assert_not_includes all_posts, new_post
+ assert_includes all_posts.reload, new_post
+ end
+
+ def test_delegates_finds_and_calculations_to_the_base_class
+ assert_not_empty Topic.all
+
+ assert_equal Topic.all.to_a, Topic.base.to_a
+ assert_equal Topic.first, Topic.base.first
+ assert_equal Topic.count, Topic.base.count
+ assert_equal Topic.average(:replies_count), Topic.base.average(:replies_count)
+ end
+
+ def test_calling_merge_at_first_in_scope
+ Topic.class_eval do
+ scope :calling_merge_at_first_in_scope, Proc.new { merge(Topic.replied) }
+ end
+ assert_equal Topic.calling_merge_at_first_in_scope.to_a, Topic.replied.to_a
+ end
+
+ def test_method_missing_priority_when_delegating
+ klazz = Class.new(ActiveRecord::Base) do
+ self.table_name = "topics"
+ scope :since, Proc.new { where("written_on >= ?", Time.now - 1.day) }
+ scope :to, Proc.new { where("written_on <= ?", Time.now) }
+ end
+ assert_equal klazz.to.since.to_a, klazz.since.to.to_a
+ end
+
+ def test_scope_should_respond_to_own_methods_and_methods_of_the_proxy
+ assert_respond_to Topic.approved, :limit
+ assert_respond_to Topic.approved, :count
+ assert_respond_to Topic.approved, :length
+ end
+
+ def test_scopes_with_options_limit_finds_to_those_matching_the_criteria_specified
+ assert_not_empty Topic.all.merge!(where: { approved: true }).to_a
+
+ assert_equal Topic.all.merge!(where: { approved: true }).to_a, Topic.approved
+ assert_equal Topic.where(approved: true).count, Topic.approved.count
+ end
+
+ def test_scopes_with_string_name_can_be_composed
+ # NOTE that scopes defined with a string as a name worked on their own
+ # but when called on another scope the other scope was completely replaced
+ assert_equal Topic.replied.approved, Topic.replied.approved_as_string
+ end
+
+ def test_scopes_are_composable
+ assert_equal((approved = Topic.all.merge!(where: { approved: true }).to_a), Topic.approved)
+ assert_equal((replied = Topic.all.merge!(where: "replies_count > 0").to_a), Topic.replied)
+ assert_not (approved == replied)
+ assert_not_empty (approved & replied)
+
+ assert_equal approved & replied, Topic.approved.replied
+ end
+
+ def test_procedural_scopes
+ topics_written_before_the_third = Topic.where("written_on < ?", topics(:third).written_on)
+ topics_written_before_the_second = Topic.where("written_on < ?", topics(:second).written_on)
+ assert_not_equal topics_written_before_the_second, topics_written_before_the_third
+
+ assert_equal topics_written_before_the_third, Topic.written_before(topics(:third).written_on)
+ assert_equal topics_written_before_the_second, Topic.written_before(topics(:second).written_on)
+ end
+
+ def test_procedural_scopes_returning_nil
+ all_topics = Topic.all
+
+ assert_equal all_topics, Topic.written_before(nil)
+ end
+
+ def test_scope_with_object
+ objects = Topic.with_object
+ assert_operator objects.length, :>, 0
+ assert objects.all?(&:approved?), "all objects should be approved"
+ end
+
+ def test_has_many_associations_have_access_to_scopes
+ assert_not_equal Post.containing_the_letter_a, authors(:david).posts
+ assert_not_empty Post.containing_the_letter_a
+
+ expected = authors(:david).posts & Post.containing_the_letter_a
+ assert_equal expected.sort_by(&:id), authors(:david).posts.containing_the_letter_a.sort_by(&:id)
+ end
+
+ def test_scope_with_STI
+ assert_equal 3, Post.containing_the_letter_a.count
+ assert_equal 1, SpecialPost.containing_the_letter_a.count
+ end
+
+ def test_has_many_through_associations_have_access_to_scopes
+ assert_not_equal Comment.containing_the_letter_e, authors(:david).comments
+ assert_not_empty Comment.containing_the_letter_e
+
+ expected = authors(:david).comments & Comment.containing_the_letter_e
+ assert_equal expected.sort_by(&:id), authors(:david).comments.containing_the_letter_e.sort_by(&:id)
+ end
+
+ def test_scopes_honor_current_scopes_from_when_defined
+ assert_not_empty Post.ranked_by_comments.limit_by(5)
+ assert_not_empty authors(:david).posts.ranked_by_comments.limit_by(5)
+ assert_not_equal Post.ranked_by_comments.limit_by(5), authors(:david).posts.ranked_by_comments.limit_by(5)
+ assert_not_equal Post.top(5), authors(:david).posts.top(5)
+ # Oracle sometimes sorts differently if WHERE condition is changed
+ assert_equal authors(:david).posts.ranked_by_comments.limit_by(5).to_a.sort_by(&:id), authors(:david).posts.top(5).to_a.sort_by(&:id)
+ assert_equal Post.ranked_by_comments.limit_by(5), Post.top(5)
+ end
+
+ def test_scopes_body_is_a_callable
+ e = assert_raises ArgumentError do
+ Class.new(Post).class_eval { scope :containing_the_letter_z, where("body LIKE '%z%'") }
+ end
+ assert_equal "The scope body needs to be callable.", e.message
+ end
+
+ def test_scopes_name_is_relation_method
+ conflicts = [
+ :records,
+ :to_ary,
+ :to_sql,
+ :explain
+ ]
+
+ conflicts.each do |name|
+ e = assert_raises ArgumentError do
+ Class.new(Post).class_eval { scope name, -> { where(approved: true) } }
+ end
+ assert_match(/You tried to define a scope named \"#{name}\" on the model/, e.message)
+ end
+ end
+
+ def test_active_records_have_scope_named__all__
+ assert_not_empty Topic.all
+
+ assert_equal Topic.all.to_a, Topic.base
+ end
+
+ def test_active_records_have_scope_named__scoped__
+ scope = Topic.where("content LIKE '%Have%'")
+ assert_not_empty scope
+
+ assert_equal scope, Topic.all.merge!(where: "content LIKE '%Have%'")
+ end
+
+ def test_first_and_last_should_allow_integers_for_limit
+ assert_equal Topic.base.first(2), Topic.base.order("id").to_a.first(2)
+ assert_equal Topic.base.last(2), Topic.base.order("id").to_a.last(2)
+ end
+
+ def test_first_and_last_should_not_use_query_when_results_are_loaded
+ topics = Topic.base
+ topics.load # force load
+ assert_no_queries do
+ topics.first
+ topics.last
+ end
+ end
+
+ def test_empty_should_not_load_results
+ topics = Topic.base
+ assert_queries(2) do
+ topics.empty? # use count query
+ topics.load # force load
+ topics.empty? # use loaded (no query)
+ end
+ end
+
+ def test_any_should_not_load_results
+ topics = Topic.base
+ assert_queries(2) do
+ topics.any? # use count query
+ topics.load # force load
+ topics.any? # use loaded (no query)
+ end
+ end
+
+ def test_any_should_call_proxy_found_if_using_a_block
+ topics = Topic.base
+ assert_queries(1) do
+ assert_not_called(topics, :empty?) do
+ topics.any? { true }
+ end
+ end
+ end
+
+ def test_any_should_not_fire_query_if_scope_loaded
+ topics = Topic.base
+ topics.load # force load
+ assert_no_queries { assert topics.any? }
+ end
+
+ def test_model_class_should_respond_to_any
+ assert_predicate Topic, :any?
+ Topic.delete_all
+ assert_not_predicate Topic, :any?
+ end
+
+ def test_many_should_not_load_results
+ topics = Topic.base
+ assert_queries(2) do
+ topics.many? # use count query
+ topics.load # force load
+ topics.many? # use loaded (no query)
+ end
+ end
+
+ def test_many_should_call_proxy_found_if_using_a_block
+ topics = Topic.base
+ assert_queries(1) do
+ assert_not_called(topics, :size) do
+ topics.many? { true }
+ end
+ end
+ end
+
+ def test_many_should_not_fire_query_if_scope_loaded
+ topics = Topic.base
+ topics.load # force load
+ assert_no_queries { assert topics.many? }
+ end
+
+ def test_many_should_return_false_if_none_or_one
+ topics = Topic.base.where(id: 0)
+ assert_not_predicate topics, :many?
+ topics = Topic.base.where(id: 1)
+ assert_not_predicate topics, :many?
+ end
+
+ def test_many_should_return_true_if_more_than_one
+ assert_predicate Topic.base, :many?
+ end
+
+ def test_model_class_should_respond_to_many
+ Topic.delete_all
+ assert_not_predicate Topic, :many?
+ Topic.create!
+ assert_not_predicate Topic, :many?
+ Topic.create!
+ assert_predicate Topic, :many?
+ end
+
+ def test_should_build_on_top_of_scope
+ topic = Topic.approved.build({})
+ assert topic.approved
+ end
+
+ def test_should_build_new_on_top_of_scope
+ topic = Topic.approved.new
+ assert topic.approved
+ end
+
+ def test_should_create_on_top_of_scope
+ topic = Topic.approved.create({})
+ assert topic.approved
+ end
+
+ def test_should_create_with_bang_on_top_of_scope
+ topic = Topic.approved.create!({})
+ assert topic.approved
+ end
+
+ def test_should_build_on_top_of_chained_scopes
+ topic = Topic.approved.by_lifo.build({})
+ assert topic.approved
+ assert_equal "lifo", topic.author_name
+ end
+
+ def test_deprecated_delegating_private_method
+ assert_deprecated do
+ scope = Topic.all.by_private_lifo
+ assert_not scope.instance_variable_get(:@delegate_to_klass)
+ end
+ end
+
+ def test_reserved_scope_names
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "topics"
+
+ scope :approved, -> { where(approved: true) }
+
+ class << self
+ public
+ def pub; end
+
+ private
+ def pri; end
+
+ protected
+ def pro; end
+ end
+ end
+
+ subklass = Class.new(klass)
+
+ conflicts = [
+ :create, # public class method on AR::Base
+ :relation, # private class method on AR::Base
+ :new, # redefined class method on AR::Base
+ :all, # a default scope
+ :public, # some important methods on Module and Class
+ :protected,
+ :private,
+ :name,
+ :parent,
+ :superclass
+ ]
+
+ non_conflicts = [
+ :find_by_title, # dynamic finder method
+ :approved, # existing scope
+ :pub, # existing public class method
+ :pri, # existing private class method
+ :pro, # existing protected class method
+ :open, # a ::Kernel method
+ ]
+
+ conflicts.each do |name|
+ e = assert_raises(ArgumentError, "scope `#{name}` should not be allowed") do
+ klass.class_eval { scope name, -> { where(approved: true) } }
+ end
+ assert_match(/You tried to define a scope named \"#{name}\" on the model/, e.message)
+
+ e = assert_raises(ArgumentError, "scope `#{name}` should not be allowed") do
+ subklass.class_eval { scope name, -> { where(approved: true) } }
+ end
+ assert_match(/You tried to define a scope named \"#{name}\" on the model/, e.message)
+ end
+
+ non_conflicts.each do |name|
+ assert_nothing_raised do
+ silence_warnings do
+ klass.class_eval { scope name, -> { where(approved: true) } }
+ end
+ end
+
+ assert_nothing_raised do
+ subklass.class_eval { scope name, -> { where(approved: true) } }
+ end
+ end
+ end
+
+ # Method delegation for scope names which look like /\A[a-zA-Z_]\w*[!?]?\z/
+ # has been done by evaluating a string with a plain def statement. For scope
+ # names which contain spaces this approach doesn't work.
+ def test_spaces_in_scope_names
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "topics"
+ scope :"title containing space", -> { where("title LIKE '% %'") }
+ scope :approved, -> { where(approved: true) }
+ end
+ assert_equal klass.send(:"title containing space"), klass.where("title LIKE '% %'")
+ assert_equal klass.approved.send(:"title containing space"), klass.approved.where("title LIKE '% %'")
+ end
+
+ def test_find_all_should_behave_like_select
+ assert_equal Topic.base.to_a.select(&:approved), Topic.base.to_a.find_all(&:approved)
+ end
+
+ def test_rand_should_select_a_random_object_from_proxy
+ assert_kind_of Topic, Topic.approved.sample
+ end
+
+ def test_should_use_where_in_query_for_scope
+ assert_equal Developer.where(name: "Jamis").to_set, Developer.where(id: Developer.jamises).to_set
+ end
+
+ def test_size_should_use_count_when_results_are_not_loaded
+ topics = Topic.base
+ assert_queries(1) do
+ assert_sql(/COUNT/i) { topics.size }
+ end
+ end
+
+ def test_size_should_use_length_when_results_are_loaded
+ topics = Topic.base
+ topics.load # force load
+ assert_no_queries do
+ topics.size # use loaded (no query)
+ end
+ end
+
+ def test_should_not_duplicates_where_values
+ relation = Topic.where("1=1")
+ assert_equal relation.where_clause, relation.scope_with_lambda.where_clause
+ end
+
+ def test_chaining_with_duplicate_joins
+ join = "INNER JOIN comments ON comments.post_id = posts.id"
+ post = Post.find(1)
+ assert_equal post.comments.size, Post.joins(join).joins(join).where("posts.id = #{post.id}").size
+ end
+
+ def test_chaining_applies_last_conditions_when_creating
+ post = Topic.rejected.new
+ assert_not_predicate post, :approved?
+
+ post = Topic.rejected.approved.new
+ assert_predicate post, :approved?
+
+ post = Topic.approved.rejected.new
+ assert_not_predicate post, :approved?
+
+ post = Topic.approved.rejected.approved.new
+ assert_predicate post, :approved?
+ end
+
+ def test_chaining_combines_conditions_when_searching
+ # Normal hash conditions
+ assert_equal Topic.where(approved: false).where(approved: true).to_a, Topic.rejected.approved.to_a
+ assert_equal Topic.where(approved: true).where(approved: false).to_a, Topic.approved.rejected.to_a
+
+ # Nested hash conditions with same keys
+ assert_equal [], Post.with_special_comments.with_very_special_comments.to_a
+
+ # Nested hash conditions with different keys
+ assert_equal [posts(:sti_comments)], Post.with_special_comments.with_post(4).to_a.uniq
+ end
+
+ def test_scopes_batch_finders
+ assert_equal 4, Topic.approved.count
+
+ assert_queries(5) do
+ Topic.approved.find_each(batch_size: 1) { |t| assert t.approved? }
+ end
+
+ assert_queries(3) do
+ Topic.approved.find_in_batches(batch_size: 2) do |group|
+ group.each { |t| assert t.approved? }
+ end
+ end
+ end
+
+ def test_table_names_for_chaining_scopes_with_and_without_table_name_included
+ assert_nothing_raised do
+ Comment.for_first_post.for_first_author.to_a
+ end
+ end
+
+ def test_scopes_with_reserved_names
+ class << Topic
+ def public_method; end
+ public :public_method
+
+ def protected_method; end
+ protected :protected_method
+
+ def private_method; end
+ private :private_method
+ end
+
+ [:public_method, :protected_method, :private_method].each do |reserved_method|
+ assert Topic.respond_to?(reserved_method, true)
+ assert_called(ActiveRecord::Base.logger, :warn) do
+ silence_warnings { Topic.scope(reserved_method, -> { }) }
+ end
+ end
+ end
+
+ def test_scopes_on_relations
+ # Topic.replied
+ approved_topics = Topic.all.approved.order("id DESC")
+ assert_equal topics(:fifth), approved_topics.first
+
+ replied_approved_topics = approved_topics.replied
+ assert_equal topics(:third), replied_approved_topics.first
+ end
+
+ def test_index_on_scope
+ approved = Topic.approved.order("id ASC")
+ assert_equal topics(:second), approved[0]
+ assert_predicate approved, :loaded?
+ end
+
+ def test_nested_scopes_queries_size
+ assert_queries(1) do
+ Topic.approved.by_lifo.replied.written_before(Time.now).to_a
+ end
+ end
+
+ # Note: these next two are kinda odd because they are essentially just testing that the
+ # query cache works as it should, but they are here for legacy reasons as they was previously
+ # a separate cache on association proxies, and these show that that is not necessary.
+ def test_scopes_are_cached_on_associations
+ post = posts(:welcome)
+
+ Post.cache do
+ assert_queries(1) { post.comments.containing_the_letter_e.to_a }
+ assert_no_queries { post.comments.containing_the_letter_e.to_a }
+ end
+ end
+
+ def test_scopes_with_arguments_are_cached_on_associations
+ post = posts(:welcome)
+
+ Post.cache do
+ one = assert_queries(1) { post.comments.limit_by(1).to_a }
+ assert_equal 1, one.size
+
+ two = assert_queries(1) { post.comments.limit_by(2).to_a }
+ assert_equal 2, two.size
+
+ assert_no_queries { post.comments.limit_by(1).to_a }
+ assert_no_queries { post.comments.limit_by(2).to_a }
+ end
+ end
+
+ def test_scopes_to_get_newest
+ post = posts(:welcome)
+ old_last_comment = post.comments.newest
+ new_comment = post.comments.create(body: "My new comment")
+ assert_equal new_comment, post.comments.newest
+ assert_not_equal old_last_comment, post.comments.newest
+ end
+
+ def test_scopes_are_reset_on_association_reload
+ post = posts(:welcome)
+
+ [:destroy_all, :reset, :delete_all].each do |method|
+ before = post.comments.containing_the_letter_e
+ post.association(:comments).send(method)
+ assert before.object_id != post.comments.containing_the_letter_e.object_id, "CollectionAssociation##{method} should reset the named scopes cache"
+ end
+ end
+
+ def test_scoped_are_lazy_loaded_if_table_still_does_not_exist
+ assert_nothing_raised do
+ require "models/without_table"
+ end
+ end
+
+ def test_eager_default_scope_relations_are_remove
+ klass = Class.new(ActiveRecord::Base)
+ klass.table_name = "posts"
+
+ assert_raises(ArgumentError) do
+ klass.send(:default_scope, klass.where(id: posts(:welcome).id))
+ end
+ end
+
+ def test_subclass_merges_scopes_properly
+ assert_equal 1, SpecialComment.where(body: "go crazy").created.count
+ end
+
+ def test_model_class_should_respond_to_extending
+ assert_raises OopsError do
+ Comment.unscoped.oops_comments.destroy_all
+ end
+ end
+
+ def test_model_class_should_respond_to_none
+ assert_not_predicate Topic, :none?
+ Topic.delete_all
+ assert_predicate Topic, :none?
+ end
+
+ def test_model_class_should_respond_to_one
+ assert_not_predicate Topic, :one?
+ Topic.delete_all
+ assert_not_predicate Topic, :one?
+ Topic.create!
+ assert_predicate Topic, :one?
+ end
+end
diff --git a/activerecord/test/cases/scoping/relation_scoping_test.rb b/activerecord/test/cases/scoping/relation_scoping_test.rb
new file mode 100644
index 0000000000..b1f2ffe29c
--- /dev/null
+++ b/activerecord/test/cases/scoping/relation_scoping_test.rb
@@ -0,0 +1,426 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+require "models/author"
+require "models/developer"
+require "models/computer"
+require "models/project"
+require "models/comment"
+require "models/category"
+require "models/person"
+require "models/reference"
+
+class RelationScopingTest < ActiveRecord::TestCase
+ fixtures :authors, :author_addresses, :developers, :projects, :comments, :posts, :developers_projects
+
+ setup do
+ developers(:david)
+ end
+
+ def test_unscoped_breaks_caching
+ author = authors :mary
+ assert_nil author.first_post
+ post = FirstPost.unscoped do
+ author.reload.first_post
+ end
+ assert post
+ end
+
+ def test_scope_breaks_caching_on_collections
+ author = authors :david
+ ids = author.reload.special_posts_with_default_scope.map(&:id)
+ assert_equal [1, 5, 6], ids.sort
+ scoped_posts = SpecialPostWithDefaultScope.unscoped do
+ author = authors :david
+ author.reload.special_posts_with_default_scope.to_a
+ end
+ assert_equal author.posts.map(&:id).sort, scoped_posts.map(&:id).sort
+ end
+
+ def test_reverse_order
+ assert_equal Developer.order("id DESC").to_a.reverse, Developer.order("id DESC").reverse_order
+ end
+
+ def test_reverse_order_with_arel_node
+ assert_equal Developer.order("id DESC").to_a.reverse, Developer.order(Developer.arel_table[:id].desc).reverse_order
+ end
+
+ def test_reverse_order_with_multiple_arel_nodes
+ assert_equal Developer.order("id DESC").order("name DESC").to_a.reverse, Developer.order(Developer.arel_table[:id].desc).order(Developer.arel_table[:name].desc).reverse_order
+ end
+
+ def test_reverse_order_with_arel_nodes_and_strings
+ assert_equal Developer.order("id DESC").order("name DESC").to_a.reverse, Developer.order("id DESC").order(Developer.arel_table[:name].desc).reverse_order
+ end
+
+ def test_double_reverse_order_produces_original_order
+ assert_equal Developer.order("name DESC"), Developer.order("name DESC").reverse_order.reverse_order
+ end
+
+ def test_scoped_find
+ Developer.where("name = 'David'").scoping do
+ assert_nothing_raised { Developer.find(1) }
+ end
+ end
+
+ def test_scoped_find_first
+ developer = Developer.find(10)
+ Developer.where("salary = 100000").scoping do
+ assert_equal developer, Developer.order("name").first
+ end
+ end
+
+ def test_scoped_find_last
+ highest_salary = Developer.order("salary DESC").first
+
+ Developer.order("salary").scoping do
+ assert_equal highest_salary, Developer.last
+ end
+ end
+
+ def test_scoped_find_last_preserves_scope
+ lowest_salary = Developer.order("salary ASC").first
+ highest_salary = Developer.order("salary DESC").first
+
+ Developer.order("salary").scoping do
+ assert_equal highest_salary, Developer.last
+ assert_equal lowest_salary, Developer.first
+ end
+ end
+
+ def test_scoped_find_combines_and_sanitizes_conditions
+ Developer.where("salary = 9000").scoping do
+ assert_equal developers(:poor_jamis), Developer.where("name = 'Jamis'").first
+ end
+ end
+
+ def test_scoped_find_all
+ Developer.where("name = 'David'").scoping do
+ assert_equal [developers(:david)], Developer.all
+ end
+ end
+
+ def test_scoped_find_select
+ Developer.select("id, name").scoping do
+ developer = Developer.where("name = 'David'").first
+ assert_equal "David", developer.name
+ assert_not developer.has_attribute?(:salary)
+ end
+ end
+
+ def test_scope_select_concatenates
+ Developer.select("id, name").scoping do
+ developer = Developer.select("salary").where("name = 'David'").first
+ assert_equal 80000, developer.salary
+ assert developer.has_attribute?(:id)
+ assert developer.has_attribute?(:name)
+ assert developer.has_attribute?(:salary)
+ end
+ end
+
+ def test_scoped_count
+ Developer.where("name = 'David'").scoping do
+ assert_equal 1, Developer.count
+ end
+
+ Developer.where("salary = 100000").scoping do
+ assert_equal 8, Developer.count
+ assert_equal 1, Developer.where("name LIKE 'fixture_1%'").count
+ end
+ end
+
+ def test_scoped_find_include
+ # with the include, will retrieve only developers for the given project
+ scoped_developers = Developer.includes(:projects).scoping do
+ Developer.where("projects.id" => 2).to_a
+ end
+ assert_includes scoped_developers, developers(:david)
+ assert_not_includes scoped_developers, developers(:jamis)
+ assert_equal 1, scoped_developers.size
+ end
+
+ def test_scoped_find_joins
+ scoped_developers = Developer.joins("JOIN developers_projects ON id = developer_id").scoping do
+ Developer.where("developers_projects.project_id = 2").to_a
+ end
+
+ assert_includes scoped_developers, developers(:david)
+ assert_not_includes scoped_developers, developers(:jamis)
+ assert_equal 1, scoped_developers.size
+ assert_equal developers(:david).attributes, scoped_developers.first.attributes
+ end
+
+ def test_scoped_create_with_where
+ new_comment = VerySpecialComment.where(post_id: 1).scoping do
+ VerySpecialComment.create body: "Wonderful world"
+ end
+
+ assert_equal 1, new_comment.post_id
+ assert_includes Post.find(1).comments, new_comment
+ end
+
+ def test_scoped_create_with_create_with
+ new_comment = VerySpecialComment.create_with(post_id: 1).scoping do
+ VerySpecialComment.create body: "Wonderful world"
+ end
+
+ assert_equal 1, new_comment.post_id
+ assert_includes Post.find(1).comments, new_comment
+ end
+
+ def test_scoped_create_with_create_with_has_higher_priority
+ new_comment = VerySpecialComment.where(post_id: 2).create_with(post_id: 1).scoping do
+ VerySpecialComment.create body: "Wonderful world"
+ end
+
+ assert_equal 1, new_comment.post_id
+ assert_includes Post.find(1).comments, new_comment
+ end
+
+ def test_ensure_that_method_scoping_is_correctly_restored
+ begin
+ Developer.where("name = 'Jamis'").scoping do
+ raise "an exception"
+ end
+ rescue
+ end
+
+ assert_not Developer.all.to_sql.include?("name = 'Jamis'"), "scope was not restored"
+ end
+
+ def test_default_scope_filters_on_joins
+ assert_equal 1, DeveloperFilteredOnJoins.all.count
+ assert_equal DeveloperFilteredOnJoins.all.first, developers(:david).becomes(DeveloperFilteredOnJoins)
+ end
+
+ def test_update_all_default_scope_filters_on_joins
+ DeveloperFilteredOnJoins.update_all(salary: 65000)
+ assert_equal 65000, Developer.find(developers(:david).id).salary
+
+ # has not changed jamis
+ assert_not_equal 65000, Developer.find(developers(:jamis).id).salary
+ end
+
+ def test_delete_all_default_scope_filters_on_joins
+ assert_not_equal [], DeveloperFilteredOnJoins.all
+
+ DeveloperFilteredOnJoins.delete_all()
+
+ assert_equal [], DeveloperFilteredOnJoins.all
+ assert_not_equal [], Developer.all
+ end
+
+ def test_current_scope_does_not_pollute_sibling_subclasses
+ Comment.none.scoping do
+ assert_not_predicate SpecialComment.all, :any?
+ assert_not_predicate VerySpecialComment.all, :any?
+ assert_not_predicate SubSpecialComment.all, :any?
+ end
+
+ SpecialComment.none.scoping do
+ assert_predicate Comment.all, :any?
+ assert_predicate VerySpecialComment.all, :any?
+ assert_not_predicate SubSpecialComment.all, :any?
+ end
+
+ SubSpecialComment.none.scoping do
+ assert_predicate Comment.all, :any?
+ assert_predicate VerySpecialComment.all, :any?
+ assert_predicate SpecialComment.all, :any?
+ end
+ end
+
+ def test_scoping_is_correctly_restored
+ Comment.unscoped do
+ SpecialComment.unscoped.created
+ end
+
+ assert_nil Comment.send(:current_scope)
+ assert_nil SpecialComment.send(:current_scope)
+ end
+
+ def test_scoping_respects_current_class
+ Comment.unscoped do
+ assert_equal "a comment...", Comment.all.what_are_you
+ assert_equal "a special comment...", SpecialComment.all.what_are_you
+ end
+ end
+
+ def test_scoping_respects_sti_constraint
+ Comment.unscoped do
+ assert_equal comments(:greetings), Comment.find(1)
+ assert_raises(ActiveRecord::RecordNotFound) { SpecialComment.find(1) }
+ end
+ end
+
+ def test_scoping_with_klass_method_works_in_the_scope_block
+ expected = SpecialPostWithDefaultScope.unscoped.to_a
+ assert_equal expected, SpecialPostWithDefaultScope.unscoped_all
+ end
+
+ def test_scoping_with_query_method_works_in_the_scope_block
+ expected = SpecialPostWithDefaultScope.unscoped.where(author_id: 0).to_a
+ assert_equal expected, SpecialPostWithDefaultScope.authorless
+ end
+
+ def test_circular_joins_with_scoping_does_not_crash
+ posts = Post.joins(comments: :post).scoping do
+ Post.first(10)
+ end
+ assert_equal posts, Post.joins(comments: :post).first(10)
+ end
+
+ def test_circular_left_joins_with_scoping_does_not_crash
+ posts = Post.left_joins(comments: :post).scoping do
+ Post.first(10)
+ end
+ assert_equal posts, Post.left_joins(comments: :post).first(10)
+ end
+end
+
+class NestedRelationScopingTest < ActiveRecord::TestCase
+ fixtures :authors, :author_addresses, :developers, :projects, :comments, :posts
+
+ def test_merge_options
+ Developer.where("salary = 80000").scoping do
+ Developer.limit(10).scoping do
+ devs = Developer.all
+ sql = devs.to_sql
+ assert_match "(salary = 80000)", sql
+ assert_match(/LIMIT 10|ROWNUM <= 10|FETCH FIRST 10 ROWS ONLY/, sql)
+ end
+ end
+ end
+
+ def test_merge_inner_scope_has_priority
+ Developer.limit(5).scoping do
+ Developer.limit(10).scoping do
+ assert_equal 10, Developer.all.size
+ end
+ end
+ end
+
+ def test_replace_options
+ Developer.where(name: "David").scoping do
+ Developer.unscoped do
+ assert_equal "Jamis", Developer.where(name: "Jamis").first[:name]
+ end
+
+ assert_equal "David", Developer.first[:name]
+ end
+ end
+
+ def test_three_level_nested_exclusive_scoped_find
+ Developer.where("name = 'Jamis'").scoping do
+ assert_equal "Jamis", Developer.first.name
+
+ Developer.unscoped.where("name = 'David'") do
+ assert_equal "David", Developer.first.name
+
+ Developer.unscoped.where("name = 'Maiha'") do
+ assert_nil Developer.first
+ end
+
+ # ensure that scoping is restored
+ assert_equal "David", Developer.first.name
+ end
+
+ # ensure that scoping is restored
+ assert_equal "Jamis", Developer.first.name
+ end
+ end
+
+ def test_nested_scoped_create
+ comment = Comment.create_with(post_id: 1).scoping do
+ Comment.create_with(post_id: 2).scoping do
+ Comment.create body: "Hey guys, nested scopes are broken. Please fix!"
+ end
+ end
+
+ assert_equal 2, comment.post_id
+ end
+
+ def test_nested_exclusive_scope_for_create
+ comment = Comment.create_with(body: "Hey guys, nested scopes are broken. Please fix!").scoping do
+ Comment.unscoped.create_with(post_id: 1).scoping do
+ assert_predicate Comment.new.body, :blank?
+ Comment.create body: "Hey guys"
+ end
+ end
+
+ assert_equal 1, comment.post_id
+ assert_equal "Hey guys", comment.body
+ end
+end
+
+class HasManyScopingTest < ActiveRecord::TestCase
+ fixtures :comments, :posts, :people, :references
+
+ def setup
+ @welcome = Post.find(1)
+ end
+
+ def test_forwarding_of_static_methods
+ assert_equal "a comment...", Comment.what_are_you
+ assert_equal "a comment...", @welcome.comments.what_are_you
+ end
+
+ def test_forwarding_to_scoped
+ assert_equal 4, Comment.search_by_type("Comment").size
+ assert_equal 2, @welcome.comments.search_by_type("Comment").size
+ end
+
+ def test_nested_scope_finder
+ Comment.where("1=0").scoping do
+ assert_equal 0, @welcome.comments.count
+ assert_equal "a comment...", @welcome.comments.what_are_you
+ end
+
+ Comment.where("1=1").scoping do
+ assert_equal 2, @welcome.comments.count
+ assert_equal "a comment...", @welcome.comments.what_are_you
+ end
+ end
+
+ def test_should_maintain_default_scope_on_associations
+ magician = BadReference.find(1)
+ assert_equal [magician], people(:michael).bad_references
+ end
+
+ def test_should_default_scope_on_associations_is_overridden_by_association_conditions
+ reference = references(:michael_unicyclist).becomes(BadReference)
+ assert_equal [reference], people(:michael).fixed_bad_references
+ end
+
+ def test_should_maintain_default_scope_on_eager_loaded_associations
+ michael = Person.where(id: people(:michael).id).includes(:bad_references).first
+ magician = BadReference.find(1)
+ assert_equal [magician], michael.bad_references
+ end
+end
+
+class HasAndBelongsToManyScopingTest < ActiveRecord::TestCase
+ fixtures :posts, :categories, :categories_posts
+
+ def setup
+ @welcome = Post.find(1)
+ end
+
+ def test_forwarding_of_static_methods
+ assert_equal "a category...", Category.what_are_you
+ assert_equal "a category...", @welcome.categories.what_are_you
+ end
+
+ def test_nested_scope_finder
+ Category.where("1=0").scoping do
+ assert_equal 0, @welcome.categories.count
+ assert_equal "a category...", @welcome.categories.what_are_you
+ end
+
+ Category.where("1=1").scoping do
+ assert_equal 2, @welcome.categories.count
+ assert_equal "a category...", @welcome.categories.what_are_you
+ end
+ end
+end
diff --git a/activerecord/test/cases/secure_token_test.rb b/activerecord/test/cases/secure_token_test.rb
new file mode 100644
index 0000000000..f5fa6aa302
--- /dev/null
+++ b/activerecord/test/cases/secure_token_test.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/user"
+
+class SecureTokenTest < ActiveRecord::TestCase
+ setup do
+ @user = User.new
+ end
+
+ def test_token_values_are_generated_for_specified_attributes_and_persisted_on_save
+ @user.save
+ assert_not_nil @user.token
+ assert_not_nil @user.auth_token
+ end
+
+ def test_regenerating_the_secure_token
+ @user.save
+ old_token = @user.token
+ old_auth_token = @user.auth_token
+ @user.regenerate_token
+ @user.regenerate_auth_token
+
+ assert_not_equal @user.token, old_token
+ assert_not_equal @user.auth_token, old_auth_token
+ end
+
+ def test_token_value_not_overwritten_when_present
+ @user.token = "custom-secure-token"
+ @user.save
+
+ assert_equal "custom-secure-token", @user.token
+ end
+end
diff --git a/activerecord/test/cases/serialization_test.rb b/activerecord/test/cases/serialization_test.rb
new file mode 100644
index 0000000000..932780bfef
--- /dev/null
+++ b/activerecord/test/cases/serialization_test.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/contact"
+require "models/topic"
+require "models/book"
+require "models/author"
+require "models/post"
+
+class SerializationTest < ActiveRecord::TestCase
+ fixtures :books
+
+ FORMATS = [ :json ]
+
+ def setup
+ @contact_attributes = {
+ name: "aaron stack",
+ age: 25,
+ avatar: "binarydata",
+ created_at: Time.utc(2006, 8, 1),
+ awesome: false,
+ preferences: { gem: "<strong>ruby</strong>" },
+ alternative_id: nil,
+ id: nil
+ }
+ end
+
+ def test_include_root_in_json_is_false_by_default
+ assert_equal false, ActiveRecord::Base.include_root_in_json, "include_root_in_json should be false by default but was not"
+ end
+
+ def test_serialize_should_be_reversible
+ FORMATS.each do |format|
+ @serialized = Contact.new.send("to_#{format}")
+ contact = Contact.new.send("from_#{format}", @serialized)
+
+ assert_equal @contact_attributes.keys.collect(&:to_s).sort, contact.attributes.keys.collect(&:to_s).sort, "For #{format}"
+ end
+ end
+
+ def test_serialize_should_allow_attribute_only_filtering
+ FORMATS.each do |format|
+ @serialized = Contact.new(@contact_attributes).send("to_#{format}", only: [ :age, :name ])
+ contact = Contact.new.send("from_#{format}", @serialized)
+ assert_equal @contact_attributes[:name], contact.name, "For #{format}"
+ assert_nil contact.avatar, "For #{format}"
+ end
+ end
+
+ def test_serialize_should_allow_attribute_except_filtering
+ FORMATS.each do |format|
+ @serialized = Contact.new(@contact_attributes).send("to_#{format}", except: [ :age, :name ])
+ contact = Contact.new.send("from_#{format}", @serialized)
+ assert_nil contact.name, "For #{format}"
+ assert_nil contact.age, "For #{format}"
+ assert_equal @contact_attributes[:awesome], contact.awesome, "For #{format}"
+ end
+ end
+
+ def test_include_root_in_json_allows_inheritance
+ original_root_in_json = ActiveRecord::Base.include_root_in_json
+ ActiveRecord::Base.include_root_in_json = true
+
+ klazz = Class.new(ActiveRecord::Base)
+ klazz.table_name = "topics"
+ assert klazz.include_root_in_json
+
+ klazz.include_root_in_json = false
+ assert ActiveRecord::Base.include_root_in_json
+ assert_not klazz.include_root_in_json
+ assert_not klazz.new.include_root_in_json
+ ensure
+ ActiveRecord::Base.include_root_in_json = original_root_in_json
+ end
+
+ def test_read_attribute_for_serialization_with_format_without_method_missing
+ klazz = Class.new(ActiveRecord::Base)
+ klazz.table_name = "books"
+
+ book = klazz.new
+ assert_nil book.read_attribute_for_serialization(:format)
+ end
+
+ def test_read_attribute_for_serialization_with_format_after_init
+ klazz = Class.new(ActiveRecord::Base)
+ klazz.table_name = "books"
+
+ book = klazz.new(format: "paperback")
+ assert_equal "paperback", book.read_attribute_for_serialization(:format)
+ end
+
+ def test_read_attribute_for_serialization_with_format_after_find
+ klazz = Class.new(ActiveRecord::Base)
+ klazz.table_name = "books"
+
+ book = klazz.find(books(:awdr).id)
+ assert_equal "paperback", book.read_attribute_for_serialization(:format)
+ end
+
+ def test_find_records_by_serialized_attributes_through_join
+ author = Author.create!(name: "David")
+ author.serialized_posts.create!(title: "Hello")
+
+ assert_equal 1, Author.joins(:serialized_posts).where(name: "David", serialized_posts: { title: "Hello" }).length
+ end
+end
diff --git a/activerecord/test/cases/serialized_attribute_test.rb b/activerecord/test/cases/serialized_attribute_test.rb
new file mode 100644
index 0000000000..f6cd4f85ee
--- /dev/null
+++ b/activerecord/test/cases/serialized_attribute_test.rb
@@ -0,0 +1,400 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+require "models/reply"
+require "models/person"
+require "models/traffic_light"
+require "models/post"
+require "bcrypt"
+
+class SerializedAttributeTest < ActiveRecord::TestCase
+ fixtures :topics, :posts
+
+ MyObject = Struct.new :attribute1, :attribute2
+
+ # NOTE: Use a duplicate of Topic so attribute
+ # changes don't bleed into other tests
+ Topic = ::Topic.dup
+
+ teardown do
+ Topic.serialize("content")
+ end
+
+ def test_serialize_does_not_eagerly_load_columns
+ Topic.reset_column_information
+ assert_no_queries do
+ Topic.serialize(:content)
+ end
+ end
+
+ def test_serialized_attribute
+ Topic.serialize("content", MyObject)
+
+ myobj = MyObject.new("value1", "value2")
+ topic = Topic.create("content" => myobj)
+ assert_equal(myobj, topic.content)
+
+ topic.reload
+ assert_equal(myobj, topic.content)
+ end
+
+ def test_serialized_attribute_in_base_class
+ Topic.serialize("content", Hash)
+
+ hash = { "content1" => "value1", "content2" => "value2" }
+ important_topic = ImportantTopic.create("content" => hash)
+ assert_equal(hash, important_topic.content)
+
+ important_topic.reload
+ assert_equal(hash, important_topic.content)
+ end
+
+ def test_serialized_attributes_from_database_on_subclass
+ Topic.serialize :content, Hash
+
+ t = Reply.new(content: { foo: :bar })
+ assert_equal({ foo: :bar }, t.content)
+ t.save!
+ t = Reply.last
+ assert_equal({ foo: :bar }, t.content)
+ end
+
+ def test_serialized_attribute_calling_dup_method
+ Topic.serialize :content, JSON
+
+ orig = Topic.new(content: { foo: :bar })
+ clone = orig.dup
+ assert_equal(orig.content, clone.content)
+ end
+
+ def test_serialized_json_attribute_returns_unserialized_value
+ Topic.serialize :content, JSON
+ my_post = posts(:welcome)
+
+ t = Topic.new(content: my_post)
+ t.save!
+ t.reload
+
+ assert_instance_of(Hash, t.content)
+ assert_equal(my_post.id, t.content["id"])
+ assert_equal(my_post.title, t.content["title"])
+ end
+
+ def test_json_read_legacy_null
+ Topic.serialize :content, JSON
+
+ # Force a row to have a JSON "null" instead of a database NULL (this is how
+ # null values are saved on 4.1 and before)
+ id = Topic.connection.insert "INSERT INTO topics (content) VALUES('null')"
+ t = Topic.find(id)
+
+ assert_nil t.content
+ end
+
+ def test_json_read_db_null
+ Topic.serialize :content, JSON
+
+ # Force a row to have a database NULL instead of a JSON "null"
+ id = Topic.connection.insert "INSERT INTO topics (content) VALUES(NULL)"
+ t = Topic.find(id)
+
+ assert_nil t.content
+ end
+
+ def test_serialized_attribute_declared_in_subclass
+ hash = { "important1" => "value1", "important2" => "value2" }
+ important_topic = ImportantTopic.create("important" => hash)
+ assert_equal(hash, important_topic.important)
+
+ important_topic.reload
+ assert_equal(hash, important_topic.important)
+ assert_equal(hash, important_topic.read_attribute(:important))
+ end
+
+ def test_serialized_time_attribute
+ myobj = Time.local(2008, 1, 1, 1, 0)
+ topic = Topic.create("content" => myobj).reload
+ assert_equal(myobj, topic.content)
+ end
+
+ def test_serialized_string_attribute
+ myobj = "Yes"
+ topic = Topic.create("content" => myobj).reload
+ assert_equal(myobj, topic.content)
+ end
+
+ def test_nil_serialized_attribute_without_class_constraint
+ topic = Topic.new
+ assert_nil topic.content
+ end
+
+ def test_nil_not_serialized_without_class_constraint
+ assert Topic.new(content: nil).save
+ assert_equal 1, Topic.where(content: nil).count
+ end
+
+ def test_nil_not_serialized_with_class_constraint
+ Topic.serialize :content, Hash
+ assert Topic.new(content: nil).save
+ assert_equal 1, Topic.where(content: nil).count
+ end
+
+ def test_serialized_attribute_should_raise_exception_on_assignment_with_wrong_type
+ Topic.serialize(:content, Hash)
+ assert_raise(ActiveRecord::SerializationTypeMismatch) do
+ Topic.new(content: "string")
+ end
+ end
+
+ def test_should_raise_exception_on_serialized_attribute_with_type_mismatch
+ myobj = MyObject.new("value1", "value2")
+ topic = Topic.new(content: myobj)
+ assert topic.save
+ Topic.serialize(:content, Hash)
+ assert_raise(ActiveRecord::SerializationTypeMismatch) { Topic.find(topic.id).content }
+ end
+
+ def test_serialized_attribute_with_class_constraint
+ settings = { "color" => "blue" }
+ Topic.serialize(:content, Hash)
+ topic = Topic.new(content: settings)
+ assert topic.save
+ assert_equal(settings, Topic.find(topic.id).content)
+ end
+
+ def test_where_by_serialized_attribute_with_array
+ settings = [ "color" => "green" ]
+ Topic.serialize(:content, Array)
+ topic = Topic.create!(content: settings)
+ assert_equal topic, Topic.where(content: settings).take
+ end
+
+ def test_where_by_serialized_attribute_with_hash
+ settings = { "color" => "green" }
+ Topic.serialize(:content, Hash)
+ topic = Topic.create!(content: settings)
+ assert_equal topic, Topic.where(content: settings).take
+ end
+
+ def test_where_by_serialized_attribute_with_hash_in_array
+ settings = { "color" => "green" }
+ Topic.serialize(:content, Hash)
+ topic = Topic.create!(content: settings)
+ assert_equal topic, Topic.where(content: [settings]).take
+ end
+
+ def test_serialized_default_class
+ Topic.serialize(:content, Hash)
+ topic = Topic.new
+ assert_equal Hash, topic.content.class
+ assert_equal Hash, topic.read_attribute(:content).class
+ topic.content["beer"] = "MadridRb"
+ assert topic.save
+ topic.reload
+ assert_equal Hash, topic.content.class
+ assert_equal "MadridRb", topic.content["beer"]
+ end
+
+ def test_serialized_no_default_class_for_object
+ topic = Topic.new
+ assert_nil topic.content
+ end
+
+ def test_serialized_boolean_value_true
+ topic = Topic.new(content: true)
+ assert topic.save
+ topic = topic.reload
+ assert_equal true, topic.content
+ end
+
+ def test_serialized_boolean_value_false
+ topic = Topic.new(content: false)
+ assert topic.save
+ topic = topic.reload
+ assert_equal false, topic.content
+ end
+
+ def test_serialize_with_coder
+ some_class = Struct.new(:foo) do
+ def self.dump(value)
+ value.foo
+ end
+
+ def self.load(value)
+ new(value)
+ end
+ end
+
+ Topic.serialize(:content, some_class)
+ topic = Topic.new(content: some_class.new("my value"))
+ topic.save!
+ topic.reload
+ assert_kind_of some_class, topic.content
+ assert_equal some_class.new("my value"), topic.content
+ end
+
+ def test_serialize_attribute_via_select_method_when_time_zone_available
+ with_timezone_config aware_attributes: true do
+ Topic.serialize(:content, MyObject)
+
+ myobj = MyObject.new("value1", "value2")
+ topic = Topic.create(content: myobj)
+
+ assert_equal(myobj, Topic.select(:content).find(topic.id).content)
+ assert_raise(ActiveModel::MissingAttributeError) { Topic.select(:id).find(topic.id).content }
+ end
+ end
+
+ def test_serialize_attribute_can_be_serialized_in_an_integer_column
+ insures = ["life"]
+ person = SerializedPerson.new(first_name: "David", insures: insures)
+ assert person.save
+ person = person.reload
+ assert_equal(insures, person.insures)
+ end
+
+ def test_regression_serialized_default_on_text_column_with_null_false
+ light = TrafficLight.new
+ assert_equal [], light.state
+ assert_equal [], light.long_state
+ end
+
+ def test_unexpected_serialized_type
+ Topic.serialize :content, Hash
+ topic = Topic.create!(content: { zomg: true })
+
+ Topic.serialize :content, Array
+
+ topic.reload
+ error = assert_raise(ActiveRecord::SerializationTypeMismatch) do
+ topic.content
+ end
+ expected = "can't load `content`: was supposed to be a Array, but was a Hash. -- {:zomg=>true}"
+ assert_equal expected, error.to_s
+ end
+
+ def test_serialized_column_should_unserialize_after_update_column
+ t = Topic.create(content: "first")
+ assert_equal("first", t.content)
+
+ t.update_column(:content, ["second"])
+ assert_equal(["second"], t.content)
+ assert_equal(["second"], t.reload.content)
+ end
+
+ def test_serialized_column_should_unserialize_after_update_attribute
+ t = Topic.create(content: "first")
+ assert_equal("first", t.content)
+
+ t.update_attribute(:content, "second")
+ assert_equal("second", t.content)
+ assert_equal("second", t.reload.content)
+ end
+
+ def test_nil_is_not_changed_when_serialized_with_a_class
+ Topic.serialize(:content, Array)
+
+ topic = Topic.new(content: nil)
+
+ assert_not_predicate topic, :content_changed?
+ end
+
+ def test_classes_without_no_arg_constructors_are_not_supported
+ assert_raises(ArgumentError) do
+ Topic.serialize(:content, Regexp)
+ end
+ end
+
+ def test_newly_emptied_serialized_hash_is_changed
+ Topic.serialize(:content, Hash)
+ topic = Topic.create(content: { "things" => "stuff" })
+ topic.content.delete("things")
+ topic.save!
+ topic.reload
+
+ assert_equal({}, topic.content)
+ end
+
+ def test_values_cast_from_nil_are_persisted_as_nil
+ # This is required to fulfil the following contract, which must be universally
+ # true in Active Record:
+ #
+ # model.attribute = value
+ # assert_equal model.attribute, model.tap(&:save).reload.attribute
+ Topic.serialize(:content, Hash)
+ topic = Topic.create!(content: {})
+ topic2 = Topic.create!(content: nil)
+
+ assert_equal [topic, topic2], Topic.where(content: nil).sort_by(&:id)
+ end
+
+ def test_nil_is_always_persisted_as_null
+ Topic.serialize(:content, Hash)
+
+ topic = Topic.create!(content: { foo: "bar" })
+ topic.update_attribute :content, nil
+ assert_equal [topic], Topic.where(content: nil)
+ end
+
+ def test_mutation_detection_does_not_double_serialize
+ coder = Object.new
+ def coder.dump(value)
+ return if value.nil?
+ value + " encoded"
+ end
+ def coder.load(value)
+ return if value.nil?
+ value.gsub(" encoded", "")
+ end
+ type = Class.new(ActiveModel::Type::Value) do
+ include ActiveModel::Type::Helpers::Mutable
+
+ def serialize(value)
+ return if value.nil?
+ value + " serialized"
+ end
+
+ def deserialize(value)
+ return if value.nil?
+ value.gsub(" serialized", "")
+ end
+ end.new
+ model = Class.new(Topic) do
+ attribute :foo, type
+ serialize :foo, coder
+ end
+
+ topic = model.create!(foo: "bar")
+ topic.foo
+ assert_not_predicate topic, :changed?
+ end
+
+ def test_serialized_attribute_works_under_concurrent_initial_access
+ model = ::Topic.dup
+
+ topic = model.last
+ topic.update group: "1"
+
+ model.serialize :group, JSON
+ model.reset_column_information
+
+ # This isn't strictly necessary for the test, but a little bit of
+ # knowledge of internals allows us to make failures far more likely.
+ model.define_singleton_method(:define_attribute) do |*args|
+ Thread.pass
+ super(*args)
+ end
+
+ threads = 4.times.map do
+ Thread.new do
+ topic.reload.group
+ end
+ end
+
+ # All the threads should retrieve the value knowing it is JSON, and
+ # thus decode it. If this fails, some threads will instead see the
+ # raw string ("1"), or raise an exception.
+ assert_equal [1] * threads.size, threads.map(&:value)
+ end
+end
diff --git a/activerecord/test/cases/statement_cache_test.rb b/activerecord/test/cases/statement_cache_test.rb
new file mode 100644
index 0000000000..e3c12f68fd
--- /dev/null
+++ b/activerecord/test/cases/statement_cache_test.rb
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/book"
+require "models/liquid"
+require "models/molecule"
+require "models/electron"
+
+module ActiveRecord
+ class StatementCacheTest < ActiveRecord::TestCase
+ def setup
+ @connection = ActiveRecord::Base.connection
+ end
+
+ def test_statement_cache
+ Book.create(name: "my book")
+ Book.create(name: "my other book")
+
+ cache = StatementCache.create(Book.connection) do |params|
+ Book.where(name: params.bind)
+ end
+
+ b = cache.execute([ "my book" ], Book.connection)
+ assert_equal "my book", b[0].name
+ b = cache.execute([ "my other book" ], Book.connection)
+ assert_equal "my other book", b[0].name
+ end
+
+ def test_statement_cache_id
+ b1 = Book.create(name: "my book")
+ b2 = Book.create(name: "my other book")
+
+ cache = StatementCache.create(Book.connection) do |params|
+ Book.where(id: params.bind)
+ end
+
+ b = cache.execute([ b1.id ], Book.connection)
+ assert_equal b1.name, b[0].name
+ b = cache.execute([ b2.id ], Book.connection)
+ assert_equal b2.name, b[0].name
+ end
+
+ def test_find_or_create_by
+ Book.create(name: "my book")
+
+ a = Book.find_or_create_by(name: "my book")
+ b = Book.find_or_create_by(name: "my other book")
+
+ assert_equal("my book", a.name)
+ assert_equal("my other book", b.name)
+ end
+
+ def test_statement_cache_with_simple_statement
+ cache = ActiveRecord::StatementCache.create(Book.connection) do |params|
+ Book.where(name: "my book").where("author_id > 3")
+ end
+
+ Book.create(name: "my book", author_id: 4)
+
+ books = cache.execute([], Book.connection)
+ assert_equal "my book", books[0].name
+ end
+
+ def test_statement_cache_with_complex_statement
+ cache = ActiveRecord::StatementCache.create(Book.connection) do |params|
+ Liquid.joins(molecules: :electrons).where("molecules.name" => "dioxane", "electrons.name" => "lepton")
+ end
+
+ salty = Liquid.create(name: "salty")
+ molecule = salty.molecules.create(name: "dioxane")
+ molecule.electrons.create(name: "lepton")
+
+ liquids = cache.execute([], Book.connection)
+ assert_equal "salty", liquids[0].name
+ end
+
+ def test_statement_cache_values_differ
+ cache = ActiveRecord::StatementCache.create(Book.connection) do |params|
+ Book.where(name: "my book")
+ end
+
+ 3.times do
+ Book.create(name: "my book")
+ end
+
+ first_books = cache.execute([], Book.connection)
+
+ 3.times do
+ Book.create(name: "my book")
+ end
+
+ additional_books = cache.execute([], Book.connection)
+ assert first_books != additional_books
+ end
+
+ def test_unprepared_statements_dont_share_a_cache_with_prepared_statements
+ Book.create(name: "my book")
+ Book.create(name: "my other book")
+
+ book = Book.find_by(name: "my book")
+ other_book = Book.connection.unprepared_statement do
+ Book.find_by(name: "my other book")
+ end
+
+ assert_not_equal book, other_book
+ end
+
+ def test_find_by_does_not_use_statement_cache_if_table_name_is_changed
+ book = Book.create(name: "my book")
+
+ Book.find_by(name: book.name) # warming the statement cache.
+
+ # changing the table name should change the query that is not cached.
+ Book.table_name = :birds
+ assert_nil Book.find_by(name: book.name)
+ ensure
+ Book.table_name = :books
+ end
+
+ def test_find_does_not_use_statement_cache_if_table_name_is_changed
+ book = Book.create(name: "my book")
+
+ Book.find(book.id) # warming the statement cache.
+
+ # changing the table name should change the query that is not cached.
+ Book.table_name = :birds
+ assert_raise ActiveRecord::RecordNotFound do
+ Book.find(book.id)
+ end
+ ensure
+ Book.table_name = :books
+ end
+ end
+end
diff --git a/activerecord/test/cases/statement_invalid_test.rb b/activerecord/test/cases/statement_invalid_test.rb
new file mode 100644
index 0000000000..16ea69c1bd
--- /dev/null
+++ b/activerecord/test/cases/statement_invalid_test.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/book"
+
+module ActiveRecord
+ class StatementInvalidTest < ActiveRecord::TestCase
+ fixtures :books
+
+ class MockDatabaseError < StandardError
+ def result
+ 0
+ end
+
+ def error_number
+ 0
+ end
+ end
+
+ test "message contains no sql" do
+ sql = Book.where(author_id: 96, cover: "hard").to_sql
+ error = assert_raises(ActiveRecord::StatementInvalid) do
+ Book.connection.send(:log, sql, Book.name) do
+ raise MockDatabaseError
+ end
+ end
+ assert_not error.message.include?("SELECT")
+ end
+
+ test "statement and binds are set on select" do
+ sql = Book.where(author_id: 96, cover: "hard").to_sql
+ binds = [Minitest::Mock.new, Minitest::Mock.new]
+ error = assert_raises(ActiveRecord::StatementInvalid) do
+ Book.connection.send(:log, sql, Book.name, binds) do
+ raise MockDatabaseError
+ end
+ end
+ assert_equal error.sql, sql
+ assert_equal error.binds, binds
+ end
+ end
+end
diff --git a/activerecord/test/cases/store_test.rb b/activerecord/test/cases/store_test.rb
new file mode 100644
index 0000000000..4457cfbd37
--- /dev/null
+++ b/activerecord/test/cases/store_test.rb
@@ -0,0 +1,251 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/admin"
+require "models/admin/user"
+
+class StoreTest < ActiveRecord::TestCase
+ fixtures :'admin/users'
+
+ setup do
+ @john = Admin::User.create!(
+ name: "John Doe", color: "black", remember_login: true,
+ height: "tall", is_a_good_guy: true,
+ parent_name: "Quinn", partner_name: "Dallas",
+ partner_birthday: "1997-11-1"
+ )
+ end
+
+ test "reading store attributes through accessors" do
+ assert_equal "black", @john.color
+ assert_nil @john.homepage
+ end
+
+ test "writing store attributes through accessors" do
+ @john.color = "red"
+ @john.homepage = "37signals.com"
+
+ assert_equal "red", @john.color
+ assert_equal "37signals.com", @john.homepage
+ end
+
+ test "reading store attributes through accessors with prefix" do
+ assert_equal "Quinn", @john.parent_name
+ assert_nil @john.parent_birthday
+ assert_equal "Dallas", @john.partner_name
+ assert_equal "1997-11-1", @john.partner_birthday
+ end
+
+ test "writing store attributes through accessors with prefix" do
+ @john.partner_name = "River"
+ @john.partner_birthday = "1999-2-11"
+
+ assert_equal "River", @john.partner_name
+ assert_equal "1999-2-11", @john.partner_birthday
+ end
+
+ test "accessing attributes not exposed by accessors" do
+ @john.settings[:icecream] = "graeters"
+ @john.save
+
+ assert_equal "graeters", @john.reload.settings[:icecream]
+ end
+
+ test "overriding a read accessor" do
+ @john.settings[:phone_number] = "1234567890"
+
+ assert_equal "(123) 456-7890", @john.phone_number
+ end
+
+ test "overriding a read accessor using super" do
+ @john.settings[:color] = nil
+
+ assert_equal "red", @john.color
+ end
+
+ test "updating the store will mark it as changed" do
+ @john.color = "red"
+ assert_predicate @john, :settings_changed?
+ end
+
+ test "updating the store populates the changed array correctly" do
+ @john.color = "red"
+ assert_equal "black", @john.settings_change[0]["color"]
+ assert_equal "red", @john.settings_change[1]["color"]
+ end
+
+ test "updating the store won't mark it as changed if an attribute isn't changed" do
+ @john.color = @john.color
+ assert_not_predicate @john, :settings_changed?
+ end
+
+ test "object initialization with not nullable column" do
+ assert_equal true, @john.remember_login
+ end
+
+ test "writing with not nullable column" do
+ @john.remember_login = false
+ assert_equal false, @john.remember_login
+ end
+
+ test "overriding a write accessor" do
+ @john.phone_number = "(123) 456-7890"
+
+ assert_equal "1234567890", @john.settings[:phone_number]
+ end
+
+ test "overriding a write accessor using super" do
+ @john.color = "yellow"
+
+ assert_equal "blue", @john.color
+ end
+
+ test "preserve store attributes data in HashWithIndifferentAccess format without any conversion" do
+ @john.json_data = ActiveSupport::HashWithIndifferentAccess.new(:height => "tall", "weight" => "heavy")
+ @john.height = "low"
+ assert_equal true, @john.json_data.instance_of?(ActiveSupport::HashWithIndifferentAccess)
+ assert_equal "low", @john.json_data[:height]
+ assert_equal "low", @john.json_data["height"]
+ assert_equal "heavy", @john.json_data[:weight]
+ assert_equal "heavy", @john.json_data["weight"]
+ end
+
+ test "convert store attributes from Hash to HashWithIndifferentAccess saving the data and access attributes indifferently" do
+ user = Admin::User.find_by_name("Jamis")
+ assert_equal "symbol", user.settings[:symbol]
+ assert_equal "symbol", user.settings["symbol"]
+ assert_equal "string", user.settings[:string]
+ assert_equal "string", user.settings["string"]
+ assert_equal true, user.settings.instance_of?(ActiveSupport::HashWithIndifferentAccess)
+
+ user.height = "low"
+ assert_equal "symbol", user.settings[:symbol]
+ assert_equal "symbol", user.settings["symbol"]
+ assert_equal "string", user.settings[:string]
+ assert_equal "string", user.settings["string"]
+ assert_equal true, user.settings.instance_of?(ActiveSupport::HashWithIndifferentAccess)
+ end
+
+ test "convert store attributes from any format other than Hash or HashWithIndifferentAccess losing the data" do
+ @john.json_data = "somedata"
+ @john.height = "low"
+ assert_equal true, @john.json_data.instance_of?(ActiveSupport::HashWithIndifferentAccess)
+ assert_equal "low", @john.json_data[:height]
+ assert_equal "low", @john.json_data["height"]
+ assert_equal false, @john.json_data.delete_if { |k, v| k == "height" }.any?
+ end
+
+ test "reading store attributes through accessors encoded with JSON" do
+ assert_equal "tall", @john.height
+ assert_nil @john.weight
+ end
+
+ test "writing store attributes through accessors encoded with JSON" do
+ @john.height = "short"
+ @john.weight = "heavy"
+
+ assert_equal "short", @john.height
+ assert_equal "heavy", @john.weight
+ end
+
+ test "accessing attributes not exposed by accessors encoded with JSON" do
+ @john.json_data["somestuff"] = "somecoolstuff"
+ @john.save
+
+ assert_equal "somecoolstuff", @john.reload.json_data["somestuff"]
+ end
+
+ test "updating the store will mark it as changed encoded with JSON" do
+ @john.height = "short"
+ assert_predicate @john, :json_data_changed?
+ end
+
+ test "object initialization with not nullable column encoded with JSON" do
+ assert_equal true, @john.is_a_good_guy
+ end
+
+ test "writing with not nullable column encoded with JSON" do
+ @john.is_a_good_guy = false
+ assert_equal false, @john.is_a_good_guy
+ end
+
+ test "all stored attributes are returned" do
+ assert_equal [:color, :homepage, :favorite_food], Admin::User.stored_attributes[:settings]
+ end
+
+ test "stored_attributes are tracked per class" do
+ first_model = Class.new(ActiveRecord::Base) do
+ store_accessor :data, :color
+ end
+ second_model = Class.new(ActiveRecord::Base) do
+ store_accessor :data, :width, :height
+ end
+
+ assert_equal [:color], first_model.stored_attributes[:data]
+ assert_equal [:width, :height], second_model.stored_attributes[:data]
+ end
+
+ test "stored_attributes are tracked per subclass" do
+ first_model = Class.new(ActiveRecord::Base) do
+ store_accessor :data, :color
+ end
+ second_model = Class.new(first_model) do
+ store_accessor :data, :width, :height
+ end
+ third_model = Class.new(first_model) do
+ store_accessor :data, :area, :volume
+ end
+
+ assert_equal [:color], first_model.stored_attributes[:data]
+ assert_equal [:color, :width, :height], second_model.stored_attributes[:data]
+ assert_equal [:color, :area, :volume], third_model.stored_attributes[:data]
+ assert_equal [:color], first_model.stored_attributes[:data]
+ end
+
+ test "YAML coder initializes the store when a Nil value is given" do
+ assert_equal({}, @john.params)
+ end
+
+ test "dump, load and dump again a model" do
+ dumped = YAML.dump(@john)
+ loaded = YAML.load(dumped)
+ assert_equal @john, loaded
+
+ second_dump = YAML.dump(loaded)
+ assert_equal @john, YAML.load(second_dump)
+ end
+
+ test "read store attributes through accessors with default suffix" do
+ @john.configs[:two_factor_auth] = true
+ assert_equal true, @john.two_factor_auth_configs
+ end
+
+ test "write store attributes through accessors with default suffix" do
+ @john.two_factor_auth_configs = false
+ assert_equal false, @john.configs[:two_factor_auth]
+ end
+
+ test "read store attributes through accessors with custom suffix" do
+ @john.configs[:login_retry] = 3
+ assert_equal 3, @john.login_retry_config
+ end
+
+ test "write store attributes through accessors with custom suffix" do
+ @john.login_retry_config = 5
+ assert_equal 5, @john.configs[:login_retry]
+ end
+
+ test "read accessor without pre/suffix in the same store as other pre/suffixed accessors still works" do
+ @john.configs[:secret_question] = "What is your high school?"
+ assert_equal "What is your high school?", @john.secret_question
+ end
+
+ test "write accessor without pre/suffix in the same store as other pre/suffixed accessors still works" do
+ @john.secret_question = "What was the Rails version when you first worked on it?"
+ assert_equal "What was the Rails version when you first worked on it?", @john.configs[:secret_question]
+ end
+
+ test "prefix/suffix do not affect stored attributes" do
+ assert_equal [:secret_question, :two_factor_auth, :login_retry], Admin::User.stored_attributes[:configs]
+ end
+end
diff --git a/activerecord/test/cases/suppressor_test.rb b/activerecord/test/cases/suppressor_test.rb
new file mode 100644
index 0000000000..9be5356901
--- /dev/null
+++ b/activerecord/test/cases/suppressor_test.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/notification"
+require "models/user"
+
+class SuppressorTest < ActiveRecord::TestCase
+ def test_suppresses_create
+ assert_no_difference -> { Notification.count } do
+ Notification.suppress do
+ Notification.create
+ Notification.create!
+ Notification.new.save
+ Notification.new.save!
+ end
+ end
+ end
+
+ def test_suppresses_update
+ user = User.create! token: "asdf"
+
+ User.suppress do
+ user.update token: "ghjkl"
+ assert_equal "asdf", user.reload.token
+
+ user.update! token: "zxcvbnm"
+ assert_equal "asdf", user.reload.token
+
+ user.token = "qwerty"
+ user.save
+ assert_equal "asdf", user.reload.token
+
+ user.token = "uiop"
+ user.save!
+ assert_equal "asdf", user.reload.token
+ end
+ end
+
+ def test_suppresses_create_in_callback
+ assert_difference -> { User.count } do
+ assert_no_difference -> { Notification.count } do
+ Notification.suppress { UserWithNotification.create! }
+ end
+ end
+ end
+
+ def test_resumes_saving_after_suppression_complete
+ Notification.suppress { UserWithNotification.create! }
+
+ assert_difference -> { Notification.count } do
+ Notification.create!(message: "New Comment")
+ end
+ end
+
+ def test_suppresses_validations_on_create
+ assert_no_difference -> { Notification.count } do
+ Notification.suppress do
+ User.create
+ User.create!
+ User.new.save
+ User.new.save!
+ end
+ end
+ end
+
+ def test_suppresses_when_nested_multiple_times
+ assert_no_difference -> { Notification.count } do
+ Notification.suppress do
+ Notification.suppress { }
+ Notification.create
+ Notification.create!
+ Notification.new.save
+ Notification.new.save!
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/tasks/database_tasks_test.rb b/activerecord/test/cases/tasks/database_tasks_test.rb
new file mode 100644
index 0000000000..3fd1813d64
--- /dev/null
+++ b/activerecord/test/cases/tasks/database_tasks_test.rb
@@ -0,0 +1,1137 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "active_record/tasks/database_tasks"
+
+module ActiveRecord
+ module DatabaseTasksSetupper
+ def setup
+ @mysql_tasks, @postgresql_tasks, @sqlite_tasks = Array.new(
+ 3,
+ Class.new do
+ def create; end
+ def drop; end
+ def purge; end
+ def charset; end
+ def collation; end
+ def structure_dump(*); end
+ def structure_load(*); end
+ end.new
+ )
+
+ $stdout, @original_stdout = StringIO.new, $stdout
+ $stderr, @original_stderr = StringIO.new, $stderr
+ end
+
+ def teardown
+ $stdout, $stderr = @original_stdout, @original_stderr
+ end
+
+ def with_stubbed_new
+ ActiveRecord::Tasks::MySQLDatabaseTasks.stub(:new, @mysql_tasks) do
+ ActiveRecord::Tasks::PostgreSQLDatabaseTasks.stub(:new, @postgresql_tasks) do
+ ActiveRecord::Tasks::SQLiteDatabaseTasks.stub(:new, @sqlite_tasks) do
+ yield
+ end
+ end
+ end
+ end
+ end
+
+ ADAPTERS_TASKS = {
+ mysql2: :mysql_tasks,
+ postgresql: :postgresql_tasks,
+ sqlite3: :sqlite_tasks
+ }
+
+ class DatabaseTasksUtilsTask < ActiveRecord::TestCase
+ def test_raises_an_error_when_called_with_protected_environment
+ protected_environments = ActiveRecord::Base.protected_environments
+ current_env = ActiveRecord::Base.connection.migration_context.current_environment
+
+ assert_called_on_instance_of(
+ ActiveRecord::MigrationContext,
+ :current_version,
+ times: 6,
+ returns: 1
+ ) do
+ assert_not_includes protected_environments, current_env
+ # Assert no error
+ ActiveRecord::Tasks::DatabaseTasks.check_protected_environments!
+
+ ActiveRecord::Base.protected_environments = [current_env]
+
+ assert_raise(ActiveRecord::ProtectedEnvironmentError) do
+ ActiveRecord::Tasks::DatabaseTasks.check_protected_environments!
+ end
+ end
+ ensure
+ ActiveRecord::Base.protected_environments = protected_environments
+ end
+
+ def test_raises_an_error_when_called_with_protected_environment_which_name_is_a_symbol
+ protected_environments = ActiveRecord::Base.protected_environments
+ current_env = ActiveRecord::Base.connection.migration_context.current_environment
+ assert_called_on_instance_of(
+ ActiveRecord::MigrationContext,
+ :current_version,
+ times: 6,
+ returns: 1
+ ) do
+ assert_not_includes protected_environments, current_env
+ # Assert no error
+ ActiveRecord::Tasks::DatabaseTasks.check_protected_environments!
+
+ ActiveRecord::Base.protected_environments = [current_env.to_sym]
+ assert_raise(ActiveRecord::ProtectedEnvironmentError) do
+ ActiveRecord::Tasks::DatabaseTasks.check_protected_environments!
+ end
+ end
+ ensure
+ ActiveRecord::Base.protected_environments = protected_environments
+ end
+
+ def test_raises_an_error_if_no_migrations_have_been_made
+ ActiveRecord::InternalMetadata.stub(:table_exists?, false) do
+ assert_called_on_instance_of(
+ ActiveRecord::MigrationContext,
+ :current_version,
+ returns: 1
+ ) do
+ assert_raise(ActiveRecord::NoEnvironmentInSchemaError) do
+ ActiveRecord::Tasks::DatabaseTasks.check_protected_environments!
+ end
+ end
+ end
+ end
+ end
+
+ class DatabaseTasksRegisterTask < ActiveRecord::TestCase
+ def test_register_task
+ klazz = Class.new do
+ def initialize(*arguments); end
+ def structure_dump(filename); end
+ end
+ instance = klazz.new
+
+ klazz.stub(:new, instance) do
+ assert_called_with(instance, :structure_dump, ["awesome-file.sql", nil]) do
+ ActiveRecord::Tasks::DatabaseTasks.register_task(/foo/, klazz)
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump({ "adapter" => :foo }, "awesome-file.sql")
+ end
+ end
+ end
+
+ def test_unregistered_task
+ assert_raise(ActiveRecord::Tasks::DatabaseNotSupported) do
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump({ "adapter" => :bar }, "awesome-file.sql")
+ end
+ end
+ end
+
+ class DatabaseTasksCreateTest < ActiveRecord::TestCase
+ include DatabaseTasksSetupper
+
+ ADAPTERS_TASKS.each do |k, v|
+ define_method("test_#{k}_create") do
+ with_stubbed_new do
+ assert_called(eval("@#{v}"), :create) do
+ ActiveRecord::Tasks::DatabaseTasks.create "adapter" => k
+ end
+ end
+ end
+ end
+ end
+
+ class DatabaseTasksDumpSchemaCacheTest < ActiveRecord::TestCase
+ def test_dump_schema_cache
+ path = "/tmp/my_schema_cache.yml"
+ ActiveRecord::Tasks::DatabaseTasks.dump_schema_cache(ActiveRecord::Base.connection, path)
+ assert File.file?(path)
+ ensure
+ ActiveRecord::Base.clear_cache!
+ FileUtils.rm_rf(path)
+ end
+ end
+
+ class DatabaseTasksCreateAllTest < ActiveRecord::TestCase
+ def setup
+ @configurations = { "development" => { "database" => "my-db" } }
+
+ $stdout, @original_stdout = StringIO.new, $stdout
+ $stderr, @original_stderr = StringIO.new, $stderr
+ end
+
+ def teardown
+ $stdout, $stderr = @original_stdout, @original_stderr
+ end
+
+ def test_ignores_configurations_without_databases
+ @configurations["development"]["database"] = nil
+
+ with_stubbed_configurations_establish_connection do
+ assert_not_called(ActiveRecord::Tasks::DatabaseTasks, :create) do
+ ActiveRecord::Tasks::DatabaseTasks.create_all
+ end
+ end
+ end
+
+ def test_ignores_remote_databases
+ @configurations["development"]["host"] = "my.server.tld"
+
+ with_stubbed_configurations_establish_connection do
+ assert_not_called(ActiveRecord::Tasks::DatabaseTasks, :create) do
+ ActiveRecord::Tasks::DatabaseTasks.create_all
+ end
+ end
+ end
+
+ def test_warning_for_remote_databases
+ @configurations["development"]["host"] = "my.server.tld"
+
+ with_stubbed_configurations_establish_connection do
+ ActiveRecord::Tasks::DatabaseTasks.create_all
+
+ assert_match "This task only modifies local databases. my-db is on a remote host.",
+ $stderr.string
+ end
+ end
+
+ def test_creates_configurations_with_local_ip
+ @configurations["development"]["host"] = "127.0.0.1"
+
+ with_stubbed_configurations_establish_connection do
+ assert_called(ActiveRecord::Tasks::DatabaseTasks, :create) do
+ ActiveRecord::Tasks::DatabaseTasks.create_all
+ end
+ end
+ end
+
+ def test_creates_configurations_with_local_host
+ @configurations["development"]["host"] = "localhost"
+
+ with_stubbed_configurations_establish_connection do
+ assert_called(ActiveRecord::Tasks::DatabaseTasks, :create) do
+ ActiveRecord::Tasks::DatabaseTasks.create_all
+ end
+ end
+ end
+
+ def test_creates_configurations_with_blank_hosts
+ @configurations["development"]["host"] = nil
+
+ with_stubbed_configurations_establish_connection do
+ assert_called(ActiveRecord::Tasks::DatabaseTasks, :create) do
+ ActiveRecord::Tasks::DatabaseTasks.create_all
+ end
+ end
+ end
+
+ private
+ def with_stubbed_configurations_establish_connection
+ old_configurations = ActiveRecord::Base.configurations
+ ActiveRecord::Base.configurations = @configurations
+
+ # To refrain from connecting to a newly created empty DB in
+ # sqlite3_mem tests
+ ActiveRecord::Base.connection_handler.stub(:establish_connection, nil) do
+ yield
+ end
+ ensure
+ ActiveRecord::Base.configurations = old_configurations
+ end
+ end
+
+ class DatabaseTasksCreateCurrentTest < ActiveRecord::TestCase
+ def setup
+ @configurations = {
+ "development" => { "database" => "dev-db" },
+ "test" => { "database" => "test-db" },
+ "production" => { "url" => "abstract://prod-db-host/prod-db" }
+ }
+ end
+
+ def test_creates_current_environment_database
+ with_stubbed_configurations_establish_connection do
+ assert_called_with(
+ ActiveRecord::Tasks::DatabaseTasks,
+ :create,
+ ["database" => "test-db"],
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.create_current(
+ ActiveSupport::StringInquirer.new("test")
+ )
+ end
+ end
+ end
+
+ def test_creates_current_environment_database_with_url
+ with_stubbed_configurations_establish_connection do
+ assert_called_with(
+ ActiveRecord::Tasks::DatabaseTasks,
+ :create,
+ ["adapter" => "abstract", "database" => "prod-db", "host" => "prod-db-host"],
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.create_current(
+ ActiveSupport::StringInquirer.new("production")
+ )
+ end
+ end
+ end
+
+ def test_creates_test_and_development_databases_when_env_was_not_specified
+ with_stubbed_configurations_establish_connection do
+ assert_called_with(
+ ActiveRecord::Tasks::DatabaseTasks,
+ :create,
+ [
+ ["database" => "dev-db"],
+ ["database" => "test-db"]
+ ],
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.create_current(
+ ActiveSupport::StringInquirer.new("development")
+ )
+ end
+ end
+ end
+
+ def test_creates_test_and_development_databases_when_rails_env_is_development
+ old_env = ENV["RAILS_ENV"]
+ ENV["RAILS_ENV"] = "development"
+
+ with_stubbed_configurations_establish_connection do
+ assert_called_with(
+ ActiveRecord::Tasks::DatabaseTasks,
+ :create,
+ [
+ ["database" => "dev-db"],
+ ["database" => "test-db"]
+ ],
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.create_current(
+ ActiveSupport::StringInquirer.new("development")
+ )
+ end
+ end
+ ensure
+ ENV["RAILS_ENV"] = old_env
+ end
+
+ def test_establishes_connection_for_the_given_environments
+ ActiveRecord::Tasks::DatabaseTasks.stub(:create, nil) do
+ assert_called_with(ActiveRecord::Base, :establish_connection, [:development]) do
+ ActiveRecord::Tasks::DatabaseTasks.create_current(
+ ActiveSupport::StringInquirer.new("development")
+ )
+ end
+ end
+ end
+
+ private
+ def with_stubbed_configurations_establish_connection
+ old_configurations = ActiveRecord::Base.configurations
+ ActiveRecord::Base.configurations = @configurations
+
+ ActiveRecord::Base.connection_handler.stub(:establish_connection, nil) do
+ yield
+ end
+ ensure
+ ActiveRecord::Base.configurations = old_configurations
+ end
+ end
+
+ class DatabaseTasksCreateCurrentThreeTierTest < ActiveRecord::TestCase
+ def setup
+ @configurations = {
+ "development" => { "primary" => { "database" => "dev-db" }, "secondary" => { "database" => "secondary-dev-db" } },
+ "test" => { "primary" => { "database" => "test-db" }, "secondary" => { "database" => "secondary-test-db" } },
+ "production" => { "primary" => { "url" => "abstract://prod-db-host/prod-db" }, "secondary" => { "url" => "abstract://secondary-prod-db-host/secondary-prod-db" } }
+ }
+ end
+
+ def test_creates_current_environment_database
+ with_stubbed_configurations_establish_connection do
+ assert_called_with(
+ ActiveRecord::Tasks::DatabaseTasks,
+ :create,
+ [
+ ["database" => "test-db"],
+ ["database" => "secondary-test-db"]
+ ]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.create_current(
+ ActiveSupport::StringInquirer.new("test")
+ )
+ end
+ end
+ end
+
+ def test_creates_current_environment_database_with_url
+ with_stubbed_configurations_establish_connection do
+ assert_called_with(
+ ActiveRecord::Tasks::DatabaseTasks,
+ :create,
+ [
+ ["adapter" => "abstract", "database" => "prod-db", "host" => "prod-db-host"],
+ ["adapter" => "abstract", "database" => "secondary-prod-db", "host" => "secondary-prod-db-host"]
+ ]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.create_current(
+ ActiveSupport::StringInquirer.new("production")
+ )
+ end
+ end
+ end
+
+ def test_creates_test_and_development_databases_when_env_was_not_specified
+ with_stubbed_configurations_establish_connection do
+ assert_called_with(
+ ActiveRecord::Tasks::DatabaseTasks,
+ :create,
+ [
+ ["database" => "dev-db"],
+ ["database" => "secondary-dev-db"],
+ ["database" => "test-db"],
+ ["database" => "secondary-test-db"]
+ ]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.create_current(
+ ActiveSupport::StringInquirer.new("development")
+ )
+ end
+ end
+ end
+
+ def test_creates_test_and_development_databases_when_rails_env_is_development
+ old_env = ENV["RAILS_ENV"]
+ ENV["RAILS_ENV"] = "development"
+
+ with_stubbed_configurations_establish_connection do
+ assert_called_with(
+ ActiveRecord::Tasks::DatabaseTasks,
+ :create,
+ [
+ ["database" => "dev-db"],
+ ["database" => "secondary-dev-db"],
+ ["database" => "test-db"],
+ ["database" => "secondary-test-db"]
+ ]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.create_current(
+ ActiveSupport::StringInquirer.new("development")
+ )
+ end
+ end
+ ensure
+ ENV["RAILS_ENV"] = old_env
+ end
+
+ def test_establishes_connection_for_the_given_environments_config
+ ActiveRecord::Tasks::DatabaseTasks.stub(:create, nil) do
+ assert_called_with(
+ ActiveRecord::Base,
+ :establish_connection,
+ [:development]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.create_current(
+ ActiveSupport::StringInquirer.new("development")
+ )
+ end
+ end
+ end
+
+ private
+ def with_stubbed_configurations_establish_connection
+ old_configurations = ActiveRecord::Base.configurations
+ ActiveRecord::Base.configurations = @configurations
+
+ ActiveRecord::Base.connection_handler.stub(:establish_connection, nil) do
+ yield
+ end
+ ensure
+ ActiveRecord::Base.configurations = old_configurations
+ end
+ end
+
+ class DatabaseTasksDropTest < ActiveRecord::TestCase
+ include DatabaseTasksSetupper
+
+ ADAPTERS_TASKS.each do |k, v|
+ define_method("test_#{k}_drop") do
+ with_stubbed_new do
+ assert_called(eval("@#{v}"), :drop) do
+ ActiveRecord::Tasks::DatabaseTasks.drop "adapter" => k
+ end
+ end
+ end
+ end
+ end
+
+ class DatabaseTasksDropAllTest < ActiveRecord::TestCase
+ def setup
+ @configurations = { development: { "database" => "my-db" } }
+
+ $stdout, @original_stdout = StringIO.new, $stdout
+ $stderr, @original_stderr = StringIO.new, $stderr
+ end
+
+ def teardown
+ $stdout, $stderr = @original_stdout, @original_stderr
+ end
+
+ def test_ignores_configurations_without_databases
+ @configurations[:development]["database"] = nil
+
+ with_stubbed_configurations do
+ assert_not_called(ActiveRecord::Tasks::DatabaseTasks, :drop) do
+ ActiveRecord::Tasks::DatabaseTasks.drop_all
+ end
+ end
+ end
+
+ def test_ignores_remote_databases
+ @configurations[:development]["host"] = "my.server.tld"
+
+ with_stubbed_configurations do
+ assert_not_called(ActiveRecord::Tasks::DatabaseTasks, :drop) do
+ ActiveRecord::Tasks::DatabaseTasks.drop_all
+ end
+ end
+ end
+
+ def test_warning_for_remote_databases
+ @configurations[:development]["host"] = "my.server.tld"
+
+ with_stubbed_configurations do
+ ActiveRecord::Tasks::DatabaseTasks.drop_all
+
+ assert_match "This task only modifies local databases. my-db is on a remote host.",
+ $stderr.string
+ end
+ end
+
+ def test_drops_configurations_with_local_ip
+ @configurations[:development]["host"] = "127.0.0.1"
+
+ with_stubbed_configurations do
+ assert_called(ActiveRecord::Tasks::DatabaseTasks, :drop) do
+ ActiveRecord::Tasks::DatabaseTasks.drop_all
+ end
+ end
+ end
+
+ def test_drops_configurations_with_local_host
+ @configurations[:development]["host"] = "localhost"
+
+ with_stubbed_configurations do
+ assert_called(ActiveRecord::Tasks::DatabaseTasks, :drop) do
+ ActiveRecord::Tasks::DatabaseTasks.drop_all
+ end
+ end
+ end
+
+ def test_drops_configurations_with_blank_hosts
+ @configurations[:development]["host"] = nil
+
+ with_stubbed_configurations do
+ assert_called(ActiveRecord::Tasks::DatabaseTasks, :drop) do
+ ActiveRecord::Tasks::DatabaseTasks.drop_all
+ end
+ end
+ end
+
+ private
+ def with_stubbed_configurations
+ old_configurations = ActiveRecord::Base.configurations
+ ActiveRecord::Base.configurations = @configurations
+
+ yield
+ ensure
+ ActiveRecord::Base.configurations = old_configurations
+ end
+ end
+
+ class DatabaseTasksDropCurrentTest < ActiveRecord::TestCase
+ def setup
+ @configurations = {
+ "development" => { "database" => "dev-db" },
+ "test" => { "database" => "test-db" },
+ "production" => { "url" => "abstract://prod-db-host/prod-db" }
+ }
+ end
+
+ def test_drops_current_environment_database
+ with_stubbed_configurations do
+ assert_called_with(ActiveRecord::Tasks::DatabaseTasks, :drop,
+ ["database" => "test-db"]) do
+ ActiveRecord::Tasks::DatabaseTasks.drop_current(
+ ActiveSupport::StringInquirer.new("test")
+ )
+ end
+ end
+ end
+
+ def test_drops_current_environment_database_with_url
+ with_stubbed_configurations do
+ assert_called_with(ActiveRecord::Tasks::DatabaseTasks, :drop,
+ ["adapter" => "abstract", "database" => "prod-db", "host" => "prod-db-host"]) do
+ ActiveRecord::Tasks::DatabaseTasks.drop_current(
+ ActiveSupport::StringInquirer.new("production")
+ )
+ end
+ end
+ end
+
+ def test_drops_test_and_development_databases_when_env_was_not_specified
+ with_stubbed_configurations do
+ assert_called_with(
+ ActiveRecord::Tasks::DatabaseTasks,
+ :drop,
+ [
+ ["database" => "dev-db"],
+ ["database" => "test-db"]
+ ]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.drop_current(
+ ActiveSupport::StringInquirer.new("development")
+ )
+ end
+ end
+ end
+
+ def test_drops_testand_development_databases_when_rails_env_is_development
+ old_env = ENV["RAILS_ENV"]
+ ENV["RAILS_ENV"] = "development"
+
+ with_stubbed_configurations do
+ assert_called_with(
+ ActiveRecord::Tasks::DatabaseTasks,
+ :drop,
+ [
+ ["database" => "dev-db"],
+ ["database" => "test-db"]
+ ]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.drop_current(
+ ActiveSupport::StringInquirer.new("development")
+ )
+ end
+ end
+ ensure
+ ENV["RAILS_ENV"] = old_env
+ end
+
+ private
+ def with_stubbed_configurations
+ old_configurations = ActiveRecord::Base.configurations
+ ActiveRecord::Base.configurations = @configurations
+
+ yield
+ ensure
+ ActiveRecord::Base.configurations = old_configurations
+ end
+ end
+
+ class DatabaseTasksDropCurrentThreeTierTest < ActiveRecord::TestCase
+ def setup
+ @configurations = {
+ "development" => { "primary" => { "database" => "dev-db" }, "secondary" => { "database" => "secondary-dev-db" } },
+ "test" => { "primary" => { "database" => "test-db" }, "secondary" => { "database" => "secondary-test-db" } },
+ "production" => { "primary" => { "url" => "abstract://prod-db-host/prod-db" }, "secondary" => { "url" => "abstract://secondary-prod-db-host/secondary-prod-db" } }
+ }
+ end
+
+ def test_drops_current_environment_database
+ with_stubbed_configurations do
+ assert_called_with(
+ ActiveRecord::Tasks::DatabaseTasks,
+ :drop,
+ [
+ ["database" => "test-db"],
+ ["database" => "secondary-test-db"]
+ ]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.drop_current(
+ ActiveSupport::StringInquirer.new("test")
+ )
+ end
+ end
+ end
+
+ def test_drops_current_environment_database_with_url
+ with_stubbed_configurations do
+ assert_called_with(
+ ActiveRecord::Tasks::DatabaseTasks,
+ :drop,
+ [
+ ["adapter" => "abstract", "database" => "prod-db", "host" => "prod-db-host"],
+ ["adapter" => "abstract", "database" => "secondary-prod-db", "host" => "secondary-prod-db-host"]
+ ]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.drop_current(
+ ActiveSupport::StringInquirer.new("production")
+ )
+ end
+ end
+ end
+
+ def test_drops_test_and_development_databases_when_env_was_not_specified
+ with_stubbed_configurations do
+ assert_called_with(
+ ActiveRecord::Tasks::DatabaseTasks,
+ :drop,
+ [
+ ["database" => "dev-db"],
+ ["database" => "secondary-dev-db"],
+ ["database" => "test-db"],
+ ["database" => "secondary-test-db"]
+ ]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.drop_current(
+ ActiveSupport::StringInquirer.new("development")
+ )
+ end
+ end
+ end
+
+ def test_drops_testand_development_databases_when_rails_env_is_development
+ old_env = ENV["RAILS_ENV"]
+ ENV["RAILS_ENV"] = "development"
+
+ with_stubbed_configurations do
+ assert_called_with(
+ ActiveRecord::Tasks::DatabaseTasks,
+ :drop,
+ [
+ ["database" => "dev-db"],
+ ["database" => "secondary-dev-db"],
+ ["database" => "test-db"],
+ ["database" => "secondary-test-db"]
+ ]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.drop_current(
+ ActiveSupport::StringInquirer.new("development")
+ )
+ end
+ end
+ ensure
+ ENV["RAILS_ENV"] = old_env
+ end
+
+ private
+ def with_stubbed_configurations
+ old_configurations = ActiveRecord::Base.configurations
+ ActiveRecord::Base.configurations = @configurations
+
+ yield
+ ensure
+ ActiveRecord::Base.configurations = old_configurations
+ end
+ end
+
+ if current_adapter?(:SQLite3Adapter) && !in_memory_db?
+ class DatabaseTasksMigrationTestCase < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ # Use a memory db here to avoid having to rollback at the end
+ setup do
+ migrations_path = MIGRATIONS_ROOT + "/valid"
+ file = ActiveRecord::Base.connection.raw_connection.filename
+ @conn = ActiveRecord::Base.establish_connection adapter: "sqlite3",
+ database: ":memory:", migrations_paths: migrations_path
+ source_db = SQLite3::Database.new file
+ dest_db = ActiveRecord::Base.connection.raw_connection
+ backup = SQLite3::Backup.new(dest_db, "main", source_db, "main")
+ backup.step(-1)
+ backup.finish
+ end
+
+ teardown do
+ @conn.release_connection if @conn
+ ActiveRecord::Base.establish_connection :arunit
+ end
+ end
+
+ class DatabaseTasksMigrateTest < DatabaseTasksMigrationTestCase
+ def test_migrate_set_and_unset_verbose_and_version_env_vars
+ verbose, version = ENV["VERBOSE"], ENV["VERSION"]
+ ENV["VERSION"] = "2"
+ ENV["VERBOSE"] = "false"
+
+ # run down migration because it was already run on copied db
+ assert_empty capture_migration_output
+
+ ENV.delete("VERSION")
+ ENV.delete("VERBOSE")
+
+ # re-run up migration
+ assert_includes capture_migration_output, "migrating"
+ ensure
+ ENV["VERBOSE"], ENV["VERSION"] = verbose, version
+ end
+
+ def test_migrate_set_and_unset_empty_values_for_verbose_and_version_env_vars
+ verbose, version = ENV["VERBOSE"], ENV["VERSION"]
+
+ ENV["VERSION"] = "2"
+ ENV["VERBOSE"] = "false"
+
+ # run down migration because it was already run on copied db
+ assert_empty capture_migration_output
+
+ ENV["VERBOSE"] = ""
+ ENV["VERSION"] = ""
+
+ # re-run up migration
+ assert_includes capture_migration_output, "migrating"
+ ensure
+ ENV["VERBOSE"], ENV["VERSION"] = verbose, version
+ end
+
+ def test_migrate_set_and_unset_nonsense_values_for_verbose_and_version_env_vars
+ verbose, version = ENV["VERBOSE"], ENV["VERSION"]
+
+ # run down migration because it was already run on copied db
+ ENV["VERSION"] = "2"
+ ENV["VERBOSE"] = "false"
+
+ assert_empty capture_migration_output
+
+ ENV["VERBOSE"] = "yes"
+ ENV["VERSION"] = "2"
+
+ # run no migration because 2 was already run
+ assert_empty capture_migration_output
+ ensure
+ ENV["VERBOSE"], ENV["VERSION"] = verbose, version
+ end
+
+ private
+ def capture_migration_output
+ capture(:stdout) do
+ ActiveRecord::Tasks::DatabaseTasks.migrate
+ end
+ end
+ end
+
+ class DatabaseTasksMigrateStatusTest < DatabaseTasksMigrationTestCase
+ def test_migrate_status_table
+ ActiveRecord::SchemaMigration.create_table
+ output = capture_migration_status
+ assert_match(/database: :memory:/, output)
+ assert_match(/down 001 Valid people have last names/, output)
+ assert_match(/down 002 We need reminders/, output)
+ assert_match(/down 003 Innocent jointable/, output)
+ ActiveRecord::SchemaMigration.drop_table
+ end
+
+ private
+
+ def capture_migration_status
+ capture(:stdout) do
+ ActiveRecord::Tasks::DatabaseTasks.migrate_status
+ end
+ end
+ end
+ end
+
+ class DatabaseTasksMigrateErrorTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ def test_migrate_raise_error_on_invalid_version_format
+ version = ENV["VERSION"]
+
+ ENV["VERSION"] = "unknown"
+ e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate }
+ assert_match(/Invalid format of target version/, e.message)
+
+ ENV["VERSION"] = "0.1.11"
+ e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate }
+ assert_match(/Invalid format of target version/, e.message)
+
+ ENV["VERSION"] = "1.1.11"
+ e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate }
+ assert_match(/Invalid format of target version/, e.message)
+
+ ENV["VERSION"] = "0 "
+ e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate }
+ assert_match(/Invalid format of target version/, e.message)
+
+ ENV["VERSION"] = "1."
+ e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate }
+ assert_match(/Invalid format of target version/, e.message)
+
+ ENV["VERSION"] = "1_"
+ e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate }
+ assert_match(/Invalid format of target version/, e.message)
+
+ ENV["VERSION"] = "1_name"
+ e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate }
+ assert_match(/Invalid format of target version/, e.message)
+ ensure
+ ENV["VERSION"] = version
+ end
+
+ def test_migrate_raise_error_on_failed_check_target_version
+ ActiveRecord::Tasks::DatabaseTasks.stub(:check_target_version, -> { raise "foo" }) do
+ e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate }
+ assert_equal "foo", e.message
+ end
+ end
+
+ def test_migrate_clears_schema_cache_afterward
+ assert_called(ActiveRecord::Base, :clear_cache!) do
+ ActiveRecord::Tasks::DatabaseTasks.migrate
+ end
+ end
+ end
+
+ class DatabaseTasksPurgeTest < ActiveRecord::TestCase
+ include DatabaseTasksSetupper
+
+ ADAPTERS_TASKS.each do |k, v|
+ define_method("test_#{k}_purge") do
+ with_stubbed_new do
+ assert_called(eval("@#{v}"), :purge) do
+ ActiveRecord::Tasks::DatabaseTasks.purge "adapter" => k
+ end
+ end
+ end
+ end
+ end
+
+ class DatabaseTasksPurgeCurrentTest < ActiveRecord::TestCase
+ def test_purges_current_environment_database
+ old_configurations = ActiveRecord::Base.configurations
+ configurations = {
+ "development" => { "database" => "dev-db" },
+ "test" => { "database" => "test-db" },
+ "production" => { "database" => "prod-db" }
+ }
+
+ ActiveRecord::Base.configurations = configurations
+
+ assert_called_with(
+ ActiveRecord::Tasks::DatabaseTasks,
+ :purge,
+ ["database" => "prod-db"]
+ ) do
+ assert_called_with(ActiveRecord::Base, :establish_connection, [:production]) do
+ ActiveRecord::Tasks::DatabaseTasks.purge_current("production")
+ end
+ end
+ ensure
+ ActiveRecord::Base.configurations = old_configurations
+ end
+ end
+
+ class DatabaseTasksPurgeAllTest < ActiveRecord::TestCase
+ def test_purge_all_local_configurations
+ old_configurations = ActiveRecord::Base.configurations
+ configurations = { development: { "database" => "my-db" } }
+ ActiveRecord::Base.configurations = configurations
+
+ assert_called_with(
+ ActiveRecord::Tasks::DatabaseTasks,
+ :purge,
+ ["database" => "my-db"]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.purge_all
+ end
+ ensure
+ ActiveRecord::Base.configurations = old_configurations
+ end
+ end
+
+ class DatabaseTasksCharsetTest < ActiveRecord::TestCase
+ include DatabaseTasksSetupper
+
+ ADAPTERS_TASKS.each do |k, v|
+ define_method("test_#{k}_charset") do
+ with_stubbed_new do
+ assert_called(eval("@#{v}"), :charset) do
+ ActiveRecord::Tasks::DatabaseTasks.charset "adapter" => k
+ end
+ end
+ end
+ end
+ end
+
+ class DatabaseTasksCollationTest < ActiveRecord::TestCase
+ include DatabaseTasksSetupper
+
+ ADAPTERS_TASKS.each do |k, v|
+ define_method("test_#{k}_collation") do
+ with_stubbed_new do
+ assert_called(eval("@#{v}"), :collation) do
+ ActiveRecord::Tasks::DatabaseTasks.collation "adapter" => k
+ end
+ end
+ end
+ end
+ end
+
+ class DatabaseTaskTargetVersionTest < ActiveRecord::TestCase
+ def test_target_version_returns_nil_if_version_does_not_exist
+ version = ENV.delete("VERSION")
+ assert_nil ActiveRecord::Tasks::DatabaseTasks.target_version
+ ensure
+ ENV["VERSION"] = version
+ end
+
+ def test_target_version_returns_nil_if_version_is_empty
+ version = ENV["VERSION"]
+
+ ENV["VERSION"] = ""
+ assert_nil ActiveRecord::Tasks::DatabaseTasks.target_version
+ ensure
+ ENV["VERSION"] = version
+ end
+
+ def test_target_version_returns_converted_to_integer_env_version_if_version_exists
+ version = ENV["VERSION"]
+
+ ENV["VERSION"] = "0"
+ assert_equal ENV["VERSION"].to_i, ActiveRecord::Tasks::DatabaseTasks.target_version
+
+ ENV["VERSION"] = "42"
+ assert_equal ENV["VERSION"].to_i, ActiveRecord::Tasks::DatabaseTasks.target_version
+
+ ENV["VERSION"] = "042"
+ assert_equal ENV["VERSION"].to_i, ActiveRecord::Tasks::DatabaseTasks.target_version
+ ensure
+ ENV["VERSION"] = version
+ end
+ end
+
+ class DatabaseTaskCheckTargetVersionTest < ActiveRecord::TestCase
+ def test_check_target_version_does_not_raise_error_on_empty_version
+ version = ENV["VERSION"]
+ ENV["VERSION"] = ""
+ assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version }
+ ensure
+ ENV["VERSION"] = version
+ end
+
+ def test_check_target_version_does_not_raise_error_if_version_is_not_setted
+ version = ENV.delete("VERSION")
+ assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version }
+ ensure
+ ENV["VERSION"] = version
+ end
+
+ def test_check_target_version_raises_error_on_invalid_version_format
+ version = ENV["VERSION"]
+
+ ENV["VERSION"] = "unknown"
+ e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version }
+ assert_match(/Invalid format of target version/, e.message)
+
+ ENV["VERSION"] = "0.1.11"
+ e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version }
+ assert_match(/Invalid format of target version/, e.message)
+
+ ENV["VERSION"] = "1.1.11"
+ e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version }
+ assert_match(/Invalid format of target version/, e.message)
+
+ ENV["VERSION"] = "0 "
+ e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version }
+ assert_match(/Invalid format of target version/, e.message)
+
+ ENV["VERSION"] = "1."
+ e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version }
+ assert_match(/Invalid format of target version/, e.message)
+
+ ENV["VERSION"] = "1_"
+ e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version }
+ assert_match(/Invalid format of target version/, e.message)
+
+ ENV["VERSION"] = "1_name"
+ e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version }
+ assert_match(/Invalid format of target version/, e.message)
+ ensure
+ ENV["VERSION"] = version
+ end
+
+ def test_check_target_version_does_not_raise_error_on_valid_version_format
+ version = ENV["VERSION"]
+
+ ENV["VERSION"] = "0"
+ assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version }
+
+ ENV["VERSION"] = "1"
+ assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version }
+
+ ENV["VERSION"] = "001"
+ assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version }
+
+ ENV["VERSION"] = "001_name.rb"
+ assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version }
+ ensure
+ ENV["VERSION"] = version
+ end
+ end
+
+ class DatabaseTasksStructureDumpTest < ActiveRecord::TestCase
+ include DatabaseTasksSetupper
+
+ ADAPTERS_TASKS.each do |k, v|
+ define_method("test_#{k}_structure_dump") do
+ with_stubbed_new do
+ assert_called_with(
+ eval("@#{v}"), :structure_dump,
+ ["awesome-file.sql", nil]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump({ "adapter" => k }, "awesome-file.sql")
+ end
+ end
+ end
+ end
+ end
+
+ class DatabaseTasksStructureLoadTest < ActiveRecord::TestCase
+ include DatabaseTasksSetupper
+
+ ADAPTERS_TASKS.each do |k, v|
+ define_method("test_#{k}_structure_load") do
+ with_stubbed_new do
+ assert_called_with(
+ eval("@#{v}"),
+ :structure_load,
+ ["awesome-file.sql", nil]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.structure_load({ "adapter" => k }, "awesome-file.sql")
+ end
+ end
+ end
+ end
+ end
+
+ class DatabaseTasksCheckSchemaFileTest < ActiveRecord::TestCase
+ def test_check_schema_file
+ assert_called_with(Kernel, :abort, [/awesome-file.sql/]) do
+ ActiveRecord::Tasks::DatabaseTasks.check_schema_file("awesome-file.sql")
+ end
+ end
+ end
+
+ class DatabaseTasksCheckSchemaFileDefaultsTest < ActiveRecord::TestCase
+ def test_check_schema_file_defaults
+ ActiveRecord::Tasks::DatabaseTasks.stub(:db_dir, "/tmp") do
+ assert_equal "/tmp/schema.rb", ActiveRecord::Tasks::DatabaseTasks.schema_file
+ end
+ end
+ end
+
+ class DatabaseTasksCheckSchemaFileSpecifiedFormatsTest < ActiveRecord::TestCase
+ { ruby: "schema.rb", sql: "structure.sql" }.each_pair do |fmt, filename|
+ define_method("test_check_schema_file_for_#{fmt}_format") do
+ ActiveRecord::Tasks::DatabaseTasks.stub(:db_dir, "/tmp") do
+ assert_equal "/tmp/#{filename}", ActiveRecord::Tasks::DatabaseTasks.schema_file(fmt)
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/tasks/mysql_rake_test.rb b/activerecord/test/cases/tasks/mysql_rake_test.rb
new file mode 100644
index 0000000000..552e623fd4
--- /dev/null
+++ b/activerecord/test/cases/tasks/mysql_rake_test.rb
@@ -0,0 +1,409 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "active_record/tasks/database_tasks"
+
+if current_adapter?(:Mysql2Adapter)
+ module ActiveRecord
+ class MysqlDBCreateTest < ActiveRecord::TestCase
+ def setup
+ @connection = Class.new { def create_database(*); end }.new
+ @configuration = {
+ "adapter" => "mysql2",
+ "database" => "my-app-db"
+ }
+ $stdout, @original_stdout = StringIO.new, $stdout
+ $stderr, @original_stderr = StringIO.new, $stderr
+ end
+
+ def teardown
+ $stdout, $stderr = @original_stdout, @original_stderr
+ end
+
+ def test_establishes_connection_without_database
+ ActiveRecord::Base.stub(:connection, @connection) do
+ assert_called_with(
+ ActiveRecord::Base,
+ :establish_connection,
+ [
+ [ "adapter" => "mysql2", "database" => nil ],
+ [ "adapter" => "mysql2", "database" => "my-app-db" ],
+ ]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration
+ end
+ end
+ end
+
+ def test_creates_database_with_no_default_options
+ with_stubbed_connection_establish_connection do
+ assert_called_with(@connection, :create_database, ["my-app-db", {}]) do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration
+ end
+ end
+ end
+
+ def test_creates_database_with_given_encoding
+ with_stubbed_connection_establish_connection do
+ assert_called_with(@connection, :create_database, ["my-app-db", charset: "latin1"]) do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration.merge("encoding" => "latin1")
+ end
+ end
+ end
+
+ def test_creates_database_with_given_collation
+ with_stubbed_connection_establish_connection do
+ assert_called_with(
+ @connection,
+ :create_database,
+ ["my-app-db", collation: "latin1_swedish_ci"]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration.merge("collation" => "latin1_swedish_ci")
+ end
+ end
+ end
+
+ def test_establishes_connection_to_database
+ ActiveRecord::Base.stub(:connection, @connection) do
+ assert_called_with(
+ ActiveRecord::Base,
+ :establish_connection,
+ [
+ ["adapter" => "mysql2", "database" => nil],
+ [@configuration]
+ ]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration
+ end
+ end
+ end
+
+ def test_when_database_created_successfully_outputs_info_to_stdout
+ with_stubbed_connection_establish_connection do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration
+
+ assert_equal "Created database 'my-app-db'\n", $stdout.string
+ end
+ end
+
+ def test_create_when_database_exists_outputs_info_to_stderr
+ with_stubbed_connection_establish_connection do
+ ActiveRecord::Base.connection.stub(
+ :create_database,
+ proc { raise ActiveRecord::Tasks::DatabaseAlreadyExists }
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration
+
+ assert_equal "Database 'my-app-db' already exists\n", $stderr.string
+ end
+ end
+ end
+
+ private
+
+ def with_stubbed_connection_establish_connection
+ ActiveRecord::Base.stub(:establish_connection, nil) do
+ ActiveRecord::Base.stub(:connection, @connection) do
+ yield
+ end
+ end
+ end
+ end
+
+ class MysqlDBCreateWithInvalidPermissionsTest < ActiveRecord::TestCase
+ def setup
+ @error = Mysql2::Error.new("Invalid permissions")
+ @configuration = {
+ "adapter" => "mysql2",
+ "database" => "my-app-db",
+ "username" => "pat",
+ "password" => "wossname"
+ }
+ $stdout, @original_stdout = StringIO.new, $stdout
+ $stderr, @original_stderr = StringIO.new, $stderr
+ end
+
+ def teardown
+ $stdout, $stderr = @original_stdout, @original_stderr
+ end
+
+ def test_raises_error
+ ActiveRecord::Base.stub(:establish_connection, -> * { raise @error }) do
+ assert_raises(Mysql2::Error, "Invalid permissions") do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration
+ end
+ end
+ end
+ end
+
+ class MySQLDBDropTest < ActiveRecord::TestCase
+ def setup
+ @connection = Class.new { def drop_database(name); end }.new
+ @configuration = {
+ "adapter" => "mysql2",
+ "database" => "my-app-db"
+ }
+ $stdout, @original_stdout = StringIO.new, $stdout
+ $stderr, @original_stderr = StringIO.new, $stderr
+ end
+
+ def teardown
+ $stdout, $stderr = @original_stdout, @original_stderr
+ end
+
+ def test_establishes_connection_to_mysql_database
+ ActiveRecord::Base.stub(:connection, @connection) do
+ assert_called_with(
+ ActiveRecord::Base,
+ :establish_connection,
+ [@configuration]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.drop @configuration
+ end
+ end
+ end
+
+ def test_drops_database
+ with_stubbed_connection_establish_connection do
+ assert_called_with(@connection, :drop_database, ["my-app-db"]) do
+ ActiveRecord::Tasks::DatabaseTasks.drop @configuration
+ end
+ end
+ end
+
+ def test_when_database_dropped_successfully_outputs_info_to_stdout
+ with_stubbed_connection_establish_connection do
+ ActiveRecord::Tasks::DatabaseTasks.drop @configuration
+
+ assert_equal "Dropped database 'my-app-db'\n", $stdout.string
+ end
+ end
+
+ private
+
+ def with_stubbed_connection_establish_connection
+ ActiveRecord::Base.stub(:establish_connection, nil) do
+ ActiveRecord::Base.stub(:connection, @connection) do
+ yield
+ end
+ end
+ end
+ end
+
+ class MySQLPurgeTest < ActiveRecord::TestCase
+ def setup
+ @connection = Class.new { def recreate_database(*); end }.new
+ @configuration = {
+ "adapter" => "mysql2",
+ "database" => "test-db"
+ }
+ end
+
+ def test_establishes_connection_to_the_appropriate_database
+ ActiveRecord::Base.stub(:connection, @connection) do
+ assert_called_with(
+ ActiveRecord::Base,
+ :establish_connection,
+ [@configuration]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.purge @configuration
+ end
+ end
+ end
+
+ def test_recreates_database_with_no_default_options
+ with_stubbed_connection_establish_connection do
+ assert_called_with(@connection, :recreate_database, ["test-db", {}]) do
+ ActiveRecord::Tasks::DatabaseTasks.purge @configuration
+ end
+ end
+ end
+
+ def test_recreates_database_with_the_given_options
+ with_stubbed_connection_establish_connection do
+ assert_called_with(
+ @connection,
+ :recreate_database,
+ ["test-db", charset: "latin", collation: "latin1_swedish_ci"]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.purge @configuration.merge(
+ "encoding" => "latin", "collation" => "latin1_swedish_ci")
+ end
+ end
+ end
+
+ private
+
+ def with_stubbed_connection_establish_connection
+ ActiveRecord::Base.stub(:establish_connection, nil) do
+ ActiveRecord::Base.stub(:connection, @connection) do
+ yield
+ end
+ end
+ end
+ end
+
+ class MysqlDBCharsetTest < ActiveRecord::TestCase
+ def setup
+ @connection = Class.new { def charset; end }.new
+ @configuration = {
+ "adapter" => "mysql2",
+ "database" => "my-app-db"
+ }
+ end
+
+ def test_db_retrieves_charset
+ ActiveRecord::Base.stub(:connection, @connection) do
+ assert_called(@connection, :charset) do
+ ActiveRecord::Tasks::DatabaseTasks.charset @configuration
+ end
+ end
+ end
+ end
+
+ class MysqlDBCollationTest < ActiveRecord::TestCase
+ def setup
+ @connection = Class.new { def collation; end }.new
+ @configuration = {
+ "adapter" => "mysql2",
+ "database" => "my-app-db"
+ }
+ end
+
+ def test_db_retrieves_collation
+ ActiveRecord::Base.stub(:connection, @connection) do
+ assert_called(@connection, :collation) do
+ ActiveRecord::Tasks::DatabaseTasks.collation @configuration
+ end
+ end
+ end
+ end
+
+ class MySQLStructureDumpTest < ActiveRecord::TestCase
+ def setup
+ @configuration = {
+ "adapter" => "mysql2",
+ "database" => "test-db"
+ }
+ end
+
+ def test_structure_dump
+ filename = "awesome-file.sql"
+ assert_called_with(
+ Kernel,
+ :system,
+ ["mysqldump", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db"],
+ returns: true
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename)
+ end
+ end
+
+ def test_structure_dump_with_extra_flags
+ filename = "awesome-file.sql"
+ expected_command = ["mysqldump", "--noop", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db"]
+
+ assert_called_with(Kernel, :system, expected_command, returns: true) do
+ with_structure_dump_flags(["--noop"]) do
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename)
+ end
+ end
+ end
+
+ def test_structure_dump_with_ignore_tables
+ filename = "awesome-file.sql"
+ ActiveRecord::SchemaDumper.stub(:ignore_tables, ["foo", "bar"]) do
+ assert_called_with(
+ Kernel,
+ :system,
+ ["mysqldump", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "--ignore-table=test-db.foo", "--ignore-table=test-db.bar", "test-db"],
+ returns: true
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename)
+ end
+ end
+ end
+
+ def test_warn_when_external_structure_dump_command_execution_fails
+ filename = "awesome-file.sql"
+ assert_called_with(
+ Kernel,
+ :system,
+ ["mysqldump", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db"],
+ returns: false
+ ) do
+ e = assert_raise(RuntimeError) {
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename)
+ }
+ assert_match(/^failed to execute: `mysqldump`$/, e.message)
+ end
+ end
+
+ def test_structure_dump_with_port_number
+ filename = "awesome-file.sql"
+ assert_called_with(
+ Kernel,
+ :system,
+ ["mysqldump", "--port=10000", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db"],
+ returns: true
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump(
+ @configuration.merge("port" => 10000),
+ filename)
+ end
+ end
+
+ def test_structure_dump_with_ssl
+ filename = "awesome-file.sql"
+ assert_called_with(
+ Kernel,
+ :system,
+ ["mysqldump", "--ssl-ca=ca.crt", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db"],
+ returns: true
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump(
+ @configuration.merge("sslca" => "ca.crt"),
+ filename)
+ end
+ end
+
+ private
+ def with_structure_dump_flags(flags)
+ old = ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = flags
+ yield
+ ensure
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = old
+ end
+ end
+
+ class MySQLStructureLoadTest < ActiveRecord::TestCase
+ def setup
+ @configuration = {
+ "adapter" => "mysql2",
+ "database" => "test-db"
+ }
+ end
+
+ def test_structure_load
+ filename = "awesome-file.sql"
+ expected_command = ["mysql", "--noop", "--execute", %{SET FOREIGN_KEY_CHECKS = 0; SOURCE #{filename}; SET FOREIGN_KEY_CHECKS = 1}, "--database", "test-db"]
+
+ assert_called_with(Kernel, :system, expected_command, returns: true) do
+ with_structure_load_flags(["--noop"]) do
+ ActiveRecord::Tasks::DatabaseTasks.structure_load(@configuration, filename)
+ end
+ end
+ end
+
+ private
+ def with_structure_load_flags(flags)
+ old = ActiveRecord::Tasks::DatabaseTasks.structure_load_flags
+ ActiveRecord::Tasks::DatabaseTasks.structure_load_flags = flags
+ yield
+ ensure
+ ActiveRecord::Tasks::DatabaseTasks.structure_load_flags = old
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/tasks/postgresql_rake_test.rb b/activerecord/test/cases/tasks/postgresql_rake_test.rb
new file mode 100644
index 0000000000..065ba7734c
--- /dev/null
+++ b/activerecord/test/cases/tasks/postgresql_rake_test.rb
@@ -0,0 +1,539 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "active_record/tasks/database_tasks"
+
+if current_adapter?(:PostgreSQLAdapter)
+ module ActiveRecord
+ class PostgreSQLDBCreateTest < ActiveRecord::TestCase
+ def setup
+ @connection = Class.new { def create_database(*); end }.new
+ @configuration = {
+ "adapter" => "postgresql",
+ "database" => "my-app-db"
+ }
+ $stdout, @original_stdout = StringIO.new, $stdout
+ $stderr, @original_stderr = StringIO.new, $stderr
+ end
+
+ def teardown
+ $stdout, $stderr = @original_stdout, @original_stderr
+ end
+
+ def test_establishes_connection_to_postgresql_database
+ ActiveRecord::Base.stub(:connection, @connection) do
+ assert_called_with(
+ ActiveRecord::Base,
+ :establish_connection,
+ [
+ [
+ "adapter" => "postgresql",
+ "database" => "postgres",
+ "schema_search_path" => "public"
+ ],
+ [
+ "adapter" => "postgresql",
+ "database" => "my-app-db"
+ ]
+ ]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration
+ end
+ end
+ end
+
+ def test_creates_database_with_default_encoding
+ with_stubbed_connection_establish_connection do
+ assert_called_with(
+ @connection,
+ :create_database,
+ ["my-app-db", @configuration.merge("encoding" => "utf8")]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration
+ end
+ end
+ end
+
+ def test_creates_database_with_given_encoding
+ with_stubbed_connection_establish_connection do
+ assert_called_with(
+ @connection,
+ :create_database,
+ ["my-app-db", @configuration.merge("encoding" => "latin")]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration.
+ merge("encoding" => "latin")
+ end
+ end
+ end
+
+ def test_creates_database_with_given_collation_and_ctype
+ with_stubbed_connection_establish_connection do
+ assert_called_with(
+ @connection,
+ :create_database,
+ [
+ "my-app-db",
+ @configuration.merge(
+ "encoding" => "utf8",
+ "collation" => "ja_JP.UTF8",
+ "ctype" => "ja_JP.UTF8"
+ )
+ ]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration.
+ merge("collation" => "ja_JP.UTF8", "ctype" => "ja_JP.UTF8")
+ end
+ end
+ end
+
+ def test_establishes_connection_to_new_database
+ ActiveRecord::Base.stub(:connection, @connection) do
+ assert_called_with(
+ ActiveRecord::Base,
+ :establish_connection,
+ [
+ [
+ "adapter" => "postgresql",
+ "database" => "postgres",
+ "schema_search_path" => "public"
+ ],
+ [
+ @configuration
+ ]
+ ]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration
+ end
+ end
+ end
+
+ def test_db_create_with_error_prints_message
+ ActiveRecord::Base.stub(:connection, @connection) do
+ ActiveRecord::Base.stub(:establish_connection, -> * { raise Exception }) do
+ assert_raises(Exception) { ActiveRecord::Tasks::DatabaseTasks.create @configuration }
+ assert_match "Couldn't create '#{@configuration['database']}' database. Please check your configuration.", $stderr.string
+ end
+ end
+ end
+
+ def test_when_database_created_successfully_outputs_info_to_stdout
+ with_stubbed_connection_establish_connection do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration
+
+ assert_equal "Created database 'my-app-db'\n", $stdout.string
+ end
+ end
+
+ def test_create_when_database_exists_outputs_info_to_stderr
+ with_stubbed_connection_establish_connection do
+ ActiveRecord::Base.connection.stub(
+ :create_database,
+ proc { raise ActiveRecord::Tasks::DatabaseAlreadyExists }
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration
+
+ assert_equal "Database 'my-app-db' already exists\n", $stderr.string
+ end
+ end
+ end
+
+ private
+
+ def with_stubbed_connection_establish_connection
+ ActiveRecord::Base.stub(:connection, @connection) do
+ ActiveRecord::Base.stub(:establish_connection, nil) do
+ yield
+ end
+ end
+ end
+ end
+
+ class PostgreSQLDBDropTest < ActiveRecord::TestCase
+ def setup
+ @connection = Class.new { def drop_database(*); end }.new
+ @configuration = {
+ "adapter" => "postgresql",
+ "database" => "my-app-db"
+ }
+ $stdout, @original_stdout = StringIO.new, $stdout
+ $stderr, @original_stderr = StringIO.new, $stderr
+ end
+
+ def teardown
+ $stdout, $stderr = @original_stdout, @original_stderr
+ end
+
+ def test_establishes_connection_to_postgresql_database
+ ActiveRecord::Base.stub(:connection, @connection) do
+ assert_called_with(
+ ActiveRecord::Base,
+ :establish_connection,
+ [
+ "adapter" => "postgresql",
+ "database" => "postgres",
+ "schema_search_path" => "public"
+ ]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.drop @configuration
+ end
+ end
+ end
+
+ def test_drops_database
+ with_stubbed_connection_establish_connection do
+ assert_called_with(
+ @connection,
+ :drop_database,
+ ["my-app-db"]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.drop @configuration
+ end
+ end
+ end
+
+ def test_when_database_dropped_successfully_outputs_info_to_stdout
+ with_stubbed_connection_establish_connection do
+ ActiveRecord::Tasks::DatabaseTasks.drop @configuration
+
+ assert_equal "Dropped database 'my-app-db'\n", $stdout.string
+ end
+ end
+
+ private
+
+ def with_stubbed_connection_establish_connection
+ ActiveRecord::Base.stub(:connection, @connection) do
+ ActiveRecord::Base.stub(:establish_connection, nil) do
+ yield
+ end
+ end
+ end
+ end
+
+ class PostgreSQLPurgeTest < ActiveRecord::TestCase
+ def setup
+ @connection = Class.new do
+ def create_database(*); end
+ def drop_database(*); end
+ end.new
+ @configuration = {
+ "adapter" => "postgresql",
+ "database" => "my-app-db"
+ }
+ end
+
+ def test_clears_active_connections
+ with_stubbed_connection do
+ ActiveRecord::Base.stub(:establish_connection, nil) do
+ assert_called(ActiveRecord::Base, :clear_active_connections!) do
+ ActiveRecord::Tasks::DatabaseTasks.purge @configuration
+ end
+ end
+ end
+ end
+
+ def test_establishes_connection_to_postgresql_database
+ with_stubbed_connection do
+ assert_called_with(
+ ActiveRecord::Base,
+ :establish_connection,
+ [
+ [
+ "adapter" => "postgresql",
+ "database" => "postgres",
+ "schema_search_path" => "public"
+ ],
+ [
+ "adapter" => "postgresql",
+ "database" => "my-app-db"
+ ]
+ ]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.purge @configuration
+ end
+ end
+ end
+
+ def test_drops_database
+ with_stubbed_connection do
+ ActiveRecord::Base.stub(:establish_connection, nil) do
+ assert_called_with(@connection, :drop_database, ["my-app-db"]) do
+ ActiveRecord::Tasks::DatabaseTasks.purge @configuration
+ end
+ end
+ end
+ end
+
+ def test_creates_database
+ with_stubbed_connection do
+ ActiveRecord::Base.stub(:establish_connection, nil) do
+ assert_called_with(
+ @connection,
+ :create_database,
+ ["my-app-db", @configuration.merge("encoding" => "utf8")]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.purge @configuration
+ end
+ end
+ end
+ end
+
+ def test_establishes_connection
+ with_stubbed_connection do
+ assert_called_with(
+ ActiveRecord::Base,
+ :establish_connection,
+ [
+ [
+ "adapter" => "postgresql",
+ "database" => "postgres",
+ "schema_search_path" => "public"
+ ],
+ [
+ @configuration
+ ]
+ ]
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.purge @configuration
+ end
+ end
+ end
+
+ private
+
+ def with_stubbed_connection
+ ActiveRecord::Base.stub(:connection, @connection) do
+ yield
+ end
+ end
+ end
+
+ class PostgreSQLDBCharsetTest < ActiveRecord::TestCase
+ def setup
+ @connection = Class.new do
+ def create_database(*); end
+ def encoding; end
+ end.new
+ @configuration = {
+ "adapter" => "postgresql",
+ "database" => "my-app-db"
+ }
+ end
+
+ def test_db_retrieves_charset
+ ActiveRecord::Base.stub(:connection, @connection) do
+ assert_called(@connection, :encoding) do
+ ActiveRecord::Tasks::DatabaseTasks.charset @configuration
+ end
+ end
+ end
+ end
+
+ class PostgreSQLDBCollationTest < ActiveRecord::TestCase
+ def setup
+ @connection = Class.new { def collation; end }.new
+ @configuration = {
+ "adapter" => "postgresql",
+ "database" => "my-app-db"
+ }
+ end
+
+ def test_db_retrieves_collation
+ ActiveRecord::Base.stub(:connection, @connection) do
+ assert_called(@connection, :collation) do
+ ActiveRecord::Tasks::DatabaseTasks.collation @configuration
+ end
+ end
+ end
+ end
+
+ class PostgreSQLStructureDumpTest < ActiveRecord::TestCase
+ def setup
+ @configuration = {
+ "adapter" => "postgresql",
+ "database" => "my-app-db"
+ }
+ @filename = "/tmp/awesome-file.sql"
+ FileUtils.touch(@filename)
+ end
+
+ def teardown
+ FileUtils.rm_f(@filename)
+ end
+
+ def test_structure_dump
+ assert_called_with(
+ Kernel,
+ :system,
+ ["pg_dump", "-s", "-x", "-O", "-f", @filename, "my-app-db"],
+ returns: true
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename)
+ end
+ end
+
+ def test_structure_dump_header_comments_removed
+ Kernel.stub(:system, true) do
+ File.write(@filename, "-- header comment\n\n-- more header comment\n statement \n-- lower comment\n")
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename)
+
+ assert_equal [" statement \n", "-- lower comment\n"], File.readlines(@filename).first(2)
+ end
+ end
+
+ def test_structure_dump_with_extra_flags
+ expected_command = ["pg_dump", "-s", "-x", "-O", "-f", @filename, "--noop", "my-app-db"]
+
+ assert_called_with(Kernel, :system, expected_command, returns: true) do
+ with_structure_dump_flags(["--noop"]) do
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename)
+ end
+ end
+ end
+
+ def test_structure_dump_with_ignore_tables
+ assert_called(
+ ActiveRecord::SchemaDumper,
+ :ignore_tables,
+ returns: ["foo", "bar"]
+ ) do
+ assert_called_with(
+ Kernel,
+ :system,
+ ["pg_dump", "-s", "-x", "-O", "-f", @filename, "-T", "foo", "-T", "bar", "my-app-db"],
+ returns: true
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename)
+ end
+ end
+ end
+
+ def test_structure_dump_with_schema_search_path
+ @configuration["schema_search_path"] = "foo,bar"
+
+ assert_called_with(
+ Kernel,
+ :system,
+ ["pg_dump", "-s", "-x", "-O", "-f", @filename, "--schema=foo", "--schema=bar", "my-app-db"],
+ returns: true
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename)
+ end
+ end
+
+ def test_structure_dump_with_schema_search_path_and_dump_schemas_all
+ @configuration["schema_search_path"] = "foo,bar"
+
+ assert_called_with(
+ Kernel,
+ :system,
+ ["pg_dump", "-s", "-x", "-O", "-f", @filename, "my-app-db"],
+ returns: true
+ ) do
+ with_dump_schemas(:all) do
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename)
+ end
+ end
+ end
+
+ def test_structure_dump_with_dump_schemas_string
+ assert_called_with(
+ Kernel,
+ :system,
+ ["pg_dump", "-s", "-x", "-O", "-f", @filename, "--schema=foo", "--schema=bar", "my-app-db"],
+ returns: true
+ ) do
+ with_dump_schemas("foo,bar") do
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename)
+ end
+ end
+ end
+
+ def test_structure_dump_execution_fails
+ filename = "awesome-file.sql"
+ assert_called_with(
+ Kernel,
+ :system,
+ ["pg_dump", "-s", "-x", "-O", "-f", filename, "my-app-db"],
+ returns: nil
+ ) do
+ e = assert_raise(RuntimeError) do
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename)
+ end
+ assert_match("failed to execute:", e.message)
+ end
+ end
+
+ private
+ def with_dump_schemas(value, &block)
+ old_dump_schemas = ActiveRecord::Base.dump_schemas
+ ActiveRecord::Base.dump_schemas = value
+ yield
+ ensure
+ ActiveRecord::Base.dump_schemas = old_dump_schemas
+ end
+
+ def with_structure_dump_flags(flags)
+ old = ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = flags
+ yield
+ ensure
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = old
+ end
+ end
+
+ class PostgreSQLStructureLoadTest < ActiveRecord::TestCase
+ def setup
+ @configuration = {
+ "adapter" => "postgresql",
+ "database" => "my-app-db"
+ }
+ end
+
+ def test_structure_load
+ filename = "awesome-file.sql"
+ assert_called_with(
+ Kernel,
+ :system,
+ ["psql", "-v", "ON_ERROR_STOP=1", "-q", "-X", "-f", filename, @configuration["database"]],
+ returns: true
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.structure_load(@configuration, filename)
+ end
+ end
+
+ def test_structure_load_with_extra_flags
+ filename = "awesome-file.sql"
+ expected_command = ["psql", "-v", "ON_ERROR_STOP=1", "-q", "-X", "-f", filename, "--noop", @configuration["database"]]
+
+ assert_called_with(Kernel, :system, expected_command, returns: true) do
+ with_structure_load_flags(["--noop"]) do
+ ActiveRecord::Tasks::DatabaseTasks.structure_load(@configuration, filename)
+ end
+ end
+ end
+
+ def test_structure_load_accepts_path_with_spaces
+ filename = "awesome file.sql"
+ assert_called_with(
+ Kernel,
+ :system,
+ ["psql", "-v", "ON_ERROR_STOP=1", "-q", "-X", "-f", filename, @configuration["database"]],
+ returns: true
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.structure_load(@configuration, filename)
+ end
+ end
+
+ private
+ def with_structure_load_flags(flags)
+ old = ActiveRecord::Tasks::DatabaseTasks.structure_load_flags
+ ActiveRecord::Tasks::DatabaseTasks.structure_load_flags = flags
+ yield
+ ensure
+ ActiveRecord::Tasks::DatabaseTasks.structure_load_flags = old
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/tasks/sqlite_rake_test.rb b/activerecord/test/cases/tasks/sqlite_rake_test.rb
new file mode 100644
index 0000000000..c1092b97c1
--- /dev/null
+++ b/activerecord/test/cases/tasks/sqlite_rake_test.rb
@@ -0,0 +1,264 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "active_record/tasks/database_tasks"
+require "pathname"
+
+if current_adapter?(:SQLite3Adapter)
+ module ActiveRecord
+ class SqliteDBCreateTest < ActiveRecord::TestCase
+ def setup
+ @database = "db_create.sqlite3"
+ @configuration = {
+ "adapter" => "sqlite3",
+ "database" => @database
+ }
+ $stdout, @original_stdout = StringIO.new, $stdout
+ $stderr, @original_stderr = StringIO.new, $stderr
+ end
+
+ def teardown
+ $stdout, $stderr = @original_stdout, @original_stderr
+ end
+
+ def test_db_checks_database_exists
+ ActiveRecord::Base.stub(:establish_connection, nil) do
+ assert_called_with(File, :exist?, [@database], returns: false) do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root"
+ end
+ end
+ end
+
+ def test_when_db_created_successfully_outputs_info_to_stdout
+ ActiveRecord::Base.stub(:establish_connection, nil) do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root"
+
+ assert_equal "Created database '#{@database}'\n", $stdout.string
+ end
+ end
+
+ def test_db_create_when_file_exists
+ File.stub(:exist?, true) do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root"
+
+ assert_equal "Database '#{@database}' already exists\n", $stderr.string
+ end
+ end
+
+ def test_db_create_with_file_does_nothing
+ File.stub(:exist?, true) do
+ assert_not_called(ActiveRecord::Base, :establish_connection) do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root"
+ end
+ end
+ end
+
+ def test_db_create_establishes_a_connection
+ assert_called_with(ActiveRecord::Base, :establish_connection, [@configuration]) do
+ ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root"
+ end
+ end
+
+ def test_db_create_with_error_prints_message
+ ActiveRecord::Base.stub(:establish_connection, proc { raise Exception }) do
+ assert_raises(Exception) { ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" }
+ assert_match "Couldn't create '#{@configuration['database']}' database. Please check your configuration.", $stderr.string
+ end
+ end
+ end
+
+ class SqliteDBDropTest < ActiveRecord::TestCase
+ def setup
+ @database = "db_create.sqlite3"
+ @configuration = {
+ "adapter" => "sqlite3",
+ "database" => @database
+ }
+ @path = Class.new do
+ def to_s; "/absolute/path" end
+ def absolute?; true end
+ end.new
+
+ $stdout, @original_stdout = StringIO.new, $stdout
+ $stderr, @original_stderr = StringIO.new, $stderr
+ end
+
+ def teardown
+ $stdout, $stderr = @original_stdout, @original_stderr
+ end
+
+ def test_creates_path_from_database
+ assert_called_with(Pathname, :new, [@database], returns: @path) do
+ ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root"
+ end
+ end
+
+ def test_removes_file_with_absolute_path
+ Pathname.stub(:new, @path) do
+ assert_called_with(FileUtils, :rm, ["/absolute/path"]) do
+ ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root"
+ end
+ end
+ end
+
+ def test_generates_absolute_path_with_given_root
+ Pathname.stub(:new, @path) do
+ @path.stub(:absolute?, false) do
+ assert_called_with(File, :join, ["/rails/root", @path],
+ returns: "/former/relative/path"
+ ) do
+ ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root"
+ end
+ end
+ end
+ end
+
+ def test_removes_file_with_relative_path
+ File.stub(:join, "/former/relative/path") do
+ @path.stub(:absolute?, false) do
+ assert_called_with(FileUtils, :rm, ["/former/relative/path"]) do
+ ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root"
+ end
+ end
+ end
+ end
+
+ def test_when_db_dropped_successfully_outputs_info_to_stdout
+ FileUtils.stub(:rm, nil) do
+ ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root"
+
+ assert_equal "Dropped database '#{@database}'\n", $stdout.string
+ end
+ end
+ end
+
+ class SqliteDBCharsetTest < ActiveRecord::TestCase
+ def setup
+ @database = "db_create.sqlite3"
+ @connection = Class.new { def encoding; end }.new
+ @configuration = {
+ "adapter" => "sqlite3",
+ "database" => @database
+ }
+ end
+
+ def test_db_retrieves_charset
+ ActiveRecord::Base.stub(:connection, @connection) do
+ assert_called(@connection, :encoding) do
+ ActiveRecord::Tasks::DatabaseTasks.charset @configuration, "/rails/root"
+ end
+ end
+ end
+ end
+
+ class SqliteDBCollationTest < ActiveRecord::TestCase
+ def setup
+ @database = "db_create.sqlite3"
+ @configuration = {
+ "adapter" => "sqlite3",
+ "database" => @database
+ }
+ end
+
+ def test_db_retrieves_collation
+ assert_raise NoMethodError do
+ ActiveRecord::Tasks::DatabaseTasks.collation @configuration, "/rails/root"
+ end
+ end
+ end
+
+ class SqliteStructureDumpTest < ActiveRecord::TestCase
+ def setup
+ @database = "db_create.sqlite3"
+ @configuration = {
+ "adapter" => "sqlite3",
+ "database" => @database
+ }
+
+ `sqlite3 #{@database} 'CREATE TABLE bar(id INTEGER)'`
+ `sqlite3 #{@database} 'CREATE TABLE foo(id INTEGER)'`
+ end
+
+ def test_structure_dump
+ dbfile = @database
+ filename = "awesome-file.sql"
+
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump @configuration, filename, "/rails/root"
+ assert File.exist?(dbfile)
+ assert File.exist?(filename)
+ assert_match(/CREATE TABLE foo/, File.read(filename))
+ assert_match(/CREATE TABLE bar/, File.read(filename))
+ ensure
+ FileUtils.rm_f(filename)
+ FileUtils.rm_f(dbfile)
+ end
+
+ def test_structure_dump_with_ignore_tables
+ dbfile = @database
+ filename = "awesome-file.sql"
+ assert_called(ActiveRecord::SchemaDumper, :ignore_tables, returns: ["foo"]) do
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename, "/rails/root")
+ end
+ assert File.exist?(dbfile)
+ assert File.exist?(filename)
+ assert_match(/bar/, File.read(filename))
+ assert_no_match(/foo/, File.read(filename))
+ ensure
+ FileUtils.rm_f(filename)
+ FileUtils.rm_f(dbfile)
+ end
+
+ def test_structure_dump_execution_fails
+ dbfile = @database
+ filename = "awesome-file.sql"
+ assert_called_with(
+ Kernel,
+ :system,
+ ["sqlite3", "--noop", "db_create.sqlite3", ".schema", out: "awesome-file.sql"],
+ returns: nil
+ ) do
+ e = assert_raise(RuntimeError) do
+ with_structure_dump_flags(["--noop"]) do
+ quietly { ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename, "/rails/root") }
+ end
+ end
+ assert_match("failed to execute:", e.message)
+ end
+ ensure
+ FileUtils.rm_f(filename)
+ FileUtils.rm_f(dbfile)
+ end
+
+ private
+ def with_structure_dump_flags(flags)
+ old = ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = flags
+ yield
+ ensure
+ ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = old
+ end
+ end
+
+ class SqliteStructureLoadTest < ActiveRecord::TestCase
+ def setup
+ @database = "db_create.sqlite3"
+ @configuration = {
+ "adapter" => "sqlite3",
+ "database" => @database
+ }
+ end
+
+ def test_structure_load
+ dbfile = @database
+ filename = "awesome-file.sql"
+
+ open(filename, "w") { |f| f.puts("select datetime('now', 'localtime');") }
+ ActiveRecord::Tasks::DatabaseTasks.structure_load @configuration, filename, "/rails/root"
+ assert File.exist?(dbfile)
+ ensure
+ FileUtils.rm_f(filename)
+ FileUtils.rm_f(dbfile)
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/test_case.rb b/activerecord/test/cases/test_case.rb
new file mode 100644
index 0000000000..5b25432dc0
--- /dev/null
+++ b/activerecord/test/cases/test_case.rb
@@ -0,0 +1,140 @@
+# frozen_string_literal: true
+
+require "active_support"
+require "active_support/testing/autorun"
+require "active_support/testing/method_call_assertions"
+require "active_support/testing/stream"
+require "active_record/fixtures"
+
+require "cases/validations_repair_helper"
+
+module ActiveRecord
+ # = Active Record Test Case
+ #
+ # Defines some test assertions to test against SQL queries.
+ class TestCase < ActiveSupport::TestCase #:nodoc:
+ include ActiveSupport::Testing::MethodCallAssertions
+ include ActiveSupport::Testing::Stream
+ include ActiveRecord::TestFixtures
+ include ActiveRecord::ValidationsRepairHelper
+
+ self.fixture_path = FIXTURES_ROOT
+ self.use_instantiated_fixtures = false
+ self.use_transactional_tests = true
+
+ def create_fixtures(*fixture_set_names, &block)
+ ActiveRecord::FixtureSet.create_fixtures(ActiveRecord::TestCase.fixture_path, fixture_set_names, fixture_class_names, &block)
+ end
+
+ def teardown
+ SQLCounter.clear_log
+ end
+
+ def capture_sql
+ ActiveRecord::Base.connection.materialize_transactions
+ SQLCounter.clear_log
+ yield
+ SQLCounter.log_all.dup
+ end
+
+ def assert_sql(*patterns_to_match)
+ capture_sql { yield }
+ ensure
+ failed_patterns = []
+ patterns_to_match.each do |pattern|
+ failed_patterns << pattern unless SQLCounter.log_all.any? { |sql| pattern === sql }
+ end
+ assert failed_patterns.empty?, "Query pattern(s) #{failed_patterns.map(&:inspect).join(', ')} not found.#{SQLCounter.log.size == 0 ? '' : "\nQueries:\n#{SQLCounter.log.join("\n")}"}"
+ end
+
+ def assert_queries(num = 1, options = {})
+ ignore_none = options.fetch(:ignore_none) { num == :any }
+ ActiveRecord::Base.connection.materialize_transactions
+ SQLCounter.clear_log
+ x = yield
+ the_log = ignore_none ? SQLCounter.log_all : SQLCounter.log
+ if num == :any
+ assert_operator the_log.size, :>=, 1, "1 or more queries expected, but none were executed."
+ else
+ mesg = "#{the_log.size} instead of #{num} queries were executed.#{the_log.size == 0 ? '' : "\nQueries:\n#{the_log.join("\n")}"}"
+ assert_equal num, the_log.size, mesg
+ end
+ x
+ end
+
+ def assert_no_queries(options = {}, &block)
+ options.reverse_merge! ignore_none: true
+ assert_queries(0, options, &block)
+ end
+
+ def assert_column(model, column_name, msg = nil)
+ assert has_column?(model, column_name), msg
+ end
+
+ def assert_no_column(model, column_name, msg = nil)
+ assert_not has_column?(model, column_name), msg
+ end
+
+ def has_column?(model, column_name)
+ model.reset_column_information
+ model.column_names.include?(column_name.to_s)
+ end
+ end
+
+ class PostgreSQLTestCase < TestCase
+ def self.run(*args)
+ super if current_adapter?(:PostgreSQLAdapter)
+ end
+ end
+
+ class Mysql2TestCase < TestCase
+ def self.run(*args)
+ super if current_adapter?(:Mysql2Adapter)
+ end
+ end
+
+ class SQLite3TestCase < TestCase
+ def self.run(*args)
+ super if current_adapter?(:SQLite3Adapter)
+ end
+ end
+
+ class SQLCounter
+ class << self
+ attr_accessor :ignored_sql, :log, :log_all
+ def clear_log; self.log = []; self.log_all = []; end
+ end
+
+ clear_log
+
+ self.ignored_sql = [/^PRAGMA/, /^SELECT currval/, /^SELECT CAST/, /^SELECT @@IDENTITY/, /^SELECT @@ROWCOUNT/, /^SAVEPOINT/, /^ROLLBACK TO SAVEPOINT/, /^RELEASE SAVEPOINT/, /^SHOW max_identifier_length/, /^BEGIN/, /^COMMIT/]
+
+ # FIXME: this needs to be refactored so specific database can add their own
+ # ignored SQL, or better yet, use a different notification for the queries
+ # instead examining the SQL content.
+ oracle_ignored = [/^select .*nextval/i, /^SAVEPOINT/, /^ROLLBACK TO/, /^\s*select .* from all_triggers/im, /^\s*select .* from all_constraints/im, /^\s*select .* from all_tab_cols/im, /^\s*select .* from all_sequences/im]
+ mysql_ignored = [/^SHOW FULL TABLES/i, /^SHOW FULL FIELDS/, /^SHOW CREATE TABLE /i, /^SHOW VARIABLES /, /^\s*SELECT (?:column_name|table_name)\b.*\bFROM information_schema\.(?:key_column_usage|tables)\b/im]
+ postgresql_ignored = [/^\s*select\b.*\bfrom\b.*pg_namespace\b/im, /^\s*select tablename\b.*from pg_tables\b/im, /^\s*select\b.*\battname\b.*\bfrom\b.*\bpg_attribute\b/im, /^SHOW search_path/i, /^\s*SELECT\b.*::regtype::oid\b/im]
+ sqlite3_ignored = [/^\s*SELECT name\b.*\bFROM sqlite_master/im, /^\s*SELECT sql\b.*\bFROM sqlite_master/im]
+
+ [oracle_ignored, mysql_ignored, postgresql_ignored, sqlite3_ignored].each do |db_ignored_sql|
+ ignored_sql.concat db_ignored_sql
+ end
+
+ attr_reader :ignore
+
+ def initialize(ignore = Regexp.union(self.class.ignored_sql))
+ @ignore = ignore
+ end
+
+ def call(name, start, finish, message_id, values)
+ return if values[:cached]
+
+ sql = values[:sql]
+ self.class.log_all << sql
+ self.class.log << sql unless ignore.match?(sql)
+ end
+ end
+
+ ActiveSupport::Notifications.subscribe("sql.active_record", SQLCounter.new)
+end
diff --git a/activerecord/test/cases/test_fixtures_test.rb b/activerecord/test/cases/test_fixtures_test.rb
new file mode 100644
index 0000000000..4411410eda
--- /dev/null
+++ b/activerecord/test/cases/test_fixtures_test.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class TestFixturesTest < ActiveRecord::TestCase
+ setup do
+ @klass = Class.new
+ @klass.include(ActiveRecord::TestFixtures)
+ end
+
+ def test_use_transactional_tests_defaults_to_true
+ assert_equal true, @klass.use_transactional_tests
+ end
+
+ def test_use_transactional_tests_can_be_overridden
+ @klass.use_transactional_tests = "foobar"
+
+ assert_equal "foobar", @klass.use_transactional_tests
+ end
+end
diff --git a/activerecord/test/cases/time_precision_test.rb b/activerecord/test/cases/time_precision_test.rb
new file mode 100644
index 0000000000..086500de38
--- /dev/null
+++ b/activerecord/test/cases/time_precision_test.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+if subsecond_precision_supported?
+ class TimePrecisionTest < ActiveRecord::TestCase
+ include SchemaDumpingHelper
+ self.use_transactional_tests = false
+
+ class Foo < ActiveRecord::Base; end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ Foo.reset_column_information
+ end
+
+ teardown do
+ @connection.drop_table :foos, if_exists: true
+ end
+
+ def test_time_data_type_with_precision
+ @connection.create_table(:foos, force: true)
+ @connection.add_column :foos, :start, :time, precision: 3
+ @connection.add_column :foos, :finish, :time, precision: 6
+ assert_equal 3, Foo.columns_hash["start"].precision
+ assert_equal 6, Foo.columns_hash["finish"].precision
+ end
+
+ def test_time_precision_is_truncated_on_assignment
+ @connection.create_table(:foos, force: true)
+ @connection.add_column :foos, :start, :time, precision: 0
+ @connection.add_column :foos, :finish, :time, precision: 6
+
+ time = ::Time.now.change(nsec: 123456789)
+ foo = Foo.new(start: time, finish: time)
+
+ assert_equal 0, foo.start.nsec
+ assert_equal 123456000, foo.finish.nsec
+
+ foo.save!
+ foo.reload
+
+ assert_equal 0, foo.start.nsec
+ assert_equal 123456000, foo.finish.nsec
+ end
+
+ def test_passing_precision_to_time_does_not_set_limit
+ @connection.create_table(:foos, force: true) do |t|
+ t.time :start, precision: 3
+ t.time :finish, precision: 6
+ end
+ assert_nil Foo.columns_hash["start"].limit
+ assert_nil Foo.columns_hash["finish"].limit
+ end
+
+ def test_invalid_time_precision_raises_error
+ assert_raises ActiveRecord::ActiveRecordError do
+ @connection.create_table(:foos, force: true) do |t|
+ t.time :start, precision: 7
+ t.time :finish, precision: 7
+ end
+ end
+ end
+
+ def test_formatting_time_according_to_precision
+ @connection.create_table(:foos, force: true) do |t|
+ t.time :start, precision: 0
+ t.time :finish, precision: 4
+ end
+ time = ::Time.utc(2000, 1, 1, 12, 30, 0, 999999)
+ Foo.create!(start: time, finish: time)
+ assert foo = Foo.find_by(start: time)
+ assert_equal 1, Foo.where(finish: time).count
+ assert_equal time.to_s, foo.start.to_s
+ assert_equal time.to_s, foo.finish.to_s
+ assert_equal 000000, foo.start.usec
+ assert_equal 999900, foo.finish.usec
+ end
+
+ def test_schema_dump_includes_time_precision
+ @connection.create_table(:foos, force: true) do |t|
+ t.time :start, precision: 4
+ t.time :finish, precision: 6
+ end
+ output = dump_table_schema("foos")
+ assert_match %r{t\.time\s+"start",\s+precision: 4$}, output
+ assert_match %r{t\.time\s+"finish",\s+precision: 6$}, output
+ end
+
+ if current_adapter?(:PostgreSQLAdapter, :SQLServerAdapter)
+ def test_time_precision_with_zero_should_be_dumped
+ @connection.create_table(:foos, force: true) do |t|
+ t.time :start, precision: 0
+ t.time :finish, precision: 0
+ end
+ output = dump_table_schema("foos")
+ assert_match %r{t\.time\s+"start",\s+precision: 0$}, output
+ assert_match %r{t\.time\s+"finish",\s+precision: 0$}, output
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/timestamp_test.rb b/activerecord/test/cases/timestamp_test.rb
new file mode 100644
index 0000000000..75ecd6fc40
--- /dev/null
+++ b/activerecord/test/cases/timestamp_test.rb
@@ -0,0 +1,488 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/ddl_helper"
+require "models/developer"
+require "models/computer"
+require "models/owner"
+require "models/pet"
+require "models/toy"
+require "models/car"
+require "models/task"
+
+class TimestampTest < ActiveRecord::TestCase
+ fixtures :developers, :owners, :pets, :toys, :cars, :tasks
+
+ def setup
+ @developer = Developer.first
+ @owner = Owner.first
+ @developer.update_columns(updated_at: Time.now.prev_month)
+ @previously_updated_at = @developer.updated_at
+ end
+
+ def test_saving_a_changed_record_updates_its_timestamp
+ @developer.name = "Jack Bauer"
+ @developer.save!
+
+ assert_not_equal @previously_updated_at, @developer.updated_at
+ end
+
+ def test_saving_a_unchanged_record_doesnt_update_its_timestamp
+ @developer.save!
+
+ assert_equal @previously_updated_at, @developer.updated_at
+ end
+
+ def test_touching_a_record_updates_its_timestamp
+ previous_salary = @developer.salary
+ @developer.salary = previous_salary + 10000
+ @developer.touch
+
+ assert_not_equal @previously_updated_at, @developer.updated_at
+ assert_equal previous_salary + 10000, @developer.salary
+ assert @developer.salary_changed?, "developer salary should have changed"
+ assert @developer.changed?, "developer should be marked as changed"
+ @developer.reload
+ assert_equal previous_salary, @developer.salary
+ end
+
+ def test_touching_a_record_with_default_scope_that_excludes_it_updates_its_timestamp
+ developer = @developer.becomes(DeveloperCalledJamis)
+
+ developer.touch
+ assert_not_equal @previously_updated_at, developer.updated_at
+ developer.reload
+ assert_not_equal @previously_updated_at, developer.updated_at
+ end
+
+ def test_saving_when_record_timestamps_is_false_doesnt_update_its_timestamp
+ Developer.record_timestamps = false
+ @developer.name = "John Smith"
+ @developer.save!
+
+ assert_equal @previously_updated_at, @developer.updated_at
+ ensure
+ Developer.record_timestamps = true
+ end
+
+ def test_saving_when_instance_record_timestamps_is_false_doesnt_update_its_timestamp
+ @developer.record_timestamps = false
+ assert Developer.record_timestamps
+
+ @developer.name = "John Smith"
+ @developer.save!
+
+ assert_equal @previously_updated_at, @developer.updated_at
+ end
+
+ def test_touching_updates_timestamp_with_given_time
+ previously_updated_at = @developer.updated_at
+ new_time = Time.utc(2015, 2, 16, 0, 0, 0)
+ @developer.touch(time: new_time)
+
+ assert_not_equal previously_updated_at, @developer.updated_at
+ assert_equal new_time, @developer.updated_at
+ end
+
+ def test_touching_an_attribute_updates_timestamp
+ previously_created_at = @developer.created_at
+ travel(1.second) do
+ @developer.touch(:created_at)
+ end
+
+ assert_not @developer.created_at_changed?, "created_at should not be changed"
+ assert_not @developer.changed?, "record should not be changed"
+ assert_not_equal previously_created_at, @developer.created_at
+ assert_not_equal @previously_updated_at, @developer.updated_at
+ end
+
+ def test_touching_update_at_attribute_as_symbol_updates_timestamp
+ travel(1.second) do
+ @developer.touch(:updated_at)
+ end
+
+ assert_not @developer.updated_at_changed?
+ assert_not @developer.changed?
+ assert_not_equal @previously_updated_at, @developer.updated_at
+ end
+
+ def test_touching_an_attribute_updates_it
+ task = Task.first
+ previous_value = task.ending
+ task.touch(:ending)
+
+ now = Time.now.change(usec: 0)
+
+ assert_not_equal previous_value, task.ending
+ assert_in_delta now, task.ending, 1
+ end
+
+ def test_touching_an_attribute_updates_timestamp_with_given_time
+ previously_updated_at = @developer.updated_at
+ previously_created_at = @developer.created_at
+ new_time = Time.utc(2015, 2, 16, 4, 54, 0)
+ @developer.touch(:created_at, time: new_time)
+
+ assert_not_equal previously_created_at, @developer.created_at
+ assert_not_equal previously_updated_at, @developer.updated_at
+ assert_equal new_time, @developer.created_at
+ assert_equal new_time, @developer.updated_at
+ end
+
+ def test_touching_many_attributes_updates_them
+ task = Task.first
+ previous_starting = task.starting
+ previous_ending = task.ending
+ task.touch(:starting, :ending)
+
+ now = Time.now.change(usec: 0)
+
+ assert_not_equal previous_starting, task.starting
+ assert_not_equal previous_ending, task.ending
+ assert_in_delta now, task.starting, 1
+ assert_in_delta now, task.ending, 1
+ end
+
+ def test_touching_a_record_without_timestamps_is_unexceptional
+ assert_nothing_raised { Car.first.touch }
+ end
+
+ def test_touching_a_no_touching_object
+ Developer.no_touching do
+ assert_predicate @developer, :no_touching?
+ assert_not_predicate @owner, :no_touching?
+ @developer.touch
+ end
+
+ assert_not_predicate @developer, :no_touching?
+ assert_not_predicate @owner, :no_touching?
+ assert_equal @previously_updated_at, @developer.updated_at
+ end
+
+ def test_touching_related_objects
+ @owner = Owner.first
+ @previously_updated_at = @owner.updated_at
+
+ Owner.no_touching do
+ @owner.pets.first.touch
+ end
+
+ assert_equal @previously_updated_at, @owner.updated_at
+ end
+
+ def test_global_no_touching
+ ActiveRecord::Base.no_touching do
+ assert_predicate @developer, :no_touching?
+ assert_predicate @owner, :no_touching?
+ @developer.touch
+ end
+
+ assert_not_predicate @developer, :no_touching?
+ assert_not_predicate @owner, :no_touching?
+ assert_equal @previously_updated_at, @developer.updated_at
+ end
+
+ def test_no_touching_threadsafe
+ Thread.new do
+ Developer.no_touching do
+ assert_predicate @developer, :no_touching?
+
+ sleep(1)
+ end
+ end
+
+ assert_not_predicate @developer, :no_touching?
+ end
+
+ def test_no_touching_with_callbacks
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "developers"
+
+ attr_accessor :after_touch_called
+
+ after_touch do |user|
+ user.after_touch_called = true
+ end
+ end
+
+ developer = klass.first
+
+ klass.no_touching do
+ developer.touch
+ assert_not developer.after_touch_called
+ end
+ end
+
+ def test_saving_a_record_with_a_belongs_to_that_specifies_touching_the_parent_should_update_the_parent_updated_at
+ pet = Pet.first
+ owner = pet.owner
+ previously_owner_updated_at = owner.updated_at
+
+ travel(1.second) do
+ pet.name = "Fluffy the Third"
+ pet.save
+ end
+
+ assert_not_equal previously_owner_updated_at, pet.owner.updated_at
+ end
+
+ def test_destroying_a_record_with_a_belongs_to_that_specifies_touching_the_parent_should_update_the_parent_updated_at
+ pet = Pet.first
+ owner = pet.owner
+ previously_owner_updated_at = owner.updated_at
+
+ travel(1.second) do
+ pet.destroy
+ end
+
+ assert_not_equal previously_owner_updated_at, pet.owner.updated_at
+ end
+
+ def test_saving_a_new_record_belonging_to_invalid_parent_with_touch_should_not_raise_exception
+ klass = Class.new(Owner) do
+ def self.name; "Owner"; end
+ validate { errors.add(:base, :invalid) }
+ end
+
+ pet = Pet.new(owner: klass.new)
+ pet.save!
+
+ assert_predicate pet.owner, :new_record?
+ end
+
+ def test_saving_a_record_with_a_belongs_to_that_specifies_touching_a_specific_attribute_the_parent_should_update_that_attribute
+ klass = Class.new(ActiveRecord::Base) do
+ def self.name; "Pet"; end
+ belongs_to :owner, touch: :happy_at
+ end
+
+ pet = klass.first
+ owner = pet.owner
+ previously_owner_happy_at = owner.happy_at
+
+ pet.name = "Fluffy the Third"
+ pet.save
+
+ assert_not_equal previously_owner_happy_at, pet.owner.happy_at
+ end
+
+ def test_touching_a_record_with_a_belongs_to_that_uses_a_counter_cache_should_update_the_parent
+ klass = Class.new(ActiveRecord::Base) do
+ def self.name; "Pet"; end
+ belongs_to :owner, counter_cache: :use_count, touch: true
+ end
+
+ pet = klass.first
+ owner = pet.owner
+ owner.update_columns(happy_at: 3.days.ago)
+ previously_owner_updated_at = owner.updated_at
+
+ travel(1.second) do
+ pet.name = "I'm a parrot"
+ pet.save
+ end
+
+ assert_not_equal previously_owner_updated_at, pet.owner.updated_at
+ end
+
+ def test_touching_a_record_touches_parent_record_and_grandparent_record
+ klass = Class.new(ActiveRecord::Base) do
+ def self.name; "Toy"; end
+ belongs_to :pet, touch: true
+ end
+
+ toy = klass.first
+ pet = toy.pet
+ owner = pet.owner
+ time = 3.days.ago
+
+ owner.update_columns(updated_at: time)
+ toy.touch
+ owner.reload
+
+ assert_not_equal time, owner.updated_at
+ end
+
+ def test_touching_a_record_touches_polymorphic_record
+ klass = Class.new(ActiveRecord::Base) do
+ def self.name; "Toy"; end
+ end
+
+ wheel_klass = Class.new(ActiveRecord::Base) do
+ def self.name; "Wheel"; end
+ belongs_to :wheelable, polymorphic: true, touch: true
+ end
+
+ toy = klass.first
+ time = 3.days.ago
+ toy.update_columns(updated_at: time)
+
+ wheel = wheel_klass.new
+ wheel.wheelable = toy
+ wheel.save
+ wheel.touch
+
+ assert_not_equal time, toy.updated_at
+ end
+
+ def test_changing_parent_of_a_record_touches_both_new_and_old_parent_record
+ klass = Class.new(ActiveRecord::Base) do
+ def self.name; "Toy"; end
+ belongs_to :pet, touch: true
+ end
+
+ toy1 = klass.find(1)
+ old_pet = toy1.pet
+
+ toy2 = klass.find(2)
+ new_pet = toy2.pet
+ time = 3.days.ago.at_beginning_of_hour
+
+ old_pet.update_columns(updated_at: time)
+ new_pet.update_columns(updated_at: time)
+
+ toy1.pet = new_pet
+ toy1.save!
+
+ old_pet.reload
+ new_pet.reload
+
+ assert_not_equal time, new_pet.updated_at
+ assert_not_equal time, old_pet.updated_at
+ end
+
+ def test_changing_parent_of_a_record_touches_both_new_and_old_polymorphic_parent_record_changes_within_same_class
+ car_class = Class.new(ActiveRecord::Base) do
+ def self.name; "Car"; end
+ end
+
+ wheel_class = Class.new(ActiveRecord::Base) do
+ def self.name; "Wheel"; end
+ belongs_to :wheelable, polymorphic: true, touch: true
+ end
+
+ car1 = car_class.find(1)
+ car2 = car_class.find(2)
+
+ wheel = wheel_class.create!(wheelable: car1)
+
+ time = 3.days.ago.at_beginning_of_hour
+
+ car1.update_columns(updated_at: time)
+ car2.update_columns(updated_at: time)
+
+ wheel.wheelable = car2
+ wheel.save!
+
+ assert_not_equal time, car1.reload.updated_at
+ assert_not_equal time, car2.reload.updated_at
+ end
+
+ def test_changing_parent_of_a_record_touches_both_new_and_old_polymorphic_parent_record_changes_with_other_class
+ car_class = Class.new(ActiveRecord::Base) do
+ def self.name; "Car"; end
+ end
+
+ toy_class = Class.new(ActiveRecord::Base) do
+ def self.name; "Toy"; end
+ end
+
+ wheel_class = Class.new(ActiveRecord::Base) do
+ def self.name; "Wheel"; end
+ belongs_to :wheelable, polymorphic: true, touch: true
+ end
+
+ car = car_class.find(1)
+ toy = toy_class.find(3)
+
+ wheel = wheel_class.create!(wheelable: car)
+
+ time = 3.days.ago.at_beginning_of_hour
+
+ car.update_columns(updated_at: time)
+ toy.update_columns(updated_at: time)
+
+ wheel.wheelable = toy
+ wheel.save!
+
+ assert_not_equal time, car.reload.updated_at
+ assert_not_equal time, toy.reload.updated_at
+ end
+
+ def test_clearing_association_touches_the_old_record
+ klass = Class.new(ActiveRecord::Base) do
+ def self.name; "Toy"; end
+ belongs_to :pet, touch: true
+ end
+
+ toy = klass.find(1)
+ pet = toy.pet
+ time = 3.days.ago.at_beginning_of_hour
+
+ pet.update_columns(updated_at: time)
+
+ toy.pet = nil
+ toy.save!
+
+ pet.reload
+
+ assert_not_equal time, pet.updated_at
+ end
+
+ def test_timestamp_column_values_are_present_in_the_callbacks
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "people"
+
+ before_create do
+ self.born_at = created_at
+ end
+ end
+
+ person = klass.create first_name: "David"
+ assert_not_equal person.born_at, nil
+ end
+
+ def test_timestamp_attributes_for_create_in_model
+ toy = Toy.first
+ assert_equal ["created_at"], toy.send(:timestamp_attributes_for_create_in_model)
+ end
+
+ def test_timestamp_attributes_for_update_in_model
+ toy = Toy.first
+ assert_equal ["updated_at"], toy.send(:timestamp_attributes_for_update_in_model)
+ end
+
+ def test_all_timestamp_attributes_in_model
+ toy = Toy.first
+ assert_equal ["created_at", "updated_at"], toy.send(:all_timestamp_attributes_in_model)
+ end
+end
+
+class TimestampsWithoutTransactionTest < ActiveRecord::TestCase
+ include DdlHelper
+ self.use_transactional_tests = false
+
+ class TimestampAttributePost < ActiveRecord::Base
+ attr_accessor :created_at, :updated_at
+ end
+
+ def test_do_not_write_timestamps_on_save_if_they_are_not_attributes
+ with_example_table ActiveRecord::Base.connection, "timestamp_attribute_posts", "id integer primary key" do
+ post = TimestampAttributePost.new(id: 1)
+ post.save! # should not try to assign and persist created_at, updated_at
+ assert_nil post.created_at
+ assert_nil post.updated_at
+ end
+ end
+
+ def test_index_is_created_for_both_timestamps
+ ActiveRecord::Base.connection.create_table(:foos, force: true) do |t|
+ t.timestamps null: true, index: true
+ end
+
+ indexes = ActiveRecord::Base.connection.indexes("foos")
+ assert_equal ["created_at", "updated_at"], indexes.flat_map(&:columns).sort
+ ensure
+ ActiveRecord::Base.connection.drop_table(:foos)
+ end
+end
diff --git a/activerecord/test/cases/touch_later_test.rb b/activerecord/test/cases/touch_later_test.rb
new file mode 100644
index 0000000000..cd3d5ed7d1
--- /dev/null
+++ b/activerecord/test/cases/touch_later_test.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/invoice"
+require "models/line_item"
+require "models/topic"
+require "models/node"
+require "models/tree"
+
+class TouchLaterTest < ActiveRecord::TestCase
+ fixtures :nodes, :trees
+
+ def test_touch_laster_raise_if_non_persisted
+ invoice = Invoice.new
+ Invoice.transaction do
+ assert_not_predicate invoice, :persisted?
+ assert_raises(ActiveRecord::ActiveRecordError) do
+ invoice.touch_later
+ end
+ end
+ end
+
+ def test_touch_later_dont_set_dirty_attributes
+ invoice = Invoice.create!
+ invoice.touch_later
+ assert_not_predicate invoice, :changed?
+ end
+
+ def test_touch_later_respects_no_touching_policy
+ time = Time.now.utc - 25.days
+ topic = Topic.create!(updated_at: time, created_at: time)
+ Topic.no_touching do
+ topic.touch_later
+ end
+ assert_equal time.to_i, topic.updated_at.to_i
+ end
+
+ def test_touch_later_update_the_attributes
+ time = Time.now.utc - 25.days
+ topic = Topic.create!(updated_at: time, created_at: time)
+ assert_equal time.to_i, topic.updated_at.to_i
+ assert_equal time.to_i, topic.created_at.to_i
+
+ Topic.transaction do
+ topic.touch_later(:created_at)
+ assert_not_equal time.to_i, topic.updated_at.to_i
+ assert_not_equal time.to_i, topic.created_at.to_i
+
+ assert_equal time.to_i, topic.reload.updated_at.to_i
+ assert_equal time.to_i, topic.reload.created_at.to_i
+ end
+ assert_not_equal time.to_i, topic.reload.updated_at.to_i
+ assert_not_equal time.to_i, topic.reload.created_at.to_i
+ end
+
+ def test_touch_touches_immediately
+ time = Time.now.utc - 25.days
+ topic = Topic.create!(updated_at: time, created_at: time)
+ assert_equal time.to_i, topic.updated_at.to_i
+ assert_equal time.to_i, topic.created_at.to_i
+
+ Topic.transaction do
+ topic.touch_later(:created_at)
+ topic.touch
+
+ assert_not_equal time, topic.reload.updated_at
+ assert_not_equal time, topic.reload.created_at
+ end
+ end
+
+ def test_touch_later_an_association_dont_autosave_parent
+ time = Time.now.utc - 25.days
+ line_item = LineItem.create!(amount: 1)
+ invoice = Invoice.create!(line_items: [line_item])
+ invoice.touch(time: time)
+
+ Invoice.transaction do
+ line_item.update(amount: 2)
+ assert_equal time.to_i, invoice.reload.updated_at.to_i
+ end
+
+ assert_not_equal time.to_i, invoice.updated_at.to_i
+ end
+
+ def test_touch_touches_immediately_with_a_custom_time
+ time = (Time.now.utc - 25.days).change(nsec: 0)
+ topic = Topic.create!(updated_at: time, created_at: time)
+ assert_equal time, topic.updated_at
+ assert_equal time, topic.created_at
+
+ Topic.transaction do
+ topic.touch_later(:created_at)
+ time = Time.now.utc - 2.days
+ topic.touch(time: time)
+
+ assert_equal time.to_i, topic.reload.updated_at.to_i
+ assert_equal time.to_i, topic.reload.created_at.to_i
+ end
+ end
+
+ def test_touch_later_dont_hit_the_db
+ invoice = Invoice.create!
+ assert_no_queries do
+ invoice.touch_later
+ end
+ end
+
+ def test_touching_three_deep
+ previous_tree_updated_at = trees(:root).updated_at
+ previous_grandparent_updated_at = nodes(:grandparent).updated_at
+ previous_parent_updated_at = nodes(:parent_a).updated_at
+ previous_child_updated_at = nodes(:child_one_of_a).updated_at
+
+ travel 5.seconds do
+ Node.create! parent: nodes(:child_one_of_a), tree: trees(:root)
+ end
+
+ assert_not_equal nodes(:child_one_of_a).reload.updated_at, previous_child_updated_at
+ assert_not_equal nodes(:parent_a).reload.updated_at, previous_parent_updated_at
+ assert_not_equal nodes(:grandparent).reload.updated_at, previous_grandparent_updated_at
+ assert_not_equal trees(:root).reload.updated_at, previous_tree_updated_at
+ end
+end
diff --git a/activerecord/test/cases/transaction_callbacks_test.rb b/activerecord/test/cases/transaction_callbacks_test.rb
new file mode 100644
index 0000000000..aa6b7915a2
--- /dev/null
+++ b/activerecord/test/cases/transaction_callbacks_test.rb
@@ -0,0 +1,665 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/owner"
+require "models/pet"
+require "models/topic"
+
+class TransactionCallbacksTest < ActiveRecord::TestCase
+ fixtures :topics, :owners, :pets
+
+ class ReplyWithCallbacks < ActiveRecord::Base
+ self.table_name = :topics
+
+ belongs_to :topic, foreign_key: "parent_id"
+
+ validates_presence_of :content
+
+ after_commit :do_after_commit, on: :create
+
+ attr_accessor :save_on_after_create
+ after_create do
+ save! if save_on_after_create
+ end
+
+ def history
+ @history ||= []
+ end
+
+ def do_after_commit
+ history << :commit_on_create
+ end
+ end
+
+ class TopicWithCallbacks < ActiveRecord::Base
+ self.table_name = :topics
+
+ has_many :replies, class_name: "ReplyWithCallbacks", foreign_key: "parent_id"
+
+ before_commit { |record| record.do_before_commit(nil) }
+ after_commit { |record| record.do_after_commit(nil) }
+ after_create_commit { |record| record.do_after_commit(:create) }
+ after_update_commit { |record| record.do_after_commit(:update) }
+ after_destroy_commit { |record| record.do_after_commit(:destroy) }
+ after_rollback { |record| record.do_after_rollback(nil) }
+ after_rollback(on: :create) { |record| record.do_after_rollback(:create) }
+ after_rollback(on: :update) { |record| record.do_after_rollback(:update) }
+ after_rollback(on: :destroy) { |record| record.do_after_rollback(:destroy) }
+
+ def history
+ @history ||= []
+ end
+
+ def before_commit_block(on = nil, &block)
+ @before_commit ||= {}
+ @before_commit[on] ||= []
+ @before_commit[on] << block
+ end
+
+ def after_commit_block(on = nil, &block)
+ @after_commit ||= {}
+ @after_commit[on] ||= []
+ @after_commit[on] << block
+ end
+
+ def after_rollback_block(on = nil, &block)
+ @after_rollback ||= {}
+ @after_rollback[on] ||= []
+ @after_rollback[on] << block
+ end
+
+ def do_before_commit(on)
+ blocks = @before_commit[on] if defined?(@before_commit)
+ blocks.each { |b| b.call(self) } if blocks
+ end
+
+ def do_after_commit(on)
+ blocks = @after_commit[on] if defined?(@after_commit)
+ blocks.each { |b| b.call(self) } if blocks
+ end
+
+ def do_after_rollback(on)
+ blocks = @after_rollback[on] if defined?(@after_rollback)
+ blocks.each { |b| b.call(self) } if blocks
+ end
+ end
+
+ def setup
+ @first = TopicWithCallbacks.find(1)
+ end
+
+ # FIXME: Test behavior, not implementation.
+ def test_before_commit_exception_should_pop_transaction_stack
+ @first.before_commit_block { raise "better pop this txn from the stack!" }
+
+ original_txn = @first.class.connection.current_transaction
+
+ begin
+ @first.save!
+ fail
+ rescue
+ assert_equal original_txn, @first.class.connection.current_transaction
+ end
+ end
+
+ def test_call_after_commit_after_transaction_commits
+ @first.after_commit_block { |r| r.history << :after_commit }
+ @first.after_rollback_block { |r| r.history << :after_rollback }
+
+ @first.save!
+ assert_equal [:after_commit], @first.history
+ end
+
+ def test_only_call_after_commit_on_update_after_transaction_commits_for_existing_record
+ add_transaction_execution_blocks @first
+
+ @first.save!
+ assert_equal [:commit_on_update], @first.history
+ end
+
+ def test_only_call_after_commit_on_destroy_after_transaction_commits_for_destroyed_record
+ add_transaction_execution_blocks @first
+
+ @first.destroy
+ assert_equal [:commit_on_destroy], @first.history
+ end
+
+ def test_only_call_after_commit_on_create_after_transaction_commits_for_new_record
+ new_record = TopicWithCallbacks.new(title: "New topic", written_on: Date.today)
+ add_transaction_execution_blocks new_record
+
+ new_record.save!
+ assert_equal [:commit_on_create], new_record.history
+ end
+
+ def test_only_call_after_commit_on_create_after_transaction_commits_for_new_record_if_create_succeeds_creating_through_association
+ topic = TopicWithCallbacks.create!(title: "New topic", written_on: Date.today)
+ reply = topic.replies.create
+
+ assert_equal [], reply.history
+ end
+
+ def test_only_call_after_commit_on_destroy_after_transaction_commits_for_destroyed_new_record
+ new_record = TopicWithCallbacks.new(title: "New topic", written_on: Date.today)
+ add_transaction_execution_blocks new_record
+
+ new_record.destroy
+ assert_equal [:commit_on_destroy], new_record.history
+ end
+
+ def test_save_in_after_create_commit_wont_invoke_extra_after_create_commit
+ new_record = TopicWithCallbacks.new(title: "New topic", written_on: Date.today)
+ add_transaction_execution_blocks new_record
+ new_record.after_commit_block(:create) { |r| r.save! }
+
+ new_record.save!
+ assert_equal [:commit_on_create, :commit_on_update], new_record.history
+ end
+
+ def test_only_call_after_commit_on_create_and_doesnt_leaky
+ r = ReplyWithCallbacks.new(content: "foo")
+ r.save_on_after_create = true
+ r.save!
+ r.content = "bar"
+ r.save!
+ r.save!
+ assert_equal [:commit_on_create], r.history
+ end
+
+ def test_only_call_after_commit_on_update_after_transaction_commits_for_existing_record_on_touch
+ add_transaction_execution_blocks @first
+
+ @first.touch
+ assert_equal [:commit_on_update], @first.history
+ end
+
+ def test_only_call_after_commit_on_top_level_transactions
+ @first.after_commit_block { |r| r.history << :after_commit }
+ assert_empty @first.history
+
+ @first.transaction do
+ @first.transaction(requires_new: true) do
+ @first.touch
+ end
+ assert_empty @first.history
+ end
+ assert_equal [:after_commit], @first.history
+ end
+
+ def test_call_after_rollback_after_transaction_rollsback
+ @first.after_commit_block { |r| r.history << :after_commit }
+ @first.after_rollback_block { |r| r.history << :after_rollback }
+
+ Topic.transaction do
+ @first.save!
+ raise ActiveRecord::Rollback
+ end
+
+ assert_equal [:after_rollback], @first.history
+ end
+
+ def test_only_call_after_rollback_on_update_after_transaction_rollsback_for_existing_record
+ add_transaction_execution_blocks @first
+
+ Topic.transaction do
+ @first.save!
+ raise ActiveRecord::Rollback
+ end
+
+ assert_equal [:rollback_on_update], @first.history
+ end
+
+ def test_only_call_after_rollback_on_update_after_transaction_rollsback_for_existing_record_on_touch
+ add_transaction_execution_blocks @first
+
+ Topic.transaction do
+ @first.touch
+ raise ActiveRecord::Rollback
+ end
+
+ assert_equal [:rollback_on_update], @first.history
+ end
+
+ def test_only_call_after_rollback_on_destroy_after_transaction_rollsback_for_destroyed_record
+ add_transaction_execution_blocks @first
+
+ Topic.transaction do
+ @first.destroy
+ raise ActiveRecord::Rollback
+ end
+
+ assert_equal [:rollback_on_destroy], @first.history
+ end
+
+ def test_only_call_after_rollback_on_create_after_transaction_rollsback_for_new_record
+ new_record = TopicWithCallbacks.new(title: "New topic", written_on: Date.today)
+ add_transaction_execution_blocks new_record
+
+ Topic.transaction do
+ new_record.save!
+ raise ActiveRecord::Rollback
+ end
+
+ assert_equal [:rollback_on_create], new_record.history
+ end
+
+ def test_call_after_rollback_when_commit_fails
+ @first.after_commit_block { |r| r.history << :after_commit }
+ @first.after_rollback_block { |r| r.history << :after_rollback }
+
+ assert_raises RuntimeError do
+ @first.transaction do
+ tx = @first.class.connection.transaction_manager.current_transaction
+ def tx.commit
+ raise
+ end
+
+ @first.save
+ end
+ end
+
+ assert_equal [:after_rollback], @first.history
+ end
+
+ def test_only_call_after_rollback_on_records_rolled_back_to_a_savepoint
+ def @first.rollbacks(i = 0); @rollbacks ||= 0; @rollbacks += i if i; end
+ def @first.commits(i = 0); @commits ||= 0; @commits += i if i; end
+ @first.after_rollback_block { |r| r.rollbacks(1) }
+ @first.after_commit_block { |r| r.commits(1) }
+
+ second = TopicWithCallbacks.find(3)
+ def second.rollbacks(i = 0); @rollbacks ||= 0; @rollbacks += i if i; end
+ def second.commits(i = 0); @commits ||= 0; @commits += i if i; end
+ second.after_rollback_block { |r| r.rollbacks(1) }
+ second.after_commit_block { |r| r.commits(1) }
+
+ Topic.transaction do
+ @first.save!
+ Topic.transaction(requires_new: true) do
+ second.save!
+ raise ActiveRecord::Rollback
+ end
+ end
+
+ assert_equal 1, @first.commits
+ assert_equal 0, @first.rollbacks
+ assert_equal 0, second.commits
+ assert_equal 1, second.rollbacks
+ end
+
+ def test_only_call_after_rollback_on_records_rolled_back_to_a_savepoint_when_release_savepoint_fails
+ def @first.rollbacks(i = 0); @rollbacks ||= 0; @rollbacks += i if i; end
+ def @first.commits(i = 0); @commits ||= 0; @commits += i if i; end
+
+ @first.after_rollback_block { |r| r.rollbacks(1) }
+ @first.after_commit_block { |r| r.commits(1) }
+
+ Topic.transaction do
+ @first.save
+ Topic.transaction(requires_new: true) do
+ @first.save!
+ raise ActiveRecord::Rollback
+ end
+ Topic.transaction(requires_new: true) do
+ @first.save!
+ raise ActiveRecord::Rollback
+ end
+ end
+
+ assert_equal 1, @first.commits
+ assert_equal 2, @first.rollbacks
+ end
+
+ def test_after_commit_callback_should_not_swallow_errors
+ @first.after_commit_block { fail "boom" }
+ assert_raises(RuntimeError) do
+ Topic.transaction do
+ @first.save!
+ end
+ end
+ end
+
+ def test_after_commit_callback_when_raise_should_not_restore_state
+ first = TopicWithCallbacks.new
+ second = TopicWithCallbacks.new
+ first.after_commit_block { fail "boom" }
+ second.after_commit_block { fail "boom" }
+
+ begin
+ Topic.transaction do
+ first.save!
+ assert_not_nil first.id
+ second.save!
+ assert_not_nil second.id
+ end
+ rescue
+ end
+ assert_not_nil first.id
+ assert_not_nil second.id
+ assert first.reload
+ end
+
+ def test_after_rollback_callback_should_not_swallow_errors_when_set_to_raise
+ error_class = Class.new(StandardError)
+ @first.after_rollback_block { raise error_class }
+ assert_raises(error_class) do
+ Topic.transaction do
+ @first.save!
+ raise ActiveRecord::Rollback
+ end
+ end
+ end
+
+ def test_after_rollback_callback_when_raise_should_restore_state
+ error_class = Class.new(StandardError)
+
+ first = TopicWithCallbacks.new
+ second = TopicWithCallbacks.new
+ first.after_rollback_block { raise error_class }
+ second.after_rollback_block { raise error_class }
+
+ begin
+ Topic.transaction do
+ first.save!
+ assert_not_nil first.id
+ second.save!
+ assert_not_nil second.id
+ raise ActiveRecord::Rollback
+ end
+ rescue error_class
+ end
+ assert_nil first.id
+ assert_nil second.id
+ end
+
+ def test_after_rollback_callbacks_should_validate_on_condition
+ assert_raise(ArgumentError) { Topic.after_rollback(on: :save) }
+ e = assert_raise(ArgumentError) { Topic.after_rollback(on: "create") }
+ assert_match(/:on conditions for after_commit and after_rollback callbacks have to be one of \[:create, :destroy, :update\]/, e.message)
+ end
+
+ def test_after_commit_callbacks_should_validate_on_condition
+ assert_raise(ArgumentError) { Topic.after_commit(on: :save) }
+ e = assert_raise(ArgumentError) { Topic.after_commit(on: "create") }
+ assert_match(/:on conditions for after_commit and after_rollback callbacks have to be one of \[:create, :destroy, :update\]/, e.message)
+ end
+
+ def test_after_commit_chain_not_called_on_errors
+ record_1 = TopicWithCallbacks.create!
+ record_2 = TopicWithCallbacks.create!
+ record_3 = TopicWithCallbacks.create!
+ callbacks = []
+ record_1.after_commit_block { raise }
+ record_2.after_commit_block { callbacks << record_2.id }
+ record_3.after_commit_block { callbacks << record_3.id }
+ begin
+ TopicWithCallbacks.transaction do
+ record_1.save!
+ record_2.save!
+ record_3.save!
+ end
+ rescue
+ # From record_1.after_commit
+ end
+ assert_equal [], callbacks
+ end
+
+ def test_saving_a_record_with_a_belongs_to_that_specifies_touching_the_parent_should_call_callbacks_on_the_parent_object
+ pet = Pet.first
+ owner = pet.owner
+ flag = false
+
+ owner.on_after_commit do
+ flag = true
+ end
+
+ pet.name = "Fluffy the Third"
+ pet.save
+
+ assert flag
+ end
+
+ private
+
+ def add_transaction_execution_blocks(record)
+ record.after_commit_block(:create) { |r| r.history << :commit_on_create }
+ record.after_commit_block(:update) { |r| r.history << :commit_on_update }
+ record.after_commit_block(:destroy) { |r| r.history << :commit_on_destroy }
+ record.after_rollback_block(:create) { |r| r.history << :rollback_on_create }
+ record.after_rollback_block(:update) { |r| r.history << :rollback_on_update }
+ record.after_rollback_block(:destroy) { |r| r.history << :rollback_on_destroy }
+ end
+end
+
+class TransactionAfterCommitCallbacksWithOptimisticLockingTest < ActiveRecord::TestCase
+ class PersonWithCallbacks < ActiveRecord::Base
+ self.table_name = :people
+
+ after_create_commit { |record| record.history << :commit_on_create }
+ after_update_commit { |record| record.history << :commit_on_update }
+ after_destroy_commit { |record| record.history << :commit_on_destroy }
+
+ def history
+ @history ||= []
+ end
+ end
+
+ def test_after_commit_callbacks_with_optimistic_locking
+ person = PersonWithCallbacks.create!(first_name: "first name")
+ person.update!(first_name: "another name")
+ person.destroy
+
+ assert_equal [:commit_on_create, :commit_on_update, :commit_on_destroy], person.history
+ end
+end
+
+class CallbacksOnMultipleActionsTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ class TopicWithCallbacksOnMultipleActions < ActiveRecord::Base
+ self.table_name = :topics
+
+ after_commit(on: [:create, :destroy]) { |record| record.history << :create_and_destroy }
+ after_commit(on: [:create, :update]) { |record| record.history << :create_and_update }
+ after_commit(on: [:update, :destroy]) { |record| record.history << :update_and_destroy }
+
+ before_commit(if: :save_before_commit_history) { |record| record.history << :before_commit }
+ before_commit(if: :update_title) { |record| record.update(title: "before commit title") }
+
+ def clear_history
+ @history = []
+ end
+
+ def history
+ @history ||= []
+ end
+
+ attr_accessor :save_before_commit_history, :update_title
+ end
+
+ def test_after_commit_on_multiple_actions
+ topic = TopicWithCallbacksOnMultipleActions.new
+ topic.save
+ assert_equal [:create_and_update, :create_and_destroy], topic.history
+
+ topic.clear_history
+ topic.approved = true
+ topic.save
+ assert_equal [:update_and_destroy, :create_and_update], topic.history
+
+ topic.clear_history
+ topic.destroy
+ assert_equal [:update_and_destroy, :create_and_destroy], topic.history
+ end
+
+ def test_before_commit_actions
+ topic = TopicWithCallbacksOnMultipleActions.new
+ topic.save_before_commit_history = true
+ topic.save
+
+ assert_equal [:before_commit, :create_and_update, :create_and_destroy], topic.history
+ end
+
+ def test_before_commit_update_in_same_transaction
+ topic = TopicWithCallbacksOnMultipleActions.new
+ topic.update_title = true
+ topic.save
+
+ assert_equal "before commit title", topic.title
+ assert_equal "before commit title", topic.reload.title
+ end
+end
+
+class CallbacksOnDestroyUpdateActionRaceTest < ActiveRecord::TestCase
+ class TopicWithHistory < ActiveRecord::Base
+ self.table_name = :topics
+
+ def self.clear_history
+ @@history = []
+ end
+
+ def self.history
+ @@history ||= []
+ end
+ end
+
+ class TopicWithCallbacksOnDestroy < TopicWithHistory
+ after_commit(on: :destroy) { |record| record.class.history << :destroy }
+ end
+
+ class TopicWithCallbacksOnUpdate < TopicWithHistory
+ after_commit(on: :update) { |record| record.class.history << :update }
+ end
+
+ def test_trigger_once_on_multiple_deletions
+ TopicWithCallbacksOnDestroy.clear_history
+ topic = TopicWithCallbacksOnDestroy.new
+ topic.save
+ topic_clone = TopicWithCallbacksOnDestroy.find(topic.id)
+ topic.destroy
+ topic_clone.destroy
+
+ assert_equal [:destroy], TopicWithCallbacksOnDestroy.history
+ end
+
+ def test_trigger_on_update_where_row_was_deleted
+ TopicWithCallbacksOnUpdate.clear_history
+ topic = TopicWithCallbacksOnUpdate.new
+ topic.save
+ topic_clone = TopicWithCallbacksOnUpdate.find(topic.id)
+ topic.destroy
+ topic_clone.author_name = "Test Author"
+ topic_clone.save
+
+ assert_equal [], TopicWithCallbacksOnUpdate.history
+ end
+end
+
+class TransactionEnrollmentCallbacksTest < ActiveRecord::TestCase
+ class TopicWithoutTransactionalEnrollmentCallbacks < ActiveRecord::Base
+ self.table_name = :topics
+
+ before_commit_without_transaction_enrollment { |r| r.history << :before_commit }
+ after_commit_without_transaction_enrollment { |r| r.history << :after_commit }
+ after_rollback_without_transaction_enrollment { |r| r.history << :rollback }
+
+ def history
+ @history ||= []
+ end
+ end
+
+ def setup
+ @topic = TopicWithoutTransactionalEnrollmentCallbacks.create!
+ end
+
+ def test_commit_does_not_run_transactions_callbacks_without_enrollment
+ @topic.transaction do
+ @topic.content = "foo"
+ @topic.save!
+ end
+ assert_empty @topic.history
+ end
+
+ def test_commit_run_transactions_callbacks_with_explicit_enrollment
+ @topic.transaction do
+ 2.times do
+ @topic.content = "foo"
+ @topic.save!
+ end
+ @topic.class.connection.add_transaction_record(@topic)
+ end
+ assert_equal [:before_commit, :after_commit], @topic.history
+ end
+
+ def test_commit_run_transactions_callbacks_with_nested_transactions
+ @topic.transaction do
+ @topic.transaction(requires_new: true) do
+ @topic.content = "foo"
+ @topic.save!
+ @topic.class.connection.add_transaction_record(@topic)
+ end
+ end
+ assert_equal [:before_commit, :after_commit], @topic.history
+ end
+
+ def test_rollback_does_not_run_transactions_callbacks_without_enrollment
+ @topic.transaction do
+ @topic.content = "foo"
+ @topic.save!
+ raise ActiveRecord::Rollback
+ end
+ assert_empty @topic.history
+ end
+
+ def test_rollback_run_transactions_callbacks_with_explicit_enrollment
+ @topic.transaction do
+ 2.times do
+ @topic.content = "foo"
+ @topic.save!
+ end
+ @topic.class.connection.add_transaction_record(@topic)
+ raise ActiveRecord::Rollback
+ end
+ assert_equal [:rollback], @topic.history
+ end
+end
+
+class CallbacksOnActionAndConditionTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ class TopicWithCallbacksOnActionAndCondition < ActiveRecord::Base
+ self.table_name = :topics
+
+ after_commit(on: [:create, :update], if: :run_callback?) { |record| record.history << :create_or_update }
+
+ def clear_history
+ @history = []
+ end
+
+ def history
+ @history ||= []
+ end
+
+ def run_callback?
+ self.history << :run_callback?
+ true
+ end
+
+ attr_accessor :save_before_commit_history, :update_title
+ end
+
+ def test_callback_on_action_with_condition
+ topic = TopicWithCallbacksOnActionAndCondition.new
+ topic.save
+ assert_equal [:run_callback?, :create_or_update], topic.history
+
+ topic.clear_history
+ topic.approved = true
+ topic.save
+ assert_equal [:run_callback?, :create_or_update], topic.history
+
+ topic.clear_history
+ topic.destroy
+ assert_equal [], topic.history
+ end
+end
diff --git a/activerecord/test/cases/transaction_isolation_test.rb b/activerecord/test/cases/transaction_isolation_test.rb
new file mode 100644
index 0000000000..2932969412
--- /dev/null
+++ b/activerecord/test/cases/transaction_isolation_test.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+unless ActiveRecord::Base.connection.supports_transaction_isolation?
+ class TransactionIsolationUnsupportedTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ class Tag < ActiveRecord::Base
+ end
+
+ test "setting the isolation level raises an error" do
+ assert_raises(ActiveRecord::TransactionIsolationError) do
+ Tag.transaction(isolation: :serializable) { Tag.connection.materialize_transactions }
+ end
+ end
+ end
+else
+ class TransactionIsolationTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ class Tag < ActiveRecord::Base
+ self.table_name = "tags"
+ end
+
+ class Tag2 < ActiveRecord::Base
+ self.table_name = "tags"
+ end
+
+ setup do
+ Tag.establish_connection :arunit
+ Tag2.establish_connection :arunit
+ Tag.destroy_all
+ end
+
+ # It is impossible to properly test read uncommitted. The SQL standard only
+ # specifies what must not happen at a certain level, not what must happen. At
+ # the read uncommitted level, there is nothing that must not happen.
+ if ActiveRecord::Base.connection.transaction_isolation_levels.include?(:read_uncommitted)
+ test "read uncommitted" do
+ Tag.transaction(isolation: :read_uncommitted) do
+ assert_equal 0, Tag.count
+ Tag2.create
+ assert_equal 1, Tag.count
+ end
+ end
+ end
+
+ # We are testing that a dirty read does not happen
+ test "read committed" do
+ Tag.transaction(isolation: :read_committed) do
+ assert_equal 0, Tag.count
+
+ Tag2.transaction do
+ Tag2.create
+ assert_equal 0, Tag.count
+ end
+ end
+
+ assert_equal 1, Tag.count
+ end
+
+ # We are testing that a nonrepeatable read does not happen
+ if ActiveRecord::Base.connection.transaction_isolation_levels.include?(:repeatable_read)
+ test "repeatable read" do
+ tag = Tag.create(name: "jon")
+
+ Tag.transaction(isolation: :repeatable_read) do
+ tag.reload
+ Tag2.find(tag.id).update(name: "emily")
+
+ tag.reload
+ assert_equal "jon", tag.name
+ end
+
+ tag.reload
+ assert_equal "emily", tag.name
+ end
+ end
+
+ # We are only testing that there are no errors because it's too hard to
+ # test serializable. Databases behave differently to enforce the serializability
+ # constraint.
+ test "serializable" do
+ Tag.transaction(isolation: :serializable) do
+ Tag.create
+ end
+ end
+
+ test "setting isolation when joining a transaction raises an error" do
+ Tag.transaction do
+ assert_raises(ActiveRecord::TransactionIsolationError) do
+ Tag.transaction(isolation: :serializable) { }
+ end
+ end
+ end
+
+ test "setting isolation when starting a nested transaction raises error" do
+ Tag.transaction do
+ assert_raises(ActiveRecord::TransactionIsolationError) do
+ Tag.transaction(requires_new: true, isolation: :serializable) { }
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/transactions_test.rb b/activerecord/test/cases/transactions_test.rb
new file mode 100644
index 0000000000..45c93ca949
--- /dev/null
+++ b/activerecord/test/cases/transactions_test.rb
@@ -0,0 +1,1139 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+require "models/reply"
+require "models/developer"
+require "models/computer"
+require "models/book"
+require "models/author"
+require "models/post"
+require "models/movie"
+
+class TransactionTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+ fixtures :topics, :developers, :authors, :author_addresses, :posts
+
+ def setup
+ @first, @second = Topic.find(1, 2).sort_by(&:id)
+ end
+
+ def test_persisted_in_a_model_with_custom_primary_key_after_failed_save
+ movie = Movie.create
+ assert_not_predicate movie, :persisted?
+ end
+
+ def test_raise_after_destroy
+ assert_not_predicate @first, :frozen?
+
+ assert_raises(RuntimeError) {
+ Topic.transaction do
+ @first.destroy
+ assert_predicate @first, :frozen?
+ raise
+ end
+ }
+
+ assert @first.reload
+ assert_not_predicate @first, :frozen?
+ end
+
+ def test_successful
+ Topic.transaction do
+ @first.approved = true
+ @second.approved = false
+ @first.save
+ @second.save
+ end
+
+ assert Topic.find(1).approved?, "First should have been approved"
+ assert_not Topic.find(2).approved?, "Second should have been unapproved"
+ end
+
+ def transaction_with_return
+ Topic.transaction do
+ @first.approved = true
+ @second.approved = false
+ @first.save
+ @second.save
+ return
+ end
+ end
+
+ def test_add_to_null_transaction
+ topic = Topic.new
+ topic.add_to_transaction
+ end
+
+ def test_successful_with_return
+ committed = false
+
+ Topic.connection.class_eval do
+ alias :real_commit_db_transaction :commit_db_transaction
+ define_method(:commit_db_transaction) do
+ committed = true
+ real_commit_db_transaction
+ end
+ end
+
+ transaction_with_return
+ assert committed
+
+ assert Topic.find(1).approved?, "First should have been approved"
+ assert_not Topic.find(2).approved?, "Second should have been unapproved"
+ ensure
+ Topic.connection.class_eval do
+ remove_method :commit_db_transaction
+ alias :commit_db_transaction :real_commit_db_transaction rescue nil
+ end
+ end
+
+ def test_number_of_transactions_in_commit
+ num = nil
+
+ Topic.connection.class_eval do
+ alias :real_commit_db_transaction :commit_db_transaction
+ define_method(:commit_db_transaction) do
+ num = transaction_manager.open_transactions
+ real_commit_db_transaction
+ end
+ end
+
+ Topic.transaction do
+ @first.approved = true
+ @first.save!
+ end
+
+ assert_equal 0, num
+ ensure
+ Topic.connection.class_eval do
+ remove_method :commit_db_transaction
+ alias :commit_db_transaction :real_commit_db_transaction rescue nil
+ end
+ end
+
+ def test_successful_with_instance_method
+ @first.transaction do
+ @first.approved = true
+ @second.approved = false
+ @first.save
+ @second.save
+ end
+
+ assert Topic.find(1).approved?, "First should have been approved"
+ assert_not Topic.find(2).approved?, "Second should have been unapproved"
+ end
+
+ def test_failing_on_exception
+ begin
+ Topic.transaction do
+ @first.approved = true
+ @second.approved = false
+ @first.save
+ @second.save
+ raise "Bad things!"
+ end
+ rescue
+ # caught it
+ end
+
+ assert @first.approved?, "First should still be changed in the objects"
+ assert_not @second.approved?, "Second should still be changed in the objects"
+
+ assert_not Topic.find(1).approved?, "First shouldn't have been approved"
+ assert Topic.find(2).approved?, "Second should still be approved"
+ end
+
+ def test_raising_exception_in_callback_rollbacks_in_save
+ def @first.after_save_for_transaction
+ raise "Make the transaction rollback"
+ end
+
+ @first.approved = true
+ e = assert_raises(RuntimeError) { @first.save }
+ assert_equal "Make the transaction rollback", e.message
+ assert_not_predicate Topic.find(1), :approved?
+ end
+
+ def test_rolling_back_in_a_callback_rollbacks_before_save
+ def @first.before_save_for_transaction
+ raise ActiveRecord::Rollback
+ end
+ assert_not @first.approved
+
+ Topic.transaction do
+ @first.approved = true
+ @first.save!
+ end
+ assert_not Topic.find(@first.id).approved?, "Should not commit the approved flag"
+ end
+
+ def test_raising_exception_in_nested_transaction_restore_state_in_save
+ topic = Topic.new
+
+ def topic.after_save_for_transaction
+ raise "Make the transaction rollback"
+ end
+
+ assert_raises(RuntimeError) do
+ Topic.transaction { topic.save }
+ end
+
+ assert topic.new_record?, "#{topic.inspect} should be new record"
+ end
+
+ def test_transaction_state_is_cleared_when_record_is_persisted
+ author = Author.create! name: "foo"
+ author.name = nil
+ assert_not author.save
+ assert_not_predicate author, :new_record?
+ end
+
+ def test_update_should_rollback_on_failure
+ author = Author.find(1)
+ posts_count = author.posts.size
+ assert posts_count > 0
+ status = author.update(name: nil, post_ids: [])
+ assert_not status
+ assert_equal posts_count, author.posts.reload.size
+ end
+
+ def test_update_should_rollback_on_failure!
+ author = Author.find(1)
+ posts_count = author.posts.size
+ assert posts_count > 0
+ assert_raise(ActiveRecord::RecordInvalid) do
+ author.update!(name: nil, post_ids: [])
+ end
+ assert_equal posts_count, author.posts.reload.size
+ end
+
+ def test_cancellation_from_before_destroy_rollbacks_in_destroy
+ add_cancelling_before_destroy_with_db_side_effect_to_topic @first
+ nbooks_before_destroy = Book.count
+ status = @first.destroy
+ assert_not status
+ @first.reload
+ assert_equal nbooks_before_destroy, Book.count
+ end
+
+ %w(validation save).each do |filter|
+ define_method("test_cancellation_from_before_filters_rollbacks_in_#{filter}") do
+ send("add_cancelling_before_#{filter}_with_db_side_effect_to_topic", @first)
+ nbooks_before_save = Book.count
+ original_author_name = @first.author_name
+ @first.author_name += "_this_should_not_end_up_in_the_db"
+ status = @first.save
+ assert_not status
+ assert_equal original_author_name, @first.reload.author_name
+ assert_equal nbooks_before_save, Book.count
+ end
+
+ define_method("test_cancellation_from_before_filters_rollbacks_in_#{filter}!") do
+ send("add_cancelling_before_#{filter}_with_db_side_effect_to_topic", @first)
+ nbooks_before_save = Book.count
+ original_author_name = @first.author_name
+ @first.author_name += "_this_should_not_end_up_in_the_db"
+
+ begin
+ @first.save!
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved
+ end
+
+ assert_equal original_author_name, @first.reload.author_name
+ assert_equal nbooks_before_save, Book.count
+ end
+ end
+
+ def test_callback_rollback_in_create
+ topic = Class.new(Topic) {
+ def after_create_for_transaction
+ raise "Make the transaction rollback"
+ end
+ }
+
+ new_topic = topic.new(title: "A new topic",
+ author_name: "Ben",
+ author_email_address: "ben@example.com",
+ written_on: "2003-07-16t15:28:11.2233+01:00",
+ last_read: "2004-04-15",
+ bonus_time: "2005-01-30t15:28:00.00+01:00",
+ content: "Have a nice day",
+ approved: false)
+
+ new_record_snapshot = !new_topic.persisted?
+ id_present = new_topic.has_attribute?(Topic.primary_key)
+ id_snapshot = new_topic.id
+
+ # Make sure the second save gets the after_create callback called.
+ 2.times do
+ new_topic.approved = true
+ e = assert_raises(RuntimeError) { new_topic.save }
+ assert_equal "Make the transaction rollback", e.message
+ assert_equal new_record_snapshot, !new_topic.persisted?, "The topic should have its old persisted value"
+ if id_snapshot.nil?
+ assert_nil new_topic.id, "The topic should have its old id"
+ else
+ assert_equal id_snapshot, new_topic.id, "The topic should have its old id"
+ end
+ assert_equal id_present, new_topic.has_attribute?(Topic.primary_key)
+ end
+ end
+
+ def test_callback_rollback_in_create_with_record_invalid_exception
+ topic = Class.new(Topic) {
+ def after_create_for_transaction
+ raise ActiveRecord::RecordInvalid.new(Author.new)
+ end
+ }
+
+ new_topic = topic.create(title: "A new topic")
+ assert_not new_topic.persisted?, "The topic should not be persisted"
+ assert_nil new_topic.id, "The topic should not have an ID"
+ end
+
+ def test_callback_rollback_in_create_with_rollback_exception
+ topic = Class.new(Topic) {
+ def after_create_for_transaction
+ raise ActiveRecord::Rollback
+ end
+ }
+
+ new_topic = topic.create(title: "A new topic")
+ assert_not new_topic.persisted?, "The topic should not be persisted"
+ assert_nil new_topic.id, "The topic should not have an ID"
+ end
+
+ def test_nested_explicit_transactions
+ Topic.transaction do
+ Topic.transaction do
+ @first.approved = true
+ @second.approved = false
+ @first.save
+ @second.save
+ end
+ end
+
+ assert Topic.find(1).approved?, "First should have been approved"
+ assert_not Topic.find(2).approved?, "Second should have been unapproved"
+ end
+
+ def test_nested_transaction_with_new_transaction_applies_parent_state_on_rollback
+ topic_one = Topic.new(title: "A new topic")
+ topic_two = Topic.new(title: "Another new topic")
+
+ Topic.transaction do
+ topic_one.save
+
+ Topic.transaction(requires_new: true) do
+ topic_two.save
+
+ assert_predicate topic_one, :persisted?
+ assert_predicate topic_two, :persisted?
+ end
+
+ raise ActiveRecord::Rollback
+ end
+
+ assert_not_predicate topic_one, :persisted?
+ assert_not_predicate topic_two, :persisted?
+ end
+
+ def test_nested_transaction_without_new_transaction_applies_parent_state_on_rollback
+ topic_one = Topic.new(title: "A new topic")
+ topic_two = Topic.new(title: "Another new topic")
+
+ Topic.transaction do
+ topic_one.save
+
+ Topic.transaction do
+ topic_two.save
+
+ assert_predicate topic_one, :persisted?
+ assert_predicate topic_two, :persisted?
+ end
+
+ raise ActiveRecord::Rollback
+ end
+
+ assert_not_predicate topic_one, :persisted?
+ assert_not_predicate topic_two, :persisted?
+ end
+
+ def test_double_nested_transaction_applies_parent_state_on_rollback
+ topic_one = Topic.new(title: "A new topic")
+ topic_two = Topic.new(title: "Another new topic")
+ topic_three = Topic.new(title: "Another new topic of course")
+
+ Topic.transaction do
+ topic_one.save
+
+ Topic.transaction do
+ topic_two.save
+
+ Topic.transaction do
+ topic_three.save
+ end
+ end
+
+ assert_predicate topic_one, :persisted?
+ assert_predicate topic_two, :persisted?
+ assert_predicate topic_three, :persisted?
+
+ raise ActiveRecord::Rollback
+ end
+
+ assert_not_predicate topic_one, :persisted?
+ assert_not_predicate topic_two, :persisted?
+ assert_not_predicate topic_three, :persisted?
+ end
+
+ def test_manually_rolling_back_a_transaction
+ Topic.transaction do
+ @first.approved = true
+ @second.approved = false
+ @first.save
+ @second.save
+
+ raise ActiveRecord::Rollback
+ end
+
+ assert @first.approved?, "First should still be changed in the objects"
+ assert_not @second.approved?, "Second should still be changed in the objects"
+
+ assert_not Topic.find(1).approved?, "First shouldn't have been approved"
+ assert Topic.find(2).approved?, "Second should still be approved"
+ end
+
+ def test_invalid_keys_for_transaction
+ assert_raise ArgumentError do
+ Topic.transaction nested: true do
+ end
+ end
+ end
+
+ def test_force_savepoint_in_nested_transaction
+ Topic.transaction do
+ @first.approved = true
+ @second.approved = false
+ @first.save!
+ @second.save!
+
+ begin
+ Topic.transaction requires_new: true do
+ @first.happy = false
+ @first.save!
+ raise
+ end
+ rescue
+ end
+ end
+
+ assert_predicate @first.reload, :approved?
+ assert_not_predicate @second.reload, :approved?
+ end if Topic.connection.supports_savepoints?
+
+ def test_force_savepoint_on_instance
+ @first.transaction do
+ @first.approved = true
+ @second.approved = false
+ @first.save!
+ @second.save!
+
+ begin
+ @second.transaction requires_new: true do
+ @first.happy = false
+ @first.save!
+ raise
+ end
+ rescue
+ end
+ end
+
+ assert_predicate @first.reload, :approved?
+ assert_not_predicate @second.reload, :approved?
+ end if Topic.connection.supports_savepoints?
+
+ def test_no_savepoint_in_nested_transaction_without_force
+ Topic.transaction do
+ @first.approved = true
+ @second.approved = false
+ @first.save!
+ @second.save!
+
+ begin
+ Topic.transaction do
+ @first.approved = false
+ @first.save!
+ raise
+ end
+ rescue
+ end
+ end
+
+ assert_not_predicate @first.reload, :approved?
+ assert_not_predicate @second.reload, :approved?
+ end if Topic.connection.supports_savepoints?
+
+ def test_many_savepoints
+ Topic.transaction do
+ @first.content = "One"
+ @first.save!
+
+ begin
+ Topic.transaction requires_new: true do
+ @first.content = "Two"
+ @first.save!
+
+ begin
+ Topic.transaction requires_new: true do
+ @first.content = "Three"
+ @first.save!
+
+ begin
+ Topic.transaction requires_new: true do
+ @first.content = "Four"
+ @first.save!
+ raise
+ end
+ rescue
+ end
+
+ @three = @first.reload.content
+ raise
+ end
+ rescue
+ end
+
+ @two = @first.reload.content
+ raise
+ end
+ rescue
+ end
+
+ @one = @first.reload.content
+ end
+
+ assert_equal "One", @one
+ assert_equal "Two", @two
+ assert_equal "Three", @three
+ end if Topic.connection.supports_savepoints?
+
+ def test_using_named_savepoints
+ Topic.transaction do
+ @first.approved = true
+ @first.save!
+ Topic.connection.create_savepoint("first")
+
+ @first.approved = false
+ @first.save!
+ Topic.connection.rollback_to_savepoint("first")
+ assert_predicate @first.reload, :approved?
+
+ @first.approved = false
+ @first.save!
+ Topic.connection.release_savepoint("first")
+ assert_not_predicate @first.reload, :approved?
+ end
+ end if Topic.connection.supports_savepoints?
+
+ def test_releasing_named_savepoints
+ Topic.transaction do
+ Topic.connection.create_savepoint("another")
+ Topic.connection.release_savepoint("another")
+
+ # The savepoint is now gone and we can't remove it again.
+ assert_raises(ActiveRecord::StatementInvalid) do
+ Topic.connection.release_savepoint("another")
+ end
+ end
+ end
+
+ def test_savepoints_name
+ Topic.transaction do
+ assert_nil Topic.connection.current_savepoint_name
+ assert_nil Topic.connection.current_transaction.savepoint_name
+
+ Topic.transaction(requires_new: true) do
+ assert_equal "active_record_1", Topic.connection.current_savepoint_name
+ assert_equal "active_record_1", Topic.connection.current_transaction.savepoint_name
+
+ Topic.transaction(requires_new: true) do
+ assert_equal "active_record_2", Topic.connection.current_savepoint_name
+ assert_equal "active_record_2", Topic.connection.current_transaction.savepoint_name
+ end
+
+ assert_equal "active_record_1", Topic.connection.current_savepoint_name
+ assert_equal "active_record_1", Topic.connection.current_transaction.savepoint_name
+ end
+ end
+ end
+
+ def test_rollback_when_commit_raises
+ assert_called(Topic.connection, :begin_db_transaction) do
+ Topic.connection.stub(:commit_db_transaction, -> { raise("OH NOES") }) do
+ assert_called(Topic.connection, :rollback_db_transaction) do
+ e = assert_raise RuntimeError do
+ Topic.transaction do
+ Topic.connection.materialize_transactions
+ end
+ end
+ assert_equal "OH NOES", e.message
+ end
+ end
+ end
+ end
+
+ def test_rollback_when_saving_a_frozen_record
+ topic = Topic.new(title: "test")
+ topic.freeze
+ e = assert_raise(FrozenError) { topic.save }
+ # Not good enough, but we can't do much
+ # about it since there is no specific error
+ # for frozen objects.
+ assert_match(/frozen/i, e.message)
+ assert_not topic.persisted?, "not persisted"
+ assert_nil topic.id
+ assert topic.frozen?, "not frozen"
+ end
+
+ def test_rollback_when_thread_killed
+ return if in_memory_db?
+
+ queue = Queue.new
+ thread = Thread.new do
+ Topic.transaction do
+ @first.approved = true
+ @second.approved = false
+ @first.save
+
+ queue.push nil
+ sleep
+
+ @second.save
+ end
+ end
+
+ queue.pop
+ thread.kill
+ thread.join
+
+ assert @first.approved?, "First should still be changed in the objects"
+ assert_not @second.approved?, "Second should still be changed in the objects"
+
+ assert_not Topic.find(1).approved?, "First shouldn't have been approved"
+ assert Topic.find(2).approved?, "Second should still be approved"
+ end
+
+ def test_restore_active_record_state_for_all_records_in_a_transaction
+ topic_without_callbacks = Class.new(ActiveRecord::Base) do
+ self.table_name = "topics"
+ end
+
+ topic_1 = Topic.new(title: "test_1")
+ topic_2 = Topic.new(title: "test_2")
+ topic_3 = topic_without_callbacks.new(title: "test_3")
+
+ Topic.transaction do
+ assert topic_1.save
+ assert topic_2.save
+ assert topic_3.save
+ @first.save
+ @second.destroy
+ assert topic_1.persisted?, "persisted"
+ assert_not_nil topic_1.id
+ assert topic_2.persisted?, "persisted"
+ assert_not_nil topic_2.id
+ assert topic_3.persisted?, "persisted"
+ assert_not_nil topic_3.id
+ assert @first.persisted?, "persisted"
+ assert_not_nil @first.id
+ assert @second.destroyed?, "destroyed"
+ raise ActiveRecord::Rollback
+ end
+
+ assert_not topic_1.persisted?, "not persisted"
+ assert_nil topic_1.id
+ assert_not topic_2.persisted?, "not persisted"
+ assert_nil topic_2.id
+ assert_not topic_3.persisted?, "not persisted"
+ assert_nil topic_3.id
+ assert @first.persisted?, "persisted"
+ assert_not_nil @first.id
+ assert_not @second.destroyed?, "not destroyed"
+ end
+
+ def test_restore_frozen_state_after_double_destroy
+ topic = Topic.create
+ reply = topic.replies.create
+
+ Topic.transaction do
+ topic.destroy # calls #destroy on reply (since dependent: destroy)
+ reply.destroy
+
+ raise ActiveRecord::Rollback
+ end
+
+ assert_not_predicate reply, :frozen?
+ assert_not_predicate topic, :frozen?
+ end
+
+ def test_restore_new_record_after_double_save
+ topic = Topic.new
+
+ Topic.transaction do
+ topic.save!
+ topic.save!
+ raise ActiveRecord::Rollback
+ end
+
+ assert_nil topic.id
+ assert_predicate topic, :new_record?
+ end
+
+ def test_dont_restore_new_record_in_subsequent_transaction
+ topic = Topic.new
+
+ Topic.transaction do
+ topic.save!
+ topic.save!
+ end
+
+ Topic.transaction do
+ topic.save!
+ raise ActiveRecord::Rollback
+ end
+
+ assert_predicate topic, :persisted?
+ assert_not_predicate topic, :new_record?
+ end
+
+ def test_restore_id_after_rollback
+ topic = Topic.new
+
+ Topic.transaction do
+ topic.save!
+ raise ActiveRecord::Rollback
+ end
+
+ assert_nil topic.id
+ end
+
+ def test_restore_custom_primary_key_after_rollback
+ movie = Movie.new(name: "foo")
+
+ Movie.transaction do
+ movie.save!
+ raise ActiveRecord::Rollback
+ end
+
+ assert_nil movie.movieid
+ end
+
+ def test_assign_id_after_rollback
+ topic = Topic.create!
+
+ Topic.transaction do
+ topic.save!
+ raise ActiveRecord::Rollback
+ end
+
+ topic.id = nil
+ assert_nil topic.id
+ end
+
+ def test_assign_custom_primary_key_after_rollback
+ movie = Movie.create!(name: "foo")
+
+ Movie.transaction do
+ movie.save!
+ raise ActiveRecord::Rollback
+ end
+
+ movie.movieid = nil
+ assert_nil movie.movieid
+ end
+
+ def test_read_attribute_after_rollback
+ topic = Topic.new
+
+ Topic.transaction do
+ topic.save!
+ raise ActiveRecord::Rollback
+ end
+
+ assert_nil topic.read_attribute(:id)
+ end
+
+ def test_read_attribute_with_custom_primary_key_after_rollback
+ movie = Movie.new(name: "foo")
+
+ Movie.transaction do
+ movie.save!
+ raise ActiveRecord::Rollback
+ end
+
+ assert_nil movie.read_attribute(:movieid)
+ end
+
+ def test_write_attribute_after_rollback
+ topic = Topic.create!
+
+ Topic.transaction do
+ topic.save!
+ raise ActiveRecord::Rollback
+ end
+
+ topic.write_attribute(:id, nil)
+ assert_nil topic.id
+ end
+
+ def test_write_attribute_with_custom_primary_key_after_rollback
+ movie = Movie.create!(name: "foo")
+
+ Movie.transaction do
+ movie.save!
+ raise ActiveRecord::Rollback
+ end
+
+ movie.write_attribute(:movieid, nil)
+ assert_nil movie.movieid
+ end
+
+ def test_rollback_of_frozen_records
+ topic = Topic.create.freeze
+ Topic.transaction do
+ topic.destroy
+ raise ActiveRecord::Rollback
+ end
+ assert topic.frozen?, "frozen"
+ end
+
+ def test_rollback_for_freshly_persisted_records
+ topic = Topic.create
+ Topic.transaction do
+ topic.destroy
+ raise ActiveRecord::Rollback
+ end
+ assert topic.persisted?, "persisted"
+ end
+
+ def test_sqlite_add_column_in_transaction
+ return true unless current_adapter?(:SQLite3Adapter)
+
+ # Test first if column creation/deletion works correctly when no
+ # transaction is in place.
+ #
+ # We go back to the connection for the column queries because
+ # Topic.columns is cached and won't report changes to the DB
+
+ assert_nothing_raised do
+ Topic.reset_column_information
+ Topic.connection.add_column("topics", "stuff", :string)
+ assert_includes Topic.column_names, "stuff"
+
+ Topic.reset_column_information
+ Topic.connection.remove_column("topics", "stuff")
+ assert_not_includes Topic.column_names, "stuff"
+ end
+
+ if Topic.connection.supports_ddl_transactions?
+ assert_nothing_raised do
+ Topic.transaction { Topic.connection.add_column("topics", "stuff", :string) }
+ end
+ else
+ Topic.transaction do
+ assert_raise(ActiveRecord::StatementInvalid) { Topic.connection.add_column("topics", "stuff", :string) }
+ raise ActiveRecord::Rollback
+ end
+ end
+ ensure
+ begin
+ Topic.connection.remove_column("topics", "stuff")
+ rescue
+ ensure
+ Topic.reset_column_information
+ end
+ end
+
+ def test_transactions_state_from_rollback
+ connection = Topic.connection
+ transaction = ActiveRecord::ConnectionAdapters::TransactionManager.new(connection).begin_transaction
+
+ assert_predicate transaction, :open?
+ assert_not_predicate transaction.state, :rolledback?
+ assert_not_predicate transaction.state, :committed?
+
+ transaction.rollback
+
+ assert_predicate transaction.state, :rolledback?
+ assert_not_predicate transaction.state, :committed?
+ end
+
+ def test_transactions_state_from_commit
+ connection = Topic.connection
+ transaction = ActiveRecord::ConnectionAdapters::TransactionManager.new(connection).begin_transaction
+
+ assert_predicate transaction, :open?
+ assert_not_predicate transaction.state, :rolledback?
+ assert_not_predicate transaction.state, :committed?
+
+ transaction.commit
+
+ assert_not_predicate transaction.state, :rolledback?
+ assert_predicate transaction.state, :committed?
+ end
+
+ def test_set_state_method_is_deprecated
+ connection = Topic.connection
+ transaction = ActiveRecord::ConnectionAdapters::TransactionManager.new(connection).begin_transaction
+
+ transaction.commit
+
+ assert_deprecated do
+ transaction.state.set_state(:rolledback)
+ end
+ end
+
+ def test_mark_transaction_state_as_committed
+ connection = Topic.connection
+ transaction = ActiveRecord::ConnectionAdapters::TransactionManager.new(connection).begin_transaction
+
+ transaction.rollback
+
+ assert_equal :committed, transaction.state.commit!
+ end
+
+ def test_mark_transaction_state_as_rolledback
+ connection = Topic.connection
+ transaction = ActiveRecord::ConnectionAdapters::TransactionManager.new(connection).begin_transaction
+
+ transaction.commit
+
+ assert_equal :rolledback, transaction.state.rollback!
+ end
+
+ def test_mark_transaction_state_as_nil
+ connection = Topic.connection
+ transaction = ActiveRecord::ConnectionAdapters::TransactionManager.new(connection).begin_transaction
+
+ transaction.commit
+
+ assert_nil transaction.state.nullify!
+ end
+
+ def test_transaction_rollback_with_primarykeyless_tables
+ connection = ActiveRecord::Base.connection
+ connection.create_table(:transaction_without_primary_keys, force: true, id: false) do |t|
+ t.integer :thing_id
+ end
+
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "transaction_without_primary_keys"
+ after_commit { } # necessary to trigger the has_transactional_callbacks branch
+ end
+
+ assert_no_difference(-> { klass.count }) do
+ ActiveRecord::Base.transaction do
+ klass.create!
+ raise ActiveRecord::Rollback
+ end
+ end
+ ensure
+ connection.drop_table "transaction_without_primary_keys", if_exists: true
+ end
+
+ def test_empty_transaction_is_not_materialized
+ assert_no_queries do
+ Topic.transaction { }
+ end
+ end
+
+ def test_unprepared_statement_materializes_transaction
+ assert_sql(/BEGIN/i, /COMMIT/i) do
+ Topic.transaction { Topic.where("1=1").first }
+ end
+ end
+
+ if ActiveRecord::Base.connection.prepared_statements
+ def test_prepared_statement_materializes_transaction
+ Topic.first
+
+ assert_sql(/BEGIN/i, /COMMIT/i) do
+ Topic.transaction { Topic.first }
+ end
+ end
+ end
+
+ def test_savepoint_does_not_materialize_transaction
+ assert_no_queries do
+ Topic.transaction do
+ Topic.transaction(requires_new: true) { }
+ end
+ end
+ end
+
+ def test_raising_does_not_materialize_transaction
+ assert_raise(RuntimeError) do
+ assert_no_queries do
+ Topic.transaction { raise }
+ end
+ end
+ end
+
+ def test_accessing_raw_connection_materializes_transaction
+ assert_sql(/BEGIN/i, /COMMIT/i) do
+ Topic.transaction { Topic.connection.raw_connection }
+ end
+ end
+
+ def test_accessing_raw_connection_disables_lazy_transactions
+ Topic.connection.raw_connection
+
+ assert_sql(/BEGIN/i, /COMMIT/i) do
+ Topic.transaction { }
+ end
+ end
+
+ def test_checking_in_connection_reenables_lazy_transactions
+ connection = Topic.connection_pool.checkout
+ connection.raw_connection
+ Topic.connection_pool.checkin connection
+
+ assert_no_queries do
+ connection.transaction { }
+ end
+ end
+
+ def test_transactions_can_be_manually_materialized
+ assert_sql(/BEGIN/i, /COMMIT/i) do
+ Topic.transaction do
+ Topic.connection.materialize_transactions
+ end
+ end
+ end
+
+ private
+
+ %w(validation save destroy).each do |filter|
+ define_method("add_cancelling_before_#{filter}_with_db_side_effect_to_topic") do |topic|
+ meta = class << topic; self; end
+ meta.send("define_method", "before_#{filter}_for_transaction") do
+ Book.create
+ throw(:abort)
+ end
+ end
+ end
+end
+
+class TransactionsWithTransactionalFixturesTest < ActiveRecord::TestCase
+ self.use_transactional_tests = true
+ fixtures :topics
+
+ def test_automatic_savepoint_in_outer_transaction
+ @first = Topic.find(1)
+
+ begin
+ Topic.transaction do
+ @first.approved = true
+ @first.save!
+ raise
+ end
+ rescue
+ assert_not_predicate @first.reload, :approved?
+ end
+ end
+
+ def test_no_automatic_savepoint_for_inner_transaction
+ @first = Topic.find(1)
+
+ Topic.transaction do
+ @first.approved = true
+ @first.save!
+
+ begin
+ Topic.transaction do
+ @first.approved = false
+ @first.save!
+ raise
+ end
+ rescue
+ end
+ end
+
+ assert_not_predicate @first.reload, :approved?
+ end
+end if Topic.connection.supports_savepoints?
+
+if ActiveRecord::Base.connection.supports_transaction_isolation?
+ class ConcurrentTransactionTest < TransactionTest
+ # This will cause transactions to overlap and fail unless they are performed on
+ # separate database connections.
+ def test_transaction_per_thread
+ threads = 3.times.map do
+ Thread.new do
+ Topic.transaction do
+ topic = Topic.find(1)
+ topic.approved = !topic.approved?
+ assert topic.save!
+ topic.approved = !topic.approved?
+ assert topic.save!
+ end
+ Topic.connection.close
+ end
+ end
+
+ threads.each(&:join)
+ end
+
+ # Test for dirty reads among simultaneous transactions.
+ def test_transaction_isolation__read_committed
+ # Should be invariant.
+ original_salary = Developer.find(1).salary
+ temporary_salary = 200000
+
+ assert_nothing_raised do
+ threads = (1..3).map do
+ Thread.new do
+ Developer.transaction do
+ # Expect original salary.
+ dev = Developer.find(1)
+ assert_equal original_salary, dev.salary
+
+ dev.salary = temporary_salary
+ dev.save!
+
+ # Expect temporary salary.
+ dev = Developer.find(1)
+ assert_equal temporary_salary, dev.salary
+
+ dev.salary = original_salary
+ dev.save!
+
+ # Expect original salary.
+ dev = Developer.find(1)
+ assert_equal original_salary, dev.salary
+ end
+ Developer.connection.close
+ end
+ end
+
+ # Keep our eyes peeled.
+ threads << Thread.new do
+ 10.times do
+ sleep 0.05
+ Developer.transaction do
+ # Always expect original salary.
+ assert_equal original_salary, Developer.find(1).salary
+ end
+ end
+ Developer.connection.close
+ end
+
+ threads.each(&:join)
+ end
+
+ assert_equal original_salary, Developer.find(1).salary
+ end
+ end
+end
diff --git a/activerecord/test/cases/type/adapter_specific_registry_test.rb b/activerecord/test/cases/type/adapter_specific_registry_test.rb
new file mode 100644
index 0000000000..b58bdd5549
--- /dev/null
+++ b/activerecord/test/cases/type/adapter_specific_registry_test.rb
@@ -0,0 +1,135 @@
+# 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
+ type = Struct.new(:args)
+ 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)
+ type = Struct.new(:args)
+ 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
+ end
+end
diff --git a/activerecord/test/cases/type/date_time_test.rb b/activerecord/test/cases/type/date_time_test.rb
new file mode 100644
index 0000000000..c9558e25b5
--- /dev/null
+++ b/activerecord/test/cases/type/date_time_test.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/task"
+
+module ActiveRecord
+ module Type
+ class DateTimeTest < ActiveRecord::TestCase
+ def test_datetime_seconds_precision_applied_to_timestamp
+ skip "This test is invalid if subsecond precision isn't supported" unless subsecond_precision_supported?
+ p = Task.create!(starting: ::Time.now)
+ assert_equal p.starting.usec, p.reload.starting.usec
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/type/integer_test.rb b/activerecord/test/cases/type/integer_test.rb
new file mode 100644
index 0000000000..15d1a675a1
--- /dev/null
+++ b/activerecord/test/cases/type/integer_test.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/company"
+
+module ActiveRecord
+ module Type
+ class IntegerTest < ActiveRecord::TestCase
+ test "casting ActiveRecord models" do
+ type = Type::Integer.new
+ firm = Firm.create(name: "Apple")
+ assert_nil type.cast(firm)
+ end
+
+ test "values which are out of range can be re-assigned" do
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "posts"
+ attribute :foo, :integer
+ end
+ model = klass.new
+
+ model.foo = 2147483648
+ model.foo = 1
+
+ assert_equal 1, model.foo
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/type/string_test.rb b/activerecord/test/cases/type/string_test.rb
new file mode 100644
index 0000000000..9e7810a6a5
--- /dev/null
+++ b/activerecord/test/cases/type/string_test.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ class StringTypeTest < ActiveRecord::TestCase
+ test "string mutations are detected" do
+ klass = Class.new(Base)
+ klass.table_name = "authors"
+
+ author = klass.create!(name: "Sean")
+ assert_not_predicate author, :changed?
+
+ author.name << " Griffin"
+ assert_predicate author, :name_changed?
+
+ author.save!
+ author.reload
+
+ assert_equal "Sean Griffin", author.name
+ assert_not_predicate author, :changed?
+ end
+ end
+end
diff --git a/activerecord/test/cases/type/type_map_test.rb b/activerecord/test/cases/type/type_map_test.rb
new file mode 100644
index 0000000000..1ce515a90c
--- /dev/null
+++ b/activerecord/test/cases/type/type_map_test.rb
@@ -0,0 +1,178 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ module Type
+ class TypeMapTest < ActiveRecord::TestCase
+ def test_default_type
+ mapping = TypeMap.new
+
+ assert_kind_of Value, mapping.lookup(:undefined)
+ end
+
+ def test_registering_types
+ boolean = Boolean.new
+ mapping = TypeMap.new
+
+ mapping.register_type(/boolean/i, boolean)
+
+ assert_equal mapping.lookup("boolean"), boolean
+ end
+
+ def test_overriding_registered_types
+ time = Time.new
+ timestamp = DateTime.new
+ mapping = TypeMap.new
+
+ mapping.register_type(/time/i, time)
+ mapping.register_type(/time/i, timestamp)
+
+ assert_equal mapping.lookup("time"), timestamp
+ end
+
+ def test_fuzzy_lookup
+ string = +""
+ mapping = TypeMap.new
+
+ mapping.register_type(/varchar/i, string)
+
+ assert_equal mapping.lookup("varchar(20)"), string
+ end
+
+ def test_aliasing_types
+ string = +""
+ mapping = TypeMap.new
+
+ mapping.register_type(/string/i, string)
+ mapping.alias_type(/varchar/i, "string")
+
+ assert_equal mapping.lookup("varchar"), string
+ end
+
+ def test_changing_type_changes_aliases
+ time = Time.new
+ timestamp = DateTime.new
+ mapping = TypeMap.new
+
+ mapping.register_type(/timestamp/i, time)
+ mapping.alias_type(/datetime/i, "timestamp")
+ mapping.register_type(/timestamp/i, timestamp)
+
+ assert_equal mapping.lookup("datetime"), timestamp
+ end
+
+ def test_aliases_keep_metadata
+ mapping = TypeMap.new
+
+ mapping.register_type(/decimal/i) { |sql_type| sql_type }
+ mapping.alias_type(/number/i, "decimal")
+
+ assert_equal mapping.lookup("number(20)"), "decimal(20)"
+ assert_equal mapping.lookup("number"), "decimal"
+ end
+
+ def test_register_proc
+ string = +""
+ binary = Binary.new
+ mapping = TypeMap.new
+
+ mapping.register_type(/varchar/i) do |type|
+ if type.include?("(")
+ string
+ else
+ binary
+ end
+ end
+
+ assert_equal mapping.lookup("varchar(20)"), string
+ assert_equal mapping.lookup("varchar"), binary
+ end
+
+ def test_additional_lookup_args
+ mapping = TypeMap.new
+
+ mapping.register_type(/varchar/i) do |type, limit|
+ if limit > 255
+ "text"
+ else
+ "string"
+ end
+ end
+ mapping.alias_type(/string/i, "varchar")
+
+ assert_equal mapping.lookup("varchar", 200), "string"
+ assert_equal mapping.lookup("varchar", 400), "text"
+ assert_equal mapping.lookup("string", 400), "text"
+ end
+
+ def test_requires_value_or_block
+ mapping = TypeMap.new
+
+ assert_raises(ArgumentError) do
+ mapping.register_type(/only key/i)
+ end
+ end
+
+ def test_lookup_non_strings
+ mapping = HashLookupTypeMap.new
+
+ mapping.register_type(1, "string")
+ mapping.register_type(2, "int")
+ mapping.alias_type(3, 1)
+
+ assert_equal mapping.lookup(1), "string"
+ assert_equal mapping.lookup(2), "int"
+ assert_equal mapping.lookup(3), "string"
+ assert_kind_of Type::Value, mapping.lookup(4)
+ end
+
+ def test_fetch
+ mapping = TypeMap.new
+ mapping.register_type(1, "string")
+
+ assert_equal "string", mapping.fetch(1) { "int" }
+ assert_equal "int", mapping.fetch(2) { "int" }
+ end
+
+ def test_fetch_yields_args
+ mapping = TypeMap.new
+
+ assert_equal "foo-1-2-3", mapping.fetch("foo", 1, 2, 3) { |*args| args.join("-") }
+ assert_equal "bar-1-2-3", mapping.fetch("bar", 1, 2, 3) { |*args| args.join("-") }
+ end
+
+ def test_fetch_memoizes
+ mapping = TypeMap.new
+
+ looked_up = false
+ mapping.register_type(1) do
+ fail if looked_up
+ looked_up = true
+ "string"
+ end
+
+ assert_equal "string", mapping.fetch(1)
+ assert_equal "string", mapping.fetch(1)
+ end
+
+ def test_fetch_memoizes_on_args
+ mapping = TypeMap.new
+ mapping.register_type("foo") { |*args| args.join("-") }
+
+ assert_equal "foo-1-2-3", mapping.fetch("foo", 1, 2, 3) { |*args| args.join("-") }
+ assert_equal "foo-2-3-4", mapping.fetch("foo", 2, 3, 4) { |*args| args.join("-") }
+ end
+
+ def test_register_clears_cache
+ mapping = TypeMap.new
+
+ mapping.register_type(1, "string")
+ mapping.lookup(1)
+ mapping.register_type(1, "int")
+
+ assert_equal "int", mapping.lookup(1)
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/type/unsigned_integer_test.rb b/activerecord/test/cases/type/unsigned_integer_test.rb
new file mode 100644
index 0000000000..dd05cf3fff
--- /dev/null
+++ b/activerecord/test/cases/type/unsigned_integer_test.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ module Type
+ class UnsignedIntegerTest < ActiveRecord::TestCase
+ test "unsigned int max value is in range" do
+ assert_equal(4294967295, UnsignedInteger.new.serialize(4294967295))
+ end
+
+ test "minus value is out of range" do
+ assert_raises(ActiveModel::RangeError) do
+ UnsignedInteger.new.serialize(-1)
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/type_test.rb b/activerecord/test/cases/type_test.rb
new file mode 100644
index 0000000000..93ae563c8b
--- /dev/null
+++ b/activerecord/test/cases/type_test.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class TypeTest < ActiveRecord::TestCase
+ setup do
+ @old_registry = ActiveRecord::Type.registry
+ ActiveRecord::Type.registry = ActiveRecord::Type::AdapterSpecificRegistry.new
+ end
+
+ teardown do
+ ActiveRecord::Type.registry = @old_registry
+ end
+
+ test "registering a new type" do
+ type = Struct.new(:args)
+ ActiveRecord::Type.register(:foo, type)
+
+ assert_equal type.new(:arg), ActiveRecord::Type.lookup(:foo, :arg)
+ end
+
+ test "looking up a type for a specific adapter" do
+ type = Struct.new(:args)
+ pgtype = Struct.new(:args)
+ ActiveRecord::Type.register(:foo, type, override: false)
+ ActiveRecord::Type.register(:foo, pgtype, adapter: :postgresql)
+
+ assert_equal type.new, ActiveRecord::Type.lookup(:foo, adapter: :sqlite)
+ assert_equal pgtype.new, ActiveRecord::Type.lookup(:foo, adapter: :postgresql)
+ end
+
+ test "lookup defaults to the current adapter" do
+ current_adapter = ActiveRecord::Base.connection.adapter_name.downcase.to_sym
+ type = Struct.new(:args)
+ adapter_type = Struct.new(:args)
+ ActiveRecord::Type.register(:foo, type, override: false)
+ ActiveRecord::Type.register(:foo, adapter_type, adapter: current_adapter)
+
+ assert_equal adapter_type.new, ActiveRecord::Type.lookup(:foo)
+ end
+end
diff --git a/activerecord/test/cases/types_test.rb b/activerecord/test/cases/types_test.rb
new file mode 100644
index 0000000000..3f7fb0a604
--- /dev/null
+++ b/activerecord/test/cases/types_test.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+module ActiveRecord
+ module ConnectionAdapters
+ class TypesTest < ActiveRecord::TestCase
+ def test_attributes_which_are_invalid_for_database_can_still_be_reassigned
+ type_which_cannot_go_to_the_database = Type::Value.new
+ def type_which_cannot_go_to_the_database.serialize(*)
+ raise
+ end
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "posts"
+ attribute :foo, type_which_cannot_go_to_the_database
+ end
+ model = klass.new
+
+ model.foo = "foo"
+ model.foo = "bar"
+
+ assert_equal "bar", model.foo
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/unconnected_test.rb b/activerecord/test/cases/unconnected_test.rb
new file mode 100644
index 0000000000..9eefc32745
--- /dev/null
+++ b/activerecord/test/cases/unconnected_test.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class TestRecord < ActiveRecord::Base
+end
+
+class TestUnconnectedAdapter < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+
+ def setup
+ @underlying = ActiveRecord::Base.connection
+ @specification = ActiveRecord::Base.remove_connection
+ end
+
+ teardown do
+ @underlying = nil
+ ActiveRecord::Base.establish_connection(@specification)
+ load_schema if in_memory_db?
+ end
+
+ def test_connection_no_longer_established
+ assert_raise(ActiveRecord::ConnectionNotEstablished) do
+ TestRecord.find(1)
+ end
+
+ assert_raise(ActiveRecord::ConnectionNotEstablished) do
+ TestRecord.new.save
+ end
+ end
+
+ def test_underlying_adapter_no_longer_active
+ assert_not @underlying.active?, "Removed adapter should no longer be active"
+ end
+end
diff --git a/activerecord/test/cases/unsafe_raw_sql_test.rb b/activerecord/test/cases/unsafe_raw_sql_test.rb
new file mode 100644
index 0000000000..d5d8f2a09a
--- /dev/null
+++ b/activerecord/test/cases/unsafe_raw_sql_test.rb
@@ -0,0 +1,319 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/post"
+require "models/comment"
+
+class UnsafeRawSqlTest < ActiveRecord::TestCase
+ fixtures :posts, :comments
+
+ test "order: allows string column name" do
+ ids_expected = Post.order(Arel.sql("title")).pluck(:id)
+
+ ids_depr = with_unsafe_raw_sql_deprecated { Post.order("title").pluck(:id) }
+ ids_disabled = with_unsafe_raw_sql_disabled { Post.order("title").pluck(:id) }
+
+ assert_equal ids_expected, ids_depr
+ assert_equal ids_expected, ids_disabled
+ end
+
+ test "order: allows symbol column name" do
+ ids_expected = Post.order(Arel.sql("title")).pluck(:id)
+
+ ids_depr = with_unsafe_raw_sql_deprecated { Post.order(:title).pluck(:id) }
+ ids_disabled = with_unsafe_raw_sql_disabled { Post.order(:title).pluck(:id) }
+
+ assert_equal ids_expected, ids_depr
+ assert_equal ids_expected, ids_disabled
+ end
+
+ test "order: allows downcase symbol direction" do
+ ids_expected = Post.order(Arel.sql("title") => Arel.sql("asc")).pluck(:id)
+
+ ids_depr = with_unsafe_raw_sql_deprecated { Post.order(title: :asc).pluck(:id) }
+ ids_disabled = with_unsafe_raw_sql_disabled { Post.order(title: :asc).pluck(:id) }
+
+ assert_equal ids_expected, ids_depr
+ assert_equal ids_expected, ids_disabled
+ end
+
+ test "order: allows upcase symbol direction" do
+ ids_expected = Post.order(Arel.sql("title") => Arel.sql("ASC")).pluck(:id)
+
+ ids_depr = with_unsafe_raw_sql_deprecated { Post.order(title: :ASC).pluck(:id) }
+ ids_disabled = with_unsafe_raw_sql_disabled { Post.order(title: :ASC).pluck(:id) }
+
+ assert_equal ids_expected, ids_depr
+ assert_equal ids_expected, ids_disabled
+ end
+
+ test "order: allows string direction" do
+ ids_expected = Post.order(Arel.sql("title") => Arel.sql("asc")).pluck(:id)
+
+ ids_depr = with_unsafe_raw_sql_deprecated { Post.order(title: "asc").pluck(:id) }
+ ids_disabled = with_unsafe_raw_sql_disabled { Post.order(title: "asc").pluck(:id) }
+
+ assert_equal ids_expected, ids_depr
+ assert_equal ids_expected, ids_disabled
+ end
+
+ test "order: allows multiple columns" do
+ ids_expected = Post.order(Arel.sql("author_id"), Arel.sql("title")).pluck(:id)
+
+ ids_depr = with_unsafe_raw_sql_deprecated { Post.order(:author_id, :title).pluck(:id) }
+ ids_disabled = with_unsafe_raw_sql_disabled { Post.order(:author_id, :title).pluck(:id) }
+
+ assert_equal ids_expected, ids_depr
+ assert_equal ids_expected, ids_disabled
+ end
+
+ test "order: allows mixed" do
+ ids_expected = Post.order(Arel.sql("author_id"), Arel.sql("title") => Arel.sql("asc")).pluck(:id)
+
+ ids_depr = with_unsafe_raw_sql_deprecated { Post.order(:author_id, title: :asc).pluck(:id) }
+ ids_disabled = with_unsafe_raw_sql_disabled { Post.order(:author_id, title: :asc).pluck(:id) }
+
+ assert_equal ids_expected, ids_depr
+ assert_equal ids_expected, ids_disabled
+ end
+
+ test "order: allows table and column name" do
+ ids_expected = Post.order(Arel.sql("title")).pluck(:id)
+
+ ids_depr = with_unsafe_raw_sql_deprecated { Post.order("posts.title").pluck(:id) }
+ ids_disabled = with_unsafe_raw_sql_disabled { Post.order("posts.title").pluck(:id) }
+
+ assert_equal ids_expected, ids_depr
+ assert_equal ids_expected, ids_disabled
+ end
+
+ test "order: allows column name and direction in string" do
+ ids_expected = Post.order(Arel.sql("title desc")).pluck(:id)
+
+ ids_depr = with_unsafe_raw_sql_deprecated { Post.order("title desc").pluck(:id) }
+ ids_disabled = with_unsafe_raw_sql_disabled { Post.order("title desc").pluck(:id) }
+
+ assert_equal ids_expected, ids_depr
+ assert_equal ids_expected, ids_disabled
+ end
+
+ test "order: allows table name, column name and direction in string" do
+ ids_expected = Post.order(Arel.sql("title desc")).pluck(:id)
+
+ ids_depr = with_unsafe_raw_sql_deprecated { Post.order("posts.title desc").pluck(:id) }
+ ids_disabled = with_unsafe_raw_sql_disabled { Post.order("posts.title desc").pluck(:id) }
+
+ assert_equal ids_expected, ids_depr
+ assert_equal ids_expected, ids_disabled
+ end
+
+ test "order: allows NULLS FIRST and NULLS LAST too" do
+ raise "precondition failed" if Post.count < 2
+
+ # Ensure there are NULL and non-NULL post types.
+ Post.first.update_column(:type, nil)
+ Post.last.update_column(:type, "Programming")
+
+ ["asc", "desc", ""].each do |direction|
+ %w(first last).each do |position|
+ ids_expected = Post.order(Arel.sql("type #{direction} nulls #{position}")).pluck(:id)
+
+ ids_depr = with_unsafe_raw_sql_deprecated { Post.order("type #{direction} nulls #{position}").pluck(:id) }
+ ids_disabled = with_unsafe_raw_sql_disabled { Post.order("type #{direction} nulls #{position}").pluck(:id) }
+
+ assert_equal ids_expected, ids_depr
+ assert_equal ids_expected, ids_disabled
+ end
+ end
+ end if current_adapter?(:PostgreSQLAdapter)
+
+ test "order: disallows invalid column name" do
+ with_unsafe_raw_sql_disabled do
+ assert_raises(ActiveRecord::UnknownAttributeReference) do
+ Post.order("len(title) asc").pluck(:id)
+ end
+ end
+ end
+
+ test "order: disallows invalid direction" do
+ with_unsafe_raw_sql_disabled do
+ assert_raises(ArgumentError) do
+ Post.order(title: :foo).pluck(:id)
+ end
+ end
+ end
+
+ test "order: disallows invalid column with direction" do
+ with_unsafe_raw_sql_disabled do
+ assert_raises(ActiveRecord::UnknownAttributeReference) do
+ Post.order("len(title)" => :asc).pluck(:id)
+ end
+ end
+ end
+
+ test "order: always allows Arel" do
+ ids_depr = with_unsafe_raw_sql_deprecated { Post.order(Arel.sql("length(title)")).pluck(:title) }
+ ids_disabled = with_unsafe_raw_sql_disabled { Post.order(Arel.sql("length(title)")).pluck(:title) }
+
+ assert_equal ids_depr, ids_disabled
+ end
+
+ test "order: allows Arel.sql with binds" do
+ ids_expected = Post.order(Arel.sql("REPLACE(title, 'misc', 'zzzz'), id")).pluck(:id)
+
+ ids_depr = with_unsafe_raw_sql_deprecated { Post.order([Arel.sql("REPLACE(title, ?, ?), id"), "misc", "zzzz"]).pluck(:id) }
+ ids_disabled = with_unsafe_raw_sql_disabled { Post.order([Arel.sql("REPLACE(title, ?, ?), id"), "misc", "zzzz"]).pluck(:id) }
+
+ assert_equal ids_expected, ids_depr
+ assert_equal ids_expected, ids_disabled
+ end
+
+ test "order: disallows invalid bind statement" do
+ with_unsafe_raw_sql_disabled do
+ assert_raises(ActiveRecord::UnknownAttributeReference) do
+ Post.order(["REPLACE(title, ?, ?), id", "misc", "zzzz"]).pluck(:id)
+ end
+ end
+ end
+
+ test "order: disallows invalid Array arguments" do
+ with_unsafe_raw_sql_disabled do
+ assert_raises(ActiveRecord::UnknownAttributeReference) do
+ Post.order(["author_id", "length(title)"]).pluck(:id)
+ end
+ end
+ end
+
+ test "order: allows valid Array arguments" do
+ ids_expected = Post.order(Arel.sql("author_id, length(title)")).pluck(:id)
+
+ ids_depr = with_unsafe_raw_sql_deprecated { Post.order(["author_id", Arel.sql("length(title)")]).pluck(:id) }
+ ids_disabled = with_unsafe_raw_sql_disabled { Post.order(["author_id", Arel.sql("length(title)")]).pluck(:id) }
+
+ assert_equal ids_expected, ids_depr
+ assert_equal ids_expected, ids_disabled
+ end
+
+ test "order: logs deprecation warning for unrecognized column" do
+ with_unsafe_raw_sql_deprecated do
+ assert_deprecated(/Dangerous query method/) do
+ Post.order("length(title)")
+ end
+ end
+ end
+
+ test "pluck: allows string column name" do
+ titles_expected = Post.pluck(Arel.sql("title"))
+
+ titles_depr = with_unsafe_raw_sql_deprecated { Post.pluck("title") }
+ titles_disabled = with_unsafe_raw_sql_disabled { Post.pluck("title") }
+
+ assert_equal titles_expected, titles_depr
+ assert_equal titles_expected, titles_disabled
+ end
+
+ test "pluck: allows symbol column name" do
+ titles_expected = Post.pluck(Arel.sql("title"))
+
+ titles_depr = with_unsafe_raw_sql_deprecated { Post.pluck(:title) }
+ titles_disabled = with_unsafe_raw_sql_disabled { Post.pluck(:title) }
+
+ assert_equal titles_expected, titles_depr
+ assert_equal titles_expected, titles_disabled
+ end
+
+ test "pluck: allows multiple column names" do
+ values_expected = Post.pluck(Arel.sql("title"), Arel.sql("id"))
+
+ values_depr = with_unsafe_raw_sql_deprecated { Post.pluck(:title, :id) }
+ values_disabled = with_unsafe_raw_sql_disabled { Post.pluck(:title, :id) }
+
+ assert_equal values_expected, values_depr
+ assert_equal values_expected, values_disabled
+ end
+
+ test "pluck: allows column names with includes" do
+ values_expected = Post.includes(:comments).pluck(Arel.sql("title"), Arel.sql("id"))
+
+ values_depr = with_unsafe_raw_sql_deprecated { Post.includes(:comments).pluck(:title, :id) }
+ values_disabled = with_unsafe_raw_sql_disabled { Post.includes(:comments).pluck(:title, :id) }
+
+ assert_equal values_expected, values_depr
+ assert_equal values_expected, values_disabled
+ end
+
+ test "pluck: allows auto-generated attributes" do
+ values_expected = Post.pluck(Arel.sql("tags_count"))
+
+ values_depr = with_unsafe_raw_sql_deprecated { Post.pluck(:tags_count) }
+ values_disabled = with_unsafe_raw_sql_disabled { Post.pluck(:tags_count) }
+
+ assert_equal values_expected, values_depr
+ assert_equal values_expected, values_disabled
+ end
+
+ test "pluck: allows table and column names" do
+ titles_expected = Post.pluck(Arel.sql("title"))
+
+ titles_depr = with_unsafe_raw_sql_deprecated { Post.pluck("posts.title") }
+ titles_disabled = with_unsafe_raw_sql_disabled { Post.pluck("posts.title") }
+
+ assert_equal titles_expected, titles_depr
+ assert_equal titles_expected, titles_disabled
+ end
+
+ test "pluck: disallows invalid column name" do
+ with_unsafe_raw_sql_disabled do
+ assert_raises(ActiveRecord::UnknownAttributeReference) do
+ Post.pluck("length(title)")
+ end
+ end
+ end
+
+ test "pluck: disallows invalid column name amongst valid names" do
+ with_unsafe_raw_sql_disabled do
+ assert_raises(ActiveRecord::UnknownAttributeReference) do
+ Post.pluck(:title, "length(title)")
+ end
+ end
+ end
+
+ test "pluck: disallows invalid column names with includes" do
+ with_unsafe_raw_sql_disabled do
+ assert_raises(ActiveRecord::UnknownAttributeReference) do
+ Post.includes(:comments).pluck(:title, "length(title)")
+ end
+ end
+ end
+
+ test "pluck: always allows Arel" do
+ values_depr = with_unsafe_raw_sql_deprecated { Post.includes(:comments).pluck(:title, Arel.sql("length(title)")) }
+ values_disabled = with_unsafe_raw_sql_disabled { Post.includes(:comments).pluck(:title, Arel.sql("length(title)")) }
+
+ assert_equal values_depr, values_disabled
+ end
+
+ test "pluck: logs deprecation warning" do
+ with_unsafe_raw_sql_deprecated do
+ assert_deprecated(/Dangerous query method/) do
+ Post.includes(:comments).pluck(:title, "length(title)")
+ end
+ end
+ end
+
+ def with_unsafe_raw_sql_disabled(&blk)
+ with_config(:disabled, &blk)
+ end
+
+ def with_unsafe_raw_sql_deprecated(&blk)
+ with_config(:deprecated, &blk)
+ end
+
+ def with_config(new_value, &blk)
+ old_value = ActiveRecord::Base.allow_unsafe_raw_sql
+ ActiveRecord::Base.allow_unsafe_raw_sql = new_value
+ blk.call
+ ensure
+ ActiveRecord::Base.allow_unsafe_raw_sql = old_value
+ end
+end
diff --git a/activerecord/test/cases/validations/absence_validation_test.rb b/activerecord/test/cases/validations/absence_validation_test.rb
new file mode 100644
index 0000000000..1982734f02
--- /dev/null
+++ b/activerecord/test/cases/validations/absence_validation_test.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/face"
+require "models/interest"
+require "models/man"
+require "models/topic"
+
+class AbsenceValidationTest < ActiveRecord::TestCase
+ def test_non_association
+ boy_klass = Class.new(Man) 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(Man) 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(Man) 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(Man) 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
diff --git a/activerecord/test/cases/validations/association_validation_test.rb b/activerecord/test/cases/validations/association_validation_test.rb
new file mode 100644
index 0000000000..ce6d42b34b
--- /dev/null
+++ b/activerecord/test/cases/validations/association_validation_test.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+require "models/reply"
+require "models/man"
+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 Man have the :inverse_of option set
+ Interest.validates_presence_of(:man)
+ man = Man.new(name: "John")
+ interest = man.interests.build(topic: "Airplanes")
+ assert interest.valid?, "Expected interest to be valid, but was not. Interest should have a man object associated"
+ end
+ end
+
+ def test_validates_presence_of_belongs_to_association__existing_parent
+ repair_validations(Interest) do
+ Interest.validates_presence_of(:man)
+ man = Man.create!(name: "John")
+ interest = man.interests.build(topic: "Airplanes")
+ assert interest.valid?, "Expected interest to be valid, but was not. Interest should have a man object associated"
+ end
+ end
+end
diff --git a/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb b/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb
new file mode 100644
index 0000000000..703c24b340
--- /dev/null
+++ b/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+
+class I18nGenerateMessageValidationTest < ActiveRecord::TestCase
+ def setup
+ Topic.clear_validators!
+ @topic = Topic.new
+ I18n.backend = I18n::Backend::Simple.new
+ end
+
+ def reset_i18n_load_path
+ @old_load_path, @old_backend = I18n.load_path.dup, I18n.backend
+ I18n.load_path.clear
+ I18n.backend = I18n::Backend::Simple.new
+ yield
+ ensure
+ I18n.load_path.replace @old_load_path
+ I18n.backend = @old_backend
+ end
+
+ # validates_associated: generate_message(attr_name, :invalid, :message => custom_message, :value => value)
+ def test_generate_message_invalid_with_default_message
+ assert_equal "is invalid", @topic.errors.generate_message(:title, :invalid, value: "title")
+ end
+
+ def test_generate_message_invalid_with_custom_message
+ assert_equal "custom message title", @topic.errors.generate_message(:title, :invalid, message: "custom message %{value}", value: "title")
+ end
+
+ # validates_uniqueness_of: generate_message(attr_name, :taken, :message => custom_message)
+ def test_generate_message_taken_with_default_message
+ assert_equal "has already been taken", @topic.errors.generate_message(:title, :taken, value: "title")
+ end
+
+ def test_generate_message_taken_with_custom_message
+ assert_equal "custom message title", @topic.errors.generate_message(:title, :taken, message: "custom message %{value}", value: "title")
+ end
+
+ # ActiveRecord#RecordInvalid exception
+
+ test "RecordInvalid exception can be localized" do
+ topic = Topic.new
+ topic.errors.add(:title, :invalid)
+ topic.errors.add(:title, :blank)
+ assert_equal "Validation failed: Title is invalid, Title can't be blank", ActiveRecord::RecordInvalid.new(topic).message
+ end
+
+ test "RecordInvalid exception translation falls back to the :errors namespace" do
+ reset_i18n_load_path do
+ I18n.backend.store_translations "en", errors: { messages: { record_invalid: "fallback message" } }
+ topic = Topic.new
+ topic.errors.add(:title, :blank)
+ assert_equal "fallback message", ActiveRecord::RecordInvalid.new(topic).message
+ end
+ end
+
+ test "translation for 'taken' can be overridden" do
+ reset_i18n_load_path do
+ I18n.backend.store_translations "en", errors: { attributes: { title: { taken: "Custom taken message" } } }
+ assert_equal "Custom taken message", @topic.errors.generate_message(:title, :taken, value: "title")
+ end
+ end
+
+ test "translation for 'taken' can be overridden in activerecord scope" do
+ reset_i18n_load_path do
+ I18n.backend.store_translations "en", activerecord: { errors: { messages: { taken: "Custom taken message" } } }
+ assert_equal "Custom taken message", @topic.errors.generate_message(:title, :taken, value: "title")
+ end
+ end
+
+ test "translation for 'taken' can be overridden in activerecord model scope" do
+ reset_i18n_load_path do
+ I18n.backend.store_translations "en", activerecord: { errors: { models: { topic: { taken: "Custom taken message" } } } }
+ assert_equal "Custom taken message", @topic.errors.generate_message(:title, :taken, value: "title")
+ end
+ end
+
+ test "translation for 'taken' can be overridden in activerecord attributes scope" do
+ reset_i18n_load_path do
+ I18n.backend.store_translations "en", activerecord: { errors: { models: { topic: { attributes: { title: { taken: "Custom taken message" } } } } } }
+ assert_equal "Custom taken message", @topic.errors.generate_message(:title, :taken, value: "title")
+ end
+ end
+end
diff --git a/activerecord/test/cases/validations/i18n_validation_test.rb b/activerecord/test/cases/validations/i18n_validation_test.rb
new file mode 100644
index 0000000000..b7c52ea18c
--- /dev/null
+++ b/activerecord/test/cases/validations/i18n_validation_test.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+require "models/reply"
+
+class I18nValidationTest < ActiveRecord::TestCase
+ repair_validations(Topic, Reply)
+
+ def setup
+ repair_validations(Topic, Reply)
+ Reply.validates_presence_of(:title)
+ @topic = Topic.new
+ @old_load_path, @old_backend = I18n.load_path.dup, I18n.backend
+ I18n.load_path.clear
+ I18n.backend = I18n::Backend::Simple.new
+ I18n.backend.store_translations("en", errors: { messages: { custom: nil } })
+ end
+
+ teardown do
+ I18n.load_path.replace @old_load_path
+ I18n.backend = @old_backend
+ end
+
+ def unique_topic
+ @unique ||= Topic.create title: "unique!"
+ end
+
+ def replied_topic
+ @replied_topic ||= begin
+ topic = Topic.create(title: "topic")
+ topic.replies << Reply.new
+ topic
+ end
+ end
+
+ # A set of common cases for ActiveModel::Validations message generation that
+ # are used to generate tests to keep things DRY
+ #
+ COMMON_CASES = [
+ # [ case, validation_options, generate_message_options]
+ [ "given no options", {}, {}],
+ [ "given custom message", { message: "custom" }, { message: "custom" }],
+ [ "given if condition", { if: lambda { true } }, {}],
+ [ "given unless condition", { unless: lambda { false } }, {}],
+ [ "given option that is not reserved", { format: "jpg" }, { format: "jpg" }],
+ [ "given on condition", { on: [:create, :update] }, {}]
+ ]
+
+ COMMON_CASES.each do |name, validation_options, generate_message_options|
+ test "validates_uniqueness_of on generated message #{name}" do
+ Topic.validates_uniqueness_of :title, validation_options
+ @topic.title = unique_topic.title
+ assert_called_with(@topic.errors, :generate_message, [:title, :taken, generate_message_options.merge(value: "unique!")]) do
+ @topic.valid?
+ end
+ end
+ end
+
+ COMMON_CASES.each do |name, validation_options, generate_message_options|
+ test "validates_associated on generated message #{name}" do
+ Topic.validates_associated :replies, validation_options
+ assert_called_with(replied_topic.errors, :generate_message, [:replies, :invalid, generate_message_options.merge(value: replied_topic.replies)]) do
+ replied_topic.save
+ end
+ end
+ end
+
+ def test_validates_associated_finds_custom_model_key_translation
+ I18n.backend.store_translations "en", activerecord: { errors: { models: { topic: { attributes: { replies: { invalid: "custom message" } } } } } }
+ I18n.backend.store_translations "en", activerecord: { errors: { messages: { invalid: "global message" } } }
+
+ Topic.validates_associated :replies
+ replied_topic.valid?
+ assert_equal ["custom message"], replied_topic.errors[:replies].uniq
+ end
+
+ def test_validates_associated_finds_global_default_translation
+ I18n.backend.store_translations "en", activerecord: { errors: { messages: { invalid: "global message" } } }
+
+ Topic.validates_associated :replies
+ replied_topic.valid?
+ assert_equal ["global message"], replied_topic.errors[:replies]
+ end
+end
diff --git a/activerecord/test/cases/validations/length_validation_test.rb b/activerecord/test/cases/validations/length_validation_test.rb
new file mode 100644
index 0000000000..a7cb718043
--- /dev/null
+++ b/activerecord/test/cases/validations/length_validation_test.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/owner"
+require "models/pet"
+require "models/person"
+
+class LengthValidationTest < ActiveRecord::TestCase
+ fixtures :owners
+
+ setup do
+ @owner = Class.new(Owner) do
+ def self.name; "Owner"; end
+ end
+ end
+
+ def test_validates_size_of_association
+ assert_nothing_raised { @owner.validates_size_of :pets, minimum: 1 }
+ o = @owner.new("name" => "nopets")
+ assert_not o.save
+ assert_predicate o.errors[:pets], :any?
+ o.pets.build("name" => "apet")
+ assert_predicate o, :valid?
+ end
+
+ def test_validates_size_of_association_using_within
+ assert_nothing_raised { @owner.validates_size_of :pets, within: 1..2 }
+ o = @owner.new("name" => "nopets")
+ assert_not o.save
+ assert_predicate o.errors[:pets], :any?
+
+ o.pets.build("name" => "apet")
+ assert_predicate o, :valid?
+
+ 2.times { o.pets.build("name" => "apet") }
+ assert_not o.save
+ assert_predicate o.errors[:pets], :any?
+ end
+
+ def test_validates_size_of_association_utf8
+ @owner.validates_size_of :pets, minimum: 1
+ o = @owner.new("name" => "あいうえおかきくけこ")
+ assert_not o.save
+ assert_predicate o.errors[:pets], :any?
+ o.pets.build("name" => "あいうえおかきくけこ")
+ assert_predicate o, :valid?
+ end
+
+ def test_validates_size_of_respects_records_marked_for_destruction
+ @owner.validates_size_of :pets, minimum: 1
+ owner = @owner.new
+ assert_not owner.save
+ assert_predicate owner.errors[:pets], :any?
+ pet = owner.pets.build
+ assert_predicate owner, :valid?
+ assert owner.save
+
+ pet_count = Pet.count
+ assert_not owner.update pets_attributes: [ { _destroy: 1, id: pet.id } ]
+ assert_not_predicate owner, :valid?
+ assert_predicate owner.errors[:pets], :any?
+ assert_equal pet_count, Pet.count
+ end
+
+ def test_validates_length_of_virtual_attribute_on_model
+ repair_validations(Pet) do
+ Pet.attr_accessor(:nickname)
+ Pet.validates_length_of(:name, minimum: 1)
+ Pet.validates_length_of(:nickname, minimum: 1)
+
+ pet = Pet.create!(name: "Fancy Pants", nickname: "Fancy")
+
+ assert_predicate pet, :valid?
+
+ pet.nickname = ""
+
+ assert_predicate pet, :invalid?
+ end
+ end
+end
diff --git a/activerecord/test/cases/validations/presence_validation_test.rb b/activerecord/test/cases/validations/presence_validation_test.rb
new file mode 100644
index 0000000000..4b9cbe9098
--- /dev/null
+++ b/activerecord/test/cases/validations/presence_validation_test.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/man"
+require "models/face"
+require "models/interest"
+require "models/speedometer"
+require "models/dashboard"
+
+class PresenceValidationTest < ActiveRecord::TestCase
+ class Boy < Man; end
+
+ repair_validations(Boy)
+
+ def test_validates_presence_of_non_association
+ Boy.validates_presence_of(:name)
+ b = Boy.new
+ assert_predicate b, :invalid?
+
+ b.name = "Alex"
+ assert_predicate b, :valid?
+ end
+
+ def test_validates_presence_of_has_one
+ Boy.validates_presence_of(:face)
+ b = Boy.new
+ assert b.invalid?, "should not be valid if has_one association missing"
+ assert_equal 1, b.errors[:face].size, "validates_presence_of should only add one error"
+ end
+
+ def test_validates_presence_of_has_one_marked_for_destruction
+ Boy.validates_presence_of(:face)
+ b = Boy.new
+ f = Face.new
+ b.face = f
+ assert_predicate b, :valid?
+
+ f.mark_for_destruction
+ assert_predicate b, :invalid?
+ end
+
+ def test_validates_presence_of_has_many_marked_for_destruction
+ Boy.validates_presence_of(:interests)
+ b = Boy.new
+ b.interests << [i1 = Interest.new, i2 = Interest.new]
+ assert_predicate b, :valid?
+
+ i1.mark_for_destruction
+ assert_predicate b, :valid?
+
+ i2.mark_for_destruction
+ assert_predicate b, :invalid?
+ end
+
+ def test_validates_presence_doesnt_convert_to_array
+ speedometer = Class.new(Speedometer)
+ speedometer.validates_presence_of :dashboard
+
+ dash = Dashboard.new
+
+ # dashboard has to_a method
+ def dash.to_a; ["(/)", '(\)']; end
+
+ s = speedometer.new
+ s.dashboard = dash
+
+ assert_nothing_raised { s.valid? }
+ end
+
+ def test_validates_presence_of_virtual_attribute_on_model
+ repair_validations(Interest) do
+ Interest.attr_accessor(:abbreviation)
+ Interest.validates_presence_of(:topic)
+ Interest.validates_presence_of(:abbreviation)
+
+ interest = Interest.create!(topic: "Thought Leadering", abbreviation: "tl")
+ assert_predicate interest, :valid?
+
+ interest.abbreviation = ""
+
+ assert_predicate interest, :invalid?
+ end
+ end
+
+ def test_validations_run_on_persisted_record
+ repair_validations(Interest) do
+ interest = Interest.new
+ interest.save!
+ assert_predicate interest, :valid?
+
+ Interest.validates_presence_of(:topic)
+
+ assert_not_predicate interest, :valid?
+ end
+ end
+
+ def test_validates_presence_with_on_context
+ repair_validations(Interest) do
+ Interest.validates_presence_of(:topic, on: :required_name)
+ interest = Interest.new
+ interest.save!
+ assert_not interest.valid?(:required_name)
+ end
+ end
+end
diff --git a/activerecord/test/cases/validations/uniqueness_validation_test.rb b/activerecord/test/cases/validations/uniqueness_validation_test.rb
new file mode 100644
index 0000000000..8f6f47e5fb
--- /dev/null
+++ b/activerecord/test/cases/validations/uniqueness_validation_test.rb
@@ -0,0 +1,557 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+require "models/reply"
+require "models/warehouse_thing"
+require "models/guid"
+require "models/event"
+require "models/dashboard"
+require "models/uuid_item"
+require "models/author"
+require "models/person"
+require "models/essay"
+
+class Wizard < ActiveRecord::Base
+ self.abstract_class = true
+
+ validates_uniqueness_of :name
+end
+
+class IneptWizard < Wizard
+ validates_uniqueness_of :city
+end
+
+class Conjurer < IneptWizard
+end
+
+class Thaumaturgist < IneptWizard
+end
+
+class ReplyTitle; end
+
+class ReplyWithTitleObject < Reply
+ validates_uniqueness_of :content, scope: :title
+
+ def title; ReplyTitle.new; end
+end
+
+class TopicWithUniqEvent < Topic
+ belongs_to :event, foreign_key: :parent_id
+ validates :event, uniqueness: true
+end
+
+class BigIntTest < ActiveRecord::Base
+ INT_MAX_VALUE = 2147483647
+ self.table_name = "cars"
+ validates :engines_count, uniqueness: true, inclusion: { in: 0..INT_MAX_VALUE }
+end
+
+class BigIntReverseTest < ActiveRecord::Base
+ INT_MAX_VALUE = 2147483647
+ self.table_name = "cars"
+ validates :engines_count, inclusion: { in: 0..INT_MAX_VALUE }
+ validates :engines_count, uniqueness: true
+end
+
+class CoolTopic < Topic
+ validates_uniqueness_of :id
+end
+
+class TopicWithAfterCreate < Topic
+ after_create :set_author
+
+ def set_author
+ update!(author_name: "#{title} #{id}")
+ end
+end
+
+class UniquenessValidationTest < ActiveRecord::TestCase
+ INT_MAX_VALUE = 2147483647
+
+ fixtures :topics, "warehouse-things"
+
+ repair_validations(Topic, Reply)
+
+ def test_validate_uniqueness
+ Topic.validates_uniqueness_of(:title)
+
+ t = Topic.new("title" => "I'm uniqué!")
+ assert t.save, "Should save t as unique"
+
+ t.content = "Remaining unique"
+ assert t.save, "Should still save t as unique"
+
+ t2 = Topic.new("title" => "I'm uniqué!")
+ assert_not t2.valid?, "Shouldn't be valid"
+ assert_not t2.save, "Shouldn't save t2 as unique"
+ assert_equal ["has already been taken"], t2.errors[:title]
+
+ t2.title = "Now I am really also unique"
+ assert t2.save, "Should now save t2 as unique"
+ end
+
+ def test_validate_uniqueness_with_alias_attribute
+ Topic.alias_attribute :new_title, :title
+ Topic.validates_uniqueness_of(:new_title)
+
+ topic = Topic.new(new_title: "abc")
+ assert_predicate topic, :valid?
+ end
+
+ def test_validates_uniqueness_with_nil_value
+ Topic.validates_uniqueness_of(:title)
+
+ t = Topic.new("title" => nil)
+ assert t.save, "Should save t as unique"
+
+ t2 = Topic.new("title" => nil)
+ assert_not t2.valid?, "Shouldn't be valid"
+ assert_not t2.save, "Shouldn't save t2 as unique"
+ assert_equal ["has already been taken"], t2.errors[:title]
+ end
+
+ def test_validates_uniqueness_with_validates
+ Topic.validates :title, uniqueness: true
+ Topic.create!("title" => "abc")
+
+ t2 = Topic.new("title" => "abc")
+ assert_not_predicate t2, :valid?
+ assert t2.errors[:title]
+ end
+
+ def test_validate_uniqueness_when_integer_out_of_range
+ entry = BigIntTest.create(engines_count: INT_MAX_VALUE + 1)
+ assert_equal entry.errors[:engines_count], ["is not included in the list"]
+ end
+
+ def test_validate_uniqueness_when_integer_out_of_range_show_order_does_not_matter
+ entry = BigIntReverseTest.create(engines_count: INT_MAX_VALUE + 1)
+ assert_equal entry.errors[:engines_count], ["is not included in the list"]
+ end
+
+ def test_validates_uniqueness_with_newline_chars
+ Topic.validates_uniqueness_of(:title, case_sensitive: false)
+
+ t = Topic.new("title" => "new\nline")
+ assert t.save, "Should save t as unique"
+ end
+
+ def test_validate_uniqueness_with_scope
+ Reply.validates_uniqueness_of(:content, scope: "parent_id")
+
+ t = Topic.create("title" => "I'm unique!")
+
+ r1 = t.replies.create "title" => "r1", "content" => "hello world"
+ assert r1.valid?, "Saving r1"
+
+ r2 = t.replies.create "title" => "r2", "content" => "hello world"
+ assert_not r2.valid?, "Saving r2 first time"
+
+ r2.content = "something else"
+ assert r2.save, "Saving r2 second time"
+
+ t2 = Topic.create("title" => "I'm unique too!")
+ r3 = t2.replies.create "title" => "r3", "content" => "hello world"
+ assert r3.valid?, "Saving r3"
+ end
+
+ def test_validate_uniqueness_with_scope_invalid_syntax
+ error = assert_raises(ArgumentError) do
+ Reply.validates_uniqueness_of(:content, scope: { parent_id: false })
+ end
+ assert_match(/Pass a symbol or an array of symbols instead/, error.to_s)
+ end
+
+ def test_validate_uniqueness_with_object_scope
+ Reply.validates_uniqueness_of(:content, scope: :topic)
+
+ t = Topic.create("title" => "I'm unique!")
+
+ r1 = t.replies.create "title" => "r1", "content" => "hello world"
+ assert r1.valid?, "Saving r1"
+
+ r2 = t.replies.create "title" => "r2", "content" => "hello world"
+ assert_not r2.valid?, "Saving r2 first time"
+ end
+
+ def test_validate_uniqueness_with_polymorphic_object_scope
+ Essay.validates_uniqueness_of(:name, scope: :writer)
+
+ a = Author.create(name: "Sergey")
+ p = Person.create(first_name: "Sergey")
+
+ e1 = a.essays.create(name: "Essay")
+ assert e1.valid?, "Saving e1"
+
+ e2 = p.essays.create(name: "Essay")
+ assert e2.valid?, "Saving e2"
+ end
+
+ def test_validate_uniqueness_with_composed_attribute_scope
+ r1 = ReplyWithTitleObject.create "title" => "r1", "content" => "hello world"
+ assert r1.valid?, "Saving r1"
+
+ r2 = ReplyWithTitleObject.create "title" => "r1", "content" => "hello world"
+ assert_not r2.valid?, "Saving r2 first time"
+ end
+
+ def test_validate_uniqueness_with_object_arg
+ Reply.validates_uniqueness_of(:topic)
+
+ t = Topic.create("title" => "I'm unique!")
+
+ r1 = t.replies.create "title" => "r1", "content" => "hello world"
+ assert r1.valid?, "Saving r1"
+
+ r2 = t.replies.create "title" => "r2", "content" => "hello world"
+ assert_not r2.valid?, "Saving r2 first time"
+ end
+
+ def test_validate_uniqueness_scoped_to_defining_class
+ t = Topic.create("title" => "What, me worry?")
+
+ r1 = t.unique_replies.create "title" => "r1", "content" => "a barrel of fun"
+ assert r1.valid?, "Saving r1"
+
+ r2 = t.silly_unique_replies.create "title" => "r2", "content" => "a barrel of fun"
+ assert_not r2.valid?, "Saving r2"
+
+ # Should succeed as validates_uniqueness_of only applies to
+ # UniqueReply and its subclasses
+ r3 = t.replies.create "title" => "r2", "content" => "a barrel of fun"
+ assert r3.valid?, "Saving r3"
+ end
+
+ def test_validate_uniqueness_with_scope_array
+ Reply.validates_uniqueness_of(:author_name, scope: [:author_email_address, :parent_id])
+
+ t = Topic.create("title" => "The earth is actually flat!")
+
+ r1 = t.replies.create "author_name" => "jeremy", "author_email_address" => "jeremy@rubyonrails.com", "title" => "You're crazy!", "content" => "Crazy reply"
+ assert r1.valid?, "Saving r1"
+
+ r2 = t.replies.create "author_name" => "jeremy", "author_email_address" => "jeremy@rubyonrails.com", "title" => "You're crazy!", "content" => "Crazy reply again..."
+ assert_not r2.valid?, "Saving r2. Double reply by same author."
+
+ r2.author_email_address = "jeremy_alt_email@rubyonrails.com"
+ assert r2.save, "Saving r2 the second time."
+
+ r3 = t.replies.create "author_name" => "jeremy", "author_email_address" => "jeremy_alt_email@rubyonrails.com", "title" => "You're wrong", "content" => "It's cubic"
+ assert_not r3.valid?, "Saving r3"
+
+ r3.author_name = "jj"
+ assert r3.save, "Saving r3 the second time."
+
+ r3.author_name = "jeremy"
+ assert_not r3.save, "Saving r3 the third time."
+ end
+
+ def test_validate_case_insensitive_uniqueness
+ Topic.validates_uniqueness_of(:title, :parent_id, case_sensitive: false, allow_nil: true)
+
+ t = Topic.new("title" => "I'm unique!", :parent_id => 2)
+ assert t.save, "Should save t as unique"
+
+ t.content = "Remaining unique"
+ assert t.save, "Should still save t as unique"
+
+ t2 = Topic.new("title" => "I'm UNIQUE!", :parent_id => 1)
+ assert_not t2.valid?, "Shouldn't be valid"
+ assert_not t2.save, "Shouldn't save t2 as unique"
+ assert_predicate t2.errors[:title], :any?
+ assert_predicate t2.errors[:parent_id], :any?
+ assert_equal ["has already been taken"], t2.errors[:title]
+
+ t2.title = "I'm truly UNIQUE!"
+ assert_not t2.valid?, "Shouldn't be valid"
+ assert_not t2.save, "Shouldn't save t2 as unique"
+ assert_empty t2.errors[:title]
+ assert_predicate t2.errors[:parent_id], :any?
+
+ t2.parent_id = 4
+ assert t2.save, "Should now save t2 as unique"
+
+ t2.parent_id = nil
+ t2.title = nil
+ assert t2.valid?, "should validate with nil"
+ assert t2.save, "should save with nil"
+
+ t_utf8 = Topic.new("title" => "Я тоже уникальный!")
+ assert t_utf8.save, "Should save t_utf8 as unique"
+
+ # If database hasn't UTF-8 character set, this test fails
+ if Topic.all.merge!(select: "LOWER(title) AS title").find(t_utf8.id).title == "я тоже уникальный!"
+ t2_utf8 = Topic.new("title" => "я тоже УНИКАЛЬНЫЙ!")
+ assert_not t2_utf8.valid?, "Shouldn't be valid"
+ assert_not t2_utf8.save, "Shouldn't save t2_utf8 as unique"
+ end
+ end
+
+ def test_validate_case_sensitive_uniqueness_with_special_sql_like_chars
+ Topic.validates_uniqueness_of(:title, case_sensitive: true)
+
+ t = Topic.new("title" => "I'm unique!")
+ assert t.save, "Should save t as unique"
+
+ t2 = Topic.new("title" => "I'm %")
+ assert t2.save, "Should save t2 as unique"
+
+ t3 = Topic.new("title" => "I'm uniqu_!")
+ assert t3.save, "Should save t3 as unique"
+ end
+
+ def test_validate_case_insensitive_uniqueness_with_special_sql_like_chars
+ Topic.validates_uniqueness_of(:title, case_sensitive: false)
+
+ t = Topic.new("title" => "I'm unique!")
+ assert t.save, "Should save t as unique"
+
+ t2 = Topic.new("title" => "I'm %")
+ assert t2.save, "Should save t2 as unique"
+
+ t3 = Topic.new("title" => "I'm uniqu_!")
+ assert t3.save, "Should save t3 as unique"
+ end
+
+ def test_validate_case_sensitive_uniqueness
+ Topic.validates_uniqueness_of(:title, case_sensitive: true, allow_nil: true)
+
+ t = Topic.new("title" => "I'm unique!")
+ assert t.save, "Should save t as unique"
+
+ t.content = "Remaining unique"
+ assert t.save, "Should still save t as unique"
+
+ t2 = Topic.new("title" => "I'M UNIQUE!")
+ assert t2.valid?, "Should be valid"
+ assert t2.save, "Should save t2 as unique"
+ assert_empty t2.errors[:title]
+ assert_empty t2.errors[:parent_id]
+ assert_not_equal ["has already been taken"], t2.errors[:title]
+
+ t3 = Topic.new("title" => "I'M uNiQUe!")
+ assert t3.valid?, "Should be valid"
+ assert t3.save, "Should save t2 as unique"
+ assert_empty t3.errors[:title]
+ assert_empty t3.errors[:parent_id]
+ assert_not_equal ["has already been taken"], t3.errors[:title]
+ end
+
+ def test_validate_case_sensitive_uniqueness_with_attribute_passed_as_integer
+ Topic.validates_uniqueness_of(:title, case_sensitive: true)
+ Topic.create!("title" => 101)
+
+ t2 = Topic.new("title" => 101)
+ assert_not_predicate t2, :valid?
+ assert t2.errors[:title]
+ end
+
+ def test_validate_uniqueness_with_non_standard_table_names
+ i1 = WarehouseThing.create(value: 1000)
+ assert_not i1.valid?, "i1 should not be valid"
+ assert i1.errors[:value].any?, "Should not be empty"
+ end
+
+ def test_validates_uniqueness_inside_scoping
+ Topic.validates_uniqueness_of(:title)
+
+ Topic.where(author_name: "David").scoping do
+ t1 = Topic.new("title" => "I'm unique!", "author_name" => "Mary")
+ assert t1.save
+ t2 = Topic.new("title" => "I'm unique!", "author_name" => "David")
+ assert_not_predicate t2, :valid?
+ end
+ end
+
+ def test_validate_uniqueness_with_columns_which_are_sql_keywords
+ repair_validations(Guid) do
+ Guid.validates_uniqueness_of :key
+ g = Guid.new
+ g.key = "foo"
+ assert_nothing_raised { !g.valid? }
+ end
+ end
+
+ def test_validate_uniqueness_with_limit
+ if current_adapter?(:SQLite3Adapter)
+ # Event.title has limit 5, but SQLite doesn't truncate.
+ e1 = Event.create(title: "abcdefgh")
+ assert e1.valid?, "Could not create an event with a unique 8 characters title"
+
+ e2 = Event.create(title: "abcdefgh")
+ assert_not e2.valid?, "Created an event whose title is not unique"
+ elsif current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter, :OracleAdapter, :SQLServerAdapter)
+ assert_raise(ActiveRecord::ValueTooLong) do
+ Event.create(title: "abcdefgh")
+ end
+ else
+ assert_raise(ActiveRecord::StatementInvalid) do
+ Event.create(title: "abcdefgh")
+ end
+ end
+ end
+
+ def test_validate_uniqueness_with_limit_and_utf8
+ if current_adapter?(:SQLite3Adapter)
+ # Event.title has limit 5, but SQLite doesn't truncate.
+ e1 = Event.create(title: "一二三四五六七八")
+ assert e1.valid?, "Could not create an event with a unique 8 characters title"
+
+ e2 = Event.create(title: "一二三四五六七八")
+ assert_not e2.valid?, "Created an event whose title is not unique"
+ elsif current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter, :OracleAdapter, :SQLServerAdapter)
+ assert_raise(ActiveRecord::ValueTooLong) do
+ Event.create(title: "一二三四五六七八")
+ end
+ else
+ assert_raise(ActiveRecord::StatementInvalid) do
+ Event.create(title: "一二三四五六七八")
+ end
+ end
+ end
+
+ def test_validate_straight_inheritance_uniqueness
+ w1 = IneptWizard.create(name: "Rincewind", city: "Ankh-Morpork")
+ assert w1.valid?, "Saving w1"
+
+ # Should use validation from base class (which is abstract)
+ w2 = IneptWizard.new(name: "Rincewind", city: "Quirm")
+ assert_not w2.valid?, "w2 shouldn't be valid"
+ assert w2.errors[:name].any?, "Should have errors for name"
+ assert_equal ["has already been taken"], w2.errors[:name], "Should have uniqueness message for name"
+
+ w3 = Conjurer.new(name: "Rincewind", city: "Quirm")
+ assert_not w3.valid?, "w3 shouldn't be valid"
+ assert w3.errors[:name].any?, "Should have errors for name"
+ assert_equal ["has already been taken"], w3.errors[:name], "Should have uniqueness message for name"
+
+ w4 = Conjurer.create(name: "The Amazing Bonko", city: "Quirm")
+ assert w4.valid?, "Saving w4"
+
+ w5 = Thaumaturgist.new(name: "The Amazing Bonko", city: "Lancre")
+ assert_not w5.valid?, "w5 shouldn't be valid"
+ assert w5.errors[:name].any?, "Should have errors for name"
+ assert_equal ["has already been taken"], w5.errors[:name], "Should have uniqueness message for name"
+
+ w6 = Thaumaturgist.new(name: "Mustrum Ridcully", city: "Quirm")
+ assert_not w6.valid?, "w6 shouldn't be valid"
+ assert w6.errors[:city].any?, "Should have errors for city"
+ assert_equal ["has already been taken"], w6.errors[:city], "Should have uniqueness message for city"
+ end
+
+ def test_validate_uniqueness_with_conditions
+ Topic.validates_uniqueness_of :title, conditions: -> { where(approved: true) }
+ Topic.create("title" => "I'm a topic", "approved" => true)
+ Topic.create("title" => "I'm an unapproved topic", "approved" => false)
+
+ t3 = Topic.new("title" => "I'm a topic", "approved" => true)
+ assert_not t3.valid?, "t3 shouldn't be valid"
+
+ t4 = Topic.new("title" => "I'm an unapproved topic", "approved" => false)
+ assert t4.valid?, "t4 should be valid"
+ end
+
+ def test_validate_uniqueness_with_non_callable_conditions_is_not_supported
+ assert_raises(ArgumentError) {
+ Topic.validates_uniqueness_of :title, conditions: Topic.where(approved: true)
+ }
+ end
+
+ def test_validate_uniqueness_on_existing_relation
+ event = Event.create
+ assert_predicate TopicWithUniqEvent.create(event: event), :valid?
+
+ topic = TopicWithUniqEvent.new(event: event)
+ assert_not_predicate topic, :valid?
+ assert_equal ["has already been taken"], topic.errors[:event]
+ end
+
+ def test_validate_uniqueness_on_empty_relation
+ topic = TopicWithUniqEvent.new
+ assert_predicate topic, :valid?
+ end
+
+ def test_validate_uniqueness_of_custom_primary_key
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "keyboards"
+ self.primary_key = :key_number
+
+ validates_uniqueness_of :key_number
+
+ def self.name
+ "Keyboard"
+ end
+ end
+
+ klass.create!(key_number: 10)
+ key2 = klass.create!(key_number: 11)
+
+ key2.key_number = 10
+ assert_not_predicate key2, :valid?
+ end
+
+ def test_validate_uniqueness_without_primary_key
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "dashboards"
+
+ validates_uniqueness_of :dashboard_id
+
+ def self.name; "Dashboard" end
+ end
+
+ abc = klass.create!(dashboard_id: "abc")
+ assert_predicate klass.new(dashboard_id: "xyz"), :valid?
+ assert_not_predicate klass.new(dashboard_id: "abc"), :valid?
+
+ abc.dashboard_id = "def"
+
+ e = assert_raises ActiveRecord::UnknownPrimaryKey do
+ abc.save!
+ end
+ assert_match(/\AUnknown primary key for table dashboards in model/, e.message)
+ assert_match(/Can not validate uniqueness for persisted record without primary key.\z/, e.message)
+ end
+
+ def test_validate_uniqueness_ignores_itself_when_primary_key_changed
+ Topic.validates_uniqueness_of(:title)
+
+ t = Topic.new("title" => "This is a unique title")
+ assert t.save, "Should save t as unique"
+
+ t.id += 1
+ assert t.valid?, "Should be valid"
+ assert t.save, "Should still save t as unique"
+ end
+
+ def test_validate_uniqueness_with_after_create_performing_save
+ TopicWithAfterCreate.validates_uniqueness_of(:title)
+ topic = TopicWithAfterCreate.create!(title: "Title1")
+ assert topic.author_name.start_with?("Title1")
+
+ topic2 = TopicWithAfterCreate.new(title: "Title1")
+ assert_not_predicate topic2, :valid?
+ assert_equal(["has already been taken"], topic2.errors[:title])
+ end
+
+ def test_validate_uniqueness_uuid
+ skip unless current_adapter?(:PostgreSQLAdapter)
+ item = UuidItem.create!(uuid: SecureRandom.uuid, title: "item1")
+ item.update(title: "item1-title2")
+ assert_empty item.errors
+
+ item2 = UuidValidatingItem.create!(uuid: SecureRandom.uuid, title: "item2")
+ item2.update(title: "item2-title2")
+ assert_empty item2.errors
+ end
+
+ def test_validate_uniqueness_regular_id
+ item = CoolTopic.create!(title: "MyItem")
+ assert_empty item.errors
+
+ item2 = CoolTopic.new(id: item.id, title: "MyItem2")
+ assert_not_predicate item2, :valid?
+
+ assert_equal(["has already been taken"], item2.errors[:id])
+ end
+end
diff --git a/activerecord/test/cases/validations_repair_helper.rb b/activerecord/test/cases/validations_repair_helper.rb
new file mode 100644
index 0000000000..6dc3b64b2b
--- /dev/null
+++ b/activerecord/test/cases/validations_repair_helper.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module ActiveRecord
+ module ValidationsRepairHelper
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ def repair_validations(*model_classes)
+ teardown do
+ model_classes.each(&:clear_validators!)
+ end
+ end
+ end
+
+ def repair_validations(*model_classes)
+ yield if block_given?
+ ensure
+ model_classes.each(&:clear_validators!)
+ end
+ end
+end
diff --git a/activerecord/test/cases/validations_test.rb b/activerecord/test/cases/validations_test.rb
new file mode 100644
index 0000000000..9a70934b7e
--- /dev/null
+++ b/activerecord/test/cases/validations_test.rb
@@ -0,0 +1,219 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+require "models/reply"
+require "models/developer"
+require "models/computer"
+require "models/parrot"
+require "models/company"
+require "models/price_estimate"
+
+class ValidationsTest < ActiveRecord::TestCase
+ fixtures :topics, :developers
+
+ # Most of the tests mess with the validations of Topic, so lets repair it all the time.
+ # Other classes we mess with will be dealt with in the specific tests
+ repair_validations(Topic)
+
+ def test_valid_uses_create_context_when_new
+ r = WrongReply.new
+ r.title = "Wrong Create"
+ assert_not_predicate r, :valid?
+ assert r.errors[:title].any?, "A reply with a bad title should mark that attribute as invalid"
+ assert_equal ["is Wrong Create"], r.errors[:title], "A reply with a bad content should contain an error"
+ end
+
+ def test_valid_uses_update_context_when_persisted
+ r = WrongReply.new
+ r.title = "Bad"
+ r.content = "Good"
+ assert r.save, "First validation should be successful"
+
+ r.title = "Wrong Update"
+ assert_not r.valid?, "Second validation should fail"
+
+ assert r.errors[:title].any?, "A reply with a bad title should mark that attribute as invalid"
+ assert_equal ["is Wrong Update"], r.errors[:title], "A reply with a bad content should contain an error"
+ end
+
+ def test_valid_using_special_context
+ r = WrongReply.new(title: "Valid title")
+ assert_not r.valid?(:special_case)
+ assert_equal "Invalid", r.errors[:author_name].join
+
+ r.author_name = "secret"
+ r.content = "Good"
+ assert r.valid?(:special_case)
+
+ r.author_name = nil
+ assert_not r.valid?(:special_case)
+ assert_equal "Invalid", r.errors[:author_name].join
+
+ r.author_name = "secret"
+ assert r.valid?(:special_case)
+ end
+
+ def test_invalid_using_multiple_contexts
+ r = WrongReply.new(title: "Wrong Create")
+ assert r.invalid?([:special_case, :create])
+ assert_equal "Invalid", r.errors[:author_name].join
+ assert_equal "is Wrong Create", r.errors[:title].join
+ end
+
+ def test_validate
+ r = WrongReply.new
+
+ r.validate
+ assert_empty r.errors[:author_name]
+
+ r.validate(:special_case)
+ assert_not_empty r.errors[:author_name]
+
+ r.author_name = "secret"
+
+ r.validate(:special_case)
+ assert_empty r.errors[:author_name]
+ end
+
+ def test_invalid_record_exception
+ assert_raise(ActiveRecord::RecordInvalid) { WrongReply.create! }
+ assert_raise(ActiveRecord::RecordInvalid) { WrongReply.new.save! }
+
+ r = WrongReply.new
+ invalid = assert_raise ActiveRecord::RecordInvalid do
+ r.save!
+ end
+ assert_equal r, invalid.record
+ end
+
+ def test_validate_with_bang
+ assert_raise(ActiveRecord::RecordInvalid) do
+ WrongReply.new.validate!
+ end
+ end
+
+ def test_validate_with_bang_and_context
+ assert_raise(ActiveRecord::RecordInvalid) do
+ WrongReply.new.validate!(:special_case)
+ end
+ r = WrongReply.new(title: "Valid title", author_name: "secret", content: "Good")
+ assert r.validate!(:special_case)
+ end
+
+ def test_exception_on_create_bang_many
+ assert_raise(ActiveRecord::RecordInvalid) do
+ WrongReply.create!([ { "title" => "OK" }, { "title" => "Wrong Create" }])
+ end
+ end
+
+ def test_exception_on_create_bang_with_block
+ assert_raise(ActiveRecord::RecordInvalid) do
+ WrongReply.create!("title" => "OK") do |r|
+ r.content = nil
+ end
+ end
+ end
+
+ def test_exception_on_create_bang_many_with_block
+ assert_raise(ActiveRecord::RecordInvalid) do
+ WrongReply.create!([{ "title" => "OK" }, { "title" => "Wrong Create" }]) do |r|
+ r.content = nil
+ end
+ end
+ end
+
+ def test_save_without_validation
+ reply = WrongReply.new
+ assert_not reply.save
+ assert reply.save(validate: false)
+ end
+
+ def test_validates_acceptance_of_with_non_existent_table
+ Object.const_set :IncorporealModel, Class.new(ActiveRecord::Base)
+
+ assert_nothing_raised do
+ IncorporealModel.validates_acceptance_of(:incorporeal_column)
+ end
+ end
+
+ def test_throw_away_typing
+ d = Developer.new("name" => "David", "salary" => "100,000")
+ assert_not_predicate d, :valid?
+ assert_equal 100, d.salary
+ assert_equal "100,000", d.salary_before_type_cast
+ end
+
+ def test_validates_acceptance_of_with_undefined_attribute_methods
+ Topic.validates_acceptance_of(:approved)
+ topic = Topic.new(approved: true)
+ Topic.undefine_attribute_methods
+ assert topic.approved
+ end
+
+ def test_validates_acceptance_of_as_database_column
+ Topic.validates_acceptance_of(:approved)
+ topic = Topic.create("approved" => true)
+ assert topic["approved"]
+ end
+
+ def test_validators
+ assert_equal 1, Parrot.validators.size
+ assert_equal 1, Company.validators.size
+ assert_equal 1, Parrot.validators_on(:name).size
+ assert_equal 1, Company.validators_on(:name).size
+ end
+
+ def test_numericality_validation_with_mutation
+ klass = Class.new(Topic) do
+ attribute :wibble, :string
+ validates_numericality_of :wibble, only_integer: true
+ end
+
+ topic = klass.new(wibble: "123-4567")
+ topic.wibble.gsub!("-", "")
+
+ assert_predicate topic, :valid?
+ end
+
+ def test_numericality_validation_checks_against_raw_value
+ klass = Class.new(Topic) do
+ def self.model_name
+ ActiveModel::Name.new(self, nil, "Topic")
+ end
+ attribute :wibble, :decimal, scale: 2, precision: 9
+ validates_numericality_of :wibble, greater_than_or_equal_to: BigDecimal("97.18")
+ end
+
+ assert_not_predicate klass.new(wibble: "97.179"), :valid?
+ assert_not_predicate klass.new(wibble: 97.179), :valid?
+ assert_not_predicate klass.new(wibble: BigDecimal("97.179")), :valid?
+ end
+
+ def test_numericality_validator_wont_be_affected_by_custom_getter
+ price_estimate = PriceEstimate.new(price: 50)
+
+ assert_equal "$50.00", price_estimate.price
+ assert_equal 50, price_estimate.price_before_type_cast
+ assert_equal 50, price_estimate.read_attribute(:price)
+
+ assert_predicate price_estimate, :price_came_from_user?
+ assert_predicate price_estimate, :valid?
+
+ price_estimate.save!
+
+ assert_not_predicate price_estimate, :price_came_from_user?
+ assert_predicate price_estimate, :valid?
+ end
+
+ def test_acceptance_validator_doesnt_require_db_connection
+ klass = Class.new(ActiveRecord::Base) do
+ self.table_name = "posts"
+ end
+ klass.reset_column_information
+
+ assert_no_queries do
+ klass.validates_acceptance_of(:foo)
+ end
+ end
+end
diff --git a/activerecord/test/cases/view_test.rb b/activerecord/test/cases/view_test.rb
new file mode 100644
index 0000000000..36b9df7ba5
--- /dev/null
+++ b/activerecord/test/cases/view_test.rb
@@ -0,0 +1,222 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/book"
+require "support/schema_dumping_helper"
+
+module ViewBehavior
+ include SchemaDumpingHelper
+ extend ActiveSupport::Concern
+
+ included do
+ fixtures :books
+ end
+
+ class Ebook < ActiveRecord::Base
+ self.table_name = "ebooks'"
+ self.primary_key = "id"
+ end
+
+ def setup
+ super
+ @connection = ActiveRecord::Base.connection
+ create_view "ebooks'", <<~SQL
+ SELECT id, name, status FROM books WHERE format = 'ebook'
+ SQL
+ end
+
+ def teardown
+ super
+ drop_view "ebooks'"
+ end
+
+ def test_reading
+ books = Ebook.all
+ assert_equal [books(:rfr).id], books.map(&:id)
+ assert_equal ["Ruby for Rails"], books.map(&:name)
+ end
+
+ def test_views
+ assert_equal [Ebook.table_name], @connection.views
+ end
+
+ def test_view_exists
+ view_name = Ebook.table_name
+ assert @connection.view_exists?(view_name), "'#{view_name}' view should exist"
+ end
+
+ def test_table_exists
+ view_name = Ebook.table_name
+ assert_not @connection.table_exists?(view_name), "'#{view_name}' table should not exist"
+ end
+
+ def test_views_ara_valid_data_sources
+ view_name = Ebook.table_name
+ assert @connection.data_source_exists?(view_name), "'#{view_name}' should be a data source"
+ end
+
+ def test_column_definitions
+ assert_equal([["id", :integer],
+ ["name", :string],
+ ["status", :integer]], Ebook.columns.map { |c| [c.name, c.type] })
+ end
+
+ def test_attributes
+ assert_equal({ "id" => 2, "name" => "Ruby for Rails", "status" => 0 },
+ Ebook.first.attributes)
+ end
+
+ def test_does_not_assume_id_column_as_primary_key
+ model = Class.new(ActiveRecord::Base) do
+ self.table_name = "ebooks'"
+ end
+ assert_nil model.primary_key
+ end
+
+ def test_does_not_dump_view_as_table
+ schema = dump_table_schema "ebooks'"
+ assert_no_match %r{create_table "ebooks'"}, schema
+ end
+
+ private
+ def quote_table_name(name)
+ @connection.quote_table_name(name)
+ end
+end
+
+if ActiveRecord::Base.connection.supports_views?
+ class ViewWithPrimaryKeyTest < ActiveRecord::TestCase
+ include ViewBehavior
+
+ private
+ def create_view(name, query)
+ @connection.execute "CREATE VIEW #{quote_table_name(name)} AS #{query}"
+ end
+
+ def drop_view(name)
+ @connection.execute "DROP VIEW #{quote_table_name(name)}" if @connection.view_exists? name
+ end
+ end
+
+ class ViewWithoutPrimaryKeyTest < ActiveRecord::TestCase
+ include SchemaDumpingHelper
+ fixtures :books
+
+ class Paperback < ActiveRecord::Base; end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.execute <<~SQL
+ CREATE VIEW paperbacks
+ AS SELECT name, status FROM books WHERE format = 'paperback'
+ SQL
+ end
+
+ teardown do
+ @connection.execute "DROP VIEW paperbacks" if @connection.view_exists? "paperbacks"
+ end
+
+ def test_reading
+ books = Paperback.all
+ assert_equal ["Agile Web Development with Rails"], books.map(&:name)
+ end
+
+ def test_views
+ assert_equal [Paperback.table_name], @connection.views
+ end
+
+ def test_view_exists
+ view_name = Paperback.table_name
+ assert @connection.view_exists?(view_name), "'#{view_name}' view should exist"
+ end
+
+ def test_table_exists
+ view_name = Paperback.table_name
+ assert_not @connection.table_exists?(view_name), "'#{view_name}' table should not exist"
+ end
+
+ def test_column_definitions
+ assert_equal([["name", :string],
+ ["status", :integer]], Paperback.columns.map { |c| [c.name, c.type] })
+ end
+
+ def test_attributes
+ assert_equal({ "name" => "Agile Web Development with Rails", "status" => 2 },
+ Paperback.first.attributes)
+ end
+
+ def test_does_not_have_a_primary_key
+ assert_nil Paperback.primary_key
+ end
+
+ def test_does_not_dump_view_as_table
+ schema = dump_table_schema "paperbacks"
+ assert_no_match %r{create_table "paperbacks"}, schema
+ end
+ end
+
+ # sqlite dose not support CREATE, INSERT, and DELETE for VIEW
+ if current_adapter?(:Mysql2Adapter, :SQLServerAdapter, :PostgreSQLAdapter)
+
+ class UpdateableViewTest < ActiveRecord::TestCase
+ self.use_transactional_tests = false
+ fixtures :books
+
+ class PrintedBook < ActiveRecord::Base
+ self.primary_key = "id"
+ end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.execute <<~SQL
+ CREATE VIEW printed_books
+ AS SELECT id, name, status, format FROM books WHERE format = 'paperback'
+ SQL
+ end
+
+ teardown do
+ @connection.execute "DROP VIEW printed_books" if @connection.view_exists? "printed_books"
+ end
+
+ def test_update_record
+ book = PrintedBook.first
+ book.name = "AWDwR"
+ book.save!
+ book.reload
+ assert_equal "AWDwR", book.name
+ end
+
+ def test_insert_record
+ PrintedBook.create! name: "Rails in Action", status: 0, format: "paperback"
+
+ new_book = PrintedBook.last
+ assert_equal "Rails in Action", new_book.name
+ end
+
+ def test_update_record_to_fail_view_conditions
+ book = PrintedBook.first
+ book.format = "ebook"
+ book.save!
+
+ assert_raises ActiveRecord::RecordNotFound do
+ book.reload
+ end
+ end
+ end
+ end # end of `if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter, :SQLServerAdapter)`
+end # end of `if ActiveRecord::Base.connection.supports_views?`
+
+if ActiveRecord::Base.connection.supports_materialized_views?
+ class MaterializedViewTest < ActiveRecord::PostgreSQLTestCase
+ include ViewBehavior
+
+ private
+ def create_view(name, query)
+ @connection.execute "CREATE MATERIALIZED VIEW #{quote_table_name(name)} AS #{query}"
+ end
+
+ def drop_view(name)
+ @connection.execute "DROP MATERIALIZED VIEW #{quote_table_name(name)}" if @connection.view_exists? name
+ end
+ end
+end
diff --git a/activerecord/test/cases/yaml_serialization_test.rb b/activerecord/test/cases/yaml_serialization_test.rb
new file mode 100644
index 0000000000..60ebdce178
--- /dev/null
+++ b/activerecord/test/cases/yaml_serialization_test.rb
@@ -0,0 +1,141 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "models/topic"
+require "models/reply"
+require "models/post"
+require "models/author"
+
+class YamlSerializationTest < ActiveRecord::TestCase
+ fixtures :topics, :authors, :author_addresses, :posts
+
+ def test_to_yaml_with_time_with_zone_should_not_raise_exception
+ with_timezone_config aware_attributes: true, zone: "Pacific Time (US & Canada)" do
+ topic = Topic.new(written_on: DateTime.now)
+ assert_nothing_raised { topic.to_yaml }
+ end
+ end
+
+ def test_roundtrip
+ topic = Topic.first
+ assert topic
+ t = YAML.load YAML.dump topic
+ assert_equal topic, t
+ end
+
+ def test_roundtrip_serialized_column
+ topic = Topic.new(content: { omg: :lol })
+ assert_equal({ omg: :lol }, YAML.load(YAML.dump(topic)).content)
+ end
+
+ def test_psych_roundtrip
+ topic = Topic.first
+ assert topic
+ t = Psych.load Psych.dump topic
+ assert_equal topic, t
+ end
+
+ def test_psych_roundtrip_new_object
+ topic = Topic.new
+ assert topic
+ t = Psych.load Psych.dump topic
+ assert_equal topic.attributes, t.attributes
+ end
+
+ def test_active_record_relation_serialization
+ [Topic.all].to_yaml
+ end
+
+ def test_raw_types_are_not_changed_on_round_trip
+ topic = Topic.new(parent_id: "123")
+ assert_equal "123", topic.parent_id_before_type_cast
+ assert_equal "123", YAML.load(YAML.dump(topic)).parent_id_before_type_cast
+ end
+
+ def test_cast_types_are_not_changed_on_round_trip
+ topic = Topic.new(parent_id: "123")
+ assert_equal 123, topic.parent_id
+ assert_equal 123, YAML.load(YAML.dump(topic)).parent_id
+ end
+
+ def test_new_records_remain_new_after_round_trip
+ topic = Topic.new
+
+ assert topic.new_record?, "Sanity check that new records are new"
+ assert YAML.load(YAML.dump(topic)).new_record?, "Record should be new after deserialization"
+
+ topic.save!
+
+ assert_not topic.new_record?, "Saved records are not new"
+ assert_not YAML.load(YAML.dump(topic)).new_record?, "Saved record should not be new after deserialization"
+
+ topic = Topic.select("title").last
+
+ assert_not topic.new_record?, "Loaded records without ID are not new"
+ assert_not YAML.load(YAML.dump(topic)).new_record?, "Record should not be new after deserialization"
+ end
+
+ def test_types_of_virtual_columns_are_not_changed_on_round_trip
+ author = Author.select("authors.*, count(posts.id) as posts_count")
+ .joins(:posts)
+ .group("authors.id")
+ .first
+ dumped = YAML.load(YAML.dump(author))
+
+ assert_equal 5, author.posts_count
+ assert_equal 5, dumped.posts_count
+ end
+
+ def test_a_yaml_version_is_provided_for_future_backwards_compat
+ coder = {}
+ Topic.first.encode_with(coder)
+
+ assert coder["active_record_yaml_version"]
+ end
+
+ def test_deserializing_rails_41_yaml
+ topic = YAML.load(yaml_fixture("rails_4_1"))
+
+ assert_predicate topic, :new_record?
+ assert_nil topic.id
+ assert_equal "The First Topic", topic.title
+ assert_equal({ omg: :lol }, topic.content)
+ end
+
+ def test_deserializing_rails_4_2_0_yaml
+ topic = YAML.load(yaml_fixture("rails_4_2_0"))
+
+ assert_not_predicate topic, :new_record?
+ assert_equal 1, topic.id
+ assert_equal "The First Topic", topic.title
+ assert_equal("Have a nice day", topic.content)
+ end
+
+ def test_yaml_encoding_keeps_mutations
+ author = Author.first
+ author.name = "Sean"
+ dumped = YAML.load(YAML.dump(author))
+
+ assert_equal "Sean", dumped.name
+ assert_equal author.name_was, dumped.name_was
+ assert_equal author.changes, dumped.changes
+ end
+
+ def test_yaml_encoding_keeps_false_values
+ topic = Topic.first
+ topic.approved = false
+ dumped = YAML.load(YAML.dump(topic))
+
+ assert_equal false, dumped.approved
+ end
+
+ private
+
+ def yaml_fixture(file_name)
+ path = File.expand_path(
+ "../support/yaml_compatibility_fixtures/#{file_name}.yml",
+ __dir__
+ )
+ File.read(path)
+ end
+end