Skip to content

Commit 769d486

Browse files
authored
Merge pull request rails#55285 from rails/fxn/deprecated-associations
Deprecated associations
2 parents f5796fd + fbd6755 commit 769d486

40 files changed

+1715
-16
lines changed

activerecord/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
* Implement support for deprecating associations:
2+
3+
```ruby
4+
has_many :posts, deprecated: true
5+
```
6+
7+
With that, Active Record will report any usage of the `posts` association.
8+
9+
Three reporting modes are supported (`:warn`, `:raise`, and `:notify`), and
10+
backtraces can be enabled or disabled. Defaults are `:warn` mode and
11+
disabled backtraces.
12+
13+
Please, check the docs for further details.
14+
15+
*Xavier Noria*
16+
117
* PostgreSQL adapter create DB now supports `locale_provider` and `locale`.
218

319
*Bengt-Ove Hollaender*

activerecord/lib/active_record.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
require "active_support"
2727
require "active_support/rails"
2828
require "active_support/ordered_options"
29+
require "active_support/core_ext/array/conversions"
2930
require "active_model"
3031
require "arel"
3132
require "yaml"
@@ -470,6 +471,29 @@ def self.permanent_connection_checkout=(value)
470471
singleton_class.attr_accessor :generate_secure_token_on
471472
self.generate_secure_token_on = :create
472473

474+
def self.deprecated_associations_options=(options)
475+
raise ArgumentError, "deprecated_associations_options must be a hash" unless options.is_a?(Hash)
476+
477+
valid_keys = [:mode, :backtrace]
478+
479+
invalid_keys = options.keys - valid_keys
480+
unless invalid_keys.empty?
481+
inflected_key = invalid_keys.size == 1 ? "key" : "keys"
482+
raise ArgumentError, "invalid deprecated_associations_options #{inflected_key} #{invalid_keys.map(&:inspect).to_sentence} (valid keys are #{valid_keys.map(&:inspect).to_sentence})"
483+
end
484+
485+
options.each do |key, value|
486+
ActiveRecord::Associations::Deprecation.send("#{key}=", value)
487+
end
488+
end
489+
490+
def self.deprecated_associations_options
491+
{
492+
mode: ActiveRecord::Associations::Deprecation.mode,
493+
backtrace: ActiveRecord::Associations::Deprecation.backtrace
494+
}
495+
end
496+
473497
def self.marshalling_format_version
474498
Marshalling.format_version
475499
end

activerecord/lib/active_record/associations.rb

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ module Builder # :nodoc:
3939
autoload :AssociationScope
4040
autoload :DisableJoinsAssociationScope
4141
autoload :AliasTracker
42+
43+
autoload :Deprecation
4244
end
4345

4446
def self.eager_load!
@@ -87,6 +89,14 @@ def association_instance_set(name, association)
8789
@association_cache[name] = association
8890
end
8991

92+
def deprecated_associations_api_guard(association, method_name)
93+
Deprecation.guard(association.reflection) { "the method #{method_name} was invoked" }
94+
end
95+
96+
def report_deprecated_association(reflection, context:)
97+
Deprecation.report(reflection, context: context)
98+
end
99+
90100
# = Active Record \Associations
91101
#
92102
# \Associations are a set of macro-like class methods for tying objects together through
@@ -1020,6 +1030,116 @@ def association_instance_set(name, association)
10201030
# associated records themselves, you can always do something along the lines of
10211031
# <tt>person.tasks.each(&:destroy)</tt>.
10221032
#
1033+
# == Deprecated Associations
1034+
#
1035+
# Associations can be marked as deprecated by passing <tt>deprecated: true</tt>:
1036+
#
1037+
# has_many :posts, deprecated: true
1038+
#
1039+
# When a deprecated association is used, a warning is issued using the
1040+
# Active Record logger, though more options are available via
1041+
# configuration.
1042+
#
1043+
# The message includes some context that helps understand the deprecated
1044+
# usage:
1045+
#
1046+
# The association Author#posts is deprecated, the method post_ids was invoked (...)
1047+
# The association Author#posts is deprecated, referenced in query to preload records (...)
1048+
#
1049+
# The dots in the examples above would have the application-level spot
1050+
# where usage occurred, to help locate what triggered the warning. That
1051+
# location is computed using the Active Record backtrace cleaner.
1052+
#
1053+
# === What is considered to be usage?
1054+
#
1055+
# * Invocation of any association methods like +posts+, <tt>posts=</tt>,
1056+
# etc.
1057+
#
1058+
# * If the association accepts nested attributes, assignment to those
1059+
# attributes.
1060+
#
1061+
# * If the association is a through association and some of its nested
1062+
# associations are deprecated, you'll get warnings for them whenever the
1063+
# top-level through is used. This is so regardless of whether the
1064+
# through itself is deprecated.
1065+
#
1066+
# * Execution of queries that refer to the association. Think execution of
1067+
# <tt>eager_load(:posts)</tt>, <tt>joins(author: :posts)</tt>, etc.
1068+
#
1069+
# * If the association has a +:dependent+ option, destroying the
1070+
# associated record issues warnings (because that has a side-effect that
1071+
# would not happen if the association was removed).
1072+
#
1073+
# * If the association has a +:touch+ option, saving or destroying the
1074+
# record issues a warning (because that has a side-effect that would not
1075+
# happen if the association was removed).
1076+
#
1077+
# === Things that do NOT issue warnings
1078+
#
1079+
# The rationale behind most of the following edge cases is that Active
1080+
# Record accesses associations lazily, when used. Before that, the
1081+
# reference to the association is basically just a Ruby symbol.
1082+
#
1083+
# * If +posts+ is deprecated, <tt>has_many :comments, through: :posts</tt>
1084+
# does not warn. Usage of the +comments+ association reports usage of
1085+
# +posts+, as we explained above, but the definition of the +has_many+
1086+
# itself does not.
1087+
#
1088+
# * Similarly, <tt>accepts_nested_attributes_for :posts</tt> does not
1089+
# warn. Assignment to the posts attributes warns, as explained above,
1090+
# but the +accepts_nested_attributes_for+ call itself does not.
1091+
#
1092+
# * Same if an association declares to be inverse of a deprecated one, the
1093+
# macro itself does not warn.
1094+
#
1095+
# * In the same line, the declaration <tt>validates_associated :posts</tt>
1096+
# does not warn by itself, though access is reported when the validation
1097+
# runs.
1098+
#
1099+
# * Relation query methods like <tt>Author.includes(:posts)</tt> do not
1100+
# warn by themselves. At that point, that is a relation that internally
1101+
# stores a symbol for later use. As explained in the previous section,
1102+
# you get a warning when/if the query is executed.
1103+
#
1104+
# * Access to the reflection object of the association as in
1105+
# <tt>Author.reflect_on_association(:posts)</tt> or
1106+
# <tt>Author.reflect_on_all_associations</tt> does not warn.
1107+
#
1108+
# === Configuration
1109+
#
1110+
# Reporting deprecated usage can be configured:
1111+
#
1112+
# config.active_record.deprecated_associations_options = { ... }
1113+
#
1114+
# If present, this has to be a hash with keys +:mode+ and/or +:backtrace+.
1115+
#
1116+
# ==== Mode
1117+
#
1118+
# * In +:warn+ mode, usage issues a warning that includes the
1119+
# application-level place where the access happened, if any. This is the
1120+
# default mode.
1121+
#
1122+
# * In +:raise+ mode, usage raises an
1123+
# ActiveRecord::DeprecatedAssociationError with a similar message and a
1124+
# clean backtrace in the exception object.
1125+
#
1126+
# * In +:notify+ mode, a <tt>deprecated_association.active_record</tt>
1127+
# Active Support notification is published. The event payload has the
1128+
# association reflection (+:reflection+), the application-level location
1129+
# (+:location+) where the access happened (a Thread::Backtrace::Location
1130+
# object, or +nil+), and a deprecation message (+:message+).
1131+
#
1132+
# ==== Backtrace
1133+
#
1134+
# If :backtrace is true, warnings include a clean backtrace in the message
1135+
# and notifications have a +:backtrace+ key in the payload with an array
1136+
# of clean Thread::Backtrace::Location objects. Exceptions always get a
1137+
# clean stack trace set.
1138+
#
1139+
# Clean backtraces are computed using the Active Record backtrace cleaner.
1140+
# In Rails applications, that is by the default the same as
1141+
# <tt>Rails.backtrace_cleaner</tt>.
1142+
#
10231143
# == Type safety with ActiveRecord::AssociationTypeMismatch
10241144
#
10251145
# If you attempt to assign an object to an association that doesn't match the inferred
@@ -1287,6 +1407,9 @@ module ClassMethods
12871407
# Defines an {association callback}[rdoc-ref:Associations::ClassMethods@Association+callbacks] that gets triggered <b>before an object is removed</b> from the association collection.
12881408
# [:after_remove]
12891409
# Defines an {association callback}[rdoc-ref:Associations::ClassMethods@Association+callbacks] that gets triggered <b>after an object is removed</b> from the association collection.
1410+
# [+:deprecated+]
1411+
# If true, marks the association as deprecated. Usage of deprecated associations is reported.
1412+
# Please, check the class documentation above for details.
12901413
#
12911414
# Option examples:
12921415
# has_many :comments, -> { order("posted_on") }
@@ -1484,6 +1607,9 @@ def has_many(name, scope = nil, **options, &extension)
14841607
# Serves as a composite foreign key. Defines the list of columns to be used to query the associated object.
14851608
# This is an optional option. By default Rails will attempt to derive the value automatically.
14861609
# When the value is set the Array size must match associated model's primary key or +query_constraints+ size.
1610+
# [+:deprecated+]
1611+
# If true, marks the association as deprecated. Usage of deprecated associations is reported.
1612+
# Please, check the class documentation above for details.
14871613
#
14881614
# Option examples:
14891615
# has_one :credit_card, dependent: :destroy # destroys the associated credit card
@@ -1676,6 +1802,9 @@ def has_one(name, scope = nil, **options)
16761802
# Serves as a composite foreign key. Defines the list of columns to be used to query the associated object.
16771803
# This is an optional option. By default Rails will attempt to derive the value automatically.
16781804
# When the value is set the Array size must match associated model's primary key or +query_constraints+ size.
1805+
# [+:deprecated+]
1806+
# If true, marks the association as deprecated. Usage of deprecated associations is reported.
1807+
# Please, check the class documentation above for details.
16791808
#
16801809
# Option examples:
16811810
# belongs_to :firm, foreign_key: "client_of"
@@ -1865,6 +1994,9 @@ def belongs_to(name, scope = nil, **options)
18651994
# <tt>:autosave</tt> to <tt>true</tt>.
18661995
# [+:strict_loading+]
18671996
# Enforces strict loading every time an associated record is loaded through this association.
1997+
# [+:deprecated+]
1998+
# If true, marks the association as deprecated. Usage of deprecated associations is reported.
1999+
# Please, check the class documentation above for details.
18682000
#
18692001
# Option examples:
18702002
# has_and_belongs_to_many :projects

activerecord/lib/active_record/associations/builder/association.rb

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class << self
1919
self.extensions = []
2020

2121
VALID_OPTIONS = [
22-
:anonymous_class, :primary_key, :foreign_key, :dependent, :validate, :inverse_of, :strict_loading, :query_constraints
22+
:anonymous_class, :primary_key, :foreign_key, :dependent, :validate, :inverse_of, :strict_loading, :query_constraints, :deprecated
2323
].freeze # :nodoc:
2424

2525
def self.build(model, name, scope, options, &block)
@@ -102,15 +102,19 @@ def self.define_accessors(model, reflection)
102102
def self.define_readers(mixin, name)
103103
mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
104104
def #{name}
105-
association(:#{name}).reader
105+
association = association(:#{name})
106+
deprecated_associations_api_guard(association, __method__)
107+
association.reader
106108
end
107109
CODE
108110
end
109111

110112
def self.define_writers(mixin, name)
111113
mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
112114
def #{name}=(value)
113-
association(:#{name}).writer(value)
115+
association = association(:#{name})
116+
deprecated_associations_api_guard(association, __method__)
117+
association.writer(value)
114118
end
115119
CODE
116120
end
@@ -138,8 +142,15 @@ def self.check_dependent_options(dependent, model)
138142
end
139143

140144
def self.add_destroy_callbacks(model, reflection)
141-
name = reflection.name
142-
model.before_destroy(->(o) { o.association(name).handle_dependency })
145+
if reflection.deprecated?
146+
# If :dependent is set, destroying the record has a side effect that
147+
# would no longer happen if the association is removed.
148+
model.before_destroy do
149+
report_deprecated_association(reflection, context: ":dependent has a side effect here")
150+
end
151+
end
152+
153+
model.before_destroy(->(o) { o.association(reflection.name).handle_dependency })
143154
end
144155

145156
def self.add_after_commit_jobs_callback(model, dependent)

activerecord/lib/active_record/associations/builder/belongs_to.rb

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@ def self.add_default_callbacks(model, reflection)
108108
end
109109

110110
def self.add_destroy_callbacks(model, reflection)
111+
if reflection.deprecated?
112+
# If :dependent is set, destroying the record has some side effect that
113+
# would no longer happen if the association is removed.
114+
model.before_destroy do
115+
report_deprecated_association(reflection, context: ":dependent has a side effect here")
116+
end
117+
end
118+
111119
model.after_destroy lambda { |o| o.association(reflection.name).handle_dependency }
112120
end
113121

@@ -145,11 +153,15 @@ def self.define_validations(model, reflection)
145153
def self.define_change_tracking_methods(model, reflection)
146154
model.generated_association_methods.class_eval <<-CODE, __FILE__, __LINE__ + 1
147155
def #{reflection.name}_changed?
148-
association(:#{reflection.name}).target_changed?
156+
association = association(:#{reflection.name})
157+
deprecated_associations_api_guard(association, __method__)
158+
association.target_changed?
149159
end
150160
151161
def #{reflection.name}_previously_changed?
152-
association(:#{reflection.name}).target_previously_changed?
162+
association = association(:#{reflection.name})
163+
deprecated_associations_api_guard(association, __method__)
164+
association.target_previously_changed?
153165
end
154166
CODE
155167
end

activerecord/lib/active_record/associations/builder/collection_association.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ def self.define_readers(mixin, name)
6060

6161
mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
6262
def #{name.to_s.singularize}_ids
63-
association(:#{name}).ids_reader
63+
association = association(:#{name})
64+
deprecated_associations_api_guard(association, __method__)
65+
association.ids_reader
6466
end
6567
CODE
6668
end
@@ -70,7 +72,9 @@ def self.define_writers(mixin, name)
7072

7173
mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
7274
def #{name.to_s.singularize}_ids=(ids)
73-
association(:#{name}).ids_writer(ids)
75+
association = association(:#{name})
76+
deprecated_associations_api_guard(association, __method__)
77+
association.ids_writer(ids)
7478
end
7579
CODE
7680
end

0 commit comments

Comments
 (0)