Rails 7.2 compatibility: Add README.md note about custom audit table names #726
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Adds information in the
README.md
about changes that must be made to client code using a custom table name for audit data, which becomes necessary in Rails 7.2 but is harmless in earlier versions.This PR was intended to have code and tests, arising from Rails 7.2's new behaviour wherein (so long as
Audited::Audit
is not marked abstract) it tries to access anaudits
table. See rails/rails#52715. The rest of this PR description is only of interest for readers wondering why this can't be done automatically.Merging this would close #725 as unfixable.
Option 1:
def self.inherited
insideAudited::Audit
The Rails team suggested just having
Audited::Audit
self-mark itself as abstract upon inheritance. Notwithstanding arguments about it being unusual for the act of inheritance to silently mutate its superclass, the approach doesn't work because the Audited gem might want the base class to be concrete since the default behaviour uses the base class table name. The example given is:If we were to blindly mark
Audited::Audit
as abstract, all such client code (the majority use case) would fail because ActiveRecord will start looking for a table name ofcustom_audits
. We want it to keep using the base class's table name, as it would when using STI.This means we need to check the table name of the inheriting subclass, to find out if it differs from the superclass default. However, doing this inside
def self.inherited
breaks since the class definition is mid-way through; ActiveRecord explodes (quite reasonably). We need to wait until after the subclass is fully defined and then enquire. The solution to that - and a surprisingly clean-reading and high performance one at that, given what it's actually doing under the hood! - is a Tracepoint.It looks something like this.
The
rescue nil
is required because if you ask for the table name at this point when one is not being defined in the subclass, ActiveRecord again crashes out, but it's a benign failure. Nonetheless, while this does work there are obvious issues:Option 2: Custom writer for the
audit_class
configuration entryThis was a world of pain! On the surface it's very simple, but we go deep, deep down the rabbit hole due to our old nemesis, the autoloader. The core of the issue isn't a custom writer - it's the default reader. In the
Audited
module, we have this (in-method comments removed for brevity):This is a reasonable implementation. The custom class is usually configured in
config/initializers/audited.rb
, perREADME.md
, but the autoloader hasn't loaded models yet. As per the documented example, the class name is given as a string - trying to reference the class directly would provokeNameError
at this stage in initialization. The current gem implementation then, on reading, constantlysafe_constantize
's that. So long as the inheriting model has yet to be autoloaded, this will yieldnil
and the Audited gem will act as if there's no configured custom class. Any gem code at all that reads through theaudit_class
accessor "too early" will believe thatAudited::Audit
is the in-use audit model.Worse, in tests, it was often the case that even at the point where a Rails application's initializer is running and actually calling the write accessor for that config value, the
Audited
module was defined butAudited::Audit
was not. This led to the following merry dance inAudited
:...with that "if initialized" check being present to account for the potential case where someone might be setting the custom class later, for any reason - a belt & braces approach. The above calls a new class method in
Audited::Audit
, which I'd written in full including comments - before I realised this was a losing battle! - as:OK, so if
Audited
andAudited::Audit
are both loaded and parsed before someone sets the custom class, everything will work. But what if that's not the case, as happened at times in dev test? Well, we can also address this from the "other side" inAudited::Audit
by adding a line underneath the above method:...and that worked fine in our real-world case but not in a minimal test case (the application in the Rails issue); here, we get back to the problem that
AuditTrail
is not yet autoloaded and theAudited.audit_class
reader therefore returnsnil
at this instant in execution.We're tying ourselves in knots with increasingly convoluted code, all of which feels like a hack; adding more layers to try and work around every possible load order feels like madness; this isn't a good approach.
Option 3: Hack the subclass
Inside
Audited::Audit
we decide to forget any notion of abstract classes. Rails 7.2 is really, really keen on usingAudited::Audit
's table name - so let's set it!The code above runs at, essentially, the instant that the parser is running through
class CustomAudit < Audited::Audit
, but before the class body is being parsed. This means we overrideCustomAudit
'stable_name
writer before the class has a chance to set a custom table name, if it's going to. Should it do so, theAudited::Audit
base class table name is set to match. It feels fragile but works, however, it does make testing difficult because - again - we are essentially mutating global properties insideAudited::Audit
just because of some seemingly innocuous action inside a subclass where a dev would normally never expect such a side effect.Behaviour is now close to standard; things are running in sort-of-STI mode all the time, just like they were before. Really, this is the most likely-to-work approach except:
The least-surprise violation again; setting a property in a subclass mutates the superclass too. That's not clever.
It's also nasty redefining the
table_name=
method in the subclass because it might for whatever reason already have its own overriding implementation and we'd squash that.Conclusion
self
abstract indef self.inherited
- but that is incorrect.README.md
. Any client of Audited on Rails 7.2 or later using a custom table name will have to figure out what's wrong and fix it manually.