Skip to content

Commit 41ac897

Browse files
committed
feat(java): Spring Data repo derived-query READS/WRITES + interface extends_interfaces bases
- java.py: add _SPRING_DATA_REPO_BASES, _SPRING_DATA_READS_PREFIXES, _SPRING_DATA_WRITES_PREFIXES - _parse_classes: extract bases from interface extends_interfaces field (tree-sitter uses type_list, not superclass) — fixes INHERITS edges for interfaces too - _extract_orm_mappings: detect Spring Data repo interfaces (JpaRepository, CrudRepository, etc.), extract entity class from generic param, emit spring_data_method records for each findBy*/deleteBy*/save* method - writer.py: write_spring_data_repo_links() — two-hop Cypher: Function → (entity_class Class)-[:MAPS_TO]→ DbTable - pipeline.py: wire write_spring_data_repo_links after write_query_links - tests: 5 new TestSpringDataRepoDetection tests (264 passed, 6 pre-existing failures)
1 parent e919c2a commit 41ac897

4 files changed

Lines changed: 222 additions & 0 deletions

File tree

src/codegraphcontext/tools/indexing/persistence/writer.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,6 +1043,50 @@ def write_mybatis_links(self, mybatis_batch: List[Dict[str, Any]]) -> None:
10431043
written += len(op_edges[i : i + batch_size])
10441044
info_logger(f"[MYBATIS] Written {written} READS/WRITES MyBatis edges")
10451045

1046+
def write_spring_data_repo_links(self, orm_batch: List[Dict[str, Any]]) -> None:
1047+
"""Write READS/WRITES edges for Spring Data repository derived-query methods.
1048+
1049+
Each record must have kind='spring_data_method' and:
1050+
entity_class, method_name, method_path, operation, line_number
1051+
1052+
Uses a two-hop lookup: Function → (entity_class Class)-[:MAPS_TO]→ DbTable
1053+
so no table name is required at parse time.
1054+
"""
1055+
records = [r for r in orm_batch if r.get("kind") == "spring_data_method"]
1056+
if not records:
1057+
return
1058+
1059+
edges = [
1060+
{
1061+
"method_name": r["method_name"],
1062+
"method_path": r["method_path"],
1063+
"entity_class": r["entity_class"],
1064+
"operation": r.get("operation", "READS"),
1065+
"line_number": r.get("line_number", 0),
1066+
}
1067+
for r in records
1068+
]
1069+
1070+
batch_size = 500
1071+
written = 0
1072+
for op in ("READS", "WRITES"):
1073+
op_edges = [e for e in edges if e["operation"] == op]
1074+
if not op_edges:
1075+
continue
1076+
for i in range(0, len(op_edges), batch_size):
1077+
with self.driver.session() as session:
1078+
session.run(
1079+
f"""
1080+
UNWIND $batch AS q
1081+
MATCH (fn:Function {{name: q.method_name, path: q.method_path}})
1082+
MATCH (entity:Class {{name: q.entity_class}})-[:MAPS_TO]->(tbl:DbTable)
1083+
MERGE (fn)-[:{op} {{line_number: q.line_number, source: 'spring_data'}}]->(tbl)
1084+
""",
1085+
batch=op_edges[i : i + batch_size],
1086+
)
1087+
written += len(op_edges[i : i + batch_size])
1088+
info_logger(f"[SPRING_DATA] Written {written} READS/WRITES derived-query edges")
1089+
10461090
def delete_repository_from_graph(self, repo_path: str) -> bool:
10471091
repo_path_str = repo_path
10481092
path_prefix = repo_path_str + "/"

src/codegraphcontext/tools/indexing/pipeline.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ async def run_tree_sitter_index_async(
142142
)
143143
writer.write_orm_mapping_links(orm_batch)
144144
writer.write_query_links(orm_batch)
145+
writer.write_spring_data_repo_links(orm_batch)
145146

146147
# ── MyBatis XML mapper READS / WRITES edges ───────────────────────────────
147148
if not is_dependency and path.is_dir():

src/codegraphcontext/tools/languages/java.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,32 @@
4848
# Determines READ vs WRITE from a SQL/CQL string
4949
_WRITE_PREFIXES = ("INSERT", "UPDATE", "DELETE", "MERGE", "TRUNCATE", "REPLACE")
5050

51+
# Spring Data repository base interfaces — presence means the interface is a repo
52+
_SPRING_DATA_REPO_BASES = frozenset({
53+
"JpaRepository", "CrudRepository", "PagingAndSortingRepository",
54+
"ListCrudRepository", "ListPagingAndSortingRepository",
55+
"MongoRepository", "ReactiveMongoRepository",
56+
"CassandraRepository", "ReactiveCassandraRepository",
57+
"R2dbcRepository", "CoroutineCrudRepository",
58+
})
59+
60+
# Spring Data derived-query method prefixes → READS
61+
_SPRING_DATA_READS_PREFIXES = (
62+
"findby", "findall", "findfirst", "findtop", "finddistinct",
63+
"readby", "getby", "queryby", "searchby",
64+
"countby", "existsby",
65+
"find", "read", "get", "count", "exists", "fetch", "load", "retrieve",
66+
)
67+
68+
# Spring Data derived-query method prefixes → WRITES
69+
_SPRING_DATA_WRITES_PREFIXES = (
70+
"saveall", "saveandflushthem", "saveandflush", "saveallandflush",
71+
"save", "insertall", "insert", "updateall", "update",
72+
"deleteall", "deletebyid", "deleteallinbatch", "deleteinbatch",
73+
"deleteby", "delete", "removeall", "removeby", "remove",
74+
"flush", "create",
75+
)
76+
5177

5278
def _parse_sql_tables(sql: str) -> List[str]:
5379
"""Extract table names from a SQL/CQL string without a full parser.
@@ -411,6 +437,27 @@ def _parse_classes(self, captures: list, source_code: str, path: Path, package_n
411437
if child.type in ('type_identifier', 'generic_type', 'scoped_type_identifier'):
412438
bases.append(self._get_node_text(child))
413439

440+
# Look for extends_interfaces (interface extends another interface)
441+
# Tree-sitter uses a different field for interface_declaration.
442+
# Scan by node type since child_by_field_name may not expose it.
443+
extends_ifaces_node = (
444+
node.child_by_field_name('extends_interfaces')
445+
or next(
446+
(c for c in node.children if c.type == 'extends_interfaces'),
447+
None,
448+
)
449+
)
450+
if extends_ifaces_node:
451+
iface_list = next(
452+
(c for c in extends_ifaces_node.children
453+
if c.type in ('type_list', 'interface_type_list')),
454+
None,
455+
)
456+
candidates = iface_list.children if iface_list else extends_ifaces_node.children
457+
for child in candidates:
458+
if child.type in ('type_identifier', 'generic_type', 'scoped_type_identifier'):
459+
bases.append(self._get_node_text(child))
460+
414461
class_data = {
415462
"name": class_name,
416463
"line_number": start_line,
@@ -660,6 +707,62 @@ def _walk(node: Any) -> None: # noqa: C901
660707
_walk(child)
661708

662709
_walk(tree.root_node)
710+
711+
# ── Spring Data repository derived-query methods ───────────────────
712+
# For interfaces extending JpaRepository<Entity, ID> etc., emit
713+
# READS/WRITES records that the writer resolves via MAPS_TO hop.
714+
for cls in parsed_classes:
715+
if str(path) != cls.get("path"):
716+
continue
717+
bases = cls.get("bases", [])
718+
entity_class: Optional[str] = None
719+
for base_str in bases:
720+
for repo_base in _SPRING_DATA_REPO_BASES:
721+
if repo_base in base_str:
722+
# Extract first generic arg: JpaRepository<UserAuth, Long> → UserAuth
723+
m = re.search(r'<\s*([A-Za-z][A-Za-z0-9_]*)', base_str)
724+
if m:
725+
entity_class = m.group(1)
726+
break
727+
if entity_class:
728+
break
729+
730+
if not entity_class:
731+
continue
732+
733+
cls_start = cls["line_number"]
734+
cls_end = cls.get("end_line", 999999)
735+
736+
for fn in parsed_functions:
737+
if fn.get("path") != str(path):
738+
continue
739+
fn_line = fn.get("line_number", 0)
740+
if not (cls_start <= fn_line <= cls_end):
741+
continue
742+
743+
method_name = fn.get("name", "")
744+
if not method_name:
745+
continue
746+
747+
mn_lower = method_name.lower()
748+
# Check WRITES first (longer prefixes must come first — handled by tuple order)
749+
if any(mn_lower.startswith(p) for p in _SPRING_DATA_WRITES_PREFIXES):
750+
operation = "WRITES"
751+
elif any(mn_lower.startswith(p) for p in _SPRING_DATA_READS_PREFIXES):
752+
operation = "READS"
753+
else:
754+
continue
755+
756+
mappings.append({
757+
"kind": "spring_data_method",
758+
"entity_class": entity_class,
759+
"method_name": method_name,
760+
"class_name": cls["name"],
761+
"method_path": str(path),
762+
"operation": operation,
763+
"line_number": fn_line,
764+
})
765+
663766
return mappings
664767

665768
def _extract_spring_injections(

tests/unit/parsers/test_java_parser.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,3 +361,77 @@ def test_orm_mappings_key_present(self, parser):
361361
assert "orm_mappings" in data
362362
assert isinstance(data["orm_mappings"], list)
363363

364+
365+
# ──────────────────────────────────────────────────────────────────────────────
366+
# Spring Data repository derived-query method detection
367+
# ──────────────────────────────────────────────────────────────────────────────
368+
369+
SPRING_DATA_REPO_SRC = """\
370+
package com.example.db;
371+
372+
import org.springframework.data.jpa.repository.JpaRepository;
373+
import org.springframework.data.repository.CrudRepository;
374+
import java.util.List;
375+
import java.util.Optional;
376+
377+
public interface UserAuthenticationRepository extends JpaRepository<UserAuthentication, Long> {
378+
379+
Optional<UserAuthentication> findByUserId(String userId);
380+
381+
List<UserAuthentication> findByEmailAndStatus(String email, String status);
382+
383+
long countByStatus(String status);
384+
385+
boolean existsByUserId(String userId);
386+
387+
void deleteByUserId(String userId);
388+
389+
List<UserAuthentication> findAllByCreatedAtAfter(long timestamp);
390+
}
391+
392+
public interface OrderRepository extends CrudRepository<Order, Long> {
393+
394+
List<Order> findByCustomerId(String customerId);
395+
396+
void deleteById(Long id);
397+
}
398+
"""
399+
400+
401+
class TestSpringDataRepoDetection:
402+
def test_spring_data_reads_emitted(self, parser):
403+
data = _write_and_parse(parser, SPRING_DATA_REPO_SRC)
404+
orm = data.get("orm_mappings", [])
405+
spring_reads = [r for r in orm if r.get("kind") == "spring_data_method" and r.get("operation") == "READS"]
406+
assert len(spring_reads) >= 4, f"Expected >=4 READS, got {len(spring_reads)}: {spring_reads}"
407+
408+
def test_spring_data_writes_emitted(self, parser):
409+
data = _write_and_parse(parser, SPRING_DATA_REPO_SRC)
410+
orm = data.get("orm_mappings", [])
411+
spring_writes = [r for r in orm if r.get("kind") == "spring_data_method" and r.get("operation") == "WRITES"]
412+
method_names = [r["method_name"] for r in spring_writes]
413+
assert "deleteByUserId" in method_names, f"Expected deleteByUserId in WRITES, got: {method_names}"
414+
415+
def test_spring_data_entity_class_extracted(self, parser):
416+
data = _write_and_parse(parser, SPRING_DATA_REPO_SRC)
417+
orm = data.get("orm_mappings", [])
418+
spring = [r for r in orm if r.get("kind") == "spring_data_method"]
419+
user_auth_methods = [r for r in spring if r.get("entity_class") == "UserAuthentication"]
420+
assert len(user_auth_methods) >= 1, f"Expected entity_class=UserAuthentication, got: {[r.get('entity_class') for r in spring]}"
421+
422+
def test_spring_data_crud_repo_detected(self, parser):
423+
data = _write_and_parse(parser, SPRING_DATA_REPO_SRC)
424+
orm = data.get("orm_mappings", [])
425+
order_methods = [r for r in orm if r.get("kind") == "spring_data_method" and r.get("entity_class") == "Order"]
426+
assert len(order_methods) >= 1, f"Expected Order entity methods, got: {order_methods}"
427+
428+
def test_spring_data_class_name_set(self, parser):
429+
data = _write_and_parse(parser, SPRING_DATA_REPO_SRC)
430+
orm = data.get("orm_mappings", [])
431+
spring = [r for r in orm if r.get("kind") == "spring_data_method"]
432+
for r in spring:
433+
assert r.get("class_name"), f"class_name missing on {r}"
434+
assert r.get("method_name"), f"method_name missing on {r}"
435+
assert r.get("method_path"), f"method_path missing on {r}"
436+
assert r.get("entity_class"), f"entity_class missing on {r}"
437+

0 commit comments

Comments
 (0)