A code-to-column change-impact knowledge graph for AI coding agents.
The Java lane lifts the answer from the SQL statement level up to the HTTP
endpoint level: it parses a Spring/MyBatis Java source tree and stitches
endpoint → controller → service → mapper onto the SQL lane’s
mapper → statement → table → column. A column-impact query then reaches the
endpoints that touch the column.
com.sun.source) in parse-only mode — it never runs a
build, never resolves your project’s dependencies, and needs no Gradle/Maven.
It parses your .java files with Spring/MyBatis absent and resolves app-local
types itself. No JDK jars beyond the standard jdk.compiler module.pom.xml/build.gradle resolution.The CLI looks for javac/java in this order:
$JAVA_HOME/bin/opt/homebrew/opt/openjdk/bin, /usr/local/opt/openjdk/bin (Homebrew keg)javac/java on PATHInstall one if you have none:
# macOS
brew install openjdk
export JAVA_HOME=/opt/homebrew/opt/openjdk # or add its bin to PATH
# Debian/Ubuntu
sudo apt-get install default-jdk
# Windows
winget install EclipseAdoptium.Temurin.21.JDK
Add --java-src <dir> (repeatable) to cascade analyze. Point it at your
backend source roots:
node bin/cascade.mjs analyze \
--ddl ../target-examples/mall/document/sql/mall.sql \
--mappers ../target-examples/mall/mall-mbg/src/main/resources \
--mappers ../target-examples/mall/mall-admin/src/main/resources/dao \
--java-src ../target-examples/mall/mall-admin/src/main/java \
--java-src ../target-examples/mall/mall-mbg/src/main/java \
--root ../target-examples/mall --out .cascade/pack --project mall
The first run compiles the worker (adapters/java/JavaFacts.java) into
.java-build/ (gitignored) and reuses it thereafter. The pack’s meta.lanes
records ["sql","java"] when the Java lane ran.
Then ask, over MCP:
endpoint_impact { "column": "pms_product.price" }
→ POST /product/create, POST /product/update/{id}, … [SOUND_SET]
| Relation | Grade | Why |
|---|---|---|
endpoint → handler (HANDLES) |
EXACT | a Spring mapping annotation on a concrete controller method is its handler — definitional. So is a path written with a constant the tree states, and a mapping annotation the project declares itself (below) |
endpoint → handler (HANDLES), handlers a request condition splits |
SOUND_SET | two or more handlers declare one route and one of them narrows it by params, headers, consumes or produces: Spring picks one per request, by what the request carries |
endpoint → handler (HANDLES), a method that is no HTTP method |
HEURISTIC | the mapping’s method names something that is not a verb (a constant this lane does not read), so the route is placed on ANY, which may be wider than the truth |
endpoint → implementer (HANDLES) |
SOUND_SET | a mapping on an interface/abstract declaration is a route contract; the handler is the implementer matched through implements by name (and arity) — a resolution, not a definition |
endpoint → handler (HANDLES), a functional route |
EXACT, SOUND_SET or HEURISTIC | a route built with calls in a method that returns a RouterFunction: EXACT where the source names the class and the method and nothing overrides it, SOUND_SET through a declared type whose every member the tree shows, HEURISTIC through a type the tree cannot close, and where only an OpenAPI document places the route and the profile does not say the document is generated from this code (below) |
endpoint → handler (HANDLES), a contract-first controller |
HEURISTIC, or EXACT when declared | a controller implements an interface a code generator writes at build time; only the generator’s naming pairs the method with the document’s route, unless the profile says the build generates the interfaces from that document (openapi.generatesCode) (below) |
clientMethod → endpoint (CALLS_HTTP) |
SOUND_SET | an HTTP client call reaches a route this pack also serves — the internal HTTP hop. Two ways of writing one: a @FeignClient/@HttpExchange method, and an imperative WebClient/RestClient/RestTemplate call |
clientMethod → endpoint (CALLS_HTTP) |
UNRESOLVED | …or one it does not serve: the target is outside the pack, so no walk follows the edge and it is counted instead (httpCallsUnresolved) |
mapperMethod → statement (IMPLEMENTS_STMT) |
EXACT | a MyBatis statement id is the mapper interface FQN + method — definitional |
daoMethod → statement (IMPLEMENTS_STMT) |
EXACT | …or the DAO names the statement outright in the call, and that literal is the key MyBatis looks it up by — definitional the same way (below) |
caller → callee, interface → impl (MAY_CALL) |
SOUND_SET | calls are resolved from the parse tree (receiver → field → declared type, method-by-name) and interface dispatch is a class-hierarchy over-approximation — a sound candidate set, not compiler-verified |
repositoryMethod → statement (IMPLEMENTS_STMT) |
EXACT | a Spring Data repository method is the statement Spring Data generates for it — definitional, same rule as MyBatis |
So an endpoint reached through the call graph carries the weakest link on its path — SOUND_SET — and is reported as a candidate, never as confirmed. This is deliberate: parse-only resolution without dependency binding cannot honestly claim EXACT for a call, and narrowing a candidate set to one member would not make it one.
Every resolved call shape, all by NAME, all graded SOUND_SET:
| written as | evidence.rule |
resolved to |
|---|---|---|
repo.save(x) |
field-receiver |
the declared type of the field repo |
this.repo.save(x) |
this-field |
the same field — the this. spelling is not a different call |
helper(x) (unqualified) |
unqualified-enclosing |
a method of the enclosing type (or one it inherits) |
this.helper(x) |
unqualified-enclosing |
the same thing — one call, one rule |
super.exportXls(…) |
super-enclosing |
the first ancestor up the extends chain that declares the method |
service.list(…) where service is typed by a type parameter |
type-param-binding |
the binding that subclass makes (class C extends B<A, IAService>), one edge from the subclass’s own copy of the method, with evidence.boundThrough naming where the binding was spelled and evidence.inheritedFrom naming the shared body the call site is in |
| an interface method | interface-dispatch |
every implementor (a class-hierarchy over-approximation) |
cmmUseService.selectCodes(…) where the field carries @Resource(name = "EgovCmmUseService") |
spring-bean-name |
the one class in this pack that answers to that bean name, when exactly one does (below) |
log.info(…) in a @Slf4j class |
generated-field |
the logger type that Lombok’s annotation generates (org.slf4j.Logger …). The field is real at run time and in no parse tree, so nothing but the annotation can explain the receiver |
ringData.computeIfAbsent(…) where the field’s type came in through import java.util.* |
wildcard-jdk |
<that package>.<Simple>. The JDK is a closed world this lane never reads, so the call leaves the project |
A simple name is resolved the way the language resolves it, in this order:
java.lang, which every compilation unit imports whether it says so or
not (String, System, Integer, Math, Thread …);Step 5 sits where it does because that is the language’s precedence: a project
class called Process in another package is not what Process.x() means in a
file that never imported it.
When a name is left over and the file carries wildcard imports, which one it
came from is decided by the project’s own import lines: somewhere in a tree
this size another file writes import org.jeecg.common.util.RedisUtil;, and
that line proves which package holds that type. Only when nothing witnesses the
name does the category decide (a package of this project first, then the JDK).
Three of the rules can fail in their own way, and the failure is named for what failed rather than folded into the field rule:
unresolvedCallsByRule key |
means |
|---|---|
super-enclosing |
super.m() whose base class this lane neither parsed nor could name (measured on jeecg-boot: 3 of 73 super. calls; 40 more name a base the imports do name, and those become an edge that leaves the project) |
type-param-unbound |
no subclass in this pack binds that type parameter, so the call site has no callee here |
inherited-field |
a receiver no field, no type and no annotation explains |
…and beside the rule that failed, unresolvedCallsByReason says what was
missing, which is the half a reader can act on:
unresolvedCallsByReason key |
means |
|---|---|
project-type-outside-roots |
a wildcard import names a package of this project and no analyzed source root holds the type. Pass --java-src <module>/src/main/java and the calls resolve — or the module is a dependency, and the chain really does end there. laneStats.typesOutsideRoots names the packages |
superclass-outside-roots |
the extends chain ended at a name this lane could not resolve at all |
type-param-unbound |
as above |
unknown |
a third-party type behind a wildcard, a constant somebody static-imported, a field a code generator adds after parsing |
type-param-binding is an over-approximation of one call site, not a guess,
and it is resolved once per subclass. A generic base writes
service.list(…) once and thirty controllers run it, so the answer is thirty
separate edges, each leaving the controller that runs that body and naming the
one service that controller binds. Pooling them on the base method instead is
the same set of targets attached to the wrong symbol, and it reads as every
controller touching every sibling’s tables: on jeecg-boot that was 9748 of
25376 endpoint-to-column pairs.
Which subclass carries the edge is read from the code, never assumed:
inherited-member-call) and the copy carries the call;super.exportXls(…), which is also what says WHICH base method runs —
jeecg-boot’s JeecgDemoController#exportXls calls super.exportXlsSheet(…),
a different method with a different body;super replaced the body, so the ancestor’s
call site is not in it and no edge is made.Every MAY_CALL edge carries its rule in evidence.rule, with a one-sentence
evidence.basis saying what that rule actually did. They share one grade
(SOUND_SET — none is compiler-verified) but they do not share one failure mode,
and a reader deciding how far to trust a chain is entitled to know which one it
rested on.
The unqualified rule is what keeps a chain alive through a controller’s private
helper: processFindForm → findPaginatedForOwnersLastName → owners.find….
A name brought in by an import static is not treated as a call on the
enclosing type — it is skipped rather than mis-attributed.
How often does the unqualified rule misattribute? It resolves to the
enclosing type, so a method that type INHERITS is attributed to the subclass
rather than to the class that declares it. Measured over every one of the 8753
unqualified call sites in macrozheng/mall (8752 bare + the one written
this.loadDataSource(), whose method its own class declares), by checking
whether the method is declared in the enclosing type’s own file:
153 misattributed, 1.7% — 152 of
them getClass()/hashCode() inherited from java.lang.Object, and one real
case (CommentGenerator#addFieldJavaDoc → addJavadocTag(), inherited from
MyBatis-generator’s DefaultCommentGenerator). In hand-written code — the 141
sites outside generated …Example/.model. classes — the rate is 1 of 141.
MyBatis has two shapes and the lane reads both. The one the documentation leads with is a mapper interface, where the statement id is the interface’s FQN plus the method name, so the binding is definitional. The other has no interface at all:
@Repository("CmmnCodeManageDAO")
public class CmmnCodeManageDAO extends EgovAbstractMapper {
public List<?> selectCmmnCodeList(ComDefaultVO vo) {
return selectList("CmmnCodeManageDAO.selectCmmnCodeList", vo);
}
}
That literal is the key MyBatis looks the statement up by at run time, and
the mapper XML declares the same key as its namespace plus the statement’s
id. There is nothing between the two to resolve, so the edge is EXACT, with
evidence.rule mybatis-statement-id. An id that already starts with the
namespace is not prefixed again, which is what MyBatis itself does
(MapperBuilderAssistant.applyCurrentNamespace): <mapper namespace="loginDAO">
with <select id="loginDAO.actionLogin"> is the statement loginDAO.actionLogin.
Measured, 112 of the 1,435 statements of the eGovFrame enterprise business
template and 43 of the common components are written that way, and until this was
read their DAO calls bound nothing.
This is the whole persistence layer of eGovFrame, which is the framework Korean
public sector projects are required to build on: 1,288 call sites in
eGovFramework/egovframe-common-components and not one @Mapper interface.
Before the rule existed, that repository connected 1,193 routes to 1,256
statements with zero edges between them.
What the rule reads, and what it refuses:
extends chain is walked by
simple name for SqlSession, SqlSessionTemplate, SqlSessionDaoSupport,
EgovAbstractMapper or EgovComAbstractDAO — by name, because that base
almost always ships in a jar this run never parsed, and the extends clause in
the source is the whole evidence. A field of one of those types answers the
same question for a DAO that holds a session rather than extending one;selectList, selectOne, selectMap,
select, selectCursor, list, selectByPk, insert, update, delete.
The name alone decides nothing: a service’s own insert(vo) has it too, and
the receiver test is what keeps the rule off it;static final String
of the same class initialised with one, shaped like <namespace>.<id>.
Measured: 1,289 of 1,298 sites in the common components are plain literals;laneStats.statementIds.unknownSamples. A mapper XML left
outside the run and a typo look the same from here, and only a reader can tell
them apart;unreadable, with the expression as it was written.laneStats.statementIds carries the whole census: sites, bound, unknown,
unreadable, repeated, fromConstant.
A constant of another class (CmmnConstants.SELECT_CODES) is one of the
unreadable, on purpose: nothing in the corpus writes one, and a resolver with
no repository behind it is a rule nobody has checked.
For the same reason a session fetched and used in one expression
(getSqlSession().selectList("X.y", vo)) is not read: the shape has zero
sites in all seventeen corpus repositories. The two getSqlSession() calls
that are there both fetch a JDBC connection to read table metadata, which names
no statement at all.
The shape above is what a project written on MyBatis 3 looks like. A project written before it looks like this:
@Repository("userDAO")
public class UserDAO extends NexacroIbatisAbstractDAO {
public List<UserVO> selectUserVoList(UserVO searchVO) {
return (List<UserVO>) list("selectUserVOList", searchVO);
}
}
Two things are different, and the rule reads both.
The receivers. SqlMapClient, SqlMapClientTemplate,
SqlMapClientDaoSupport and EgovAbstractDAO join the session list above, and
so does any class whose extends chain names a type ending in
IbatisAbstractDAO. That suffix is there because a framework vendor writes its
own base on top of one of those and ships it in a jar, so the tree says
extends NexacroIbatisAbstractDAO and nothing more; naming the vendor instead
would be a rule that works on one product. queryForList, queryForObject and
queryForMap join the method list.
The id. iBATIS defaults useStatementNamespaces to false, so the runtime
key is a bare word with no namespace on it. A bare word is a weaker witness
than Namespace.id — it could be anything — so the worker records it under its
own field and the bridge binds it only when the pack holds exactly one statement
under that id, which is what the SQL lane produces when the configuration leaves
namespaces off (docs/setup/sql-lane.md). A bare word never finds a namespaced
statement by looking like the end of it: selectCodeList does not bind
codeDAO.selectCodeList. A word that answers to no statement changes nothing at
all and stays in the unreadable census with the expression as written.
@Resource(name = "EgovCmmUseService")
private EgovCmmUseService cmmUseService;
The declared type is the interface, so the dispatch rule alone reaches every
implementor. The annotation says which object the container really puts there,
and when exactly one class in this pack answers to that bean name the call site
goes straight to it: evidence.rule spring-bean-name, candidateCount: 1,
the bean name and the interface on the evidence.
A class answers to a bean name in one of two ways, and the evidence says which
(nameFrom):
declared — its own stereotype annotation names it: @Service("x"),
@Repository("x"), @Component("x");default-name — it carries a stereotype with no value, and its decapitalised
simple name is that name, which is the name Spring gives it.The grade does not move. It stays SOUND_SET, because this reads the name a
field asks for and the names classes declare, without the container that would
settle a @Primary, a profile, or a bean an XML or a @Bean method declares.
Narrowing a candidate set to one member does not make it a proof.
Two things the rule declines, both counted in laneStats.beanNames:
unknownName), which is usually
a bean an XML declares. Measured on
eGovFramework/egovframe-enterprise-business-template: 87 of 145 named fields
match a @Service("…") outright and 16 more by the default name, and every
one of the rest is typed by something this run never parsed (typeNotRead),
which is a different answer from “not an interface” and is counted as one;transactionBoundary). Some projects write @Transactional on the service
INTERFACE, where there is no body; the engine marks that member and computes
the transaction’s footprint by walking forward from it. Routing the caller past
it would leave the boundary on the graph with nothing under it.@GetMapping("/sys/api/getUserById") on a method is not, by itself, evidence
that this project serves that route. The lane classifies it by the enclosing
type first:
| enclosing type | what the mapping means |
|---|---|
a concrete class annotated @RestController / @Controller |
it serves the route — HANDLES, EXACT |
an interface annotated @FeignClient (or a class-level @HttpExchange) |
it calls the route over HTTP — CALLS_HTTP, never HANDLES |
| a plain interface or abstract class | it declares the route for an implementer to serve — a route contract |
| anything else (a concrete class with no controller annotation) | unchanged: it serves the route, so a controller marked by a meta-annotation this engine does not know still gets its routes |
Why this matters, measured on jeecgboot/JeecgBoot: it ships a local and a
cloud flavour of the same API, and the cloud flavour is @FeignClient
interfaces whose methods carry the same mapping annotations as the controllers
that answer them. Read as declarations, 107 routes came back with two
handlers — one of which was the caller. Classified, every route has exactly
one handler, and the 116 mapped methods across the 6 @FeignClient interfaces
become 116 CALLS_HTTP edges: 107 to a route this pack also serves, 9 to a route
it does not.
A mapping annotation can name more than one HTTP method or more than one path:
method = {RequestMethod.PUT, RequestMethod.POST},
@GetMapping({"/list-all-simple", "simple-list"}), or a list of paths on the
class’s own @RequestMapping. Spring serves the handler at every one of them,
so the Java worker records one route for each class path, method and path the
annotations name, and two spellings of one route are one. Each is a route the
source states, so it is classified as above and its HANDLES edge is EXACT
like any other mapping’s. The worker used to read only the first entry of each
list, which left routes Spring serves off the graph and a frontend call to one
of them with nothing to land on.
A method on the class’s own @RequestMapping joins the method’s: the handler
answers the verbs of both. A method element that is not an HTTP method (a
constant this lane does not read) leaves the verb unknown, so the route is
placed on ANY and its HANDLES edge is HEURISTIC, with what was written in
evidence.methodUnread (rule route-method-not-read). The verbs it does name
are routes of their own, as before.
Two ways of writing a mapping leave something one file cannot settle, and the
lane reads both against the whole tree (src/adapters/java/mapping_reads.mjs,
javafacts/22):
names a constant of another type. The worker reads a
literal and a static final String of the same class; a constant of another
class it hands on in parts, and the bridge reads it from that type's own
record, or a type above it, when the tree gives it one value. The route is
then EXACT, rule route-path-constant, with what was written in
evidence.written. A path that stays unread is no route: nothing is placed
at an address the source does not state. It is said instead
(path-not-literal). ruoyi-vue-pro's five IoTDeviceApiImpl methods moved
from POST / to POST /rpc-api/iot/…` this way.@AnonymousPostMapping("/login"), an annotation of its own that carries
@RequestMapping(method = POST). A controller method with an annotation the
worker does not know, whose type the tree declares with a meta
@RequestMapping or one of its shortcuts, serves a route: the meta
annotation’s methods, and the path the annotation’s own value or path
gives when @AliasFor points it at the meta mapping’s (else the meta
mapping’s own path). Its HANDLES edge is EXACT, rule
route-composed-mapping, with the annotation in evidence.annotation. An
attribute the annotation does not alias is not what Spring reads, so it is
said (composed-mapping-attribute-not-aliased), and an annotation this run
did not read on a method with no mapping is said too
(mapping-annotation-not-read). eladmin gains POST /auth/login and five
more routes.One route, handlers a condition splits. Two or more methods can declare one
route when one of them narrows it with params, headers, consumes or
produces. Spring runs the one whose condition the request meets, which is a
choice made per request. So each of their HANDLES edges is SOUND_SET, with the
conditions and the number of candidates on the evidence (rule
route-condition-split). Two declarations with no condition keep the grade
they had: two modules that each serve a route are two deployables, not one
choice.
meta.laneStats.mappingAnnotations counts all of this (pathsFromConstants,
pathsUnread, composedRoutes, composedNotRead, methodsUnread,
conditionSplits) with up to 20 samples, and cascade analyze prints one line
and the first five samples as JAVA_MAPPING_NOT_READ:
Java lane: mapping annotations: <n> path(s) read through a constant, <n> route(s) from a composed mapping annotation, ...
[warn] JAVA_MAPPING_NOT_READ <handler>:<line> path-not-literal: <what was written>
The line is printed only when one of the counts is not zero.
Measured: jeecg-boot serves 47 more routes (40 handlers mapped with
method = {PUT, POST} now answer POST too, and OpenApiController#call all
eight methods), and 29 of its frontend calls now reach a route, POST
/sys/user/edit among them. ruoyi-vue-pro serves 16 more: 14 simple-list
paths, and the catch-all for its disabled trade and promotion modules, which
151 frontend calls now reach, as calls to its product module already did.
ruoyi-vue serves one more. mall, jpetstore-6, halo, spring-petclinic, litemall,
eladmin and spring-petclinic-microservices give the pack they gave before.
pathPrefixes)Spring can serve controllers under a prefix that no mapping writes:
RequestMappingHandlerMapping.setPathPrefixes(...), a map from a prefix to a
predicate over the controller class, or PathMatchConfigurer.addPathPrefix(prefix,
predicate) in a WebMvcConfigurer or a WebFluxConfigurer. The predicate is a
lambda and the prefix is usually a property. This engine does not read either (a
prefix written as a constant is readable in principle, and is not read either),
so such a controller’s routes were recorded without their prefix, and a
frontend call to the real address named nothing. The profile says what the
prefixes are:
{ "pathPrefixes": [
{ "prefix": "/admin-api", "packages": "**.controller.admin.**", "annotation": "RestController" },
{ "prefix": "/app-api", "packages": "**.controller.app.**", "annotation": "RestController" } ] }
prefix is the prefix as Spring puts it before a route, with any property it
names already resolved. A ${...} or a space in it is refused.packages is an Ant pattern over the controller class’s package, with .
between segments, read as Spring’s AntPathMatcher(".") reads it: * within
one segment, ? one character, ** any number of whole segments, none
included. A project that hands such a pattern to Spring copies it across as
it is.annotation is the simple name of an annotation the class itself carries.
One it inherits, or a meta-annotation, is not read.from is a note of where the value was read, and rides on the endpoint.Either test may be left out, and an entry with neither applies to every controller. Any other key is refused, because a misspelt test would put the prefix on every controller.
The lane tests the class that serves a route, and the first entry the class
passes wins, as it does in Spring. The prefix goes before the path before the
endpoint is keyed, so the route is the address a frontend calls. A route
contract is served under its implementer’s prefix, because that is the class
Spring tests. A contract nobody implements, a @FeignClient route (the
address it calls, written at the call) and a functional route (Spring applies
these prefixes to the annotation handler mapping, not to a router function)
take none. A prefixed endpoint carries pathPrefix (the value, the declaration
pathPrefixes[i], its tests and its from) and apiGroup, the first path
segment after the prefix, so the views that group routes read the group below
the prefix instead of putting every route in one. Its HANDLES edge names the
rule route-path-prefix and keeps the grade of the mapping.
cascade analyze says what came of it, and three warnings say what may be
wrong:
Java lane: 2 declared path prefix(es) (pathPrefixes): /admin-api on 3101 route(s), /app-api on 153 route(s)
SETTING_IN_CODE: the Java facts show a call to Spring’s setPathPrefixes
or addPathPrefix on a receiver declared as that Spring type, or as a type of
the tree that extends or implements it however far up, and pathPrefixes is
empty. It names the file, the line and this key. The type name is read the
way javac reads it in that file: a type the file declares, a single-type
import and a type of the file’s own package all come before a package
imported whole. A call on this, on a field a superclass declares, and on a
var whose new names the type is read the same way. A call whose
receiver’s type the file does not state, in a file where that type’s name
means Spring’s, is said at severity info, since nothing proves it is
Spring’s; a receiver declared as another type is not the setting. The calls
are named by the spring-mvc rule pack, through the
java.code-setting kind (rules.md).PATH_PREFIX_UNUSED: an entry no controller class passed. A typo, or a
package that moved.PREFIX_NOT_ON_CALLS: frontend calls that named no route would name a served
one with a declared prefix before them, on a method that route serves. This
happens when the frontend’s base URL carries the prefix and the web lane did
not read or apply it, and the warning names the gatewayRoutes entry that
settles it ({"*": "/admin-api"}). It counts exact paths only, so the number
is a floor, and it says apart how many of those calls were traced to no
client.While SETTING_IN_CODE stands, a frontend call that only /** catch-all routes
match may be meant for a concrete route behind the prefix nobody declared.
Where dropping the call’s leading segments finds one, the web lane makes that
route a HEURISTIC candidate too, and the catch-all edge HEURISTIC
(catchAll, prefixShift; see
the web lane). On ruoyi-vue-pro, 2,388
of the 2,400 calls onto DefaultController’s catch-alls moved from SOUND_SET
to HEURISTIC and gained 2,390 candidates on the concrete controllers.
Declaring the key settles them.
The key needs spring-mvc in frameworkPacks (or a --java-src run);
otherwise RECORDED_NOT_ACTED says an unflagged run reads no controller to put
the prefixes on. It is left
out of the profile digest until it is set, so a project that declares none
keeps its digest, and cascade init run again keeps a declared list. The
working-tree overlay applies the same prefixes.
Measured on ruoyi-vue-pro, which serves its admin and app controllers under
/admin-api and /app-api from setPathPrefixes: with nothing declared the
pack is unchanged and SETTING_IN_CODE names YudaoWebAutoConfiguration.java:53.
Declared, 3,101 routes go under /admin-api and 153 under /app-api,
endpoints rise from 3,213 to 3,270 (before, an admin route and an app route
with the same path were one node with two handlers), and endpoints reaching SQL
from 2,626 to 2,663.
A method declared to return a RouterFunction builds its routes with calls,
not annotations:
@Bean RouterFunction<ServerResponse> routes(OwnerHandler handler) {
return route().nest(path("/owners"), b -> b.GET("/{id}", handler::show)).build();
}
The worker records such a method’s body (and that of a method returning a
Supplier of one) as a tree of the calls it is written with, and decides
nothing. The spring-functional rule pack says which call starts a builder,
adds a route, nests, joins or keeps routes, and which argument is the path, the
predicate, the handler or springdoc’s operation, for Spring’s own builders on
WebFlux and WebMvc and for springdoc’s SpringdocRouteBuilder
(rules.md). A builder a project adds is
a pack entry.
@Bean method’s routes are served at the
paths their calls compose. A route-building method of the same class that the
bean calls with no argument is read there, under the bean’s prefix. Any other
one is mounted by code elsewhere, which decides its prefix, so its route is
placed only where an OpenAPI document declares the operation id the route
names, with its verb, at a path that ends with the route’s own. The route
then has the document’s full path, its HANDLES edge is HEURISTIC (no mount
the source states puts it there, only a document that may be stale), and the
evidence names the document. When the profile says the build writes that
document from this code as it is now (openapi.generatedFromCode, springdoc
does this), the document says where the code serves the route, and the link
is graded as its handler is read below; the evidence names the declaration
(declared: {key, document}). Two routes of the code that name one operation
are placed by neither. The operation id is the one the builder was given
last. A later statement that may set it where this reader does not follow
(under an if, in a loop, inside another call’s argument) leaves it not
known, and such a route, which only its id could place, is not placed
(unmounted in laneStats.functionalRoutes).this::list, or
OwnerHandler::list of a type the tree declares with that method). Through
a declared type (a field, a parameter, a local, or this with an override
below it) it is SOUND_SET, over every method an object of that type may run:
the type’s own or inherited one, every override below it, an interface’s
default body where a class and the classes above it declare none (the default
it inherits runs, not a method the class never declares), the interface’s own
default, which an implementor outside the tree may keep, and every anonymous
class the tree writes from the type (new Handler() { ... }), and every class
a method body declares from it, that overrides the method. The worker records
which interface methods have a body, one record per anonymous class and one
per class a method declares (javafacts/22). A lambda that calls one method
names that method.evidence.openSet names the gap: the type is an interface with
one abstract method, so any lambda or method reference anywhere may be the
object (functional-interface); the type was not read (type-not-read); or a
class of the set inherits the method from a superclass this run did not read
(superclass-not-read).handlerUnread: true and no HANDLES edge. A
path in a variable, a predicate or a call the pack does not name, and a
helper handed arguments are counted in laneStats.functionalRoutes, and the
first few are printed as JAVA_ROUTE_NOT_READ. An operation id the code
names that a document gives to another route is counted in
operationIdDisagreements and printed as OPERATION_ID_DISAGREES.pathPrefixes is not put on these routes; see above.--no-openapi turns off the placement by operation id with the rest of the
OpenAPI lane. The working-tree overlay reads the same documents, so it places
the same routes.
Measured on halo, which builds its custom API this way: none of the 400 routes
its OpenAPI documents declare had a handler before; now 167 do, among 176
routes with a handler. 12 of those links are EXACT and 164 are HEURISTIC, every
one of them placed by operation id, so the default conservative mode walks 12
routes to a handler and heuristic walks all 176. None reaches a SQL
statement, because halo stores its data through its own extension store.
halo’s documents are what springdoc writes from its code. Declared as such
(openapi.generatedFromCode, five documents), 168 of the links are EXACT and 8
SOUND_SET, so every route with a handler is walked at conservative. Without
the key, cascade analyze names the routes that rest on an undeclared document
(ROUTE_MOUNT_FROM_DOCUMENT), and the overview’s fix for them is this key. The gateways of jeecg-boot and
spring-petclinic-microservices each serve one route this way, GET /,
from an inline lambda, so each is a node with handlerUnread and no HANDLES
edge.
A contract-first project builds its API from an OpenAPI document with
openapi-generator, which writes one interface per group of operations
(OwnersApi) at build time. The project’s @RestController implements it.
The interface is not in the source tree, so this lane sees a controller whose
methods carry no mapping, and serves nothing from it.
The openapi-generator rule pack pairs them after the document’s routes are on
the graph: a method of a served class whose name is an operation’s operationId,
where the class implements an interface outside the tree named as the generator
names that operation’s group, handles that operation’s route. The HANDLES edge
is HEURISTIC, because the pairing rests on a naming convention and neither the
interface nor the generator’s configuration is read. The rule, what it links
and what it says instead are in rules.md.
Measured on spring-petclinic-rest: 36 of the 37 routes its document declares
are linked (the 37th, /oops, has no implementer in the source), each on a
method the source marks @Override. Endpoints reaching SQL go from 0 of 38 to
13 of 38 at mode=heuristic, and stay at 0 at conservative, as a HEURISTIC
link should.
Saying the build generates the interfaces. What the tree cannot show is
that the build really generates this code’s interfaces from that document. The
profile can say it: openapi.generatesCode lists the documents the build
generates from. A link on one of them is EXACT, the method its class declares,
when the interface’s name does not depend on a setting of the generator this
engine does not read: openapi-generator groups operations by tag or by path,
and the link holds only if every scheme that gives some operation the
interface’s name puts this operation into it. Each link records both
(namedBy, nameFrom); a link that depends on the scheme stays HEURISTIC and
its evidence says which grouping it depends on (naming). The evidence names
the declaration (declared: {key, document}). On spring-petclinic-rest,
declaring its document makes all 37 HANDLES edges EXACT, and 13 routes reach
SQL at conservative. Without the key, cascade analyze names the links that
rest on an undeclared document (CONTRACT_FROM_DOCUMENT) and the overview’s
contract-links gap says to declare it.
Most service-to-service traffic is not annotated at all. It is a fluent chain or
a RestTemplate request, where the verb is a method name and the url is an
argument somebody built:
webClient.get().uri("http://customers-service.invalid/owners/{ownerId}", ownerId).retrieve()
restClient.post().uri(baseUri + "/owners/" + id + "/pets").retrieve()
restTemplate.exchange(url, HttpMethod.POST, body, Result.class)
The lane reads all three, and reads the url only as far as one file allows: a
literal, a literal with {…} placeholders, or a + whose literal halves are
kept and whose base is named rather than resolved. A scheme://host is
stripped off the front and the host is kept as evidence, because it is usually
a logical service name a discovery server resolves, not a machine. The path is then
matched against the routes this pack serves — exactly, then by template, so a
call that interpolates an id lands on the route whose {ownerId} stands where
the value went — and the profile’s gatewayRoutes rewrites the prefix first, the
same declaration the web lane applies to a frontend call.
A url the lane could not reduce to a path (a bare variable, a fully computed
string) draws no edge at all and is counted (httpCallsUrlUnreadable). A
route nobody wrote is not put in the graph to stand in for one. A call whose
verb was an argument the lane could not read is matched on the path alone and
graded HEURISTIC, below the conservative floor.
Measured on spring-petclinic-microservices, five services in one repository with
no @FeignClient anywhere: 0 CALLS_HTTP edges before this rule, 6 after —
the gateway to GET /owners/{ownerId} and GET /pets/visits, the genai service
to GET /vets, GET /owners, POST /owners and POST /owners/{ownerId}/pets,
every one of them SOUND_SET. The gateway’s own route then reaches the customers
and visits tables through the hop instead of stopping at the service boundary.
Which deployable actually answers a client call is not knowable from source,
so it is never claimed. The annotation’s service name and url ride as evidence —
with evidence.serviceLiteral saying whether the source spelled a string or
named a constant (ServiceNameConstants.SERVICE_SYSTEM, which a parse-only
worker cannot resolve) — and the grade says only what was checked: SOUND_SET
when a route with that method+path is in the pack, UNRESOLVED when none is.
A route contract nobody in the pack implements is still emitted, with
contractOnly: true on the endpoint node, so a census can say “N routes are
declared by an interface nobody implements here” instead of dropping them.
A CALLS_HTTP edge to a route this pack serves is a real step of a real
request: clientMethod --CALLS_HTTP--> route --HANDLES--> handler. Both walks
cross it — flow in either direction, map, coupling, overview reach,
endpoint_impact and changed_impact — and every row reached that way is
marked viaHttp: true with httpHops, because the code on the far side belongs
to another deployable and must not read as one call stack. The hop costs two
real steps of depth; nothing teleports.
A route the pack only calls is marked outbound: true on the node. It is not
one of the pack’s endpoints: the per-endpoint census skips it (it would be an
endpoint reaching nothing, which is somebody else’s endpoint), overview
reports it as reach.outboundEndpoints and names it in gaps
(http-calls-leaving-pack), and the two numbers always add up to the endpoint
node count.
A @Controller that is not a @RestController and carries no class-level
@ResponseBody answers a request by naming a view, and a template engine
turns that name into the page the browser gets. The worker records one view
record per handler:
{ "kind": "view", "owner": "com.example.OwnerController", "method": "initFindForm",
"paramCount": 1, "unresolved": 0,
"views": [{ "name": "owners/findOwners", "kind": "view", "from": "literal" }] }
Four places a name is read from, and one honest gap:
from |
what it read |
|---|---|
literal |
a returned string literal, and every literal leaf of a returned ternary |
model-and-view |
new ModelAndView("x", …) |
set-view-name |
mav.setViewName("x") |
constant |
a static final String field of the same class, initialised with a literal |
helper |
a private or package-private method of the same class whose every return is such a literal or such a field. The method’s name is on the record |
A redirect: or forward: prefix becomes the record’s kind rather than part
of the name, because what it names is a ROUTE and not a page.
The last two rows are what makes this useful on real controllers, and neither
resolves anything across a file. private static final String
VIEWS_OWNER_CREATE_OR_UPDATE_FORM = "owners/createOrUpdateOwnerForm"; has its
value on the line that declares it, and return addPaginationModel(page, …)
calls a method of the same class whose returns are right there. Measured on
jpetstore-6, where every controller uses the constant idiom: 10 view names
before this rule, 16 after, and zero unread returns. Two guards keep them
honest:
A helper with several literal returns gives several views, exactly as a ternary
does. Anything else a returned name could be — a field of a superclass, a method
somebody overrides, a constant from another file, a local variable — is not
resolved. That is a data-flow or a cross-file question this parse-only worker
does not answer, so it is counted in unresolved and the run says how many
pages it could not name. The one exception is the ModelAndView idiom:
ModelAndView mav = new ModelAndView("x"); … return mav; names the page on the
line that builds the object, so the bare return mav is not counted as a gap.
A method carrying @ResponseBody, and every method of a @RestController,
answers with a body and has no view at all: no record, not an empty one.
Which FILE owners/findOwners means is not the worker’s business. That depends
on the view resolver’s prefix and suffix, which are the project’s configuration;
src/core/discover.mjs reads them and src/adapters/web_bridge.mjs joins the
two into a screen. See the web lane for the page end of it.
skippedLocalReceivers on its own header, 4786 on litemall) and emits
nothing rather than guessing. A chain that passes through one of them is not
in this graph. The number is a per-run tally on the worker’s header, which
the per-file fact shards do not carry, so the pack cannot report it yet.externalCalls; the walks stop there, which is where the chain really stops.
A call whose target this lane could not name at all is unresolved
(unresolvedCalls, split by unresolvedCallsByReason) — no dependency
classpath is consulted either way.RUNTIME_ONLY axis, not built).An eGovFrame service does not number its own rows. It asks a bean:
@Resource(name = "egovIdGnrService")
private EgovIdGnrService egovIdGnrService;
...
String id = egovIdGnrService.getNextStringId();
The bean is an EgovTableIdGnrServiceImpl declared in a Spring XML, and it hands
the key out by reading and advancing a row of its own table, so the service reads
and writes that table and no mapper says so. A real run of the eGovFrame web
sample recorded it: POST /addSample.do touched SAMPLE and IDS, and the pack
knew only SAMPLE.
So the lane reads the bean (its name or id, and the table, tableName,
tableNameFieldName and nextIdFieldName properties, with the class’s own
defaults ids, id, table_name and next_id where the bean sets none, read off
the class file of egovframe-rte-fdl-idgnr 5.0.1), writes the two statements the
class runs through the SQL lane like any other statement
(SELECT next_id FROM IDS WHERE table_name = ? FOR UPDATE and
UPDATE IDS SET next_id = ? WHERE table_name = ?), and binds a call on a field
injected by that bean’s name, to one of the getNext…Id methods, to a symbol of
that bean, rule egov-id-generator, SOUND_SET. Each bean has its own symbol: the
interface method every generator shares would otherwise hand every generator’s
table to every caller. A sequence or UUID generator touches no table and is not
read. Measured: the common components declare 115 generators and bind 116 of
the 119 calls that ask one by an injected field, and the business template
declares 111 and binds all 9, every one onto COMTECOPSEQ or IDS.
When the Java lane runs and the profile declares the jpa framework pack (which
cascade init does for you the moment it counts an @Entity file), a second
bridge maps persistence that is declared in the mapping rather than written
as SQL.
These naming grades remain subject to the uncertainties described below, including version-dependent physical spellings and unread mapping annotations.
| From | To | Grade |
|---|---|---|
@Entity + @Table(name="owners") |
the written logical name, passed through the physical naming strategy | EXACT when that strategy is declared or all supported strategies agree on the spelling; otherwise HEURISTIC |
@Entity with no @Table |
the table the naming strategy gives, from the entity’s name: the class’s, or the one @Entity(name = …) gives it |
EXACT with a usable physical naming declaration and no unmodelled implicit strategy; otherwise HEURISTIC |
@Column(name="visit_date") |
the written logical name, passed through the physical naming strategy | EXACT when that strategy is declared or all supported strategies agree on the spelling; otherwise HEURISTIC |
a field with no @Column |
the column the naming strategy gives | EXACT with a usable physical naming declaration and no unmodelled implicit strategy; otherwise HEURISTIC |
@Id |
the primary-key column | the grade of its column name |
no @Id the tree declares (it may sit in a superclass outside the tree), or an @EmbeddedId whose type was not read |
the assumed default key id, by the naming strategy |
HEURISTIC, and so is every foreign key, join table column, subclass key and JOINS edge named after it. Said once as primary-key-assumed, and on a by-id lookup that reads it |
@EmbeddedId |
the embeddable’s columns are the key columns, including nested embeddables | as a column |
@Embedded |
the embeddable’s columns, including nested values and dotted attribute overrides on the embedding attribute | as a column |
@JoinColumns and @MapsId |
composite foreign keys and shared primary keys; explicit referenced-column names pair the columns | pairing only by position stays HEURISTIC and is reported |
@ManyToOne/@OneToOne + @JoinColumn(name=…) |
that foreign-key column, plus a JOINS edge |
weakest of the two entity mappings and of the target’s key |
@ManyToOne/@OneToOne with no @JoinColumn |
one foreign-key column per key column of the target, named <attribute>_<key column>, so a composite key gives several |
as above |
@OneToMany(@JoinColumn) |
the foreign key on the target table, plus a JOINS edge |
as above |
@ManyToMany, and a @OneToMany with neither @JoinColumn nor mappedBy |
a join table with one foreign-key column per key column on each side, plus JOINS edges. @JoinTable supplies logical names; defaults use the physical and implicit rules below |
an unnamed table is HEURISTIC where the supported implicit strategies disagree and neither the profile nor usable configuration states one; other name and key uncertainties still apply |
an inverse @OneToMany/@ManyToMany(mappedBy = …) |
no column and no join table of its own: the owning side maps them | EXACT |
@MappedSuperclass |
its attributes are inherited by every subclass entity | as above |
a derived query (findByLastNameStartingWith) |
select reading the predicate and ordering columns |
the statement’s table by the table’s name, each column by its own |
@Query JPQL |
the columns its aliases and paths name; SET targets are writes |
as above |
@Query(nativeQuery = true) |
the SQL goes through the SQL lane’s own analyzer (lineage.py), like a MyBatis statement — its JPA bind markers (?1, :name) are normalized to ? first, exactly as MyBatis’s #{} are |
EXACT (a real SQL parse) |
a called-but-undeclared CrudRepository method (save, findById, …) |
the rows that method touches | as above |
save* writes every mapped column of the row (a JPA merge writes the whole
entity) and follows cascade = ALL/PERSIST/MERGE associations to the child
rows. delete* removes the row and follows cascade = ALL/REMOVE the same way.
A cascaded reach is capped at SOUND_SET: the cascade is declared in the
source, but whether a given call has a child to write or remove is a runtime
fact. orphanRemoval is not read.
A to-many’s other side is its element type, the last type argument: a
List<Pet> and a Map<PetType, Pet> both hold pets. targetEntity wins over
either. @Entity(name = "Hound") renames the entity: its default table is
derived from that name, and a JPQL FROM Hound finds it. Re-measured with
these rules and the hierarchies below, nine JPA packs keep their digest (petclinic-rest,
polls-app, jhipster-sample-app, eladmin, spring-petclinic,
spring-petclinic-microservices, egovframe-msa-edu, mes4u, ngrinder), and
shopizer’s four join table columns take JPA’s default names
(MERCHANT_LANGUAGE.stores_merchant_id and languages_language_id among them).
An entity can extend another entity, through any @MappedSuperclass between
them. The root’s @Inheritance says where the hierarchy’s rows live, and the
lane reads it the way JPA applies it:
| The root’s strategy | Where a subclass’s rows and columns are |
|---|---|
SINGLE_TABLE, or no @Inheritance at all (JPA’s default) |
the root’s table. The subclass has no table of its own, and a @Table on it is ignored, as Hibernate ignores it. The root’s table also holds the column that tells the rows apart: with no @DiscriminatorColumn it is JPA’s DTYPE by the naming strategy (dtype under spring-snake-case), drawn HEURISTIC (discriminator-assumed), read by every query on a subclass and written by every save. A declared @DiscriminatorColumn is not read and not drawn (jpa-column-not-drawn) |
JOINED |
each class’s own table holds the columns that class declares, and an inherited column stays on its parent’s table. The subclass’s table carries the key that joins it to its parent’s (the column @PrimaryKeyJoinColumn names, else the parent key’s name), with a JOINS edge between the two, and a statement on the subclass reads or writes every table its row spans |
TABLE_PER_CLASS |
the subclass’s own table, with every column, inherited ones too |
| anything else | not read. The run says so (inheritance-strategy-unread), and the subclass’s table is HEURISTIC |
A statement on an entity that has subclass entities may read or write one of
their rows, which carry columns (and under JOINED or TABLE_PER_CLASS,
tables) of their own. Those are not followed, and the statement says so
(polymorphic-subclasses-not-read).
A repository method names one entity and reads several tables. Nothing in the
source says so, and the database does it anyway: an association whose fetch is
EAGER comes back in the same round trip, and so does everything eager on the rows
it brought with it. In spring-petclinic, OwnerRepository.findById names
owners and the running query reads four tables, because Owner.pets is
fetch = EAGER, Pet.type is a @ManyToOne (eager unless the mapping says
otherwise) and Pet.visits is eager too.
So every statement whose result is an entity carries that closure:
Rule (evidence.rule) |
What it followed |
|---|---|
jpa-eager-fetch |
an association whose effective fetch is EAGER. evidence.fetch says explicit when the mapping wrote fetch = EAGER, and default when it wrote nothing and the JPA specification decided (a to-one is eager, a to-many is lazy) |
jpql-join-fetch |
a JOIN FETCH / LEFT JOIN FETCH in the method’s own JPQL. The query overrides the mapping, so this is followed whatever fetch = says |
jpa-entity-graph |
an @EntityGraph attribute path on the method, written out or named through the entity’s @NamedEntityGraph. Same override, same reason |
jpa-cascade |
a save or a delete reaching a child row through cascade |
Every followed edge carries evidence.path, the attribute path from the entity
the query returns (Owner.pets.visits), so a table nobody expected can be traced
back to the field that brought it. The rule itself is EXACT — the annotation and
the specification decide it, nothing was resolved. A table the fetch or a
cascade reaches is graded by that rule (a cascade is SOUND_SET) and by the
table’s own name. A foreign key whose name the naming strategy derived keeps
that doubt on the key column’s own READS or WRITES edge, and does not make
the table it leads to a guess.
Three things it deliberately does not do:
jpaEvidence.limits, and overview counts them all under
jpa.lazyAssociationsNotFollowed.save follows cascade and not fetch. A merge writes a row; it is not a
query whose result somebody reads.fetch-depth-capped on the statement when it hit the cap. A path that names no
association of the entity is reported as fetch-path-unresolved, and an
@EntityGraph naming a plan no entity in the pack declares as
entity-graph-unresolved. A nested @NamedSubgraph is not read.@ModelAttribute: the call Spring makes and no line of source writesSpring runs a controller’s @ModelAttribute methods before every handler of
that controller. Nothing calls them, so nothing led to them, and
GET /owners/{ownerId}/edit in spring-petclinic answered no table while a real
agent capture of that request read four: the owner is loaded by
OwnerController#findOwner, a @ModelAttribute("owner") method.
The lane now draws that edge: MAY_CALL from every handler of a
@Controller/@RestController class to every @ModelAttribute method the
same class declares, rule spring-model-attribute, graded EXACT —
nothing was resolved, the framework’s own contract says the method runs.
What it does not follow, and counts instead (laneStats.modelAttribute):
@ControllerAdvice’s model attributes (onAdvice), because which
controllers an advice runs for is not a fact about one class;onSuperclass);inheritedHandlers).A @ModelAttribute on a PARAMETER (save(@ModelAttribute Owner owner)) is a
binding, not a method Spring runs, and is never read as one.
Hibernate turns Owner/lastName into a physical owners/last_name with a
naming strategy the application configures. This engine does not run your
application, but it reads where the application declares the strategy:
spring.jpa.hibernate.naming.physical-strategy (or
spring.jpa.properties.hibernate.physical_naming_strategy) in the
application*.properties/application*.yml beside each Java source root the run
reads (src/main/resources, and its config/ directory), in any spelling Spring
binds, whether the roots were named by flags or discovered. Another module’s
configuration is not read for this one. A
recognized class there is a declaration only when the setting reaches the
factory that builds this persistence unit (see below), and the run says so
(JPA_NAMING_FROM_CONFIGURATION). A class this engine does not model, or two
configuration files that name different strategies, declare nothing it can
apply, and the run says that too. So does a strategy that only a profile-specific
file (application-dev.yml) or a document activated by a profile names, or a
profile that names another strategy than the base configuration: which profile
runs is not in the tree. Under spring.jpa.properties the key counts only as
Hibernate spells it (hibernate.physical_naming_strategy), because Spring binds
that map verbatim.
The physical default depends on who builds the EntityManagerFactory:
| Factory found in the tree | Assumed physical / implicit naming |
|---|---|
| no project-owned factory | Spring Boot: spring-snake-case / spring |
built from Boot’s EntityManagerFactoryBuilder |
Boot’s defaults, with an explicit caveat that the builder carries them only from Boot 3.4; earlier versions carry only spring.jpa.properties |
built by hand with new, Spring XML, or a factory @Bean without Boot’s builder |
Hibernate: identity / jpa-compliant |
The lane reports the factory’s file and line (JPA_NAMING_FROM_FACTORY). It
reads factory types, naming setters and property maps through the jpa rule
pack. It does not execute configuration code or determine the Boot version.
A setting found in code is reported as SETTING_IN_CODE; an explicit profile
declaration settles the corresponding naming dimension.
For both physical and implicit settings, Boot’s own
spring.jpa.hibernate.naming.*-strategy key reaches Boot’s factory, reaches a
builder-made factory only from 3.4 (therefore HEURISTIC here), and does not reach
a hand-built factory. The exact spring.jpa.properties.hibernate.*_naming_strategy
key reaches Boot’s and builder-made factories in every version, but not a
hand-built one. Ignored settings are reported. cascade init declares neither
strategy automatically.
When neither the profile nor usable configuration declares a physical strategy,
the factory’s default is an assumption. Names the supported physical strategies
spell differently stay HEURISTIC. endpoint_impact in conservative mode leaves
those links out and explains why in limits. Matching a catalog name does not
promote them.
That is not a bug to work around; it is the engine refusing to present a guess as a fact. Declare the rule in the profile (it wins over the configuration) and the supported mappings can become EXACT, subject to other mapping uncertainties:
{
"frameworkPacks": ["spring-mvc", "jpa"],
"jpa": { "namingStrategy": "spring-snake-case" }
}
"spring-snake-case" — Spring Boot’s default, CamelCaseToUnderscoresNamingStrategy.
Exactly Hibernate’s rule: a . becomes _, and an underscore goes before a
capital only when a lower-case letter is on both sides of it, never before the
last character, so lastName is last_name but userID is userid and
myURLValue is myurlvalue. Hibernate 7 (Spring Boot 4) also counts a digit as
lower-case, so the two versions spell address2Line differently (address2line,
address2_line). This class exists in both, so a name like that is graded
HEURISTIC even when this strategy is declared: the project’s Hibernate version
decides it."snake-case-hibernate6" — the same rule, letters only, fixed: Spring Boot 2’s
SpringPhysicalNamingStrategy."snake-case-hibernate7" — the rule that counts digits, fixed:
PhysicalNamingStrategySnakeCaseImpl, which exists from Hibernate 7.0 on."identity" — the logical name is the physical name (Hibernate’s
PhysicalNamingStrategyStandardImpl, what you get with
spring.jpa.hibernate.naming.physical-strategy set to it).null (the default) — take the strategy the project’s Spring configuration
names if it reaches the factory; otherwise assume that factory’s default and grade uncertain names HEURISTIC.Names written in @Table, @Column, @JoinColumn and @JoinTable also pass
through the physical strategy. With snake case, @Column(name="createdBy")
becomes created_by; identity keeps createdBy. A written name is EXACT
without a declaration only when the supported physical strategies agree.
Implicit naming is a separate declaration. jpa.namingStrategy states the
physical rule only. jpa.implicitNamingStrategy accepts:
"spring": a default join table uses the owning table and association
attribute (owners_special_tags)."jpa-compliant": it uses the two tables (owners_tags).null: read usable configuration, otherwise assume the factory’s default.
Where the two supported rules disagree, the derived table and its links stay
HEURISTIC (JPA_IMPLICIT_NAMING_ASSUMED). Where they agree, this uncertainty
alone does not lower the grade.The configuration reader recognizes SpringImplicitNamingStrategy (including
its Boot 4 package) and ImplicitNamingStrategyJpaCompliantImpl under
spring.jpa.hibernate.naming.implicit-strategy or the exact passthrough key
spring.jpa.properties.hibernate.implicit_naming_strategy, with the factory
reach rules above. Custom, legacy-HBM, legacy-JPA and component-path classes
are not modelled; their differences extend beyond a join-table formula, so
they are reported rather than accepted as aliases. When such a setting can
reach the factory, derived names stay HEURISTIC; written names retain their
physical-strategy grading. Verify the actual strategy
before declaring one of the supported values.
For example, after verifying both runtime rules:
{ "jpa": { "namingStrategy": "identity", "implicitNamingStrategy": "jpa-compliant" } }
Declaring one dimension settles only that dimension. The new implicit key is left out of the profile digest while null. Collection tables remain unread.
Invariant I-1, in this lane: if the derived table name happens to exist in
the DB catalog, that is recorded on the node as jpaCatalogMatch: true and
nothing else. A coincidence is evidence for a human, not a promotion.
@ElementCollection keeps its values in a table of its own, which this
lane does not read. The attribute is no column of its owner, no statement
here reaches that table, and the run says so (element-collection-not-read).@Formula, @JoinFormula and their kin), an unsupported override
or @AssociationOverride; and on the class, @SecondaryTable or an override, which
covers every column of the entity. Such a column keeps its plain reading,
graded HEURISTIC, and the run names the annotation it did not read
(jpa-mapping-unread).@OrderColumn, @IndexColumn, @MapKeyColumn,
@MapKeyJoinColumn(s), @CollectionId, @Any, @ManyToAny, and a to-many
Map with no map-key annotation (jpa-column-not-drawn).jpa-annotation-unknown), and the attribute’s column is HEURISTIC: what
it does to the column is not known. The lane keeps a list of the annotations
it knows change nothing it draws. A marker annotation declared in the tree
can also be recognized as inert when its metadata is inert and no Hibernate
boot extension could interpret it. Implemented extension points and
META-INF/services registrations block this inference. Resource files are
read during discovery; an explicit-lane run that did not read them reports
jpa-resources-unread, and the working-tree overlay retains the recorded inputs.jpa-reach-unread): @Fetch, @Where, @SQLRestriction,
@Cascade and their kin.@Convert is not modelled.@BatchSize is not read: it changes how a fetch is issued, not
whether it happens.@Query(name = "…"), @NamedQuery) is not read.flush() gets no statement of its own: it writes what the other statements in
the transaction already made pending.hasUnresolved: true and the reason
on the node — never dropped, never silently empty. So does a statement on a
mapping this lane does not read: a query on an entity with subclasses
(polymorphic-subclasses-not-read) and a by-id lookup on an assumed key
(primary-key-assumed). overview counts them
(jpa.unresolvedStatements) and cascade analyze prints them. A mapping’s
own reasons are on no node: they are in meta.laneStats.jpa.unresolved with
statement: null, printed as JPA_UNRESOLVED (mapping).MyBatis writes its SQL down. JPA writes nothing down but at least declares a repository method per query. MyBatis-Plus writes down less than either:
sysUserDepartMapper.selectList(
new LambdaQueryWrapper<SysUserDepart>().eq(SysUserDepart::getDepId, id));
There is no SQL text and no method name to read. The table comes from a global
naming rule applied to the class SysUserDepart, the column from the same rule
applied to the JavaBeans property behind getDepId, and the verb from the fact
that selectList is a method of BaseMapper<T> this project never wrote.
Measured on jeecgboot/JeecgBoot, 735 of 969 routes reached no statement at all before this lane existed — not because their code touches no table, but because everything it touches is spelled this way. With it, 717 of 969 do.
Turn it on with frameworkPacks: ["mybatis-plus"]; cascade init writes that
itself when discovery sees extends BaseMapper< or @TableName in the tree.
It is independent of mybatis-xml — a project can have both, and jeecg-boot
does (80 mapper XML files for 65 mappers, generic CRUD for everything else).
| from the source | to | grade |
|---|---|---|
@TableName("sys_user_depart") |
the table | EXACT |
SysUser with no @TableName |
sys_user by the naming rule |
EXACT if mybatisPlus.namingStrategy is declared, HEURISTIC if assumed |
@TableField("dep_id") / @TableId("id") |
the column | EXACT |
a field with no @TableField |
its column by the naming rule | as above |
@TableField(exist = false), static, transient |
no column — MyBatis-Plus does not persist them | EXACT (definitional) |
a BaseMapper / IService / ServiceImpl built-in a caller reached |
one statement per (owner, method) | IMPLEMENTS_STMT EXACT — the method is the statement MyBatis-Plus generates |
X::getY in a wrapper op |
the column the property y maps to |
the mapping’s grade |
eq("status", …) on a QueryWrapper |
the column status, as written |
EXACT (it is the physical name MyBatis-Plus passes through) |
@TableLogic + any delete*/remove* |
an UPDATE that writes the flag column | the mapping’s grade |
@TableLogic + any query |
a READ of the flag column (evidence.implicitFilter) |
the mapping’s grade |
The entity is found through the generic bases, transitively and with type
substitution — no project base-class name is hardcoded. jeecg-boot puts its own
JeecgServiceImpl<M extends BaseMapper<T>, T extends JeecgEntity> extends
ServiceImpl<M, T> in between, and 24 services extend that; the lane substitutes
T down the chain the same way it would for anyone else’s base class.
A class is an entity when the source says so in exactly two ways: it carries
@TableName, or a BaseMapper<T> / IService<T> / ServiceImpl<M, T> in the
pack names it as T. A class that merely carries @TableField on a couple of
fields is not one — jeecg-boot has six of those, every one a DTO shaped for
a result map, and mapping them would have invented six tables no schema has.
| built-in | access | columns |
|---|---|---|
insert, save, saveBatch |
write | every mapped column — MyBatis-Plus writes the non-null fields, and which those are is a run-time fact, so all of them are candidates |
updateById, updateBatchById |
write | every mapped non-id column, plus a read of the key |
update(entity, wrapper) |
write | the wrapper’s set(...) columns when one is visible, else every mapped column |
saveOrUpdate* |
write | every mapped column, plus a read of the key |
deleteById, removeById, removeByIds, deleteBatchIds |
delete (or write, under @TableLogic) |
a read of the key |
delete(wrapper), remove(wrapper) |
delete (or write, under @TableLogic) |
the wrapper’s predicate columns |
selectById, getById, listByIds |
read | the key |
selectList, list, getOne, count, page, selectPage, exists, … |
read | the wrapper’s predicate/order columns and the projection — every mapped column, or the select(...) list when the wrapper narrows it |
A method that runs no SQL is deliberately absent: getBaseMapper,
lambdaQuery(), lambdaUpdate() get no statement, because giving them one would
invent a row-touching fact.
If the owning type declares the method itself, the call runs that method and
not the built-in: no generic statement is created, and the case is counted
(builtinsOverridden) and named in the diagnostics.
The worker records, per enclosing method, what kind of wrapper was built, for
which entity type, which ops were called on it with which method references and
literals, and where it ended up. It decides nothing: eq is recorded as the
op named eq, and that eq is an equality predicate on a column is the bridge’s
reading.
Every op falls into one of five readings, and the table is total — an op
outside it is counted under opsUninterpreted and named in the analyze output,
so a lane that quietly ignored one cannot pass for a lane that saw none:
| reading | ops | what it means |
|---|---|---|
column |
eq ne gt ge lt le in notIn like notLike likeLeft likeRight notLikeLeft notLikeRight between notBetween isNull isNotNull |
names ONE column: its first string-literal argument (after MyBatis-Plus’s optional leading boolean condition), or the method references it carries |
columns |
select groupBy orderByAsc orderByDesc orderBy |
names several — every top-level string literal |
write |
set setIncrBy setDecrBy |
the columns it names are WRITTEN |
sql |
apply last setSql exists notExists having inSql notInSql |
its string argument is a raw SQL fragment, not a column name — it is run through the SQL analyzer, see below |
structural |
and or not nested lambda func clone getCustomSqlSegment getSqlSegment |
names no column of its own — but a method reference inside it still does, because X::getY is unambiguous wherever it appears |
A wrapper this lane cannot see through is the interesting case. jeecg-boot builds 64 of its wrappers with
QueryWrapper<T> queryWrapper = QueryGenerator.initQueryWrapper(object, request.getParameterMap());
whose conditions come from the HTTP query string. No source line names those
columns. The lane does not guess and does not go quiet: the table stays a
fact, the statement carries columnsRuntimeOnly: true with the reason, and
column_impact on any column of that table adds a limit saying “N statements
touch this table with columns decided at run time — the list above is a lower
bound”, naming them. That is the grade table staying total: RUNTIME_ONLY is a
grade, not a silence.
A raw SQL fragment goes to the SQL analyzer. apply / last / setSql / inSql
/ notInSql / exists / notExists / having hand MyBatis-Plus a piece of SQL TEXT,
and SQL belongs to the SQL lane. The op says how the fragment is written into a
statement — apply(cond) is a WHERE, setSql(x) an UPDATE … SET,
last("limit 10") a tail — the FROM is the table the wrapper filters, and
MyBatis-Plus’s own {0} bind placeholder becomes the ? a parser accepts. The
result is an ordinary statement run through the same lineage.py, the same
catalog, dialect and identifier rule, and the same content-addressed shard cache
as a mapper statement or a native @Query, under the id
<owner>.<method>#frag<n>. What comes back is attached to the wrapper’s
statement with evidence.fragmentOp naming the op, so
w.apply("date_format(create_time,'%Y-%m') = {0}", ym);
makes the statement READ create_time even though no method reference and no
projection names it. Two things stay honest about it: a fragment that does not
parse keeps the old unresolved note with its text, and a fragment on a
wrapper with no entity of its own (a QueryWrapper<T> in a generic base class,
whose table is decided per call site) is not written out at all and says so —
nothing is invented in either case. jeecg-boot uses none of these ops, so the
fixture behind this path is the synthetic project in
test/mp_fragments.test.mjs, driven through the real CLI and both workers.
wrapper = new
LambdaQueryWrapper<>();) is not a new wrapper record — the scan reads
declarations and chains. In every case measured the variable already had a
record, so its ops still land; the origin is what is lost.this.addEasyQuery(queryWrapper, …)) is recorded
with that sink and produces no statement there — the helper is not a built-in.@Version, @TableField(fill = …), @KeySequence, MyBatis-Plus’s multi-tenant
and dynamic-table-name interceptors are recorded or ignored, never modelled.mybatisPlus.logicNotDeleteValue is recorded and not acted on: the lane records
that the flag column is read as a filter, not which value it is compared with.These are the MyBatis-Plus keys. jpa.namingStrategy is above, pathPrefixes
in A prefix set in configuration code, the openapi keys in Routes built
with calls and A controller that implements a generated interface, and every
key a profile can hold, with an example of each, in
concepts.md.
{
"frameworkPacks": ["spring-mvc", "mybatis-xml", "mybatis-plus"],
"mybatisPlus": {
"namingStrategy": "underscore",
"tablePrefix": null,
"logicDeleteValue": "1",
"logicNotDeleteValue": "0"
}
}
"underscore" — MyBatis-Plus’s own default (table-underline: true,
map-underscore-to-camel-case: true), SysUser → sys_user."identity" — the project that turned it off; the logical name is the physical one.null (the default) — undeclared, assume underscore, grade HEURISTIC, and
the mybatisPlus axis is degraded rather than shipped. A column named by
that assumption is then not reached at conservative, since a walk counts
only the READS and WRITES edges the mode admits: ruoyi-vue-pro reaches 15
columns there, where it counted 8,010, until this is declared, and a
mode=heuristic census counts them all.Invariant I-1, in this lane: if the derived table name happens to exist in
the DB catalog, that is recorded as mpCatalogMatch: true and nothing else.
Node ids go through the same identity fold the SQL lane used
(sqlIdentifierCase). jeecg-boot’s sys_user_depart spells its key ID in the
DDL while the entity derives id; keyed by string that is two nodes for one
column and every answer about it is half an answer.
macrozheng/mall (Apache-2.0) is the MyBatis reference target — 383 files, 160
endpoints, 887 mapper-method↔statement bindings. See
sql-lane.md for cloning it.
spring-projects/spring-petclinic (Apache-2.0) is the JPA reference target.
test/petclinic.test.mjs clones it at a pinned commit and checks the mapping
against src/main/resources/db/mysql/schema.sql:
git clone https://github.com/spring-projects/spring-petclinic ../target-examples/spring-petclinic
node --test test/petclinic.test.mjs # or set CASCADE_PETCLINIC=<path>