Search every interface when checking whether a class node implements one - #16401
Conversation
GrailsASTUtils.isSubclassOfOrImplementsInterface and the datastore's AstUtils.findInterface walk a class's interfaces, and for the first one with super-interfaces of its own they returned whatever the search of those super-interfaces found, without looking at the interfaces after it. A class declaring "implements Unrelated, Target", where Unrelated extends anything, was reported as not implementing Target; so was one reaching Target through an interface that extends two, the first with its own super-interfaces. The callers decide real things: FactoriesFileWriter which classes are listed in grails.factories as ArtefactHandler or TraitInjector implementations, BootInitializerClassInjector whether a class is a GrailsPluginApplication, and GormEntityTransformation whether a domain class is an RxEntity and so gets no injected id and version. Each would have missed a class in that position. Nothing in this repository is: every generated grails.factories is byte-identical before and after.
jdaugherty
left a comment
There was a problem hiding this comment.
Verified locally on 7a81bae: the three bug cases in GrailsASTUtilsSpec and the findInterface case in AstUtilsSpec fail against the unfixed sources and pass with the fix. The grails-core, grails-datastore-core and grails-web-boot tests and codeStyle are green.
@matrei the same early return is on 7.0.x, in both GrailsASTUtils.implementsInterfaceInternal and the datastore AstUtils.implementsInterfaceInternal. Should we backport this?
| ClassNode[] childInterfaces = anInterface.getInterfaces(); | ||
| if (childInterfaces != null && childInterfaces.length > 0) { | ||
| return implementsInterfaceInternal(childInterfaces, interfaceName); | ||
| if (childInterfaces != null && childInterfaces.length > 0 && | ||
| implementsInterfaceInternal(childInterfaces, interfaceName)) { | ||
| return true; | ||
| } |
There was a problem hiding this comment.
Optional: Groovy's ClassNode#implementsInterface(ClassNode) already does this traversal. It walks the superclass chain, declaresInterface searches every super-interface, and ClassNode equality is by name. So implementsInterface above could be
private static boolean implementsInterface(ClassNode classNode, String interfaceName) {
return classNode.implementsInterface(ClassHelper.make(interfaceName));
}and implementsInterfaceInternal removed. #16399 uses the same API for its check.
There was a problem hiding this comment.
Done in 398c123: implementsInterface now returns classNode.implementsInterface(ClassHelper.make(interfaceName)), and implementsInterfaceInternal is gone. I checked it against Groovy 5.1.0's ClassNode (superclass walk, declaresInterface over every super-interface, equality by name). The GrailsASTUtilsSpec cases pass, and with the dependent modules' compileGroovy forced to re-run, every generated grails.factories is still byte-identical to 8.0.x.
| // Every interface is searched. Returning the first one's super-interfaces' answer missed | ||
| // an interface listed after one that has super-interfaces of its own. | ||
| ClassNode[] childInterfaces = anInterface.getInterfaces() | ||
| if (childInterfaces != null && childInterfaces.length > 0) { | ||
| return implementsInterfaceInternal(childInterfaces, interfaceName) | ||
| ClassNode found = implementsInterfaceInternal(childInterfaces, interfaceName) | ||
| if (found != null) { | ||
| return found | ||
| } | ||
| } |
There was a problem hiding this comment.
Nit: this comment describes the old bug rather than the code, so it fits better in the commit message (same for the one in GrailsASTUtils if that helper stays). The length > 0 check is also redundant now, since recursing on an empty array returns null:
| // Every interface is searched. Returning the first one's super-interfaces' answer missed | |
| // an interface listed after one that has super-interfaces of its own. | |
| ClassNode[] childInterfaces = anInterface.getInterfaces() | |
| if (childInterfaces != null && childInterfaces.length > 0) { | |
| return implementsInterfaceInternal(childInterfaces, interfaceName) | |
| ClassNode found = implementsInterfaceInternal(childInterfaces, interfaceName) | |
| if (found != null) { | |
| return found | |
| } | |
| } | |
| ClassNode[] childInterfaces = anInterface.getInterfaces() | |
| if (childInterfaces != null) { | |
| ClassNode found = implementsInterfaceInternal(childInterfaces, interfaceName) | |
| if (found != null) { | |
| return found | |
| } | |
| } |
There was a problem hiding this comment.
Applied in 398c123: the comment moved to the commit message and the length check is dropped. findInterface keeps its own walk, since it returns the interface node it finds, which ClassNode#implementsInterface doesn't give back.
GrailsASTUtils.implementsInterface now asks Groovy's ClassNode implementsInterface, which walks the superclass chain and searches every super-interface, so the hand-written walk is gone. In the datastore's AstUtils, which returns the interface node it finds, the length check is redundant (recursing on an empty array finds nothing) and the comment described the old bug rather than the code.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #16401 +/- ##
==================================================
+ Coverage 57.5998% 57.6117% +0.0118%
+ Complexity 22697 22693 -4
==================================================
Files 2133 2133
Lines 104226 104215 -11
Branches 18692 18689 -3
==================================================
+ Hits 60034 60040 +6
+ Misses 35906 35890 -16
+ Partials 8286 8285 -1
🚀 New features to boost your workflow:
|
✅ All tests passed ✅🏷️ Commit: 398c123 Learn more about TestLens at testlens.app/docs. |
matrei
left a comment
There was a problem hiding this comment.
Review of #16401: Search every interface when checking whether a class node implements one
Head: 398c1230d3 (2 commits) · Base: 8.0.x · merges cleanly, CI green.
What I ran (on the head): :grails-core:test --tests GrailsASTUtilsSpec (14 tests, 0 failures) and :grails-datastore-core:test --tests AstUtilsSpec (6 tests, 0 failures).
The fix looks right to me. Delegating GrailsASTUtils.implementsInterface to ClassNode#implementsInterface is the better end state. In Groovy 5.1.3 it walks the superclass chain down to null, and declaresInterface checks every direct interface before recursing into each one. ClassNode#equals compares by name, so the node from ClassHelper.make(interfaceName) matches exactly what the old name comparison matched. Keeping the hand-written walk in AstUtils.findInterface makes sense, because it has to return the node it found, and the new loop now falls through correctly when a branch finds nothing.
I also looked for other hand-rolled interface walkers with the same early return. AstUtils.findAbstractMethodsInternal, GrailsBeansASTTransformation.collectMethodNames/findStaticFinalField and the CLI's hasAtLeastOneInterface all either recurse into every interface or intentionally check only direct ones. So these two helpers are the only ones affected.
A few small things, none blocking:
1. 7.0.x has the same bug
Both implementsInterfaceInternal helpers on 7.0.x still return the result of the first interface with super-interfaces (GrailsASTUtils.java:753 and AstUtils.groovy:798). Since this is a bug fix, should it target 7.0.x and be merged forward? If not, it could be backported there. The change applies as-is, because ClassNode#implementsInterface and declaresInterface are the same in Groovy 4.0.x.
2. PR description
- "What the callers decide" leaves out
GlobalGrailsClassInjectorTransformation:163, which callsisSubclassOfOrImplementsInterface(classNode, 'grails.boot.config.GrailsAutoConfiguration'). That is a class, so theisSubclassOfhalf decides it and the interface walk doesn't change anything. It's still worth listing for completeness. - The snippet and "Both now search every interface" describe the first commit. After
398c1230d3, theGrailsASTUtilshalf defers to Groovy instead. It would help to reword this before the squash, so the merged commit message matches the code.
3. Test nits
- In
GrailsASTUtilsSpec, theTARGET/BASE/WITH_SUPER/MIXEDconstants sit between two feature methods. The spec reads more easily with them at the top of the class, next to any other fields. - The
'the same, reached through a superclass'row pushes the||column out of line with the other rows.
Approving; the 7.0.x question can be settled separately.
GrailsASTUtils.isSubclassOfOrImplementsInterfaceand the datastore'sAstUtils.findInterfacewalk a class node's interfaces. For the first interface that has super-interfaces of its own, they returned whatever the search of those super-interfaces found, and never looked at the interfaces after it:So a class declaring
implements Unrelated, Target, whereUnrelatedextends anything, was reported as not implementingTarget. The same happened to one reachingTargetthrough an interface that extends two, when the first of those has super-interfaces of its own. Both now search every interface.This came up in #16399. The unit-test detection there needed a check like this and uses Groovy's own
ClassNode#implementsInterfaceinstead, so this fix is separate.What the callers decide
FactoriesFileWriter: which classes are listed ingrails.factoriesasArtefactHandlerorTraitInjectorimplementations.BootInitializerClassInjector: whether a class is aGrailsPluginApplication, which gets no generated boot initializer.GormEntityTransformation(throughAstUtils.findInterface): whether a domain class is anRxEntity, which gets no injectedidandversion.Each would have missed a class in that position. Nothing in this repository is in it. I compiled every module that implements
ArtefactHandlerorTraitInjectorbefore and after the change, and all 16 generatedgrails.factoriesfiles are byte-identical.Validation
GrailsASTUtilsSpec(the target after an interface with its own super-interfaces, the same one level deeper, the same reached through a superclass, plus direct and negative cases) and inAstUtilsSpec. Without the fix, the three bug cases and thefindInterfacecase fail, and the direct and negative cases pass.grails.factoriescomparison::classesfor grails-codecs, -controllers, -core, -datamapping-support, -domain-class, -taglib, -web-taglib, -interceptors, -quartz, -rest-transforms, -web-core, -web-databinding and -web-boot, before and after. 24 compile tasks re-ran against the fixed transform, and all 16 main-sourcegrails.factoriesfiles are byte-identical.