diff --git a/AGENTS.md b/AGENTS.md index 13d3c41e..bc25155a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,19 +171,21 @@ mvn exec:java -Dexec.mainClass="com.raditha.cleanunit.TestFixer" \ 1. **Repository Limit**: Currently processes first JPA repository only 2. **Liquibase Path**: Must be at `/src/main/resources/db/changelog/db.changelog-master.xml` -3. **GEMINI_API_KEY**: Required in environment or `generator.yml` +3. **API Key Required**: `GEMINI_API_KEY` or `OPENAI_API_KEY` environment variable required (depending on provider) 4. **Call Site Updates**: Only updates direct method calls on repository fields, not indirect invocations ## Configuration **Primary Config**: `src/main/resources/generator.yml` +### Gemini Configuration + ```yaml base_path: ${projects_folder}/BM/csi-bm-approval-java-service/ ai_service: provider: "gemini" - model: "gemini-2.5-flash-lite-preview-09-2025" + model: "gemini-3-flash-preview" # or "gemini-2.5-flash-lite-preview-09-2025" api_key: "${GEMINI_API_KEY}" timeout_seconds: 90 max_retries: 2 @@ -192,6 +194,25 @@ ai_service: **Required**: `GEMINI_API_KEY` environment variable +### OpenAI Configuration + +```yaml +base_path: ${projects_folder}/BM/csi-bm-approval-java-service/ + +ai_service: + provider: "openai" + model: "gpt-4o" # or "gpt-4o-mini" (recommended budget option) + api_key: "${OPENAI_API_KEY}" + api_endpoint: "https://api.openai.com/v1/chat/completions" # Optional + timeout_seconds: 90 + max_retries: 2 + track_usage: true +``` + +**Required**: `OPENAI_API_KEY` environment variable + +**Supported Providers**: `gemini` (default) or `openai` + **Liquibase**: Must be at `/src/main/resources/db/changelog/db.changelog-master.xml` ## Development Patterns @@ -209,7 +230,7 @@ ai_service: - JavaParser 3.26.2 (AST parsing/modification) - JSQLParser 5.3 (SQL parsing) - ANTLR4 Runtime 4.13.1 (HQL parsing) -- Gemini API (AI optimization) +- Gemini API or OpenAI API (AI optimization) - JUnit 5 + Mockito (testing) ## Output Artifacts diff --git a/README.md b/README.md index ba5b218c..58d6c37a 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,25 @@ This module provides a suite of advanced utilities and tools built on top of the - [Development Setup](#development-setup) - [Maven Parent POM Converter](#maven-parent-pom-converter) - [Query Optimizer](#query-optimizer) +- [JPA Repository Analyzer](#jpa-repository-analyzer) - [Spring Boot Migration Tools](#spring-boot-migration-tools) - [2.1 to 2.2 Migrator](#spring-boot-2122-migrator) - [2.2 to 2.3 Migrator](#spring-boot-2223-migrator) + - [2.3 to 2.4 Migrator](#spring-boot-2324-migrator) + - [2.4 to 2.5 Migrator](#spring-boot-2425-migrator) + - [Spring Boot Version Detector](#spring-boot-version-detector) - [Circular Dependency Tool](#circular-dependency-tool) - [Code Quality & Analysis Tools](#code-quality--analysis-tools) + - [AnnotationFinder](#annotationfinder) - [TestFixer](#testfixer) - - [Liquibase Indexes](#liquibase-indexes) + - [Logger Analysis](#logger-analysis) + - [Liquibase Tools](#liquibase-tools) + - [Liquibase Indexes](#liquibase-indexes) + - [Liquibase Validator](#liquibase-validator) + - [Liquibase Validation MCP Server](#liquibase-validation-mcp-server) - [HardDelete Detection](#harddelete-detection) - [UsageFinder](#usagefinder) +- [Knowledge Graph](#knowledge-graph) --- @@ -114,6 +124,24 @@ Ensure your `generator.yml` is set up with valid AI service credentials and poin --- +## JPA Repository Analyzer + +Bulk exports all JPA repository queries to CSV for auditing and analysis. + +### Quick Start +```bash +mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.examples.JPARepositoryAnalyzer" \ + -Dexec.args="-b /path/to/project -o repository_queries.csv" +``` + +### Features +- Extracts all `@Query` annotations from JPA repositories +- Exports query metadata to CSV format +- Supports custom output file paths +- Analyzes both explicit queries and derived query methods + +--- + ## Spring Boot Migration Tools Automated tools to upgrade Spring Boot applications across major/minor versions. @@ -132,6 +160,25 @@ java -cp target/classes com.raditha.spring.SpringBoot22to23Migrator --project-pa ``` πŸ“– **[Quick Start](docs/spring_boot_2.2_to_2.3_quickstart.md)** +### Spring Boot 2.3β†’2.4 Migrator +Upgrades Spring Boot 2.3 apps to 2.4. +```bash +java -cp target/classes com.raditha.spring.SpringBoot23to24Migrator --project-path /path/to/project +``` + +### Spring Boot 2.4β†’2.5 Migrator +Upgrades Spring Boot 2.4 apps to 2.5. +```bash +java -cp target/classes com.raditha.spring.SpringBoot24to25Migrator --project-path /path/to/project +``` + +### Spring Boot Version Detector +Detects the Spring Boot version from a POM file. +```bash +java -cp target/classes sa.com.cloudsolutions.antikythera.examples.SpringBootVersionDetector /path/to/pom.xml +``` +Outputs the detected Spring Boot version to stdout. + ### Circular Dependency Tool Detects and resolves circular bean dependencies using strategies like `@Lazy`, setter injection, and **Method Extraction** for `@PostConstruct` cycles. @@ -146,6 +193,34 @@ mvn exec:java -Dexec.mainClass="com.raditha.spring.cycle.CircularDependencyTool" ## Code Quality & Analysis Tools +### AnnotationFinder +Finds all classes and methods annotated with a specific annotation. + +**Quick Start:** +```bash +# Detailed output (default) - includes full method signatures +mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.examples.AnnotationFinder" \ + -Dexec.args="Service" + +# Simple output - method name only, duplicates eliminated +mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.examples.AnnotationFinder" \ + -Dexec.args="Service --simple" +``` + +**Features:** +- Searches entire codebase for specified annotation +- Handles both simple names (e.g., `Service`) and fully qualified names (e.g., `org.springframework.stereotype.Service`) +- Two output modes: detailed (default) and simple +- Supports any annotation name (e.g., `@Service`, `@Test`, `@Entity`, `@Query`) + +**Output Format:** +- **Class annotations**: `com.example.MyService` (same in both modes) +- **Method annotations (detailed mode)**: `com.example.MyClass#print()` or `com.example.MyClass#print(String s)` +- **Method annotations (simple mode)**: `com.example.MyClass#print` (method name only, no duplicates for overloaded methods) + +**Options:** +- `--simple` or `-s`: Enable simple output mode (method name only, eliminates duplicates) + ### TestFixer Identifies and fixes bad testing patterns (missing assertions, framework migration). ```bash @@ -153,13 +228,36 @@ mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.examples.TestF ``` πŸ“– **[Documentation](docs/test_fixer.md)** -### Liquibase Indexes -Audits database indexes defined in Liquibase changelogs. +### Logger Analysis +Analyzes and standardizes logger usage across the codebase, ensuring consistent logging patterns. +```bash +mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.examples.Logger" +``` +Processes all classes in the project and standardizes logger field declarations and usage. + +### Liquibase Tools + +#Audits database indexes defined in Liquibase changelogs. ```bash mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.liquibase.Indexes" -Dexec.args="path/to/db.changelog-master.xml" ``` πŸ“– **[Documentation](docs/liquibase_indexes.md)** +#### Liquibase Validator +Validates Liquibase changelog XML files for syntax errors and structural issues. +```bash +mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.mcp.LiquibaseValidator" \ + -Dexec.args="path/to/changelog.xml" +``` +Outputs validation results in JSON format with exit code 0 for valid files, 1 for invalid. + +#### Liquibase Validation MCP Server +MCP (Model Context Protocol) server for Liquibase validation, enabling integration with AI agents and tools. +```bash +mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.mcp.LiquibaseValidationMcpServer" +``` +Starts an MCP server that provides Liquibase validation capabilities via the MCP protocol. + ### HardDelete Detection Finds hard delete operations in repositories to enforce soft-delete policies. ```bash @@ -173,3 +271,19 @@ Analyzes collection usage (List, Set, Map) in non-entity classes to detect poten mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.examples.UsageFinder" ``` πŸ“– **[Documentation](docs/usage_finder.md)** + +--- + +## Knowledge Graph + +### KnowledgeGraphCLI +Generates knowledge graphs from Java codebases, visualizing relationships between classes, methods, and dependencies. +```bash +mvn exec:java -Dexec.mainClass="com.raditha.graph.KnowledgeGraphCLI" \ + -Dexec.args="--base-path=/path/to/project --config=src/main/resources/graph.yml" +``` + +### Features +- Builds comprehensive dependency graphs +- Configurable analysis scope via YAML configuration +- Visualizes code relationships and dependencies diff --git a/docs/findAll-vs-visit.md b/docs/findAll-vs-visit.md new file mode 100644 index 00000000..a562fb78 --- /dev/null +++ b/docs/findAll-vs-visit.md @@ -0,0 +1,70 @@ +# Java Parser Method Finding Benchmark + +## Overview + +This benchmark compares the performance of two approaches for finding a specific method declaration in a JavaParser AST: + +1. **Using `findAll()`**: Calling `TypeDeclaration.findAll(MethodDeclaration.class)` and filtering by name +2. **Using `VoidVisitor`**: Using a custom `VoidVisitorAdapter` to traverse the AST + +## Benchmark Details + +- **Target File**: `AbstractCompiler.java` (~1591 lines) +- **Target Method**: `camelToSnakeCase` +- **Iterations**: 1000 per approach +- **JVM Warmup**: 100 iterations before actual benchmark + +## Results + +Based on multiple benchmark runs, the results consistently show: + +| Approach | Average Time (1000 iterations) | Performance | +|----------|-------------------------------|-------------| +| `findAll()` | ~87-90 ms | **2.3-2.6x FASTER** | +| `VoidVisitor` | ~205-225 ms | Baseline | + +## Key Findings + +1. **`findAll()` is significantly faster** (~2.4x on average) than using a VoidVisitor pattern +2. Both methods successfully locate the same method declaration (verification passed) +3. Results are consistent across multiple runs with minimal variance + +## Why is `findAll()` Faster? + +The `findAll()` method in JavaParser is highly optimized and uses internal shortcuts to efficiently traverse the AST. The VoidVisitor pattern, while more flexible, involves: +- Additional object creation (visitor instance) +- Virtual method dispatch overhead +- More callback invocations during traversal + +## When to Use Each Approach + +### Use `findAll()` when: +- You need to find all instances of a specific node type +- Performance is critical +- The search criteria is simple (type-based filtering) + +### Use `VoidVisitor` when: +- You need complex custom logic during traversal +- You need to maintain state across multiple node visits +- You need to visit multiple different node types in a single pass +- You need to modify the AST during traversal + +## Running the Benchmark + +```bash +cd /home/raditha/csi/Antikythera/antikythera-examples +mvn clean compile test-compile +java -cp "target/test-classes:target/classes:$(mvn dependency:build-classpath -q -Dmdep.outputFile=/dev/stdout)" com.raditha.perf.JavaParserMethodFindingBenchmark +``` + +## Implementation Notes + +- JavaParser is configured to support Java 21 language features +- The benchmark includes JVM warmup to ensure accurate measurements +- Both methods operate on the same parsed `CompilationUnit` to ensure fair comparison +- Timing is done using `System.nanoTime()` for high precision + +## Conclusion + +For simple method lookups by name, **`findAll()` is the clear winner** in terms of performance. However, the VoidVisitor pattern remains valuable for more complex traversal scenarios where its flexibility outweighs the performance overhead. + diff --git a/docs/knowledge-graph-design-review.md b/docs/knowledge-graph-design-review.md deleted file mode 100644 index 2e87564f..00000000 --- a/docs/knowledge-graph-design-review.md +++ /dev/null @@ -1,568 +0,0 @@ -# Knowledge Graph Implementation Plan - Technical Review - -**Reviewer**: GitHub Copilot -**Date**: January 12, 2026 -**Document Reviewed**: knowledge-graph-design.md -**Status**: βœ… **APPROVED WITH RECOMMENDATIONS** - ---- - -## Executive Summary - -The Knowledge Graph Builder design is **architecturally sound** and demonstrates excellent understanding of Antikythera's core capabilities. The phased implementation approach is appropriate, and the proposed advanced features leverage the engine's strengths effectively. - -**Key Strengths**: -- βœ… Correct identification of `DependencyAnalyzer` as the base class -- βœ… Proper understanding of `GraphNode` wrapping semantics -- βœ… Strategic use of existing infrastructure (`AntikytheraRunTime`, `Evaluator`) -- βœ… Well-structured data model with `KnowledgeGraphEdge` record - -**Critical Issues**: None -**Recommended Enhancements**: 7 (detailed below) - ---- - -## Section-by-Section Analysis - -### 1. Overview & Architecture (Sections 1-2.1) - -**Assessment**: βœ… **Excellent** - -The choice to extend `DependencyAnalyzer` rather than `DepSolver` is correct: -- `DependencyAnalyzer` is the analysis-only base (no code generation) -- `DepSolver` extends it with code generation features (not needed for graph building) - -**Validated Against Codebase**: -```java -// From DependencyAnalyzer.java -public Set collectDependencies(Collection methods) { - resetAnalysis(); - for (MethodDeclaration method : methods) { - createAnalysisNode(method); // βœ… Confirmed: extensible hook - } - dfs(); // βœ… Confirmed: traversal engine - return new HashSet<>(discoveredNodes); -} -``` - ---- - -### 2.2 Data Model - -**Assessment**: βœ… **Good** with minor clarifications needed - -#### Issue 1: GraphNode Identity Documentation -**Severity**: 🟑 Low (Documentation) - -The plan states: -> **Identity**: Unique signature based on FQN and method signature. - -**Reality Check**: -```java -// From GraphNode.java -@Override -public int hashCode() { - return toString().hashCode(); // Uses Node.toString() -} - -@Override -public boolean equals(Object obj) { - if (obj instanceof GraphNode other) { - return node.equals(other.node); // Delegates to AST Node equality - } - return false; -} -``` - -**Recommendation**: The `sourceId` and `targetId` in `KnowledgeGraphEdge` should use a stable signature format. Consider: -```java -public String getStableSignature(GraphNode node) { - Node n = node.getNode(); - if (n instanceof MethodDeclaration md) { - String fqn = node.getEnclosingType().getFullyQualifiedName().orElse("Unknown"); - String params = md.getParameters().stream() - .map(p -> p.getType().asString()) - .collect(Collectors.joining(",")); - return fqn + "#" + md.getNameAsString() + "(" + params + ")"; - } else if (n instanceof FieldDeclaration fd) { - String fqn = node.getEnclosingType().getFullyQualifiedName().orElse("Unknown"); - return fqn + "#" + fd.getVariable(0).getNameAsString(); - } - // ... handle other node types - return node.toString(); -} -``` - -#### Issue 2: InitializerDeclaration Support -**Severity**: 🟒 Already Handled (Good News!) - -The plan mentions static blocks "might be skipped by the default analyzer." - -**Reality**: Antikythera **already processes** `InitializerDeclaration`: -```java -// From Evaluator.java (FieldVisitor class) -@Override -public void visit(InitializerDeclaration init, Void arg) { - init.findAncestor(ClassOrInterfaceDeclaration.class) - .flatMap(ClassOrInterfaceDeclaration::getFullyQualifiedName) - .ifPresent(name -> { - if (name.equals(matchingClass)) { - try { - executeBlock(init.getBody().getStatements()); // βœ… Fully supported - } - } - }); -} -``` - -**Action**: Phase 2, Task 2.2 can be **simplified**. No special override needed; the existing `createAnalysisNode()` handles it via the visitor pattern. - ---- - -### 3. Implementation Plan (Phases 1-4) - -**Assessment**: βœ… **Well-Structured** with tactical adjustments - -#### Phase 1: Core Data Structures - -**Task 1.1**: EdgeType Enum -βœ… **Approved**. Suggestion: Add two more types early: -```java -public enum EdgeType { - CONTAINS, // Structural - CALLS, // Behavioral - ACCESSES, // Behavioral - USES, // Type dependency - IMPLEMENTS, // ⭐ From Section 4.1 - EXTENDS // ⭐ From Section 4.1 -} -``` -This avoids schema evolution issues later. - -**Task 1.2**: KnowledgeGraphEdge Record -βœ… **Approved**. The record design is clean. Consider adding a builder for complex edges: -```java -public record KnowledgeGraphEdge( - String sourceId, - String targetId, - EdgeType type, - Map attributes -) { - public static Builder builder() { return new Builder(); } - - public static class Builder { - private String sourceId; - private String targetId; - private EdgeType type; - private Map attributes = new HashMap<>(); - - public Builder source(GraphNode node) { - this.sourceId = getStableSignature(node); - return this; - } - public Builder target(GraphNode node) { - this.targetId = getStableSignature(node); - return this; - } - public Builder type(EdgeType type) { - this.type = type; - return this; - } - public Builder attribute(String key, String value) { - attributes.put(key, value); - return this; - } - public KnowledgeGraphEdge build() { - return new KnowledgeGraphEdge(sourceId, targetId, type, - Collections.unmodifiableMap(attributes)); - } - } -} -``` - -**Task 1.3**: Extend GraphNode -⚠️ **CAUTION**: The plan says "create a wrapper or side-car map." - -**Better Alternative** (from Section 5.2): The recommendation to add `Map metadata` to `GraphNode` itself is **not recommended** because: -1. `GraphNode` is a core engine class used by `DepSolver` for code generation -2. Adding analysis-specific metadata pollutes the core abstraction -3. The side-car map approach is cleaner: - -```java -public class KnowledgeGraphBuilder extends DependencyAnalyzer { - private Map> nodeMetadata = new HashMap<>(); - - public void setNodeMetadata(GraphNode node, String key, Object value) { - nodeMetadata.computeIfAbsent(node, k -> new HashMap<>()).put(key, value); - } -} -``` - -#### Phase 2: Builder Implementation - -**Task 2.1**: Skeleton -βœ… **Approved**. Starter code: -```java -package sa.com.cloudsolutions.antikythera.depsolver; - -import com.github.javaparser.ast.CompilationUnit; -import java.util.*; - -public class KnowledgeGraphBuilder extends DependencyAnalyzer { - private final List edges = new ArrayList<>(); - private final Map> nodeMetadata = new HashMap<>(); - - public KnowledgeGraph build(List compilationUnits) { - resetAnalysis(); - - // Step 1: Discover all methods/fields to analyze - List methods = new ArrayList<>(); - for (CompilationUnit cu : compilationUnits) { - cu.findAll(MethodDeclaration.class).forEach(methods::add); - } - - // Step 2: Trigger DFS (this populates discoveredNodes) - collectDependencies(methods); - - // Step 3: Extract edges (implemented in Phase 3) - // The DependencyVisitor will have already created edges - - return new KnowledgeGraph(discoveredNodes, edges); - } -} -``` - -**Task 2.2**: Node Discovery Override -βœ… **SIMPLIFIED** (see Issue 2 above). No special handling needed. - -**Task 2.3**: Traversal Logic -βœ… **Approved** (see Task 2.1 code). - -#### Phase 3: Visitor Logic - -**Critical Enhancement Needed**: - -The plan describes creating new visitor methods, but the **correct approach** is to extend the existing `DependencyVisitor`: - -```java -public class KnowledgeGraphBuilder extends DependencyAnalyzer { - - @Override - protected DependencyVisitor createVisitor() { - return new KnowledgeGraphVisitor(); - } - - protected class KnowledgeGraphVisitor extends DependencyVisitor { - - @Override - public void visit(MethodCallExpr mce, GraphNode node) { - super.visit(mce, node); // ⭐ Preserve default behavior - - // Extract edge data - MCEWrapper wrap = Resolver.resolveArgumentTypes(node, mce); - if (wrap.getTargetMethod() != null) { - Map params = new HashMap<>(); - for (int i = 0; i < mce.getArguments().size(); i++) { - params.put("arg" + i, mce.getArgument(i).toString()); - } - - edges.add(KnowledgeGraphEdge.builder() - .source(node) - .target(GraphNode.graphNodeFactory(wrap.getTargetMethod())) - .type(EdgeType.CALLS) - .attribute("parameters", params.toString()) - .build()); - } - } - - @Override - public void visit(FieldAccessExpr fae, GraphNode node) { - super.visit(fae, node); - // Extract field access edges... - } - } -} -``` - -**Issue**: The plan's Task 3.1 says "Use `Resolver.resolveArgumentTypes`" but doesn't mention that this method is **already called** by the default `DependencyVisitor`. The Knowledge Graph builder should **reuse** that work, not duplicate it. - -**Validation**: -```java -// From Resolver.java -static void wrapCallable(GraphNode node, NodeWithArguments callExpression, NodeList types) { - MCEWrapper wrap = resolveArgumentTypes(node, callExpression); // βœ… Confirmed - GraphNode gn = Resolver.chainedMethodCall(node, wrap); - // ... creates graph nodes automatically -} -``` - -#### Phase 4: Serialization - -**Task 4.1**: Graph Export API -βœ… **Approved**. Suggested signature: -```java -public class KnowledgeGraph { - private final Set nodes; - private final List edges; - - public Set getNodes() { return Collections.unmodifiableSet(nodes); } - public List getEdges() { return Collections.unmodifiableList(edges); } -} -``` - -**Task 4.2**: JSON Serialization -⚠️ **IMPLEMENTATION DETAIL NEEDED** - -The plan is vague on the JSON structure. Recommendation: - -```json -{ - "nodes": [ - { - "id": "com.example.Service#processOrder(Order)", - "type": "METHOD", - "enclosingClass": "com.example.Service", - "metadata": {} - } - ], - "edges": [ - { - "source": "com.example.Service#processOrder(Order)", - "target": "com.example.Repository#save(Order)", - "type": "CALLS", - "attributes": { - "arg0": "order" - } - } - ] -} -``` - -Suggested implementation: -```java -public class KnowledgeGraphSerializer { - public String toJson(KnowledgeGraph graph) { - // Use Jackson or Gson - ObjectMapper mapper = new ObjectMapper(); - return mapper.writeValueAsString(graph); - } -} -``` - ---- - -### 4. Advanced Reuse Opportunities - -**Assessment**: 🌟 **EXCELLENT STRATEGIC THINKING** - -#### 4.1 Inheritance Mapping - -βœ… **Validated**. The APIs exist and work as described: -```java -// From AntikytheraRunTime.java -public static Set findSubClasses(String parent) { - return extensions.getOrDefault(parent, new HashSet<>()); -} - -public static Set findImplementations(String iface) { - return interfaces.getOrDefault(iface, new HashSet<>()); -} -``` - -**Implementation**: -```java -public void addInheritanceEdges() { - for (GraphNode node : discoveredNodes) { - if (node.getNode() instanceof ClassOrInterfaceDeclaration cid) { - String fqn = cid.getFullyQualifiedName().orElse(null); - if (fqn != null) { - // Add EXTENDS edges - for (String child : AntikytheraRunTime.findSubClasses(fqn)) { - edges.add(createEdge(child, fqn, EdgeType.EXTENDS)); - } - - // Add IMPLEMENTS edges - for (String impl : AntikytheraRunTime.findImplementations(fqn)) { - edges.add(createEdge(impl, fqn, EdgeType.IMPLEMENTS)); - } - } - } - } -} -``` - -#### 4.2 Value-Aware Edges - -βœ… **Validated**. The API exists: -```java -// From Evaluator.java -public static Variable evaluateLiteral(Expression expr) throws EvaluatorException { - return switch (expr) { - case BooleanLiteralExpr b -> new Variable(..., b.getValue()); - case StringLiteralExpr s -> new Variable(..., s.getValue()); - // ... etc - }; -} -``` - -**Caution**: This method throws `EvaluatorException` for non-literal expressions. Guard against failures: -```java -Map params = new HashMap<>(); -for (int i = 0; i < mce.getArguments().size(); i++) { - Expression arg = mce.getArgument(i); - try { - Variable v = Evaluator.evaluateLiteral(arg); - params.put("arg" + i + "_value", String.valueOf(v.getValue())); - params.put("arg" + i + "_expr", arg.toString()); - } catch (EvaluatorException e) { - // Non-literal; store expression only - params.put("arg" + i + "_expr", arg.toString()); - } -} -``` - -#### 4.3 Path-Conditional Edges - -🟑 **AMBITIOUS - REQUIRES CAREFUL DESIGN** - -The plan references `ControlFlowEvaluator` and `TruthTable`. These exist but are **complex subsystems** designed for test generation. - -**Reality Check**: -```java -// From ConditionVisitor.java (part of control flow analysis) -public class ConditionVisitor extends VoidVisitorAdapter { - // Tracks conditions, branches, and execution paths -} -``` - -**Recommendation**: **Defer to Phase 2 of Knowledge Graph** (after basic graph works). The integration is non-trivial and requires: -1. Running the Evaluator during graph construction (heavyweight) -2. Handling conditional expressions that can't be statically resolved -3. Potentially exponential edge explosion (one edge per conditional path) - -For now, add a simpler `lineNumber` attribute: -```java -edges.add(KnowledgeGraphEdge.builder() - .source(node) - .target(targetNode) - .type(EdgeType.CALLS) - .attribute("lineNumber", String.valueOf(mce.getBegin().get().line)) - .build()); -``` - ---- - -### 5. Recommended Engine Abstractions - -**Assessment**: ⚠️ **WELL-INTENTIONED BUT PROBLEMATIC** - -#### 5.1 Pluggable Discovery (DiscoveryListener) - -**Status**: πŸ”΄ **Not Recommended** - -While the listener pattern is clean, it introduces **significant complexity** for minimal gain: - -**Problems**: -1. **Backward compatibility**: Existing `DepSolver` subclasses would break -2. **Performance**: Listener dispatch adds overhead to hot paths -3. **Unnecessary**: The Knowledge Graph can already hook into the visitor pattern (as shown in Phase 3 above) - -**Alternative**: The current approach of **extending `DependencyVisitor`** achieves the same goal without engine changes. - -#### 5.2 Extensible Metadata on GraphNode - -**Status**: 🟑 **Defer to Core Team** - -Adding `Map metadata` to `GraphNode` has implications: - -**Pros**: -- Cleaner API for attaching analysis-specific data -- Avoids side-car maps - -**Cons**: -- `GraphNode` is a foundational class; changes affect all of Antikythera -- The metadata field would be unused by `DepSolver` (memory waste) -- Unclear lifecycle: when is metadata cleared? - -**Recommendation**: Start with the **side-car map pattern** in `KnowledgeGraphBuilder`. If this pattern becomes common across multiple analyzers, then propose the core change. - ---- - -## Recommended Adjustments - -### Priority Changes - -#### πŸ”΄ Critical (Must Fix Before Implementation) -1. **Add `getStableSignature(GraphNode)` utility** to ensure edge IDs are deterministic (see Section 2.2, Issue 1) - -#### 🟑 Important (Recommended) -2. **Extend `EdgeType` enum early** to include `IMPLEMENTS` and `EXTENDS` (see Phase 1, Task 1.1) -3. **Add `KnowledgeGraphEdge.Builder`** for cleaner edge construction (see Phase 1, Task 1.2) -4. **Modify Phase 3 approach**: Extend `DependencyVisitor` rather than creating parallel visitors (see Phase 3 analysis) - -#### 🟒 Nice-to-Have (Optional) -5. **Define JSON schema explicitly** before implementing serialization (see Phase 4, Task 4.2) -6. **Defer path-conditional edges** to Phase 2 (see Section 4.3) -7. **Do not implement Section 5 engine abstractions** until Knowledge Graph proves the need (see Section 5 analysis) - ---- - -## Additional Observations - -### Strengths of the Design - -1. **Proper Layering**: The design correctly identifies that the Knowledge Graph is an **analyzer**, not a code generator. This keeps it in the right abstraction layer. - -2. **Reuse Strategy**: Excellent awareness of existing capabilities (`AntikytheraRunTime` maps, `Evaluator.evaluateLiteral`, etc.). - -3. **Phased Approach**: The 4-phase plan is realistic and testable. - -### Potential Pitfalls - -1. **Scope Creep**: Section 4.3 (path-conditional edges) could balloon into a mini-project. Keep it scoped. - -2. **Performance**: The plan doesn't discuss performance for large codebases. Consider: - - Streaming edge generation instead of in-memory lists - - Incremental graph updates (if analyzing a single changed class) - -3. **Testing Strategy**: The plan lacks a testing section. Recommendation: - ``` - Phase 1 Test: Verify EdgeType enum and KnowledgeGraphEdge record - Phase 2 Test: Verify builder discovers all nodes from a test codebase - Phase 3 Test: Verify correct edge generation for each edge type - Phase 4 Test: Verify JSON output matches schema - ``` - ---- - -## Final Verdict - -**Status**: βœ… **APPROVED FOR IMPLEMENTATION** with the following conditions: - -1. βœ… Implement **Phases 1-3** as planned (with adjustments noted above) -2. βœ… Implement **Section 4.1** (inheritance edges) in Phase 3 -3. βœ… Implement **Section 4.2** (value-aware edges) as optional enhancement -4. ⏸️ **Defer Section 4.3** (path-conditional edges) to future work -5. ❌ **Do not implement Section 5** (engine abstractions) at this time - -**Overall Quality**: 9/10 - This is a well-researched, architecturally sound plan that demonstrates deep understanding of the Antikythera engine. - ---- - -## Next Steps - -1. **Pre-Implementation**: Create a test project with sample classes demonstrating all edge types -2. **Phase 1**: Implement data structures (1-2 days) -3. **Phase 2**: Implement builder skeleton with basic traversal (2-3 days) -4. **Phase 3**: Implement visitor extensions for edge extraction (3-5 days) -5. **Phase 4**: Implement serialization (1-2 days) -6. **Validation**: Run against the `antikythera-examples` codebase and verify graph correctness - -**Total Estimated Effort**: 10-15 developer days for core functionality. - ---- - -**Document Prepared By**: GitHub Copilot AI -**Technical Authority**: Antikythera Codebase Analysis -**Confidence Level**: High (validated against actual implementation) - diff --git a/docs/knowledge-graph-design.md b/docs/knowledge-graph-design.md index 616496e5..7ce3f77a 100644 --- a/docs/knowledge-graph-design.md +++ b/docs/knowledge-graph-design.md @@ -1,9 +1,13 @@ # Knowledge Graph Builder Design ## 1. Overview -The Knowledge Graph Builder maps structural and behavioral relationships within Java source code by leveraging the **Antikythera** engine. Edges are **streamed directly to Neo4j** during traversal to support large codebases without memory pressure. +The Knowledge Graph Builder maps structural and behavioral relationships within Java source code by leveraging the **Antikythera** engine. Edges are **streamed directly to the graph store** during traversal to support large codebases without memory pressure. -**Output**: A Neo4j graph where nodes represent code elements and edges represent relationships. +**Output**: A graph (Neo4j or Apache AGE) where nodes represent code elements and edges represent relationships. + +**Supported Backends**: +* **Neo4j** β€” Bolt protocol via the official Neo4j Java driver. +* **Apache AGE** β€” PostgreSQL extension accessed via JDBC. ## 2. Quality Standards * **Testing**: Each phase includes unit tests targeting β‰₯80% branch coverage. @@ -20,7 +24,7 @@ The Knowledge Graph Builder maps structural and behavioral relationships within #### Nodes Reuses `sa.com.cloudsolutions.antikythera.depsolver.GraphNode`. * **Identity**: Stable Signature (FQN + logical name/params). -* **Supported Types**: `TypeDeclaration`, `CallableDeclaration`, `FieldDeclaration`, `InitializerDeclaration`. +* **Supported Types**: `TypeDeclaration` (Class/Interface/Enum), `CallableDeclaration`, `FieldDeclaration`, `InitializerDeclaration`, `EnumConstantDeclaration`, `LambdaExpr`. #### Edges (`KnowledgeGraphEdge`) ```java @@ -39,47 +43,77 @@ public record KnowledgeGraphEdge( | `CALLS` | Behavioral | Method invokes Method | | `ACCESSES` | Behavioral | Method reads/writes Field | | `USES` | Dependency | Method uses Type | +| `REFERENCES` | Behavioral | Method references Method (Method Reference) | ### 3.3 Streaming Architecture -Edges are **NOT accumulated in memory**. As each edge is discovered during DFS traversal, it is immediately persisted to Neo4j via batch transactions. +Edges are **NOT accumulated in memory**. As each edge is discovered during DFS traversal, it is immediately persisted to the graph store via batch transactions. ``` -Traverse AST β†’ discover edge β†’ Neo4jGraphStore.persistEdge(edge) β†’ commit every N edges +Traverse AST β†’ discover edge β†’ GraphStore.persistEdge(edge) β†’ commit every N edges ``` -### 3.4 Neo4j Storage +### 3.4 Graph Storage #### Schema -* **Nodes**: Labeled by type (`Class`, `Method`, `Field`, `StaticBlock`). +* **Nodes**: Labeled by type (`Class`, `Interface`, `Enum`, `Method`, `Field`, `StaticBlock`, `Lambda`). * **Properties**: `signature`, `name`, `fqn`, `lineNumber`. * **Relationships**: Labeled by edge type with `attributes` map. -#### Configuration (`application.yml`) +#### Backend Abstraction +The `GraphStore` interface decouples the builder from any specific database. `GraphStoreFactory` reads the `graph:` section from the YAML configuration (loaded via `Settings`) and creates the appropriate implementation. + +``` +GraphStore (interface) +β”œβ”€β”€ Neo4jGraphStore β€” Bolt protocol, batched Cypher UNWIND +└── ApacheAgeGraphStore β€” JDBC, SQL-wrapped Cypher +``` + +#### Configuration (`graph.yml`) +All graph settings live under a single `graph:` key. The `type` field selects the backend; `batch_size` is shared. + +**Neo4j** +```yaml +graph: + type: neo4j + batch_size: 1000 + neo4j: + uri: bolt://localhost:7687 + username: neo4j + password: ${NEO4J_PASSWORD} + database: neo4j +``` + +**Apache AGE** ```yaml -antikythera: - graph: - neo4j: - uri: bolt://localhost:7687 - username: neo4j - password: ${NEO4J_PASSWORD} - database: antikythera - batchSize: 1000 +graph: + type: age + batch_size: 1000 + age: + url: jdbc:postgresql://localhost:5432/postgres + user: postgres + password: ${AGE_PASSWORD} + graph_name: antikythera_graph ``` +Sample configuration files are provided in `src/main/resources/`: +* `graph-neo4j.yml.example` +* `graph-age.yml.example` + ## 4. Implementation Plan ### Phase 1: Antikythera Core Improvements * **Task 1.1-1.5**: Signature expansion, visitor hooks, inner class/lambda traversal. * **Task 1.6**: Unit tests (80% coverage). -### Phase 2: Data Structures & Neo4j Store +### Phase 2: Data Structures & Graph Store * **Task 2.1**: `SignatureUtils` for deterministic IDs. * **Task 2.2**: `EdgeType` enum + `KnowledgeGraphEdge` record. -* **Task 2.3**: `Neo4jGraphStore` with `persistNode()` and `persistEdge()`. -* **Task 2.4**: Unit tests (80% coverage). +* **Task 2.3**: `GraphStore` interface + `Neo4jGraphStore` and `ApacheAgeGraphStore` implementations. +* **Task 2.4**: `GraphStoreFactory` β€” reads `graph:` config from `Settings`, creates the right backend. +* **Task 2.5**: Unit tests (80% coverage). ### Phase 3: Builder & Streaming -* **Task 3.1**: `KnowledgeGraphBuilder` with injected `Neo4jGraphStore`. +* **Task 3.1**: `KnowledgeGraphBuilder` with injected `GraphStore`. * **Task 3.2**: `build(List)` entry point with streaming. * **Task 3.3**: Unit tests (80% coverage). @@ -91,9 +125,20 @@ antikythera: * **Task 5.1**: Cypher query wrappers (`findCallers`, `findCallees`). * **Task 5.2**: Integration tests with test-bed/test-helper. -## Appendix: Critical Findings +## 5. Modeling Guidelines + +### 5.1 Enums +Enums are treated as `TypeDeclaration` nodes. Their constants are linked via `CONTAINS` edges. + +### 5.2 Lambdas and Functional Interfaces +* **Lambdas**: Modeled as distinct nodes with a generated stable signature. All behavioral edges (`CALLS`, `ACCESSES`) from within the lambda must originate from the lambda node, not the enclosing method. +* **Method References**: Modeled as a `REFERENCES` edge from the source method to the target method. + +### 5.3 Chain Resolution +Behavioral chains (fluent APIs, Streams) must be decomposed into individual edges. Intermediate types in the chain (e.g., `Stream`) are captured via `USES` edges. + +## Appendix: Original Finding (Revised) -1. **getNodeSignature() incomplete**: Only handles `CallableDeclaration`. -2. **No `createVisitor()` hook**: `DependencyVisitor` instantiated inline. -3. **`FieldAccessExpr` not visited**: Relies on `ExpressionStmt` handling. -4. **Inner class/lambda not traversed**: Requires new visitor methods. +1. **getNodeSignature() incomplete**: Expanded to handle Enums, Initializers, and Lambdas. +2. **Structural Integrity**: Entry point moved to `CompilationUnit` level to ensure `EXTENDS`/`IMPLEMENTS`/`CONTAINS` are captured. +3. **Behavioral Accuracy**: Scope resolution improved to distinguish between local, instance, and static calls. diff --git a/docs/knowledge-graph-remediation-plan.md b/docs/knowledge-graph-remediation-plan.md new file mode 100644 index 00000000..a53077b5 --- /dev/null +++ b/docs/knowledge-graph-remediation-plan.md @@ -0,0 +1,115 @@ +# Implementation Plan: Knowledge Graph Structural and Behavioral Fixes + +This plan revises the previous remediation steps based on the current implementation in `antikythera-examples` and the design expectations in `knowledge-graph-design.md`. + +## 1. Gap Summary (Why the previous plan is incomplete) + +1. `KnowledgeGraphBuilder` still builds from `Collection` only, so class-level structure is not traversed. +2. `GraphVisitor` does not visit `ClassOrInterfaceDeclaration`, `EnumDeclaration`, or `MethodReferenceExpr`, so `EXTENDS`/`IMPLEMENTS`/most `CONTAINS`/`REFERENCES` edges are not produced. +3. `onMemberDiscovered(...)` collapses non-method members to `Class#member`, creating ID collisions for fields/initializers/enum constants. +4. Behavioral resolution still uses raw scope text (`repo#save()`, `user#name`) rather than resolved type signatures. +5. Lambda bodies are traversed with enclosing method scope, so lambda-origin edges are flattened incorrectly. +6. The previous plan referenced `Resolver.resolveMethodReference(...)`, but that API is package-private in `sa.com.cloudsolutions.antikythera.depsolver` and not callable from `com.raditha.graph`. +7. `EdgeType` lacks `REFERENCES`, while the design includes it. +8. Verification referenced `KnowledgeGraphBuilderBugReproductionTest`, which does not exist. +9. Test compilation currently fails because `KnowledgeGraphIntegrationTest` imports `org.testcontainers.containers.Neo4jContainer`, but no Testcontainers dependency is declared in `antikythera-examples/pom.xml`. +10. ~~Config model is inconsistent~~ β€” **Resolved**: unified under `graph:` section in YAML, read by `GraphStoreFactory` via `Settings`. `Neo4jConfig` removed. + +## 2. Corrected Remediation Plan + +### Component: `antikythera-examples` + +#### [MODIFY] `src/main/java/com/raditha/graph/KnowledgeGraphBuilder.java` + +1. Add `build(List units)` as the primary entry point. +2. Keep `build(Collection methods)` temporarily as compatibility wrapper (delegate to the new flow where possible). +3. Traverse each `CompilationUnit` by top-level `TypeDeclaration`, not by methods only. +4. Refactor `GraphVisitor` to support scope transitions: + - Type scope (`ClassOrInterfaceDeclaration`, `EnumDeclaration`). + - Member scope (`MethodDeclaration`, `ConstructorDeclaration`, `FieldDeclaration`, `InitializerDeclaration`, enum constants). + - Lambda scope (`LambdaExpr` gets its own synthetic signature and source context). +5. Emit structural edges during type/member traversal: + - `EXTENDS`, `IMPLEMENTS`, `CONTAINS`, `ENCLOSES`. +6. Add explicit handling for `MethodReferenceExpr` and emit `REFERENCES` edges. +7. Resolve behavioral targets by type, not raw variable text: + - Build a local symbol table per callable using parameter and variable declarations. + - Use symbol table first, then `AbstractCompiler.findType(...)`, then fallback textual target. +8. Ensure chain calls and stream/lambda constructs generate per-invocation edges with stable source IDs. + +#### [MODIFY] `src/main/java/com/raditha/graph/SignatureUtils.java` + +1. Add stable signatures for: + - Enum constants (for `CONTAINS` on enum members). + - Lambda nodes (deterministic suffix strategy tied to enclosing callable + source position/index). +2. Keep initializer signature behavior and ensure uniqueness across multiple blocks. +3. Add utility methods used by builder for member signature generation to avoid `Class#member` fallback. + +#### [MODIFY] `src/main/java/com/raditha/graph/EdgeType.java` + +1. Add `REFERENCES` to align with the design model. + +#### [MODIFY] `src/main/java/com/raditha/graph/KnowledgeGraphCLI.java` + +1. Collect `CompilationUnit`s from `AntikytheraRunTime.getResolvedCompilationUnits().values()`. +2. Invoke `builder.build(units)` as the default path. + +#### [DELETED] `src/main/java/com/raditha/graph/Neo4jConfig.java` + +Removed. All graph configuration is now read by `GraphStoreFactory` from the `graph:` section of the YAML config via `Settings`. Sample files: `graph-neo4j.yml.example`, `graph-age.yml.example`. + +#### [MODIFY] `src/main/java/com/raditha/graph/Neo4jGraphStore.java` (optional but recommended) + +1. Keep edge-streaming behavior. +2. Add/ensure node-upsert calls for key node kinds so node labels/properties in the design are materially represented (not only relationship-created placeholder nodes). + +#### [MODIFY] `pom.xml` + +1. Add missing test dependency for Testcontainers Neo4j, or gate `KnowledgeGraphIntegrationTest` so the class compiles without that dependency. + +## 3. Implementation Notes + +### 3.1 Scope-Safe Traversal + +Use a traversal model that explicitly changes source context: + +`CompilationUnit -> Type -> Member -> Expression/Lambda` + +Edges discovered in each nested context must use that context's signature as `sourceId`. + +### 3.2 Method Reference Resolution + +Do not call `Resolver.resolveMethodReference(...)` directly from `com.raditha.graph`. +Instead: + +1. Resolve scope type using symbol table + `AbstractCompiler`. +2. Build a method target signature from resolved type and identifier. +3. Emit `REFERENCES` edge. +4. Use conservative fallback when resolution is partial. + +### 3.3 Behavioral Fallback Rules + +1. Exact type resolved: emit fully-qualified target signature. +2. Type unresolved but scope present: emit scoped textual target with a `resolution=partial` attribute. +3. Unscoped call: resolve against enclosing type first, then fallback to method name signature. + +## 4. Verification Plan + +### Automated Tests + +1. Extend `RelationshipExtractionTest` with: + - Class-level traversal assertions (`EXTENDS`, `IMPLEMENTS`, `CONTAINS`). + - Enum and enum-constant containment. + - Lambda attribution (edge source is lambda node). + - Method reference extraction (`REFERENCES`). + - Resolution quality cases (resolved and fallback). +2. Extend `KnowledgeGraphDataStructuresTest` with signature uniqueness for enum constants and lambdas. +3. Update `KnowledgeGraphIntegrationTest` to use compilation-unit entrypoint and assert presence of structural + behavioral edge categories. +4. Ensure test build is green by fixing Testcontainers dependency or test gating strategy. + +### Manual Verification + +1. Run `KnowledgeGraphCLI` on a medium testbed and compare edge counts by category before/after. +2. Inspect sample Cypher: + - `MATCH ()-[r:EXTENDS|IMPLEMENTS|CONTAINS]->() RETURN type(r), count(*)` + - `MATCH (m)-[r:REFERENCES]->(t) RETURN m.signature, t.signature LIMIT 50` + - `MATCH (l)-[:ENCLOSES {kind:'lambda'}]->(x) RETURN l.signature, x.signature LIMIT 50` diff --git a/docs/knowledge-graph-wbs.md b/docs/knowledge-graph-wbs.md deleted file mode 100644 index 8a83eafa..00000000 --- a/docs/knowledge-graph-wbs.md +++ /dev/null @@ -1,73 +0,0 @@ -# Knowledge Graph Builder: Work Breakdown Structure - -## Quality Standards -* **Testing**: Each phase includes tests targeting β‰₯80% branch coverage. -* **Complexity**: No function shall exceed cognitive complexity of 20. - ---- - -## Phase 1: Antikythera Core Improvements -- [ ] **Task 1.1: Expand `getNodeSignature` in `DependencyAnalyzer`** - - [ ] Handle `FieldDeclaration`, `TypeDeclaration`, `InitializerDeclaration` -- [ ] **Task 1.2: Add `createVisitor()` hook to `DependencyAnalyzer`** - - [ ] Refactor `callableSearch()` to call protected factory method -- [ ] **Task 1.3: Add `FieldAccessExpr` visitor** - - [ ] Implement `visit(FieldAccessExpr, GraphNode)` for read-only accesses -- [ ] **Task 1.4: Add `onTypeUsed` hook** - - [ ] Intercept type dependencies from `ImportUtils.addImport` -- [ ] **Task 1.5: Add Inner Class/Lambda traversal** - - [ ] Add `visit(ClassOrInterfaceDeclaration, GraphNode)` for nested types - - [ ] Add `visit(LambdaExpr, GraphNode)` for lambdas - - [ ] Add `visit(ObjectCreationExpr, GraphNode)` for anonymous classes -- [ ] **Task 1.6: Phase 1 Unit Tests (80% coverage)** - -## Phase 2: Data Structures & Neo4j Store -- [ ] **Task 2.1: Stable Signature Utility** - - [ ] Create `SignatureUtils` for method, field, class, static-block signatures -- [ ] **Task 2.2: Edge Schema** - - [ ] Implement `EdgeType` enum (7 types) - - [ ] Implement `KnowledgeGraphEdge` record + Builder -- [ ] **Task 2.3: Neo4jGraphStore** - - [ ] Add Neo4j Java Driver dependency - - [ ] Create `Neo4jConfig` for connection settings - - [ ] Implement `persistNode(GraphNode)` method - - [ ] Implement `persistEdge(KnowledgeGraphEdge)` method - - [ ] Implement batch commit logic (configurable batch size) -- [ ] **Task 2.4: Phase 2 Unit Tests (80% coverage)** - - [ ] Create test schema before each test - - [ ] Tear down test schema after each test - -## Phase 3: Builder & Streaming -- [ ] **Task 3.1: KnowledgeGraphBuilder** - - [ ] Extend `DependencyAnalyzer` - - [ ] Inject `Neo4jGraphStore` dependency - - [ ] Stream edges to Neo4j during DFS (no in-memory accumulation) -- [ ] **Task 3.2: Build Orchestration** - - [ ] Implement `build(List)` entry point - - [ ] Ensure proper transaction management -- [ ] **Task 3.3: Phase 3 Unit Tests (80% coverage)** - -## Phase 4: Relationship Extraction -- [ ] **Task 4.1: Hook Integration** - - [ ] Override `onCallableDiscovered` for CALLS - - [ ] Override `onTypeUsed` for USES -- [ ] **Task 4.2: CALLS Edges** - - [ ] Capture method call targets - - [ ] Integrate `Evaluator.evaluateLiteral` for constant values -- [ ] **Task 4.3: ACCESSES Edges** - - [ ] Capture field accesses with READ/WRITE detection -- [ ] **Task 4.4: Structural Edges** - - [ ] CONTAINS from `TypeDeclaration.getMembers()` - - [ ] ENCLOSES for inner classes, anonymous classes, lambdas - - [ ] IMPLEMENTS/EXTENDS via `AntikytheraRunTime` -- [ ] **Task 4.5: Phase 4 Unit Tests (80% coverage)** - -## Phase 5: Query API & Integration -- [ ] **Task 5.1: Cypher Query Wrappers** - - [ ] `findCallers(methodSignature)` - - [ ] `findCallees(methodSignature)` - - [ ] `findUsages(typeSignature)` -- [ ] **Task 5.2: Integration Validation** - - [ ] Run against `antikythera-test-helper` samples - - [ ] Run against `test-bed` Spring Boot projects - - [ ] Validate cross-service dependency mapping diff --git a/docs/query_optimizer.md b/docs/query_optimizer.md new file mode 100644 index 00000000..2a55740d --- /dev/null +++ b/docs/query_optimizer.md @@ -0,0 +1,481 @@ +# QueryOptimizer - Automated JPA Query Optimization Tool + +## Overview + +QueryOptimizer is an intelligent static analysis tool that automatically optimizes JPA repository queries by analyzing column cardinality, reordering WHERE clause conditions, and generating database indexes. It uses AI (Gemini or OpenAI) to provide intelligent recommendations for query restructuring and automatically applies code changes across your codebase. + +## What Does QueryOptimizer Do? + +QueryOptimizer performs three main optimizations: + +1. **Query Restructuring**: Reorders WHERE clause columns in `@Query` annotations based on column cardinality (moving high-cardinality filters to the front) +2. **Method Signature Updates**: Renames repository methods and reorders parameters to match optimized query structure +3. **Call Site Propagation**: Automatically updates all method calls across dependent classes (Services, Controllers, Tests) +4. **Schema Enhancement**: Generates Liquibase changesets with new index recommendations + +## Features + +- **AI-Powered Analysis**: Uses Gemini or OpenAI to analyze queries and provide optimization recommendations +- **Automatic Code Modification**: Updates `@Query` annotations, method signatures, and call sites +- **Cardinality Analysis**: Classifies columns as HIGH (PK/unique), MEDIUM (indexed), or LOW (boolean/enum/flags) +- **Batch Processing**: Efficiently processes multiple queries and updates in batches +- **Checkpoint Support**: Can resume from checkpoints for large codebases +- **Liquibase Integration**: Automatically generates index changesets +- **Token Usage Tracking**: Monitors AI API usage and costs + +## Usage + +### Basic Usage + +```bash +mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.examples.QueryOptimizer" +``` + +### With Command-Line Options + +```bash +# Quiet mode (less verbose output) +mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.examples.QueryOptimizer" \ + -Dexec.args="--quiet" + +# Fresh start (clear checkpoint) +mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.examples.QueryOptimizer" \ + -Dexec.args="--fresh" + +# With cardinality overrides +mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.examples.QueryOptimizer" \ + -Dexec.args="--low-cardinality=status,type,category --high-cardinality=user_id,order_id" +``` + +### Command-Line Flags + +- `--quiet` or `-q`: Reduces verbose output +- `--fresh` or `-f`: Clears checkpoint and starts fresh +- `--low-cardinality=`: Override column cardinality classification +- `--high-cardinality=`: Override column cardinality classification + +## Configuration + +### Primary Configuration File + +The tool reads configuration from `src/main/resources/generator.yml`: + +```yaml +variables: + projects_folder: ${HOME}/csi/repos + +base_path: ${projects_folder}/BM/csi-bm-approval-java-service/ + +# AI service configuration +ai_service: + provider: "gemini" # or "openai" + model: "gemini-3-flash-preview" # For Gemini: "gemini-3-flash-preview" | For OpenAI: "gpt-4o-mini" (recommended) + api_key: "${GEMINI_API_KEY}" # or "${OPENAI_API_KEY}" for OpenAI + api_endpoint: "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent" # Optional, defaults provided + timeout_seconds: 120 + max_retries: 2 + queries_per_request: 40 + track_usage: true + cost_per_1k_tokens: 0.00015 # Optional, for cost tracking + max_tokens_per_request: 32000 + enable_request_compression: false + +# Query optimizer specific configuration +query_optimizer: + # Optional: Analyze only this specific repository + # target_class: "com.example.UserRepository" + + # Database dialects for Liquibase changesets + supported_dialects: + - postgresql + - oracle + + # Path to master Liquibase changelog + liquibase_master_file: ${base_path}/src/main/resources/db/changelog/db.changelog-master.xml + +database: + query_conversion: + enabled: true + skip_processed: true + log_file: query-optimization-stats.csv +``` + +### AI Provider Configuration + +#### Gemini Configuration + +```yaml +ai_service: + provider: "gemini" + model: "gemini-3-flash-preview" # or "gemini-2.5-flash-lite-preview-09-2025" + api_key: "${GEMINI_API_KEY}" # Required: Set GEMINI_API_KEY environment variable + api_endpoint: "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent" # Optional + timeout_seconds: 120 + max_retries: 2 + queries_per_request: 40 + track_usage: true +``` + +**Required Environment Variable:** +```bash +export GEMINI_API_KEY="your-gemini-api-key" +``` + +#### OpenAI Configuration + +**Recommended: `gpt-4o-mini` for best value-for-money** + +```yaml +ai_service: + provider: "openai" + model: "gpt-4o-mini" # ⭐ RECOMMENDED: Best value-for-money (16.7x cheaper than gpt-4o) + api_key: "${OPENAI_API_KEY}" # Required: Set OPENAI_API_KEY environment variable + api_endpoint: "https://api.openai.com/v1/chat/completions" # βœ… Correct endpoint for GPT-4o models + timeout_seconds: 90 + max_retries: 2 + queries_per_request: 40 + track_usage: true +``` + +**Required Environment Variable:** +```bash +export OPENAI_API_KEY="your-openai-api-key" +``` + +**Optional Environment Variable:** +```bash +export OPENAI_API_ENDPOINT="https://api.openai.com/v1/chat/completions" # For custom endpoints +``` + +**Important:** The endpoint `/v1/chat/completions` is correct for GPT-4o and GPT-4o-mini models. This is the **Chat Completions API**, not the older `/v1/completions` endpoint (which is deprecated for GPT-3-style models). The code uses the messages array format which matches the Chat Completions API specification. + +### Supported Models + +#### Gemini Models +- `gemini-3-flash-preview` (recommended) +- `gemini-2.5-flash-lite-preview-09-2025` +- `gemini-pro` + +#### OpenAI Models + +**Cost Comparison (per 1M tokens):** + +| Model | Input Cost | Output Cost | Best For | +|-------|-----------|-------------|----------| +| **`gpt-4o-mini`** ⭐ | **$0.15** | **$0.60** | **Query optimization (recommended)** | +| `gpt-4o` | $2.50 | $10.00 | Complex reasoning tasks | +| `gpt-4-turbo` | $10.00 | $30.00 | Legacy (not recommended) | +| `gpt-4` | $30.00 | $60.00 | Legacy (not recommended) | + +**Recommendation:** Use **`gpt-4o-mini`** for query optimization. It provides excellent quality for structured tasks like SQL query optimization at **16.7x lower cost** than `gpt-4o`. The quality difference is minimal for this use case, making it the best value-for-money option. + +**Example Cost Calculation:** +- Processing 100 queries with ~50K input tokens and ~10K output tokens: + - **gpt-4o-mini**: ~$0.008 per batch + - **gpt-4o**: ~$0.135 per batch + - **Savings**: ~94% cost reduction with gpt-4o-mini + +### Liquibase Configuration + +The tool requires a Liquibase master changelog file at: +``` +/src/main/resources/db/changelog/db.changelog-master.xml +``` + +The tool will automatically append new index changesets to this file. + +## How It Works + +### 1. Repository Discovery + +Scans for JPA repository interfaces extending `JpaRepository`: + +```java +@Repository +public interface UserRepository extends JpaRepository { + @Query("SELECT u FROM User u WHERE u.status = :status AND u.email = :email") + List findByStatusAndEmail(@Param("status") String status, @Param("email") String email); +} +``` + +### 2. Query Analysis + +- Extracts queries from `@Query` annotations and derived query methods +- Loads Liquibase XML to classify column cardinality +- Analyzes WHERE clause ordering using JSQLParser +- Applies rule-based optimization detection + +### 3. AI Optimization + +- Batches queries with cardinality context +- Sends to AI service (Gemini or OpenAI) for recommendations +- Receives optimized query structures and explanations +- Tracks token usage and costs + +### 4. Code Modification + +- Updates `@Query` annotation values with optimized SQL +- Renames methods if parameter order changes (e.g., `findByStatusAndEmail` β†’ `findByEmailAndStatus`) +- Reorders method parameters to match optimized WHERE clause +- Updates all call sites in dependent classes + +### 5. Index Generation + +- Generates Liquibase changesets for recommended indexes +- Appends to master changelog file +- Supports multiple database dialects + +## Example Transformations + +### Example 1: Query Reordering + +**Before:** +```java +@Repository +public interface UserRepository extends JpaRepository { + @Query("SELECT u FROM User u WHERE u.status = :status AND u.email = :email") + List findByStatusAndEmail(@Param("status") String status, @Param("email") String email); +} +``` + +**After:** +```java +@Repository +public interface UserRepository extends JpaRepository { + @Query(""" + SELECT u FROM User u WHERE u.email = :email AND u.status = :status + """) + List findByEmailAndStatus(@Param("email") String email, @Param("status") String status); +} +``` + +**Service Call Updated:** +```java +// Before +userRepository.findByStatusAndEmail("ACTIVE", "user@example.com"); + +// After +userRepository.findByEmailAndStatus("user@example.com", "ACTIVE"); +``` + +### Example 2: Index Generation + +The tool generates Liquibase changesets: + +```xml + + + + + + +``` + +## Cardinality Classification + +Columns are automatically classified based on database metadata: + +- **HIGH Cardinality**: Primary keys, unique constraints, indexed columns +- **MEDIUM Cardinality**: Columns with indexes but not unique +- **LOW Cardinality**: Boolean fields, enums, flags (e.g., `status`, `is_active`, `deleted`) + +### Override Cardinality + +You can override cardinality classification via command-line: + +```bash +mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.examples.QueryOptimizer" \ + -Dexec.args="--low-cardinality=tenant_id,hospital_id --high-cardinality=transaction_id" +``` + +## Output Artifacts + +### 1. Modified Source Files + +- Repository interfaces with updated `@Query` annotations +- Method signatures with reordered parameters +- Dependent classes with updated method calls + +### 2. Liquibase Changesets + +Generated XML file with CREATE INDEX statements appended to master changelog. + +### 3. Statistics Report + +Console output and CSV file (`query-optimization-stats.csv`) with: +- Queries analyzed +- Optimizations applied +- Method signatures changed +- Call sites updated +- Token usage and costs + +### 4. Console Output + +``` +--- Query Optimization Summary --- +Repositories analyzed: 5 +Queries optimized: 12 +Method signatures changed: 8 +Dependent classes modified: 23 +Method calls updated: 45 + +--- Final AI Token Usage Report --- +Total input tokens: 45,230 +Total output tokens: 12,450 +Total cost: $0.15 +----------------------------------- +``` + +## Checkpoint and Resume + +For large codebases, the tool supports checkpoint/resume: + +- **Checkpoint**: Automatically saves progress after processing each repository +- **Resume**: Automatically resumes from last checkpoint on restart +- **Fresh Start**: Use `--fresh` flag to clear checkpoint and start over + +## Comparison: QueryOptimizationChecker vs QueryOptimizer + +| Feature | QueryOptimizationChecker | QueryOptimizer | +|---------|-------------------------|----------------| +| Analyzes queries | βœ“ | βœ“ | +| Generates reports | βœ“ | βœ“ | +| AI recommendations | βœ“ | βœ“ | +| Liquibase generation | βœ“ | βœ“ | +| Modifies `@Query` | βœ— | βœ“ | +| Renames methods | βœ— | βœ“ | +| Updates call sites | βœ— | βœ“ | +| Dependencies map | βœ— | βœ“ | + +**Use QueryOptimizationChecker when:** +- You want to review recommendations before applying +- Running in CI/CD for quality gates +- Generating reports for developers +- Creating Liquibase changesets only + +**Use QueryOptimizer when:** +- You trust the AI recommendations +- Performing bulk optimization across repositories +- You want automated refactoring +- You need both code changes AND index changes + +## Common Pitfalls + +1. **Repository Limit**: Currently processes first JPA repository only (may be updated in future versions) +2. **Liquibase Path**: Must be at `/src/main/resources/db/changelog/db.changelog-master.xml` +3. **API Key Required**: Must set `GEMINI_API_KEY` or `OPENAI_API_KEY` environment variable +4. **Call Site Updates**: Only updates direct method calls on repository fields, not indirect invocations +5. **Method References**: Method references (e.g., `repo::methodName`) are updated, but only if there's exactly one candidate (no overloads) + +## Best Practices + +### 1. Review Before Committing + +Always review the generated changes before committing: + +```bash +# Run QueryOptimizer +mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.examples.QueryOptimizer" + +# Review the diff +git diff + +# Test the changes +mvn test +``` + +### 2. Use Checkpoints for Large Codebases + +For large projects, let the tool checkpoint automatically: + +```bash +# First run - will checkpoint after each repository +mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.examples.QueryOptimizer" + +# If interrupted, restart - will resume from checkpoint +mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.examples.QueryOptimizer" +``` + +### 3. Start with QueryOptimizationChecker + +For initial analysis, use the read-only checker: + +```bash +mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.examples.QueryOptimizationChecker" +``` + +Review the recommendations, then run QueryOptimizer if satisfied. + +### 4. Configure Cardinality Overrides + +For domain-specific columns, use cardinality overrides: + +```bash +mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.examples.QueryOptimizer" \ + -Dexec.args="--low-cardinality=tenant_id,hospital_id,is_active" +``` + +### 5. Monitor Token Usage + +Track AI API costs by enabling usage tracking: + +```yaml +ai_service: + track_usage: true + cost_per_1k_tokens: 0.00015 # Adjust based on your model +``` + +## Troubleshooting + +### API Key Not Found + +**Error:** +``` +AI service API key is required. Set GEMINI_API_KEY environment variable... +``` + +**Solution:** +```bash +export GEMINI_API_KEY="your-api-key" +# or +export OPENAI_API_KEY="your-api-key" +``` + +### Liquibase File Not Found + +**Error:** +``` +Liquibase master file not found at: ... +``` + +**Solution:** +Ensure the path in `generator.yml` is correct: +```yaml +query_optimizer: + liquibase_master_file: ${base_path}/src/main/resources/db/changelog/db.changelog-master.xml +``` + +### Method Call Updates Skipped + +**Issue:** Some method calls aren't updated. + +**Possible Causes:** +- Method is called indirectly (not on a field) +- Method has overloads and arity can't be determined +- Method reference with ambiguous overloads + +**Solution:** Manually review and update remaining call sites. + +## Related Tools + +- **QueryOptimizationChecker**: Read-only analysis without code modification +- **JPARepositoryAnalyzer**: Exports all repository queries to CSV +- **HardDelete**: Detects hard delete operations +- **UsageFinder**: Analyzes collection usage patterns + +## See Also + +- [Query Optimization Review Prompt](query-optimization-review-prompt.md) - AI prompt for reviewing QueryOptimizer changes +- [Liquibase Indexes](liquibase_indexes.md) - Liquibase index management +- [AGENTS.md](../AGENTS.md) - Complete agent documentation + diff --git a/pom.xml b/pom.xml index 2972343c..6c421370 100644 --- a/pom.xml +++ b/pom.xml @@ -134,6 +134,11 @@ + + org.postgresql + postgresql + 42.7.2 + org.neo4j.driver neo4j-java-driver @@ -146,6 +151,19 @@ 2.17.2 + + org.testcontainers + neo4j + 1.20.4 + test + + + + uk.org.webcompere + system-stubs-jupiter + 2.1.6 + test + diff --git a/src/main/java/com/raditha/graph/ApacheAgeGraphStore.java b/src/main/java/com/raditha/graph/ApacheAgeGraphStore.java new file mode 100644 index 00000000..4ac9e1ce --- /dev/null +++ b/src/main/java/com/raditha/graph/ApacheAgeGraphStore.java @@ -0,0 +1,258 @@ +package com.raditha.graph; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +/** + * Apache AGE implementation of GraphStore. + * Uses PostgreSQL JDBC driver and Apache AGE extension. + */ +@SuppressWarnings("java:S2077") +public class ApacheAgeGraphStore implements GraphStore { + + private static final Logger logger = LoggerFactory.getLogger(ApacheAgeGraphStore.class); + + private final String url; + private final String user; + private final String password; + private final String graphName; + private final int batchSize; + + private Connection connection; + private final List pendingEdges = new ArrayList<>(); + private int edgeCount = 0; + + private static final String BASE_LABEL = "CodeElement"; + + public ApacheAgeGraphStore(String url, String user, String password, String graphName, int batchSize) throws SQLException { + this.url = url; + this.user = user; + this.password = password; + this.graphName = graphName; + this.batchSize = batchSize; + initialize(); + } + + private void initialize() throws SQLException { + ensureConnection(); + try (Statement stmt = connection.createStatement()) { + // Ensure AGE extension is loaded + stmt.execute("LOAD 'age'"); + stmt.execute("SET search_path = ag_catalog, \"$user\", public"); + + // Check if graph exists, if not create it + // Note: creating graph strictly might fail if it already exists, need to handle gracefully or check first + // simple check via select * from ag_graph where name = ? + try (PreparedStatement checkStmt = connection.prepareStatement("SELECT 1 FROM ag_graph WHERE name = ?")) { + checkStmt.setString(1, graphName); + try (ResultSet rs = checkStmt.executeQuery()) { + if (!rs.next()) { + stmt.execute("SELECT create_graph('" + graphName + "')"); + logger.info("Created Apache AGE graph '{}'", graphName); + } else { + logger.info("Using existing Apache AGE graph '{}'", graphName); + } + } + } + } + } + + private void ensureConnection() throws SQLException { + if (connection == null || connection.isClosed()) { + Properties props = new Properties(); + props.setProperty("user", user); + props.setProperty("password", password); + connection = DriverManager.getConnection(url, props); + } + } + + @Override + public void persistEdge(KnowledgeGraphEdge edge) { + pendingEdges.add(edge); + edgeCount++; + if (pendingEdges.size() >= batchSize) { + flushEdges(); + } + } + + @Override + public void persistNode(String signature, String nodeType, String name, String fqn) { + try { + ensureConnection(); + Map params = new HashMap<>(); + params.put("signature", signature); + params.put("name", name); + params.put("fqn", fqn != null ? fqn : name); + params.put("nodeType", nodeType); + + // AGE does not support SET n:Label β€” store the type as a property instead + String cypher = """ + SELECT * FROM cypher('%s', $$ + MERGE (n:%s {signature: $signature}) + SET n.name = $name, n.fqn = $fqn, n.nodeType = $nodeType + $$, ?) as (v agtype) + """.formatted(graphName, BASE_LABEL); + + String jsonParams = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(params); + + try (PreparedStatement stmt = connection.prepareStatement(cypher)) { + stmt.setObject(1, jsonParams, java.sql.Types.OTHER); + stmt.execute(); + } + + } catch (Exception e) { + logger.error("Failed to persist node: {}", signature, e); + } + } + + @Override + public void flushEdges() { + if (pendingEdges.isEmpty()) { + return; + } + try { + ensureConnection(); + logger.debug("Flushing {} pending edges to Apache AGE", pendingEdges.size()); + + for (KnowledgeGraphEdge edge : pendingEdges) { + Map params = new HashMap<>(); + params.put("sourceId", edge.sourceId()); + params.put("targetId", edge.targetId()); + + // Build individual SET clauses β€” AGE does not support SET r += $map + StringBuilder setClauses = new StringBuilder(); + for (Map.Entry attr : edge.attributes().entrySet()) { + params.put("attr_" + attr.getKey(), attr.getValue()); + setClauses.append("SET r.").append(attr.getKey()) + .append(" = $attr_").append(attr.getKey()).append(" "); + } + + String cypher = """ + SELECT * FROM cypher('%s', $$ + MATCH (source:%s {signature: $sourceId}) + MATCH (target:%s {signature: $targetId}) + MERGE (source)-[r:%s]->(target) + %s + $$, ?) as (v agtype) + """.formatted(graphName, BASE_LABEL, BASE_LABEL, + edge.type().name(), setClauses.toString()); + + String jsonParams = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(params); + + try (PreparedStatement stmt = connection.prepareStatement(cypher)) { + stmt.setObject(1, jsonParams, java.sql.Types.OTHER); + stmt.execute(); + } + } + pendingEdges.clear(); + + } catch (Exception e) { + logger.error("Failed to flush edges", e); + } + } + + @Override + public int getEdgeCount() { + return edgeCount; + } + + @Override + public void clearGraph() { + try { + ensureConnection(); + try (Statement stmt = connection.createStatement()) { + // dropping and recreating is often cleaner for "clear" + stmt.execute("SELECT drop_graph('" + graphName + "', true)"); + stmt.execute("SELECT create_graph('" + graphName + "')"); + logger.info("Graph cleared"); + } + } catch (SQLException e) { + logger.error("Failed to clear graph", e); + } + } + + @Override + public List findCallers(String signature) { + return executeQuery(""" + MATCH (n)-[:CALLS]->(m:%s {signature: $sig}) RETURN n.signature + """.formatted(BASE_LABEL), signature); + } + + @Override + public List findCallees(String signature) { + return executeQuery(""" + MATCH (n:%s {signature: $sig})-[:CALLS]->(m) RETURN m.signature + """.formatted(BASE_LABEL), signature); + } + + @Override + public List findUsages(String signature) { + return executeQuery(""" + MATCH (n)-[:USES]->(m:%s {signature: $sig}) RETURN n.signature + """.formatted(BASE_LABEL), signature); + } + + private List executeQuery(String cypherFragment, String signatureParam) { + List results = new ArrayList<>(); + try { + ensureConnection(); + String query = """ + SELECT * FROM cypher('%s', $$ + %s + $$, ?) as (v agtype) + """.formatted(graphName, cypherFragment); + + Map params = new HashMap<>(); + params.put("sig", signatureParam); + String jsonParams = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(params); + + try (PreparedStatement stmt = connection.prepareStatement(query)) { + stmt.setObject(1, jsonParams, java.sql.Types.OTHER); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + // AGE returns results as PG objects, often need parsing if complex. + // Here we expect simple strings? AGE returns 'agtype' which is JSON-like. + // Ideally we parse the JSON result. + String val = rs.getString(1); + // rudimentary parsing: remove quotes if present or parse JSON + if (val != null) { + // E.g. "signature_value" (quoted JSON string) + if (val.startsWith("\"") && val.endsWith("\"")) { + results.add(val.substring(1, val.length() - 1)); + } else { + results.add(val); + } + } + } + } + } + } catch (Exception e) { + logger.error("Query failed", e); + } + return results; + } + + @Override + public void close() { + flushEdges(); + if (connection != null) { + try { + connection.close(); + } catch (SQLException e) { + logger.warn("Error closing connection", e); + } + } + } +} diff --git a/src/main/java/com/raditha/graph/EdgeType.java b/src/main/java/com/raditha/graph/EdgeType.java index a2db8c70..24415032 100644 --- a/src/main/java/com/raditha/graph/EdgeType.java +++ b/src/main/java/com/raditha/graph/EdgeType.java @@ -24,5 +24,8 @@ public enum EdgeType { ACCESSES, /** Dependency: A method/class uses a type (in parameters, return types, casts, etc.). */ - USES + USES, + + /** Behavioral: A method/block references a method (e.g. method references). */ + REFERENCES } diff --git a/src/main/java/com/raditha/graph/GraphStore.java b/src/main/java/com/raditha/graph/GraphStore.java new file mode 100644 index 00000000..5576ac22 --- /dev/null +++ b/src/main/java/com/raditha/graph/GraphStore.java @@ -0,0 +1,61 @@ +package com.raditha.graph; + +import java.util.List; + +/** + * Interface defines the contract for a Graph Store implementation. + */ +public interface GraphStore extends AutoCloseable { + + /** + * Persist an edge to the graph store. + * @param edge the edge to persist + */ + void persistEdge(KnowledgeGraphEdge edge); + + /** + * Persist a node to the graph store. + * @param signature unique signature of the node + * @param nodeType type of the node (e.g. Class, Method, Field) + * @param name name of the node + * @param fqn fully qualified name of the node + */ + void persistNode(String signature, String nodeType, String name, String fqn); + + /** + * Flush any pending edges to the graph store. + */ + void flushEdges(); + + /** + * Get the number of edges persisted so far. + * @return number of edges + */ + int getEdgeCount(); + + /** + * Clear the graph. + */ + void clearGraph(); + + /** + * Find nodes that call the given target node. + * @param signature signature of the target node + * @return list of caller signatures + */ + List findCallers(String signature); + + /** + * Find nodes called by the given source node. + * @param signature signature of the source node + * @return list of callee signatures + */ + List findCallees(String signature); + + /** + * Find nodes that use the given target type/node. + * @param signature signature of the usage target + * @return list of user signatures + */ + List findUsages(String signature); +} diff --git a/src/main/java/com/raditha/graph/GraphStoreFactory.java b/src/main/java/com/raditha/graph/GraphStoreFactory.java new file mode 100644 index 00000000..ac294296 --- /dev/null +++ b/src/main/java/com/raditha/graph/GraphStoreFactory.java @@ -0,0 +1,124 @@ +package com.raditha.graph; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sa.com.cloudsolutions.antikythera.configuration.Settings; + +import java.io.File; +import java.io.IOException; +import java.sql.SQLException; +import java.util.Map; + +/** + * Factory for creating GraphStore instances based on configuration. + *

+ * Reads all graph settings from the {@code graph:} section of the YAML config + * loaded via {@link Settings}. The expected structure is: + *

+ * graph:
+ *   type: neo4j          # or "age"
+ *   batch_size: 1000
+ *   neo4j:
+ *     uri: bolt://localhost:7687
+ *     username: neo4j
+ *     password: secret
+ *     database: neo4j
+ *   age:
+ *     url: jdbc:postgresql://localhost:5432/postgres
+ *     user: postgres
+ *     password: secret
+ *     graph_name: antikythera_graph
+ * 
+ */ +public class GraphStoreFactory { + + private static final Logger logger = LoggerFactory.getLogger(GraphStoreFactory.class); + + private static final String DEFAULT_TYPE = "neo4j"; + private static final int DEFAULT_BATCH_SIZE = 1000; + + // Neo4j defaults + private static final String DEFAULT_NEO4J_URI = "bolt://localhost:7687"; + private static final String DEFAULT_NEO4J_USERNAME = "neo4j"; + private static final String DEFAULT_NEO4J_DATABASE = "neo4j"; + + // Apache AGE defaults + private static final String DEFAULT_AGE_URL = "jdbc:postgresql://localhost:5432/postgres"; + private static final String DEFAULT_AGE_USER = "postgres"; + private static final String DEFAULT_AGE_GRAPH_NAME = "antikythera_graph"; + + private GraphStoreFactory() { + } + + /** + * Create a GraphStore from a configuration file. + * Loads the file into Settings, then reads the {@code graph:} section. + */ + public static GraphStore createGraphStore(File configFile) throws IOException, SQLException { + Settings.loadConfigMap(configFile); + return createGraphStore(); + } + + /** + * Create a GraphStore from already-loaded Settings. + */ + @SuppressWarnings("unchecked") + public static GraphStore createGraphStore() throws SQLException { + Map graphConfig = Settings.getProperty("graph", Map.class) + .orElse(Map.of()); + + String type = getString(graphConfig, "type", DEFAULT_TYPE); + int batchSize = getInt(graphConfig, "batch_size", DEFAULT_BATCH_SIZE); + + logger.info("Creating {} graph store with batch size {}", type, batchSize); + + if ("age".equalsIgnoreCase(type)) { + return createAgeStore(graphConfig, batchSize); + } + return createNeo4jStore(graphConfig, batchSize); + } + + @SuppressWarnings("unchecked") + private static GraphStore createNeo4jStore(Map graphConfig, int batchSize) { + Map neo4jConfig = (Map) graphConfig.getOrDefault(DEFAULT_TYPE, Map.of()); + + String uri = getString(neo4jConfig, "uri", DEFAULT_NEO4J_URI); + String username = getString(neo4jConfig, "username", DEFAULT_NEO4J_USERNAME); + String password = getString(neo4jConfig, "password", ""); + String database = getString(neo4jConfig, "database", DEFAULT_NEO4J_DATABASE); + + return new Neo4jGraphStore(uri, username, password, database, batchSize); + } + + @SuppressWarnings("unchecked") + private static GraphStore createAgeStore(Map graphConfig, int batchSize) throws SQLException { + Map ageConfig = (Map) graphConfig.getOrDefault("age", Map.of()); + + String url = getString(ageConfig, "url", DEFAULT_AGE_URL); + String user = getString(ageConfig, "user", DEFAULT_AGE_USER); + String password = getString(ageConfig, "password", ""); + String graphName = getString(ageConfig, "graph_name", DEFAULT_AGE_GRAPH_NAME); + + return new ApacheAgeGraphStore(url, user, password, graphName, batchSize); + } + + private static String getString(Map config, String key, String defaultValue) { + Object value = config.get(key); + return value != null ? value.toString() : defaultValue; + } + + private static int getInt(Map config, String key, int defaultValue) { + Object value = config.get(key); + if (value instanceof Number n) { + return n.intValue(); + } + if (value instanceof String s) { + try { + return Integer.parseInt(s); + } catch (NumberFormatException e) { + logger.warn("Invalid integer value '{}' for key '{}', using default {}", s, key, defaultValue); + } + } + return defaultValue; + } +} diff --git a/src/main/java/com/raditha/graph/KnowledgeGraphBuilder.java b/src/main/java/com/raditha/graph/KnowledgeGraphBuilder.java index 45adfe21..3120f0d6 100644 --- a/src/main/java/com/raditha/graph/KnowledgeGraphBuilder.java +++ b/src/main/java/com/raditha/graph/KnowledgeGraphBuilder.java @@ -1,79 +1,126 @@ package com.raditha.graph; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.Node; import com.github.javaparser.ast.body.BodyDeclaration; +import com.github.javaparser.ast.body.CallableDeclaration; import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.ConstructorDeclaration; +import com.github.javaparser.ast.body.EnumConstantDeclaration; +import com.github.javaparser.ast.body.EnumDeclaration; +import com.github.javaparser.ast.body.FieldDeclaration; +import com.github.javaparser.ast.body.InitializerDeclaration; import com.github.javaparser.ast.body.MethodDeclaration; +import com.github.javaparser.ast.body.Parameter; +import com.github.javaparser.ast.body.TypeDeclaration; +import com.github.javaparser.ast.body.VariableDeclarator; +import com.github.javaparser.ast.expr.CastExpr; +import com.github.javaparser.ast.expr.Expression; import com.github.javaparser.ast.expr.FieldAccessExpr; import com.github.javaparser.ast.expr.LambdaExpr; import com.github.javaparser.ast.expr.MethodCallExpr; +import com.github.javaparser.ast.expr.MethodReferenceExpr; +import com.github.javaparser.ast.expr.NameExpr; +import com.github.javaparser.ast.expr.ObjectCreationExpr; +import com.github.javaparser.ast.expr.ThisExpr; +import com.github.javaparser.ast.expr.VariableDeclarationExpr; import com.github.javaparser.ast.type.ClassOrInterfaceType; import com.github.javaparser.ast.type.Type; +import com.github.javaparser.ast.visitor.VoidVisitorAdapter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.github.javaparser.ast.visitor.VoidVisitorAdapter; import sa.com.cloudsolutions.antikythera.depsolver.DependencyAnalyzer; import sa.com.cloudsolutions.antikythera.depsolver.Graph; import sa.com.cloudsolutions.antikythera.depsolver.GraphNode; +import sa.com.cloudsolutions.antikythera.generator.TypeWrapper; +import sa.com.cloudsolutions.antikythera.parser.AbstractCompiler; -import com.fasterxml.jackson.databind.ObjectMapper; import java.io.File; import java.io.IOException; +import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; /** - * Knowledge Graph Builder that extends DependencyAnalyzer. - * Overrides hook methods to stream edges directly to Neo4j during AST traversal. - * - * Note: The hook methods (onFieldAccessed, onTypeUsed, etc.) were added in Phase 1. - * Until that version is published, these methods will not be called automatically - * during DFS traversal but can be called manually for testing. + * Knowledge Graph Builder that streams structural and behavioral relationships to Neo4j. */ public class KnowledgeGraphBuilder extends DependencyAnalyzer { private static final Logger logger = LoggerFactory.getLogger(KnowledgeGraphBuilder.class); private static final ObjectMapper objectMapper = new ObjectMapper(); + public static final String RESOLUTION = "resolution"; + public static final String LAMBDA = "lambda"; + public static final String EXACT = "exact"; + public static final String PARTIAL = "partial"; - private final Neo4jGraphStore graphStore; + private final GraphStore graphStore; private boolean autoClose = true; - /** - * Create a builder with an existing Neo4jGraphStore. - */ - public KnowledgeGraphBuilder(Neo4jGraphStore graphStore) { + public KnowledgeGraphBuilder(GraphStore graphStore) { this.graphStore = graphStore; } + public static KnowledgeGraphBuilder fromSettings(File configFile) throws IOException, java.sql.SQLException { + GraphStore store = GraphStoreFactory.createGraphStore(configFile); + return new KnowledgeGraphBuilder(store); + } + /** - * Create a builder from graph.yml configuration file. - * - * @param configFile path to graph.yml - * @throws IOException if config cannot be read + * Primary entry point: build graph from compilation units. */ - public static KnowledgeGraphBuilder fromSettings(File configFile) throws IOException { - Neo4jGraphStore store = Neo4jGraphStore.fromSettings(configFile); - return new KnowledgeGraphBuilder(store); + public void build(List units) { + logger.info("Starting knowledge graph build for {} compilation units", units.size()); + try { + for (CompilationUnit cu : units) { + for (TypeDeclaration type : cu.getTypes()) { + traverseType(type, null); + } + } + graphStore.flushEdges(); + logger.info("Knowledge graph build complete. Total edges: {}", graphStore.getEdgeCount()); + } finally { + if (autoClose) { + close(); + } + } } /** - * Build the knowledge graph from a collection of methods. - * This is the main entry point for graph construction. - * - * @param methods collection of methods to analyze + * Compatibility wrapper for existing method-based callers. */ public void build(Collection methods) { - logger.info("Starting knowledge graph build for {} methods", methods.size()); + Set units = methods.stream() + .map(MethodDeclaration::findCompilationUnit) + .filter(java.util.Optional::isPresent) + .map(java.util.Optional::get) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + if (!units.isEmpty()) { + build(new ArrayList<>(units)); + return; + } + + logger.info("No compilation units found for compatibility mode. Falling back to method traversal."); try { - // Manually traverse AST to ensure hooks are called (bypass DependencyAnalyzer mismatch) - GraphVisitor visitor = new GraphVisitor(); for (MethodDeclaration method : methods) { - // Ensure GraphNode is created and tracked GraphNode node = Graph.createGraphNode(method); - method.accept(visitor, node); + String sourceId = SignatureUtils.getSignature(node); + if (sourceId == null) { + continue; + } + persistNode(sourceId, "Method", method.getNameAsString(), sourceId); + + ScopeContext context = fromGraphNode(node, sourceId); + method.accept(new GraphVisitor(), context); } graphStore.flushEdges(); - logger.info("Knowledge graph build complete. Total edges: {}", graphStore.getEdgeCount()); } finally { if (autoClose) { close(); @@ -81,67 +128,43 @@ public void build(Collection methods) { } } - /** - * Set whether to automatically close the graph store after build. - */ public void setAutoClose(boolean autoClose) { this.autoClose = autoClose; } - /** - * Close the graph store connection. - */ public void close() { if (graphStore != null) { - graphStore.close(); + try { + graphStore.close(); + } catch (Exception e) { + logger.warn("Error closing graph store", e); + } } } - /** - * Get the underlying graph store for direct access. - */ - public Neo4jGraphStore getGraphStore() { + public GraphStore getGraphStore() { return graphStore; } - // ======================== - // Hook Methods (called by DependencyAnalyzer during DFS) - // These override protected methods in DependencyAnalyzer - // ======================== - - /** - * Called when a field is accessed during traversal. - * Creates an ACCESSES edge in the knowledge graph. - */ @Override protected void onFieldAccessed(GraphNode node, FieldAccessExpr fae) { String sourceId = SignatureUtils.getSignature(node); - if (sourceId == null) return; - - String targetId = resolveFieldSignature(node, fae); - - KnowledgeGraphEdge edge = KnowledgeGraphEdge.builder() - .source(sourceId) - .target(targetId) - .type(EdgeType.ACCESSES) - .accessType("READ") - .build(); + if (sourceId == null) { + return; + } - graphStore.persistEdge(edge); - logger.trace("Edge: {} --ACCESSES--> {}", sourceId, targetId); + ScopeContext context = fromGraphNode(node, sourceId); + emitFieldAccess(context, fae); } - /** - * Called when a type is explicitly used (e.g., in casts, catch clauses). - * Creates a USES edge in the knowledge graph. - */ @Override protected void onTypeUsed(GraphNode node, Type type) { String sourceId = SignatureUtils.getSignature(node); - if (sourceId == null) return; - - String targetId = type.asString(); + if (sourceId == null) { + return; + } + String targetId = resolveTypeSignature(node.getCompilationUnit(), type); KnowledgeGraphEdge edge = KnowledgeGraphEdge.builder() .source(sourceId) .target(targetId) @@ -152,40 +175,36 @@ protected void onTypeUsed(GraphNode node, Type type) { logger.trace("Edge: {} --USES--> {}", sourceId, targetId); } - /** - * Called when a lambda expression is discovered. - * Creates an ENCLOSES edge in the knowledge graph. - */ @Override protected void onLambdaDiscovered(GraphNode node, LambdaExpr lambda) { String sourceId = SignatureUtils.getSignature(node); - if (sourceId == null) return; + if (sourceId == null) { + return; + } - int line = lambda.getBegin().map(p -> p.line).orElse(0); - String lambdaId = sourceId + "$lambda_L" + line; + String lambdaId = SignatureUtils.getLambdaSignature(sourceId, lambda, 0); + persistNode(lambdaId, "Lambda", LAMBDA, node.getEnclosingType() == null ? sourceId : SignatureUtils.getTypeSignature(node.getEnclosingType())); KnowledgeGraphEdge edge = KnowledgeGraphEdge.builder() .source(sourceId) .target(lambdaId) .type(EdgeType.ENCLOSES) - .attribute("kind", "lambda") + .attribute("kind", LAMBDA) .build(); graphStore.persistEdge(edge); logger.trace("Edge: {} --ENCLOSES--> {} (lambda)", sourceId, lambdaId); } - /** - * Called when a nested type (inner class) is discovered. - * Creates an ENCLOSES edge in the knowledge graph. - */ @Override protected void onNestedTypeDiscovered(GraphNode node, ClassOrInterfaceDeclaration nestedType) { String sourceId = SignatureUtils.getSignature(node); - if (sourceId == null) return; + if (sourceId == null) { + return; + } - String targetId = nestedType.getFullyQualifiedName() - .orElse(nestedType.getNameAsString()); + String targetId = SignatureUtils.getTypeSignature(nestedType); + persistNode(targetId, nestedType.isInterface() ? "Interface" : "Class", nestedType.getNameAsString(), targetId); KnowledgeGraphEdge edge = KnowledgeGraphEdge.builder() .source(sourceId) @@ -198,149 +217,470 @@ protected void onNestedTypeDiscovered(GraphNode node, ClassOrInterfaceDeclaratio logger.trace("Edge: {} --ENCLOSES--> {} (nested type)", sourceId, targetId); } - // ======================== - // Helper Methods - // ======================== + public void onMethodCalled(GraphNode node, MethodCallExpr mce) { + String sourceId = SignatureUtils.getSignature(node); + if (sourceId == null) { + return; + } - private String resolveFieldSignature(GraphNode node, FieldAccessExpr fae) { - String scopeType = fae.getScope().toString(); - return scopeType + "#" + fae.getNameAsString(); + ScopeContext context = fromGraphNode(node, sourceId); + emitMethodCall(context, mce); } - // ======================== - // Phase 4: Additional Hook Methods - // ======================== + public void onMemberDiscovered(ClassOrInterfaceDeclaration clazz, BodyDeclaration member) { + String sourceId = SignatureUtils.getTypeSignature(clazz); + String targetId = SignatureUtils.getMemberSignature(sourceId, member); - /** - * Called when a method call is discovered. - * Creates a CALLS edge in the knowledge graph. - */ - public void onMethodCalled(GraphNode node, MethodCallExpr mce) { - String sourceId = SignatureUtils.getSignature(node); - if (sourceId == null) return; + KnowledgeGraphEdge edge = KnowledgeGraphEdge.builder() + .source(sourceId) + .target(targetId) + .type(EdgeType.CONTAINS) + .build(); + + graphStore.persistEdge(edge); + logger.trace("Edge: {} --CONTAINS--> {}", sourceId, targetId); + } + + public void onExtendsDiscovered(ClassOrInterfaceDeclaration clazz, ClassOrInterfaceType extendedType) { + CompilationUnit cu = clazz.findCompilationUnit().orElse(null); + String sourceId = SignatureUtils.getTypeSignature(clazz); + String targetId = resolveTypeSignature(cu, extendedType); + + KnowledgeGraphEdge edge = KnowledgeGraphEdge.builder() + .source(sourceId) + .target(targetId) + .type(EdgeType.EXTENDS) + .build(); + + graphStore.persistEdge(edge); + logger.trace("Edge: {} --EXTENDS--> {}", sourceId, targetId); + } + + public void onImplementsDiscovered(ClassOrInterfaceDeclaration clazz, ClassOrInterfaceType implementedType) { + CompilationUnit cu = clazz.findCompilationUnit().orElse(null); + String sourceId = SignatureUtils.getTypeSignature(clazz); + String targetId = resolveTypeSignature(cu, implementedType); + + KnowledgeGraphEdge edge = KnowledgeGraphEdge.builder() + .source(sourceId) + .target(targetId) + .type(EdgeType.IMPLEMENTS) + .build(); + + graphStore.persistEdge(edge); + logger.trace("Edge: {} --IMPLEMENTS--> {}", sourceId, targetId); + } + private void traverseType(TypeDeclaration type, String enclosingSignature) { + String typeSignature = SignatureUtils.getTypeSignature(type); + persistNode(typeSignature, nodeTypeLabel(type), type.getNameAsString(), typeSignature); + + if (enclosingSignature != null) { + graphStore.persistEdge(KnowledgeGraphEdge.builder() + .source(enclosingSignature) + .target(typeSignature) + .type(EdgeType.ENCLOSES) + .attribute("kind", "inner_class") + .build()); + } + + if (type instanceof ClassOrInterfaceDeclaration coid) { + for (ClassOrInterfaceType extendedType : coid.getExtendedTypes()) { + onExtendsDiscovered(coid, extendedType); + } + for (ClassOrInterfaceType implementedType : coid.getImplementedTypes()) { + onImplementsDiscovered(coid, implementedType); + } + } + + for (BodyDeclaration member : type.getMembers()) { + emitContains(typeSignature, member); + processMember(type, typeSignature, member); + if (member instanceof TypeDeclaration nestedType) { + traverseType(nestedType, typeSignature); + } + } + + if (type instanceof EnumDeclaration enumDeclaration) { + for (EnumConstantDeclaration constant : enumDeclaration.getEntries()) { + String constantSignature = SignatureUtils.getEnumConstantSignature(typeSignature, constant); + persistNode(constantSignature, "EnumConstant", constant.getNameAsString(), typeSignature); + + graphStore.persistEdge(KnowledgeGraphEdge.builder() + .source(typeSignature) + .target(constantSignature) + .type(EdgeType.CONTAINS) + .build()); + } + } + } + + private void processMember(TypeDeclaration ownerType, String ownerSignature, BodyDeclaration member) { + CompilationUnit cu = ownerType.findCompilationUnit().orElse(null); + + if (member instanceof MethodDeclaration md) { + String methodSignature = SignatureUtils.getMethodSignature(ownerSignature, md); + persistNode(methodSignature, "Method", md.getNameAsString(), ownerSignature); + processCallableBody(cu, ownerSignature, methodSignature, md, md.getParameters(), md.getBody().orElse(null)); + return; + } + + if (member instanceof ConstructorDeclaration cd) { + String constructorSignature = SignatureUtils.getMethodSignature(ownerSignature, cd); + persistNode(constructorSignature, "Constructor", cd.getNameAsString(), ownerSignature); + processCallableBody(cu, ownerSignature, constructorSignature, cd, cd.getParameters(), cd.getBody()); + return; + } + + if (member instanceof FieldDeclaration fd) { + for (VariableDeclarator v : fd.getVariables()) { + String fieldSignature = SignatureUtils.getFieldSignature(ownerSignature, v.getNameAsString()); + persistNode(fieldSignature, "Field", v.getNameAsString(), ownerSignature); + + v.getInitializer().ifPresent(init -> { + ScopeContext context = new ScopeContext(ownerSignature, ownerSignature, cu, new HashMap<>(), new AtomicInteger()); + context.symbolTypes().put("this", ownerSignature); + init.accept(new GraphVisitor(), context); + }); + } + return; + } + + if (member instanceof InitializerDeclaration initializer) { + String initSignature = SignatureUtils.getInitializerSignature(ownerSignature, initializer, ownerType); + persistNode(initSignature, "StaticBlock", "", ownerSignature); + ScopeContext context = new ScopeContext(initSignature, ownerSignature, cu, new HashMap<>(), new AtomicInteger()); + context.symbolTypes().put("this", ownerSignature); + initializer.accept(new GraphVisitor(), context); + } + } + + private void processCallableBody( + CompilationUnit cu, + String ownerTypeSignature, + String callableSignature, + CallableDeclaration callable, + List parameters, + Node bodyNode + ) { + Map symbols = new HashMap<>(); + symbols.put("this", ownerTypeSignature); + + for (Parameter parameter : parameters) { + symbols.put(parameter.getNameAsString(), resolveTypeSignature(cu, parameter.getType())); + } + + ScopeContext context = new ScopeContext(callableSignature, ownerTypeSignature, cu, symbols, new AtomicInteger()); + + if (bodyNode != null) { + bodyNode.accept(new GraphVisitor(), context); + } + + if (callable instanceof MethodDeclaration methodDeclaration) { + String returnType = methodDeclaration.getType().asString(); + if (!"void".equals(returnType)) { + graphStore.persistEdge(KnowledgeGraphEdge.builder() + .source(callableSignature) + .target(resolveTypeSignature(cu, methodDeclaration.getType())) + .type(EdgeType.USES) + .build()); + } + } + } + + private void emitContains(String ownerSignature, BodyDeclaration member) { + if (member instanceof FieldDeclaration fd) { + for (String fieldSignature : SignatureUtils.getFieldSignatures(ownerSignature, fd)) { + graphStore.persistEdge(KnowledgeGraphEdge.builder() + .source(ownerSignature) + .target(fieldSignature) + .type(EdgeType.CONTAINS) + .build()); + } + return; + } + + String memberSignature = SignatureUtils.getMemberSignature(ownerSignature, member); + graphStore.persistEdge(KnowledgeGraphEdge.builder() + .source(ownerSignature) + .target(memberSignature) + .type(EdgeType.CONTAINS) + .build()); + } + + private void emitMethodCall(ScopeContext context, MethodCallExpr mce) { String targetId; + String resolution = EXACT; + if (mce.getScope().isPresent()) { - targetId = mce.getScope().get().toString() + "#" + mce.getNameAsString() + "()"; - } else if (node.getEnclosingType() != null) { - targetId = node.getEnclosingType().getFullyQualifiedName() - .orElse(node.getEnclosingType().getNameAsString()) + "#" + mce.getNameAsString() + "()"; + Expression scope = mce.getScope().orElseThrow(); + String resolvedScopeType = resolveExpressionType(context, scope); + if (resolvedScopeType != null) { + targetId = resolvedScopeType + "#" + mce.getNameAsString() + "()"; + } else { + targetId = scope + "#" + mce.getNameAsString() + "()"; + resolution = PARTIAL; + } } else { - targetId = mce.getNameAsString() + "()"; + targetId = context.enclosingTypeSignature() + "#" + mce.getNameAsString() + "()"; } List args = mce.getArguments().stream() - .map(com.github.javaparser.ast.expr.Expression::toString) + .map(Expression::toString) .collect(Collectors.toList()); KnowledgeGraphEdge.Builder edgeBuilder = KnowledgeGraphEdge.builder() - .source(sourceId) + .source(context.sourceId()) .target(targetId) .type(EdgeType.CALLS); + if (!EXACT.equals(resolution)) { + edgeBuilder.attribute(RESOLUTION, resolution); + } + if (!args.isEmpty()) { try { - String paramsJson = objectMapper.writeValueAsString(args); - edgeBuilder.attribute("parameterValues", paramsJson); + edgeBuilder.attribute("parameterValues", objectMapper.writeValueAsString(args)); } catch (Exception e) { logger.warn("Failed to serialize arguments for call: {}", targetId, e); } } - KnowledgeGraphEdge edge = edgeBuilder.build(); - - graphStore.persistEdge(edge); - logger.trace("Edge: {} --CALLS--> {}", sourceId, targetId); + graphStore.persistEdge(edgeBuilder.build()); + logger.trace("Edge: {} --CALLS--> {}", context.sourceId(), targetId); } - /** - * Called when a class member (method, field, etc.) is discovered. - * Creates a CONTAINS edge in the knowledge graph. - */ - public void onMemberDiscovered(ClassOrInterfaceDeclaration clazz, BodyDeclaration member) { - String sourceId = clazz.getFullyQualifiedName().orElse(clazz.getNameAsString()); + private void emitFieldAccess(ScopeContext context, FieldAccessExpr fae) { String targetId; - if (member instanceof MethodDeclaration md) { - targetId = SignatureUtils.getMethodSignature(sourceId, md); + String resolution = EXACT; + + String scopeType = resolveExpressionType(context, fae.getScope()); + if (scopeType != null) { + targetId = scopeType + "#" + fae.getNameAsString(); } else { - targetId = sourceId + "#member"; + targetId = fae.getScope() + "#" + fae.getNameAsString(); + resolution = PARTIAL; } - KnowledgeGraphEdge edge = KnowledgeGraphEdge.builder() - .source(sourceId) + KnowledgeGraphEdge.Builder edgeBuilder = KnowledgeGraphEdge.builder() + .source(context.sourceId()) .target(targetId) - .type(EdgeType.CONTAINS) - .build(); + .type(EdgeType.ACCESSES) + .accessType("READ"); - graphStore.persistEdge(edge); - logger.trace("Edge: {} --CONTAINS--> {}", sourceId, targetId); + if (!EXACT.equals(resolution)) { + edgeBuilder.attribute(RESOLUTION, resolution); + } + + graphStore.persistEdge(edgeBuilder.build()); + logger.trace("Edge: {} --ACCESSES--> {}", context.sourceId(), targetId); } - /** - * Called when a class extends another class. - * Creates an EXTENDS edge in the knowledge graph. - */ - public void onExtendsDiscovered(ClassOrInterfaceDeclaration clazz, ClassOrInterfaceType extendedType) { - String sourceId = clazz.getFullyQualifiedName().orElse(clazz.getNameAsString()); - String targetId = extendedType.getNameAsString(); + private String resolveExpressionType(ScopeContext context, Expression expression) { + if (expression instanceof NameExpr nameExpr) { + String name = nameExpr.getNameAsString(); + String fromSymbols = context.symbolTypes().get(name); + if (fromSymbols != null) { + return fromSymbols; + } - KnowledgeGraphEdge edge = KnowledgeGraphEdge.builder() - .source(sourceId) - .target(targetId) - .type(EdgeType.EXTENDS) - .build(); + return AbstractCompiler.findFullyQualifiedName(context.compilationUnit(), name); + } - graphStore.persistEdge(edge); - logger.trace("Edge: {} --EXTENDS--> {}", sourceId, targetId); + if (expression instanceof ThisExpr) { + return context.enclosingTypeSignature(); + } + + if (expression instanceof FieldAccessExpr fieldAccessExpr) { + return resolveExpressionType(context, fieldAccessExpr.getScope()); + } + + if (expression instanceof ObjectCreationExpr objectCreationExpr) { + return resolveTypeSignature(context.compilationUnit(), objectCreationExpr.getType()); + } + + if (expression instanceof CastExpr castExpr) { + return resolveTypeSignature(context.compilationUnit(), castExpr.getType()); + } + + return null; + } + + private String resolveTypeSignature(CompilationUnit compilationUnit, Type type) { + TypeWrapper wrapper = AbstractCompiler.findType(compilationUnit, type); + if (wrapper != null) { + String fqn = wrapper.getFullyQualifiedName(); + if (fqn != null) { + return fqn; + } + // Local class inside a method body β€” use the Java compiler naming convention + TypeDeclaration localType = wrapper.getType(); + if (localType != null) { + return buildLocalClassName(localType); + } + } + return type.asString(); } /** - * Called when a class implements an interface. - * Creates an IMPLEMENTS edge in the knowledge graph. + * Builds a name for a local class using the Java compiler convention: OuterClass$NLocalName + * where N is a 1-based index among local classes with the same name in the enclosing type. */ - public void onImplementsDiscovered(ClassOrInterfaceDeclaration clazz, ClassOrInterfaceType implementedType) { - String sourceId = clazz.getFullyQualifiedName().orElse(clazz.getNameAsString()); - String targetId = implementedType.getNameAsString(); + private String buildLocalClassName(TypeDeclaration localType) { + String localName = localType.getNameAsString(); + TypeDeclaration enclosing = AbstractCompiler.getEnclosingType(localType.getParentNode().orElse(null)); + if (enclosing != null) { + String enclosingFqn = enclosing.getFullyQualifiedName().orElse(enclosing.getNameAsString()); + int index = 1; + for (TypeDeclaration t : enclosing.findAll(TypeDeclaration.class)) { + if (t == localType) { + break; + } + if (t.getNameAsString().equals(localName) && t.getFullyQualifiedName().isEmpty()) { + index++; + } + } + return enclosingFqn + "$" + index + localName; + } + return localName; + } - KnowledgeGraphEdge edge = KnowledgeGraphEdge.builder() - .source(sourceId) - .target(targetId) - .type(EdgeType.IMPLEMENTS) - .build(); + private ScopeContext fromGraphNode(GraphNode node, String sourceId) { + CompilationUnit cu = node.getCompilationUnit(); + String ownerType = node.getEnclosingType() == null ? sourceId : SignatureUtils.getTypeSignature(node.getEnclosingType()); - graphStore.persistEdge(edge); - logger.trace("Edge: {} --IMPLEMENTS--> {}", sourceId, targetId); + Map symbols = new HashMap<>(); + symbols.put("this", ownerType); + + Node astNode = node.getNode(); + if (astNode instanceof CallableDeclaration callable) { + for (Parameter parameter : callable.getParameters()) { + symbols.put(parameter.getNameAsString(), resolveTypeSignature(cu, parameter.getType())); + } + } + + return new ScopeContext(sourceId, ownerType, cu, symbols, new AtomicInteger()); } - /** - * Internal Visitor to traverse AST and trigger hooks. - * This ensures our hooks are called regardless of the parent DependencyAnalyzer version. - */ - private class GraphVisitor extends VoidVisitorAdapter { + + private String nodeTypeLabel(TypeDeclaration typeDeclaration) { + if (typeDeclaration instanceof EnumDeclaration) { + return "Enum"; + } + if (typeDeclaration instanceof ClassOrInterfaceDeclaration coid && coid.isInterface()) { + return "Interface"; + } + return "Class"; + } + + private void persistNode(String signature, String nodeType, String name, String fqn) { + try { + graphStore.persistNode(signature, nodeType, name, fqn); + } catch (Exception e) { + logger.debug("Node upsert skipped for {} due to store implementation: {}", signature, e.getMessage()); + } + } + + private record ScopeContext( + String sourceId, + String enclosingTypeSignature, + CompilationUnit compilationUnit, + Map symbolTypes, + AtomicInteger lambdaCounter + ) { + ScopeContext child(String childSource, Map childSymbols) { + return new ScopeContext(childSource, enclosingTypeSignature, compilationUnit, childSymbols, new AtomicInteger()); + } + + int nextLambdaIndex() { + return lambdaCounter.incrementAndGet(); + } + } + + private class GraphVisitor extends VoidVisitorAdapter { + private void emitMethodReference(ScopeContext context, MethodReferenceExpr mre) { + String scopeType = resolveExpressionType(context, mre.getScope()); + String targetId; + String resolution = EXACT; + + if (scopeType != null) { + targetId = scopeType + "#" + mre.getIdentifier() + "()"; + } else { + targetId = mre.getScope() + "#" + mre.getIdentifier() + "()"; + resolution = PARTIAL; + } + + KnowledgeGraphEdge.Builder edgeBuilder = KnowledgeGraphEdge.builder() + .source(context.sourceId()) + .target(targetId) + .type(EdgeType.REFERENCES); + + if (!EXACT.equals(resolution)) { + edgeBuilder.attribute(RESOLUTION, resolution); + } + + graphStore.persistEdge(edgeBuilder.build()); + logger.trace("Edge: {} --REFERENCES--> {}", context.sourceId(), targetId); + } + + @Override + public void visit(MethodCallExpr n, ScopeContext context) { + emitMethodCall(context, n); + super.visit(n, context); + } + @Override - public void visit(MethodCallExpr n, GraphNode scope) { - onMethodCalled(scope, n); - super.visit(n, scope); + public void visit(FieldAccessExpr n, ScopeContext context) { + emitFieldAccess(context, n); + super.visit(n, context); } @Override - public void visit(FieldAccessExpr n, GraphNode scope) { - onFieldAccessed(scope, n); - super.visit(n, scope); + public void visit(MethodReferenceExpr n, ScopeContext context) { + emitMethodReference(context, n); + super.visit(n, context); } @Override - public void visit(LambdaExpr n, GraphNode scope) { - onLambdaDiscovered(scope, n); - // Continue traversal with same scope for now - super.visit(n, scope); + public void visit(ClassOrInterfaceType n, ScopeContext context) { + graphStore.persistEdge(KnowledgeGraphEdge.builder() + .source(context.sourceId()) + .target(resolveTypeSignature(context.compilationUnit(), n)) + .type(EdgeType.USES) + .build()); + super.visit(n, context); } - + @Override - public void visit(ClassOrInterfaceType n, GraphNode scope) { - // USES edges for types - onTypeUsed(scope, n); - super.visit(n, scope); + public void visit(VariableDeclarationExpr n, ScopeContext context) { + for (VariableDeclarator variable : n.getVariables()) { + context.symbolTypes().put(variable.getNameAsString(), resolveTypeSignature(context.compilationUnit(), variable.getType())); + } + super.visit(n, context); + } + + @Override + public void visit(LambdaExpr n, ScopeContext context) { + int index = context.nextLambdaIndex(); + String lambdaSignature = SignatureUtils.getLambdaSignature(context.sourceId(), n, index); + persistNode(lambdaSignature, "Lambda", LAMBDA, context.enclosingTypeSignature()); + + graphStore.persistEdge(KnowledgeGraphEdge.builder() + .source(context.sourceId()) + .target(lambdaSignature) + .type(EdgeType.ENCLOSES) + .attribute("kind", LAMBDA) + .build()); + + Map lambdaSymbols = new HashMap<>(context.symbolTypes()); + for (Parameter parameter : n.getParameters()) { + if (!parameter.getType().isUnknownType()) { + lambdaSymbols.put(parameter.getNameAsString(), resolveTypeSignature(context.compilationUnit(), parameter.getType())); + } + } + + ScopeContext lambdaContext = context.child(lambdaSignature, lambdaSymbols); + n.getBody().accept(this, lambdaContext); } } } diff --git a/src/main/java/com/raditha/graph/KnowledgeGraphCLI.java b/src/main/java/com/raditha/graph/KnowledgeGraphCLI.java index d4e3c27f..9efceec5 100644 --- a/src/main/java/com/raditha/graph/KnowledgeGraphCLI.java +++ b/src/main/java/com/raditha/graph/KnowledgeGraphCLI.java @@ -1,7 +1,7 @@ package com.raditha.graph; import com.github.javaparser.ast.CompilationUnit; -import com.github.javaparser.ast.body.MethodDeclaration; +import org.codehaus.plexus.util.xml.pull.XmlPullParserException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sa.com.cloudsolutions.antikythera.configuration.Settings; @@ -11,13 +11,19 @@ import java.io.File; import java.io.IOException; -import java.util.ArrayList; +import java.sql.SQLException; +import java.util.Arrays; import java.util.List; +import java.util.Optional; /** * Command Line Interface for generating the Knowledge Graph. *

- * Usage: java com.raditha.graph.KnowledgeGraphCLI [path-to-graph.yml] + * Usage: + * java com.raditha.graph.KnowledgeGraphCLI [--config=] [--base-path=] + *
+ * Backward-compatible positional usage: + * java com.raditha.graph.KnowledgeGraphCLI [path-to-graph.yml] *

*/ @SuppressWarnings("java:S106") @@ -25,33 +31,75 @@ public class KnowledgeGraphCLI { private static final Logger logger = LoggerFactory.getLogger(KnowledgeGraphCLI.class); - public static void main(String[] args) { - if (args.length < 1) { - System.err.println("Usage: java com.raditha.graph.KnowledgeGraphCLI [path-to-graph.yml]"); - System.exit(1); - } - - String projectPath = args[0]; - String configPath = args.length > 1 ? args[1] : "src/main/resources/graph.yml"; + private static final String DEFAULT_CONFIG_PATH = "src/main/resources/graph.yml"; + private static final String CONFIG_OPTION = "--config="; + private static final String BASE_PATH_OPTION = "--base-path="; + private static final String PROJECT_PATH_OPTION = "--project-path="; + public static void main(String[] args) { try { - new KnowledgeGraphCLI().run(projectPath, configPath); + CliOptions options = parseArgs(args); + new KnowledgeGraphCLI().run(options.basePath(), options.configPath()); } catch (Exception e) { logger.error("Analysis failed", e); System.exit(1); } } - public void run(String projectPath, String configPath) throws IOException { - logger.info("Initializing Knowledge Graph Builder..."); - logger.info("Target Project: {}", projectPath); - logger.info("Configuration: {}", configPath); + static CliOptions parseArgs(String[] args) throws IOException { + List positionalArgs = Arrays.stream(args) + .filter(arg -> !arg.startsWith("--")) + .toList(); - // 1. Load Configuration + String configPath = findOptionValue(args, CONFIG_OPTION) + .orElseGet(() -> positionalArgs.size() > 1 ? positionalArgs.get(1) : DEFAULT_CONFIG_PATH); File configFile = new File(configPath); if (!configFile.exists()) { throw new IOException("Configuration file not found: " + configPath); } + + // Load first so base_path can be sourced from graph.yml (same settings-driven pattern as other tools). + Settings.loadConfigMap(configFile); + + String optionBasePath = findOptionValue(args, BASE_PATH_OPTION) + .or(() -> findOptionValue(args, PROJECT_PATH_OPTION)) + .orElse(null); + + String positionalBasePath = positionalArgs.isEmpty() ? null : positionalArgs.getFirst(); + + String basePath = optionBasePath != null ? optionBasePath : positionalBasePath; + if (basePath == null) { + basePath = Settings.getProperty(Settings.BASE_PATH, String.class).orElse(null); + } + + if (basePath == null || basePath.isBlank()) { + throw new IllegalArgumentException(""" + Missing project source path. + Provide one of: + - --base-path= (or --project-path=...) + - positional arg: + - base_path in graph.yml + """.stripIndent()); + } + + return new CliOptions(basePath, configPath); + } + + private static Optional findOptionValue(String[] args, String prefix) { + return Arrays.stream(args) + .filter(arg -> arg.startsWith(prefix)) + .map(arg -> arg.substring(prefix.length())) + .filter(value -> !value.isBlank()) + .findFirst(); + } + + public void run(String projectPath, String configPath) throws IOException, SQLException, XmlPullParserException { + logger.info("Initializing Knowledge Graph Builder..."); + logger.info("Target Project: {}", projectPath); + logger.info("Configuration: {}", configPath); + + // 1. Ensure Configuration is loaded from selected file + File configFile = new File(configPath); Settings.loadConfigMap(configFile); // 2. Override base_path with the provided CLI argument @@ -60,44 +108,38 @@ public void run(String projectPath, String configPath) throws IOException { // 3. Initialize Parser via MavenHelper to resolve dependencies AbstractCompiler.reset(); - try { - MavenHelper mavenHelper = new MavenHelper(); - mavenHelper.readPomFile(); - mavenHelper.buildJarPaths(); - } catch (Exception e) { - logger.warn("Could not load Maven dependencies (pom.xml not found or invalid). Proceeding with limited resolution.", e); - } - logger.info("Pre-processing project sources via Antikythera..."); + MavenHelper mavenHelper = new MavenHelper(); + mavenHelper.readPomFile(); + mavenHelper.buildJarPaths(); + + new AbstractCompiler(); AbstractCompiler.preProcess(); - // 4. Collect ALL methods from the project - List methods = collectMethods(); - logger.info("Found {} methods to analyze.", methods.size()); + // 4. Collect resolved compilation units from runtime + List units = collectCompilationUnits(); + logger.info("Found {} compilation units to analyze.", units.size()); - if (methods.isEmpty()) { - logger.warn("No methods found. Check the project path and structure."); + if (units.isEmpty()) { + logger.warn("No compilation units found. Check the project path and structure."); return; } - // 5. Build Graph - Neo4jGraphStore store = Neo4jGraphStore.fromSettings(configFile); + // 5. Build Graph (Settings already loaded above, use the no-arg variant + // so the base_path override from step 2 is preserved) + GraphStore store = GraphStoreFactory.createGraphStore(); KnowledgeGraphBuilder builder = new KnowledgeGraphBuilder(store); - builder.build(methods); + builder.build(units); } private void updateBasePath(String projectPath) { Settings.setProperty("base_path", projectPath); } - private List collectMethods() { - // We ignore projectRoot here because AbstractCompiler.preProcess() has already populated the runtime - // based on the Settings.base_path (which we updated). - - List allMethods = new ArrayList<>(); - for (CompilationUnit cu : AntikytheraRunTime.getResolvedCompilationUnits().values()) { - allMethods.addAll(cu.findAll(MethodDeclaration.class)); - } - return allMethods; + private List collectCompilationUnits() { + return new java.util.ArrayList<>(AntikytheraRunTime.getResolvedCompilationUnits().values()); + } + + record CliOptions(String basePath, String configPath) { } } diff --git a/src/main/java/com/raditha/graph/Neo4jConfig.java b/src/main/java/com/raditha/graph/Neo4jConfig.java deleted file mode 100644 index 68c0fb62..00000000 --- a/src/main/java/com/raditha/graph/Neo4jConfig.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.raditha.graph; - -import sa.com.cloudsolutions.antikythera.configuration.Settings; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.File; -import java.io.IOException; -import java.util.Map; -import java.util.Optional; - -/** - * Configuration helper for Neo4j connection settings. - * Reads from graph.yml using the Settings infrastructure. - */ -public class Neo4jConfig { - - private static final Logger logger = LoggerFactory.getLogger(Neo4jConfig.class); - - private static final String DEFAULT_URI = "bolt://localhost:7687"; - private static final String DEFAULT_USERNAME = "neo4j"; - private static final String DEFAULT_DATABASE = "neo4j"; - private static final int DEFAULT_BATCH_SIZE = 1000; - - private static Map neo4jSettings; - - private Neo4jConfig() { - // Utility class - } - - /** - * Load the graph.yml configuration file. - * - * @param configFile path to graph.yml - * @throws IOException if file cannot be read - */ - public static void load(File configFile) throws IOException { - Settings.loadConfigMap(configFile); - loadNeo4jSettings(); - } - - /** - * Load the graph.yml from the default location in current directory. - * - * @throws IOException if file cannot be read - */ - public static void load() throws IOException { - File defaultConfig = new File("graph.yml"); - if (!defaultConfig.exists()) { - defaultConfig = new File("src/main/resources/graph.yml"); - } - if (!defaultConfig.exists()) { - throw new IOException("graph.yml not found in current directory or src/main/resources/"); - } - load(defaultConfig); - } - - @SuppressWarnings("unchecked") - private static void loadNeo4jSettings() { - Optional neo4jOpt = Settings.getProperty(DEFAULT_USERNAME, Map.class); - neo4jSettings = neo4jOpt.orElse(Map.of()); - logger.info("Neo4j config loaded: uri={}", getUri()); - } - - public static String getUri() { - return getStringProperty("uri", DEFAULT_URI); - } - - public static String getUsername() { - return getStringProperty("username", DEFAULT_USERNAME); - } - - public static String getPassword() { - return getStringProperty("password", ""); - } - - public static String getDatabase() { - return getStringProperty("database", DEFAULT_DATABASE); - } - - public static int getBatchSize() { - Object value = neo4jSettings.get("batch_size"); - if (value instanceof Number n) { - return n.intValue(); - } - return DEFAULT_BATCH_SIZE; - } - - private static String getStringProperty(String key, String defaultValue) { - Object value = neo4jSettings.get(key); - return value != null ? value.toString() : defaultValue; - } - - /** - * Check if Neo4j configuration is present. - */ - public static boolean isConfigured() { - return neo4jSettings != null && !neo4jSettings.isEmpty(); - } -} diff --git a/src/main/java/com/raditha/graph/Neo4jGraphStore.java b/src/main/java/com/raditha/graph/Neo4jGraphStore.java index 87ab9b6b..0804782f 100644 --- a/src/main/java/com/raditha/graph/Neo4jGraphStore.java +++ b/src/main/java/com/raditha/graph/Neo4jGraphStore.java @@ -8,8 +8,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.File; -import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -17,7 +15,7 @@ * Neo4j graph store for persisting Knowledge Graph nodes and edges. * Supports streaming persistence with configurable batch sizes. */ -public class Neo4jGraphStore implements AutoCloseable { +public class Neo4jGraphStore implements GraphStore { private static final Logger logger = LoggerFactory.getLogger(Neo4jGraphStore.class); @@ -32,33 +30,6 @@ public class Neo4jGraphStore implements AutoCloseable { private final List pendingEdges = new ArrayList<>(); private int edgeCount = 0; - /** - * Create a Neo4jGraphStore from graph.yml configuration. - * - * @param configFile path to graph.yml - * @return configured Neo4jGraphStore instance - * @throws IOException if config file cannot be read - */ - public static Neo4jGraphStore fromSettings(File configFile) throws IOException { - Neo4jConfig.load(configFile); - return fromSettings(); - } - - /** - * Create a Neo4jGraphStore from already-loaded Settings. - * - * @return configured Neo4jGraphStore instance - */ - public static Neo4jGraphStore fromSettings() { - return new Neo4jGraphStore( - Neo4jConfig.getUri(), - Neo4jConfig.getUsername(), - Neo4jConfig.getPassword(), - Neo4jConfig.getDatabase(), - Neo4jConfig.getBatchSize() - ); - } - /** * Create a Neo4jGraphStore with an existing driver. * Useful for testing with mock drivers. diff --git a/src/main/java/com/raditha/graph/SignatureUtils.java b/src/main/java/com/raditha/graph/SignatureUtils.java index 47ca975e..11b38b84 100644 --- a/src/main/java/com/raditha/graph/SignatureUtils.java +++ b/src/main/java/com/raditha/graph/SignatureUtils.java @@ -2,11 +2,18 @@ import com.github.javaparser.ast.Node; import com.github.javaparser.ast.body.CallableDeclaration; +import com.github.javaparser.ast.body.ConstructorDeclaration; +import com.github.javaparser.ast.body.EnumConstantDeclaration; import com.github.javaparser.ast.body.FieldDeclaration; import com.github.javaparser.ast.body.InitializerDeclaration; +import com.github.javaparser.ast.body.MethodDeclaration; +import com.github.javaparser.ast.body.BodyDeclaration; import com.github.javaparser.ast.body.TypeDeclaration; +import com.github.javaparser.ast.expr.LambdaExpr; import sa.com.cloudsolutions.antikythera.depsolver.GraphNode; +import java.util.ArrayList; +import java.util.List; import java.util.stream.Collectors; /** @@ -49,6 +56,16 @@ public static String getSignature(GraphNode node) { return getInitializerSignature(className, node, id); } + if (astNode instanceof EnumConstantDeclaration constant) { + return getEnumConstantSignature(className, constant); + } + + if (astNode instanceof LambdaExpr lambda) { + int line = lambda.getBegin().map(p -> p.line).orElse(0); + int column = lambda.getBegin().map(p -> p.column).orElse(0); + return className + "#"; + } + return null; } @@ -74,6 +91,20 @@ public static String getFieldSignature(String className, FieldDeclaration fd) { return className + "#" + fieldName; } + /** + * Get signatures for all variables in a field declaration. + * Format: ClassName#fieldName + */ + public static List getFieldSignatures(String className, FieldDeclaration fd) { + List signatures = new ArrayList<>(); + fd.getVariables().forEach(v -> signatures.add(className + "#" + v.getNameAsString())); + return signatures; + } + + public static String getFieldSignature(String className, String fieldName) { + return className + "#" + fieldName; + } + /** * Get signature for a type (class, interface, enum). * Format: fully.qualified.ClassName @@ -91,6 +122,41 @@ public static String getInitializerSignature(String className, GraphNode node, I return className + "#" + (index > 0 ? "_" + index : ""); } + public static String getInitializerSignature(String className, InitializerDeclaration id, TypeDeclaration enclosingType) { + int index = getInitializerIndex(enclosingType, id); + return className + "#" + (index > 0 ? "_" + index : ""); + } + + public static String getEnumConstantSignature(String enumClassName, EnumConstantDeclaration constant) { + return enumClassName + "#" + constant.getNameAsString(); + } + + public static String getLambdaSignature(String enclosingSignature, LambdaExpr lambda, int ordinal) { + int line = lambda.getBegin().map(p -> p.line).orElse(0); + int column = lambda.getBegin().map(p -> p.column).orElse(0); + return enclosingSignature + "$lambda_L" + line + "C" + column + "_" + ordinal; + } + + public static String getMemberSignature(String ownerTypeSignature, BodyDeclaration member) { + if (member instanceof MethodDeclaration md) { + return getMethodSignature(ownerTypeSignature, md); + } + if (member instanceof ConstructorDeclaration cd) { + return getMethodSignature(ownerTypeSignature, cd); + } + if (member instanceof FieldDeclaration fd) { + return getFieldSignature(ownerTypeSignature, fd); + } + if (member instanceof InitializerDeclaration id) { + TypeDeclaration enclosing = member.findAncestor(TypeDeclaration.class).orElse(null); + return getInitializerSignature(ownerTypeSignature, id, enclosing); + } + if (member instanceof TypeDeclaration td) { + return getTypeSignature(td); + } + return ownerTypeSignature + "#member@" + member.getBegin().map(p -> p.line + ":" + p.column).orElse("unknown"); + } + private static String getEnclosingClassName(GraphNode node) { if (node.getEnclosingType() == null) { return "Unknown"; @@ -100,12 +166,16 @@ private static String getEnclosingClassName(GraphNode node) { } private static int getInitializerIndex(GraphNode node, InitializerDeclaration id) { - if (node.getEnclosingType() == null) { + return getInitializerIndex(node.getEnclosingType(), id); + } + + private static int getInitializerIndex(TypeDeclaration enclosingType, InitializerDeclaration id) { + if (enclosingType == null) { return 0; } int targetLine = id.getBegin().map(p -> p.line).orElse(-1); int index = 0; - for (var member : node.getEnclosingType().getMembers()) { + for (var member : enclosingType.getMembers()) { if (member instanceof InitializerDeclaration init) { int memberLine = init.getBegin().map(p -> p.line).orElse(-2); if (memberLine == targetLine) { diff --git a/src/main/java/com/raditha/spring/AICodeGenerationHelper.java b/src/main/java/com/raditha/spring/AICodeGenerationHelper.java index 96d5ce5a..47702f37 100644 --- a/src/main/java/com/raditha/spring/AICodeGenerationHelper.java +++ b/src/main/java/com/raditha/spring/AICodeGenerationHelper.java @@ -128,7 +128,7 @@ public static GeminiAIService createConfiguredService() throws IOException { public static String generateCode(String prompt) throws IOException, InterruptedException { GeminiAIService aiService = createConfiguredService(); String request = buildAIRequest(prompt); - String response = aiService.sendApiRequest(request); + String response = aiService.sendRawRequest(request); return extractGeneratedCode(response); } } diff --git a/src/main/java/sa/com/cloudsolutions/antikythera/examples/AIServiceFactory.java b/src/main/java/sa/com/cloudsolutions/antikythera/examples/AIServiceFactory.java new file mode 100644 index 00000000..e5e86b4f --- /dev/null +++ b/src/main/java/sa/com/cloudsolutions/antikythera/examples/AIServiceFactory.java @@ -0,0 +1,33 @@ +package sa.com.cloudsolutions.antikythera.examples; + +import java.io.IOException; +import java.util.Map; + +/** + * Factory class for creating AI service instances based on configuration. + */ +public class AIServiceFactory { + + private AIServiceFactory() { + // Private constructor to prevent instantiation + } + + /** + * Creates an AI service instance based on the provider specified in the configuration. + * + * @param config Configuration map containing provider and other settings + * @return An instance of AbstractAIService (GeminiAIService or OpenAIService) + * @throws IOException if service initialization fails + * @throws IllegalArgumentException if an unknown provider is specified + */ + public static AbstractAIService create(Map config) throws IOException { + String provider = (String) config.getOrDefault("provider", "gemini"); + + return switch (provider.toLowerCase()) { + case "gemini" -> new GeminiAIService(); + case "openai" -> new OpenAIService(); + default -> throw new IllegalArgumentException( + "Unknown AI provider: " + provider + ". Supported providers: gemini, openai"); + }; + } +} diff --git a/src/main/java/sa/com/cloudsolutions/antikythera/examples/AbstractAIService.java b/src/main/java/sa/com/cloudsolutions/antikythera/examples/AbstractAIService.java new file mode 100644 index 00000000..15417fe5 --- /dev/null +++ b/src/main/java/sa/com/cloudsolutions/antikythera/examples/AbstractAIService.java @@ -0,0 +1,603 @@ +package sa.com.cloudsolutions.antikythera.examples; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.javaparser.ParserConfiguration; +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.ImportDeclaration; +import com.github.javaparser.ast.Node; +import com.github.javaparser.ast.NodeList; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.MethodDeclaration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sa.com.cloudsolutions.antikythera.generator.QueryType; +import sa.com.cloudsolutions.antikythera.generator.RepositoryQuery; +import sa.com.cloudsolutions.antikythera.parser.BaseRepositoryParser; + +import java.io.IOException; +import java.io.InputStream; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Abstract base class for AI service implementations. + * Provides common functionality for interacting with LLM providers for query optimization. + * Subclasses must implement provider-specific request building, API communication, and response parsing. + */ +public abstract class AbstractAIService { + protected static final Logger logger = LoggerFactory.getLogger(AbstractAIService.class); + + protected Map config; + protected HttpClient httpClient; + protected final ObjectMapper objectMapper; + protected TokenUsage lastTokenUsage; + protected final String systemPrompt; + + public static final String OPTIMIZED_CODE_ELEMENT = "optimizedCodeElement"; + public static final String NOTES = "notes"; + public static final String API_KEY = "api_key"; + public static final String CONTENT = "content"; + public static final String MODEL = "model"; + public static final String STRING = "string"; + public static final String PARTS = "parts"; + + /** + * Constructor initializes common components. + */ + protected AbstractAIService() throws IOException { + // Configure JavaParser to support Java 21 features (including text blocks) + ParserConfiguration parserConfig = new ParserConfiguration(); + parserConfig.setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_21); + StaticJavaParser.setConfiguration(parserConfig); + + this.objectMapper = new ObjectMapper(); + this.lastTokenUsage = new TokenUsage(); + this.systemPrompt = loadSystemPrompt(); + } + + /** + * Configures the AI service with the provided configuration. + */ + public void configure(Map config) { + this.config = config; + validateConfig(); + + int timeoutSeconds = getConfigInt("timeout_seconds", 90); + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(timeoutSeconds)) + .build(); + } + + /** + * Analyzes a batch of queries and returns optimization recommendations. + * This method orchestrates the request/response flow using abstract methods. + */ + public List analyzeQueryBatch(QueryBatch batch) throws IOException, InterruptedException { + String requestPayload = buildRequestPayload(batch); + String response = sendApiRequest(requestPayload); + return parseResponse(response, batch); + } + + /** + * Gets the token usage from the last API call. + */ + public TokenUsage getLastTokenUsage() { + return lastTokenUsage; + } + + /** + * Sends an API request with the given payload. + * Default implementation delegates to sendApiRequest with initial retry count from config. + * Subclasses can override for provider-specific behavior. + */ + protected String sendApiRequest(String payload) throws IOException, InterruptedException { + int remainingRetries = getConfigInt("initial_retry_count", 0); + return sendApiRequest(payload, remainingRetries); + } + + /** + * Executes an HTTP request with retry logic on timeout. + * Common logic for sending requests and handling responses. + * + * @param request The HTTP request to send + * @param payload The original payload (for retry) + * @param retryCount The number of remaining retry attempts + * @return The response body + */ + protected String executeHttpRequest(HttpRequest request, String payload, int retryCount) + throws IOException, InterruptedException { + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new IOException( + "API request failed with status: " + response.statusCode() + ", body: " + response.body()); + } + + // Extract token usage if available + extractTokenUsage(response.body()); + + return response.body(); + } catch (HttpTimeoutException e) { + if (retryCount > 0) { + logger.info("HTTP timeout occurred, retrying... ({} retries remaining)", retryCount); + return sendApiRequest(payload, retryCount - 1); + } + throw e; + } + } + + /** + * Public method for sending raw API requests. + * This is for external callers like AICodeGenerationHelper. + */ + public String sendRawRequest(String payload) throws IOException, InterruptedException { + return sendApiRequest(payload); + } + + /** + * Builds the request payload specific to the AI provider. + */ + protected abstract String buildRequestPayload(QueryBatch batch) throws IOException; + + + /** + * Sends the API request to the AI service with retry support. + * Provider-specific implementation required. + * + * @param payload The request payload + * @param retryCount The number of remaining retry attempts + */ + protected abstract String sendApiRequest(String payload, int retryCount) throws IOException, InterruptedException; + + /** + * Parses the AI response and converts it to OptimizationIssue objects. + * Provider-specific implementation required. + */ + protected abstract List parseResponse(String responseBody, QueryBatch batch) throws IOException; + + /** + * Extracts token usage information from the API response. + * Provider-specific implementation required. + * + * @param responseBody The API response body + */ + protected abstract void extractTokenUsage(String responseBody) throws IOException; + + /** + * Validates the configuration to ensure required settings are present. + * Can be overridden by subclasses for provider-specific validation. + */ + protected void validateConfig() { + String apiKey = getConfigString(API_KEY, null); + if (apiKey == null || apiKey.trim().isEmpty()) { + throw new IllegalStateException( + "AI service API key is required. Configure ai_service.api_key in generator.yml or set the appropriate environment variable"); + } + } + + /** + * Loads the system prompt from the resource file. + */ + protected String loadSystemPrompt() throws IOException { + try (InputStream inputStream = getClass() + .getResourceAsStream("/ai-prompts/query-optimization-system-prompt.md")) { + if (inputStream == null) { + throw new IllegalStateException( + "System prompt file not found: /ai-prompts/query-optimization-system-prompt.md"); + } + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + } + + /** + * Parses a single optimization recommendation from the AI response. + * Always returns an OptimizationIssue - never returns null. + * If no optimization is needed, returns an issue indicating no action required. + */ + protected OptimizationIssue parseOptimizationRecommendation(JsonNode recommendation, RepositoryQuery originalQuery) + throws IOException { + String optimizedCodeElement = recommendation.path(OPTIMIZED_CODE_ELEMENT).asText(); + String notes = recommendation.path(NOTES).asText(); + + // Determine if optimization was applied + boolean optimizationNeeded = !notes.contains("N/A") && !notes.contains("unchanged") + && !notes.contains("already optimized"); + + /* + * Extract the current column order from the original RepositoryQuery. + * For the recommended order, we need to create a new RepositoryQuery from the + * optimized code element. + */ + List currentColumnOrder; + List recommendedColumnOrder; + RepositoryQuery optimizedQuery = null; + + currentColumnOrder = extractColumnOrderFromRepositoryQuery(originalQuery); + if (optimizationNeeded) { + OptimizedQueryResult optimizedResult = extractRecommendedColumnOrder(optimizedCodeElement, originalQuery); + recommendedColumnOrder = optimizedResult.columnOrder(); + optimizedQuery = optimizedResult.optimizedQuery(); + } else { + recommendedColumnOrder = currentColumnOrder; + } + + if (!optimizationNeeded) { + return new OptimizationIssue( + originalQuery, + currentColumnOrder, + recommendedColumnOrder, // Same as current since no optimization needed + "Where clause is already optimized", + notes, + null); + } + + return new OptimizationIssue( + originalQuery, + currentColumnOrder, + recommendedColumnOrder, + notes, + notes, + optimizedQuery); + } + + /** + * Extracts column order from a RepositoryQuery using QueryOptimizationExtractor. + * This replaces the manual method signature parsing with proper WHERE clause analysis. + */ + protected List extractColumnOrderFromRepositoryQuery(RepositoryQuery repositoryQuery) { + List columns = new ArrayList<>(); + + if (repositoryQuery == null) { + return columns; + } + + List whereConditions = QueryOptimizationExtractor + .extractWhereConditions(repositoryQuery.getStatement()); + + // Extract column names in the order they appear in WHERE clause + for (WhereCondition condition : whereConditions) { + String columnName = condition.columnName(); + if (columnName != null && !columnName.trim().isEmpty()) { + columns.add(columnName); + } + } + return columns; + } + + /** + * Record to hold both the optimized query and its column order. + */ + protected record OptimizedQueryResult(RepositoryQuery optimizedQuery, List columnOrder) { + } + + /** + * Extracts recommended column order from the optimized code element. + * Creates a new RepositoryQuery that clones the original method but with the + * optimized method signature, then passes it through to extractColumnOrderFromRepositoryQuery. + */ + protected OptimizedQueryResult extractRecommendedColumnOrder(String optimizedCodeElement, RepositoryQuery originalQuery) + throws IOException { + try { + CompilationUnit originalCompilationUnit = originalQuery.getMethodDeclaration() + .getCallableDeclaration().findCompilationUnit().orElseThrow(); + + ClassOrInterfaceDeclaration cdecl = cloneClassSignature( + originalQuery.getMethodDeclaration() + .getCallableDeclaration() + .findAncestor(ClassOrInterfaceDeclaration.class) + .orElseThrow()); + CompilationUnit cu = new CompilationUnit(); + cu.addType(cdecl); + + CompilationUnit tmp = StaticJavaParser.parse(String.format("interface Dummy{ %s }", optimizedCodeElement)); + for (ImportDeclaration importDecl : originalCompilationUnit.getImports()) { + cu.addImport(importDecl); + } + MethodDeclaration newMethod = tmp.findFirst(MethodDeclaration.class).orElseThrow(); + // Replace it with the newly created method instance + cdecl.addMember(newMethod); + + BaseRepositoryParser parser = BaseRepositoryParser.create(cu); + parser.processTypes(); + parser.buildQueries(); + + // Handle case where no queries were produced (e.g., parsing failed or query not recognized) + var queries = parser.getAllQueries(); + if (queries.isEmpty()) { + if (logger.isWarnEnabled()) { + logger.warn("Failed to parse optimized query, falling back to original column order. Code: {}", + optimizedCodeElement.substring(0, Math.min(100, optimizedCodeElement.length()))); + } + return new OptimizedQueryResult(originalQuery, extractColumnOrderFromRepositoryQuery(originalQuery)); + } + + RepositoryQuery rq = queries.stream().findFirst().orElseThrow(); + return new OptimizedQueryResult(rq, extractColumnOrderFromRepositoryQuery(rq)); + } catch (Exception e) { + logger.warn("Error parsing optimized code element, falling back to original: {}", e.getMessage()); + return new OptimizedQueryResult(originalQuery, extractColumnOrderFromRepositoryQuery(originalQuery)); + } + } + + /** + * Efficiently creates a new ClassOrInterfaceDeclaration with the exact same + * signature as the original, but with an empty body (no members). + * + * @param original The original class declaration to copy the signature from. + * @return A new declaration containing only the signature. + */ + protected ClassOrInterfaceDeclaration cloneClassSignature(ClassOrInterfaceDeclaration original) { + if (original == null) { + return null; + } + + // 1. Create a new, completely empty declaration + ClassOrInterfaceDeclaration signatureClone = new ClassOrInterfaceDeclaration(); + + // 2. Set the signature properties by cloning them. + + // Copy Modifiers (e.g., public, abstract) + signatureClone.setModifiers(cloneNodeList(original.getModifiers())); + + // Copy Annotations (e.g., @Deprecated) + signatureClone.setAnnotations(cloneNodeList(original.getAnnotations())); + + // Copy 'interface' or 'class' flag + signatureClone.setInterface(original.isInterface()); + + // Copy Name + signatureClone.setName(original.getName().clone()); + + // Copy Type Parameters (e.g., ) + signatureClone.setTypeParameters(cloneNodeList(original.getTypeParameters())); + + // Copy 'extends' clause + signatureClone.setExtendedTypes(cloneNodeList(original.getExtendedTypes())); + + // Copy 'implements' clause + signatureClone.setImplementedTypes(cloneNodeList(original.getImplementedTypes())); + + // We deliberately do not copy the members. + + return signatureClone; + } + + /** + * A helper method to correctly "clone" a NodeList. + * It creates a new NodeList and fills it with deep clones of each + * node from the original list. + * + * @param list The original list of nodes to clone. + * @param The type of Node in the list. + * @return A new NodeList containing cloned nodes. + */ + protected NodeList cloneNodeList(NodeList list) { + if (list == null || list.isEmpty()) { + return new NodeList<>(); + } + + NodeList clonedList = new NodeList<>(); + for (T node : list) { + clonedList.add((T) node.clone()); + } + return clonedList; + } + + /** + * Gets a string configuration value with fallback to environment variables. + * Can be overridden by subclasses for provider-specific environment variable names. + */ + protected String getConfigString(String key, String defaultValue) { + if (config == null) + return defaultValue; + + Object value = config.get(key); + if (value instanceof String str && !str.trim().isEmpty()) { + return str; + } + + return defaultValue; + } + + /** + * Gets an integer configuration value. + */ + protected int getConfigInt(String key, int defaultValue) { + if (config == null) + return defaultValue; + + Object value = config.get(key); + if (value instanceof Integer i) { + return i; + } else if (value instanceof String str) { + return Integer.parseInt(str); + } + return defaultValue; + } + + /** + * Gets a double configuration value. + */ + protected double getConfigDouble(String key, double defaultValue) { + if (config == null) + return defaultValue; + + Object value = config.get(key); + if (value instanceof Number n) { + return n.doubleValue(); + } else if (value instanceof String str) { + return Double.parseDouble(str); + } + + return defaultValue; + } + + /** + * Gets a boolean configuration value. + */ + protected boolean getConfigBoolean(String key, boolean defaultValue) { + if (config == null) + return defaultValue; + + Object value = config.get(key); + if (value instanceof Boolean b) { + return b; + } else if (value instanceof String str) { + return Boolean.parseBoolean(str); + } + + return defaultValue; + } + + /** + * Builds the query data array that is common to all AI providers. + * This creates a JSON array with query information including method signature, + * query type, query text, and table schema with cardinality. + * + * @param batch The query batch containing queries and cardinality information + * @return JSON string representing the array of query data + * @throws IOException if JSON serialization fails + */ + protected String buildQueryDataArray(QueryBatch batch) throws IOException { + ArrayNode queries = objectMapper.createArrayNode(); + + for (RepositoryQuery query : batch.getQueries()) { + ObjectNode queryNode = queries.addObject(); + + String tableSchemaAndCardinality = buildTableSchemaString(batch, query); + String fullMethodSignature = query.getMethodDeclaration().getCallableDeclaration().toString(); + String queryText = getQueryText(query); + + queryNode.put("method", fullMethodSignature); + queryNode.put("queryType", query.getQueryType().toString()); + queryNode.put("queryText", queryText); + queryNode.put("tableSchemaAndCardinality", tableSchemaAndCardinality); + } + + return objectMapper.writeValueAsString(queries); + } + + /** + * Gets the appropriate query text based on the query type. + * For DERIVED queries, returns the method name; otherwise returns the original query. + * + * @param query The repository query + * @return The query text + */ + protected String getQueryText(RepositoryQuery query) { + if (query.getQueryType() == QueryType.DERIVED) { + return query.getMethodName(); + } + return query.getOriginalQuery(); + } + + /** + * Builds the table schema and cardinality string for the AI prompt. + * + * @param batch The query batch containing cardinality information + * @param query The repository query + * @return Schema string in format "tableName (col1:CARDINALITY, col2:CARDINALITY, ...)" + */ + protected String buildTableSchemaString(QueryBatch batch, RepositoryQuery query) { + StringBuilder schema = new StringBuilder(); + String tableName = query.getPrimaryTable(); + if (tableName == null || tableName.isEmpty()) { + tableName = "UnknownTable"; + } + + schema.append(tableName).append(" ("); + + boolean first = true; + for (var entry : batch.getColumnCardinalities().entrySet()) { + if (!first) { + schema.append(", "); + } + schema.append(entry.getKey()).append(":").append(entry.getValue()); + first = false; + } + + schema.append(")"); + return schema.toString(); + } + + + /** + * Common method to parse recommendations from JSON response. + * Handles both direct arrays and wrapped objects. + */ + protected List parseRecommendationsFromJson(String jsonResponse, QueryBatch batch) throws IOException { + List issues = new ArrayList<>(); + + if (jsonResponse.trim().isEmpty()) { + logger.warn("No valid JSON found in AI response"); + return issues; + } + + JsonNode responseNode = objectMapper.readTree(jsonResponse); + JsonNode responseArray = responseNode; + + // If the response is wrapped in an object, try to find the array + if (responseNode.isObject()) { + // Look for common array field names + if (responseNode.has("recommendations")) { + responseArray = responseNode.get("recommendations"); + } else if (responseNode.has("results")) { + responseArray = responseNode.get("results"); + } else if (responseNode.has("data")) { + responseArray = responseNode.get("data"); + } else if (responseNode.fields().hasNext()) { + // Try to find the first array field + responseArray = responseNode.fields().next().getValue(); + } + } + + if (!responseArray.isArray()) { + logger.warn("AI response does not contain a JSON array as expected"); + logger.warn(jsonResponse); + System.exit(1); + } + + // Process each optimization recommendation + List queries = batch.getQueries(); + for (int i = 0; i < responseArray.size() && i < queries.size(); i++) { + JsonNode recommendation = responseArray.get(i); + RepositoryQuery originalQuery = queries.get(i); + + OptimizationIssue issue = parseOptimizationRecommendation(recommendation, originalQuery); + issues.add(issue); + } + + return issues; + } + + /** + * Parses the text response from AI to extract optimization recommendations. + * Since Gemini is configured with JSON mode, the response should already be valid JSON. + */ + List parseRecommendations(String textResponse, QueryBatch batch) throws IOException { + if (textResponse == null || textResponse.trim().isEmpty()) { + logger.warn("Empty response from AI service"); + return new ArrayList<>(); + } + + try { + return parseRecommendationsFromJson(textResponse, batch); + } catch (Exception e) { + logger.error("Failed to parse AI response as JSON. Response: {}", + textResponse.substring(0, Math.min(200, textResponse.length())), e); + return new ArrayList<>(); + } + } +} diff --git a/src/main/java/sa/com/cloudsolutions/antikythera/examples/AnnotationFinder.java b/src/main/java/sa/com/cloudsolutions/antikythera/examples/AnnotationFinder.java new file mode 100644 index 00000000..18cffb39 --- /dev/null +++ b/src/main/java/sa/com/cloudsolutions/antikythera/examples/AnnotationFinder.java @@ -0,0 +1,153 @@ +package sa.com.cloudsolutions.antikythera.examples; + +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.MethodDeclaration; +import com.github.javaparser.ast.expr.AnnotationExpr; +import sa.com.cloudsolutions.antikythera.configuration.Settings; +import sa.com.cloudsolutions.antikythera.evaluator.AntikytheraRunTime; +import sa.com.cloudsolutions.antikythera.parser.AbstractCompiler; + +import java.io.File; +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Tool to find all classes and methods annotated with a specific annotation. + * Handles both simple names (e.g., "Service") and fully qualified names (e.g., "org.springframework.stereotype.Service"). + * + *

Usage: + *

+ * mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.examples.AnnotationFinder" \
+ *   -Dexec.args="Service"
+ * 
+ * # Simple output mode (method name only, no duplicates)
+ * mvn exec:java -Dexec.mainClass="sa.com.cloudsolutions.antikythera.examples.AnnotationFinder" \
+ *   -Dexec.args="Service --simple"
+ * 
+ * + *

Output format: + *

    + *
  • For class annotations: Fully qualified class name (e.g., com.example.MyService)
  • + *
  • For method annotations (detailed mode): Fully qualified class name#method signature + * (e.g., com.example.MyClass#print() or com.example.MyClass#print(String s))
  • + *
  • For method annotations (simple mode): Fully qualified class name#method name + * (e.g., com.example.MyClass#print) - duplicates eliminated
  • + *
+ */ +@SuppressWarnings("java:S106") +public class AnnotationFinder { + + private static String finalSearchTerm; + private static boolean isSimpleMode; + + public static void main(String[] args) throws IOException { + validate(args); + + // Parse arguments + for (String arg : args) { + if (arg.equals("--simple") || arg.equals("-s")) { + isSimpleMode = true; + } else if (!arg.startsWith("-") && finalSearchTerm == null) { + finalSearchTerm = arg; + } + } + + if (finalSearchTerm == null) { + System.err.println("Error: Annotation name is required"); + System.exit(1); + } + + final String simpleName = extractSimpleName(finalSearchTerm); + + + File yamlFile = new File(Settings.class.getClassLoader().getResource("graph.yml").getFile()); + Settings.loadConfigMap(yamlFile); + AbstractCompiler.preProcess(); + + final Set seen = isSimpleMode ? new HashSet<>() : null; + + AntikytheraRunTime.getResolvedCompilationUnits().forEach((cls, cu) -> + findAnnotations(cu, simpleName, seen) + ); + } + + private static void findAnnotations(CompilationUnit cu, String simpleName, Set seen) { + cu.findAll(ClassOrInterfaceDeclaration.class).forEach(classDecl -> { + final String className = classDecl.getFullyQualifiedName().orElse(classDecl.getNameAsString()); + + if (hasAnnotation(classDecl.getAnnotations(), finalSearchTerm, simpleName)) { + System.out.println(className); + } + + findMethodAnnotation(simpleName, seen, classDecl, className); + }); + } + + private static void findMethodAnnotation(String simpleName, Set seen, ClassOrInterfaceDeclaration classDecl, String className) { + classDecl.getMethods().forEach(method -> { + if (hasAnnotation(method.getAnnotations(), finalSearchTerm, simpleName)) { + if (isSimpleMode && seen != null) { + final String output = className + "#" + method.getNameAsString(); + if (seen.add(output)) { + System.out.println(output); + } + } else { + final String output = className + "#" + buildMethodSignature(method); + System.out.println(output); + } + } + }); + } + + private static void validate(String[] args) { + if (args.length == 0) { + System.err.println("Usage: AnnotationFinder [--simple|-s]"); + System.err.println("Example: AnnotationFinder Service"); + System.err.println("Example: AnnotationFinder org.springframework.stereotype.Service --simple"); + System.err.println(""); + System.err.println("Options:"); + System.err.println(" --simple, -s Simple output mode: method name only (no parameters), duplicates eliminated"); + System.exit(1); + } + } + + /** + * Checks if any annotation matches the search term (handles both simple and fully qualified names). + */ + static boolean hasAnnotation(java.util.List annotations, String searchTerm, String simpleName) { + return annotations.stream().anyMatch(ann -> matchesAnnotation(ann.getNameAsString(), searchTerm, simpleName)); + } + + /** + * Checks if an annotation name matches the search term. + * Matches if: exact match, fully qualified match, or simple name match. + */ + static boolean matchesAnnotation(String annotationName, String searchTerm, String simpleName) { + return annotationName.equals(searchTerm) + || annotationName.equals(simpleName) + || annotationName.endsWith("." + searchTerm) + || annotationName.endsWith("." + simpleName); + } + + /** + * Extracts the simple name from a fully qualified name. + */ + static String extractSimpleName(String name) { + int lastDot = name.lastIndexOf('.'); + return lastDot >= 0 ? name.substring(lastDot + 1) : name; + } + + /** + * Builds a method signature: methodName(ParamType1 param1, ParamType2 param2) + */ + static String buildMethodSignature(MethodDeclaration method) { + String params = method.getParameters().stream() + .map(p -> p.getType().asString() + " " + p.getNameAsString()) + .collect(Collectors.joining(", ")); + return method.getNameAsString() + "(" + params + ")"; + } +} + diff --git a/src/main/java/sa/com/cloudsolutions/antikythera/examples/GeminiAIService.java b/src/main/java/sa/com/cloudsolutions/antikythera/examples/GeminiAIService.java index 9cfd69be..0f8ffa60 100644 --- a/src/main/java/sa/com/cloudsolutions/antikythera/examples/GeminiAIService.java +++ b/src/main/java/sa/com/cloudsolutions/antikythera/examples/GeminiAIService.java @@ -1,33 +1,12 @@ package sa.com.cloudsolutions.antikythera.examples; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.github.javaparser.ParserConfiguration; -import com.github.javaparser.StaticJavaParser; -import com.github.javaparser.ast.CompilationUnit; -import com.github.javaparser.ast.ImportDeclaration; -import com.github.javaparser.ast.Node; -import com.github.javaparser.ast.NodeList; -import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; - -import org.jspecify.annotations.NonNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import sa.com.cloudsolutions.antikythera.generator.QueryType; -import sa.com.cloudsolutions.antikythera.generator.RepositoryQuery; -import sa.com.cloudsolutions.antikythera.parser.BaseRepositoryParser; -import com.github.javaparser.ast.body.MethodDeclaration; import java.io.IOException; -import java.io.InputStream; import java.net.URI; -import java.net.http.HttpClient; import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.net.http.HttpTimeoutException; -import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.List; @@ -40,9 +19,7 @@ * Provides simple API communication capabilities for query optimization * analysis. */ -public class GeminiAIService { - private static final Logger logger = LoggerFactory.getLogger(GeminiAIService.class); - +public class GeminiAIService extends AbstractAIService { public static final String GEMINI_1_5_FLASH = "gemini-1.5-flash"; public static final String GEMINI_3_FLASH = "gemini-3-flash"; private static final Map MODEL_PRICING = Map.ofEntries( @@ -56,56 +33,10 @@ public class GeminiAIService { Map.entry(GEMINI_3_FLASH, new ModelPricing(0.50, 1.00, 3.00, 4.50, 0.10, 200000)), Map.entry("gemini-1.0-pro", new ModelPricing(0.50, 1.50, 0.25)) ); - public static final String NOTES = "notes"; - public static final String STRING = "string"; - public static final String PARTS = "parts"; - public static final String OPTIMIZED_CODE_ELEMENT = "optimizedCodeElement"; - public static final String API_KEY = "api_key"; - - private Map config; - private HttpClient httpClient; - private final ObjectMapper objectMapper; - private TokenUsage lastTokenUsage; - private final String systemPrompt; - - public GeminiAIService() throws IOException { - // Configure JavaParser to support Java 21 features (including text blocks) - ParserConfiguration parserConfig = new ParserConfiguration(); - parserConfig.setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_21); - StaticJavaParser.setConfiguration(parserConfig); - - this.objectMapper = new ObjectMapper(); - this.lastTokenUsage = new TokenUsage(); - this.systemPrompt = loadSystemPrompt(); - } - - /** - * Configures the AI service with the provided configuration. - */ - public void configure(Map config) { - this.config = config; - validateConfig(); - - int timeoutSeconds = getConfigInt("timeout_seconds", 90); - this.httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(timeoutSeconds)) - .build(); - } - /** - * Analyzes a batch of queries and returns optimization recommendations. - */ - public List analyzeQueryBatch(QueryBatch batch) throws IOException, InterruptedException { - String requestPayload = buildRequestPayload(batch); - String response = sendApiRequest(requestPayload); - return parseResponse(response, batch); - } - /** - * Gets the token usage from the last API call. - */ - public TokenUsage getLastTokenUsage() { - return lastTokenUsage; + public GeminiAIService() throws IOException { + super(); } /** @@ -119,19 +50,7 @@ public double getCacheEfficiency() { return (double) lastTokenUsage.getCachedContentTokenCount() / lastTokenUsage.getTotalTokens() * 100.0; } - /** - * Loads the system prompt from the resource file. - */ - private String loadSystemPrompt() throws IOException { - try (InputStream inputStream = getClass() - .getResourceAsStream("/ai-prompts/query-optimization-system-prompt.md")) { - if (inputStream == null) { - throw new IllegalStateException( - "System prompt file not found: /ai-prompts/query-optimization-system-prompt.md"); - } - return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - } - } + /** * Builds the request payload for the Gemini AI API using proper message @@ -139,22 +58,10 @@ private String loadSystemPrompt() throws IOException { * Separates system instructions from user query data for better API * interaction. */ - String buildRequestPayload(QueryBatch batch) throws IOException { - ArrayNode queries = objectMapper.createArrayNode(); - - for (RepositoryQuery query : batch.getQueries()) { - ObjectNode queryNode = queries.addObject(); - - String tableSchemaAndCardinality = buildTableSchemaString(batch, query); - String fullMethodSignature = query.getMethodDeclaration().getCallableDeclaration().toString(); - String queryText = getQueryText(query); - - queryNode.put("method", fullMethodSignature); - queryNode.put("queryType", query.getQueryType().toString()); - queryNode.put("queryText", queryText); - queryNode.put("tableSchemaAndCardinality", tableSchemaAndCardinality); - } - return buildGeminiApiRequest(objectMapper.writeValueAsString(queries)); + @Override + protected String buildRequestPayload(QueryBatch batch) throws IOException { + String userQueryData = buildQueryDataArray(batch); + return buildGeminiApiRequest(userQueryData); } /** @@ -202,40 +109,6 @@ String buildGeminiApiRequest(String userQueryData) throws IOException { return objectMapper.writeValueAsString(root); } - /** - * Gets the appropriate query text based on the query type. - */ - String getQueryText(RepositoryQuery query) { - if (QueryType.DERIVED.equals(query.getQueryType())) { - return query.getMethodName(); - } - return query.getOriginalQuery(); - } - - /** - * Builds the table schema and cardinality string for the AI prompt. - */ - String buildTableSchemaString(QueryBatch batch, RepositoryQuery query) { - StringBuilder schema = new StringBuilder(); - String tableName = query.getPrimaryTable(); - if (tableName == null || tableName.isEmpty()) { - tableName = "UnknownTable"; - } - - schema.append(tableName).append(" ("); - - boolean first = true; - for (var entry : batch.getColumnCardinalities().entrySet()) { - if (!first) { - schema.append(", "); - } - schema.append(entry.getKey()).append(":").append(entry.getValue()); - first = false; - } - - schema.append(")"); - return schema.toString(); - } /** * Escapes a string for JSON format. @@ -251,30 +124,21 @@ String escapeJsonString(String str) { .replace("\t", "\\t"); } - /** - * Sends the API request to Gemini AI service. - */ - public String sendApiRequest(String payload) throws IOException, InterruptedException { - return sendApiRequest(payload, 0); - } - - private String sendApiRequest(String payload, int retryCount) throws IOException, InterruptedException { + @Override + protected String sendApiRequest(String payload, int retryCount) throws IOException, InterruptedException { String apiEndpoint = getConfigString("api_endpoint", "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"); String model = getConfigString("model", GEMINI_3_FLASH); String apiKey = getConfigString(API_KEY, null); int timeoutSeconds = getConfigInt("timeout_seconds", 90); + int initialRetryCount = getConfigInt("initial_retry_count", 0); - if (retryCount > 0) { + // If retryCount is less than initial, we're on a retry attempt + if (retryCount < initialRetryCount) { timeoutSeconds += 30; logger.info("Retrying API request with extra 30 seconds timeout (total: {}s)", timeoutSeconds); } - if (apiKey == null || apiKey.trim().isEmpty()) { - throw new IllegalStateException( - "AI service API key is required. Set GEMINI_API_KEY environment variable or configure ai_service.api_key in generator.yml"); - } - String url = apiEndpoint.replace("{model}", model) + "?key=" + apiKey; HttpRequest request = HttpRequest.newBuilder() @@ -284,30 +148,14 @@ private String sendApiRequest(String payload, int retryCount) throws IOException .timeout(Duration.ofSeconds(timeoutSeconds)) .build(); - try { - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200) { - throw new IOException( - "API request failed with status: " + response.statusCode() + ", body: " + response.body()); - } - - // Extract token usage if available - extractTokenUsage(response.body()); - - return response.body(); - } catch (HttpTimeoutException e) { - if (retryCount == 0) { - return sendApiRequest(payload, 1); - } - throw e; - } + return executeHttpRequest(request, payload, retryCount); } /** * Extracts token usage information from the API response. */ - void extractTokenUsage(String responseBody) throws IOException { + @Override + protected void extractTokenUsage(String responseBody) throws IOException { JsonNode root = objectMapper.readTree(responseBody); JsonNode usageMetadata = root.path("usageMetadata"); @@ -354,7 +202,8 @@ void extractTokenUsage(String responseBody) throws IOException { /** * Parses the AI response and converts it to OptimizationIssue objects. */ - List parseResponse(String responseBody, QueryBatch batch) throws IOException { + @Override + protected List parseResponse(String responseBody, QueryBatch batch) throws IOException { JsonNode root = objectMapper.readTree(responseBody); JsonNode candidates = root.path("candidates"); @@ -372,285 +221,11 @@ List parseResponse(String responseBody, QueryBatch batch) thr return new ArrayList<>(); } - /** - * Parses the text response from AI to extract optimization recommendations. - * Expects a JSON array response format as defined in the new prompt. - */ - List parseRecommendations(String textResponse, QueryBatch batch) throws IOException { - List issues = new ArrayList<>(); - - // Extract JSON from the response (it might be wrapped in markdown code blocks) - String jsonResponse = extractJsonFromResponse(textResponse); - - if (jsonResponse.trim().isEmpty()) { - logger.warn("No valid JSON found in AI response"); - return issues; - } - - // Parse the JSON array - JsonNode responseArray = objectMapper.readTree(jsonResponse); - - if (!responseArray.isArray()) { - logger.warn("AI response is not a JSON array as expected"); - return issues; - } - - // Process each optimization recommendation - List queries = batch.getQueries(); - for (int i = 0; i < responseArray.size() && i < queries.size(); i++) { - JsonNode recommendation = responseArray.get(i); - RepositoryQuery originalQuery = queries.get(i); - - OptimizationIssue issue = parseOptimizationRecommendation(recommendation, originalQuery); - issues.add(issue); - } - - return issues; - } - - /** - * Extracts JSON content from the AI response, handling markdown code blocks. - */ - String extractJsonFromResponse(String response) { - if (response == null) { - return null; - } - - // Look for JSON array patterns - int jsonStart = response.indexOf('['); - int jsonEnd = response.lastIndexOf(']'); - - if (jsonStart >= 0 && jsonEnd > jsonStart) { - return response.substring(jsonStart, jsonEnd + 1); - } - - return extractJsonFromCodeBlocks(response); - } - - private static @NonNull String extractJsonFromCodeBlocks(String response) { - // If no array found, try to find JSON in code blocks - String[] lines = response.split("\n"); - StringBuilder jsonBuilder = new StringBuilder(); - boolean inCodeBlock = false; - boolean foundJson = false; - - for (String line : lines) { - if (line.trim().startsWith("```")) { - inCodeBlock = !inCodeBlock; - continue; - } - - if (inCodeBlock || line.trim().startsWith("[") || foundJson) { - jsonBuilder.append(line).append("\n"); - foundJson = true; - if (line.trim().endsWith("]")) { - break; - } - } - } - - return jsonBuilder.toString().trim(); - } - - /** - * Parses a single optimization recommendation from the AI response. - * Always returns an OptimizationIssue - never returns null. - * If no optimization is needed, returns an issue indicating no action required. - */ - OptimizationIssue parseOptimizationRecommendation(JsonNode recommendation, RepositoryQuery originalQuery) - throws IOException { - String optimizedCodeElement = recommendation.path(OPTIMIZED_CODE_ELEMENT).asText(); - String notes = recommendation.path(NOTES).asText(); - - // Determine if optimization was applied - boolean optimizationNeeded = !notes.contains("N/A") && !notes.contains("unchanged") - && !notes.contains("already optimized"); - - /* - * Extract the current column order from the original RepositoryQuery. - * For the recommended order, we need to create a new RepositoryQuery from the - * optimized code element. - */ - List currentColumnOrder; - List recommendedColumnOrder; - RepositoryQuery optimizedQuery = null; - - currentColumnOrder = extractColumnOrderFromRepositoryQuery(originalQuery); - if (optimizationNeeded) { - OptimizedQueryResult optimizedResult = extractRecommendedColumnOrder(optimizedCodeElement, originalQuery); - recommendedColumnOrder = optimizedResult.columnOrder(); - optimizedQuery = optimizedResult.optimizedQuery(); - } else { - recommendedColumnOrder = currentColumnOrder; - } - - if (!optimizationNeeded) { - return new OptimizationIssue( - originalQuery, - currentColumnOrder, - recommendedColumnOrder, // Same as current since no optimization needed - "Where clause is already optimized", - notes, - null); - } - - return new OptimizationIssue( - originalQuery, - currentColumnOrder, - recommendedColumnOrder, - notes, - notes, - optimizedQuery); - } - - /** - * Extracts column order from a RepositoryQuery using - * QueryOptimizationExtractor. - * This replaces the manual method signature parsing with proper WHERE clause - * analysis. - */ - List extractColumnOrderFromRepositoryQuery(RepositoryQuery repositoryQuery) { - List columns = new ArrayList<>(); - - if (repositoryQuery == null) { - return columns; - } - - List whereConditions = QueryOptimizationExtractor - .extractWhereConditions(repositoryQuery.getStatement()); - - // Extract column names in the order they appear in WHERE clause - for (WhereCondition condition : whereConditions) { - String columnName = condition.columnName(); - if (columnName != null && !columnName.trim().isEmpty()) { - columns.add(columnName); - } - } - return columns; - } - - /** - * Record to hold both the optimized query and its column order. - */ - record OptimizedQueryResult(RepositoryQuery optimizedQuery, List columnOrder) { - } - - /** - * Extracts recommended column order from the optimized code element. - * Creates a new RepositoryQuery that clones the original method but with the - * optimized method signature, - * then passes it through to extractColumnOrderFromRepositoryQuery. - */ - OptimizedQueryResult extractRecommendedColumnOrder(String optimizedCodeElement, RepositoryQuery originalQuery) - throws IOException { - try { - CompilationUnit originalCompilationUnit = originalQuery.getMethodDeclaration() - .getCallableDeclaration().findCompilationUnit().orElseThrow(); - - ClassOrInterfaceDeclaration cdecl = cloneClassSignature( - originalQuery.getMethodDeclaration() - .getCallableDeclaration() - .findAncestor(ClassOrInterfaceDeclaration.class) - .orElseThrow()); - CompilationUnit cu = new CompilationUnit(); - cu.addType(cdecl); - - CompilationUnit tmp = StaticJavaParser.parse(String.format("interface Dummy{ %s }", optimizedCodeElement)); - for (ImportDeclaration importDecl : originalCompilationUnit.getImports()) { - cu.addImport(importDecl); - } - MethodDeclaration newMethod = tmp.findFirst(MethodDeclaration.class).orElseThrow(); - // Replace it with the newly created method instance - cdecl.addMember(newMethod); - - BaseRepositoryParser parser = BaseRepositoryParser.create(cu); - parser.processTypes(); - parser.buildQueries(); - - // Handle case where no queries were produced (e.g., parsing failed or query not recognized) - var queries = parser.getAllQueries(); - if (queries.isEmpty()) { - logger.warn("Failed to parse optimized query, falling back to original column order. Code: {}", - optimizedCodeElement.substring(0, Math.min(100, optimizedCodeElement.length()))); - return new OptimizedQueryResult(originalQuery, extractColumnOrderFromRepositoryQuery(originalQuery)); - } - - RepositoryQuery rq = queries.stream().findFirst().orElseThrow(); - return new OptimizedQueryResult(rq, extractColumnOrderFromRepositoryQuery(rq)); - } catch (Exception e) { - logger.warn("Error parsing optimized code element, falling back to original: {}", e.getMessage()); - return new OptimizedQueryResult(originalQuery, extractColumnOrderFromRepositoryQuery(originalQuery)); - } - } - - /** - * Efficiently creates a new ClassOrInterfaceDeclaration with the exact same - * signature as the original, but with an empty body (no members). - * - * @param original The original class declaration to copy the signature from. - * @return A new declaration containing only the signature. - */ - public ClassOrInterfaceDeclaration cloneClassSignature(ClassOrInterfaceDeclaration original) { - if (original == null) { - return null; - } - - // 1. Create a new, completely empty declaration - ClassOrInterfaceDeclaration signatureClone = new ClassOrInterfaceDeclaration(); - - // 2. Set the signature properties by cloning them. - - // Copy Modifiers (e.g., public, abstract) - signatureClone.setModifiers(cloneNodeList(original.getModifiers())); - - // Copy Annotations (e.g., @Deprecated) - signatureClone.setAnnotations(cloneNodeList(original.getAnnotations())); - - // Copy 'interface' or 'class' flag - signatureClone.setInterface(original.isInterface()); - - // Copy Name - signatureClone.setName(original.getName().clone()); - - // Copy Type Parameters (e.g., ) - signatureClone.setTypeParameters(cloneNodeList(original.getTypeParameters())); - - // Copy 'extends' clause - signatureClone.setExtendedTypes(cloneNodeList(original.getExtendedTypes())); - - // Copy 'implements' clause - signatureClone.setImplementedTypes(cloneNodeList(original.getImplementedTypes())); - - // We deliberately do not copy the members. - - return signatureClone; - } - - /** - * A helper method to correctly "clone" a NodeList. - * It creates a new NodeList and fills it with deep clones of each - * node from the original list. - * - * @param list The original list of nodes to clone. - * @param The type of Node in the list. - * @return A new NodeList containing cloned nodes. - */ - NodeList cloneNodeList(NodeList list) { - if (list == null || list.isEmpty()) { - return new NodeList<>(); - } - - NodeList clonedList = new NodeList<>(); - for (T node : list) { - clonedList.add((T) node.clone()); - } - return clonedList; - } - /** * Validates the configuration to ensure required settings are present. */ - void validateConfig() { + @Override + protected void validateConfig() { String apiKey = getConfigString(API_KEY, null); if (apiKey == null || apiKey.trim().isEmpty()) { throw new IllegalStateException( @@ -660,8 +235,10 @@ void validateConfig() { /** * Gets a string configuration value with fallback to environment variables. + * Overrides base class to add Gemini-specific environment variable support. */ - String getConfigString(String key, String defaultValue) { + @Override + protected String getConfigString(String key, String defaultValue) { if (config == null) return defaultValue; @@ -686,53 +263,5 @@ String getConfigString(String key, String defaultValue) { return defaultValue; } - /** - * Gets an integer configuration value. - */ - int getConfigInt(String key, int defaultValue) { - if (config == null) - return defaultValue; - - Object value = config.get(key); - if (value instanceof Integer i) { - return i; - } else if (value instanceof String str) { - return Integer.parseInt(str); - } - return defaultValue; - } - - /** - * Gets a double configuration value. - */ - double getConfigDouble(String key, double defaultValue) { - if (config == null) - return defaultValue; - - Object value = config.get(key); - if (value instanceof Number n) { - return n.doubleValue(); - } else if (value instanceof String str) { - return Double.parseDouble(str); - } - - return defaultValue; - } - - /** - * Gets a boolean configuration value. - */ - boolean getConfigBoolean(String key, boolean defaultValue) { - if (config == null) - return defaultValue; - - Object value = config.get(key); - if (value instanceof Boolean b) { - return b; - } else if (value instanceof String str) { - return Boolean.parseBoolean(str); - } - return defaultValue; - } } diff --git a/src/main/java/sa/com/cloudsolutions/antikythera/examples/OpenAIService.java b/src/main/java/sa/com/cloudsolutions/antikythera/examples/OpenAIService.java new file mode 100644 index 00000000..c88db907 --- /dev/null +++ b/src/main/java/sa/com/cloudsolutions/antikythera/examples/OpenAIService.java @@ -0,0 +1,228 @@ +package sa.com.cloudsolutions.antikythera.examples; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * OpenAI implementation of the AI service for query optimization. + * Uses OpenAI's Chat Completions API with GPT models. + */ +public class OpenAIService extends AbstractAIService { + + + public static final String GPT_4_O = "gpt-4o"; + + /** + * OpenAI model pricing (per 1M tokens). + * Prices verified as of February 2026. + * Note: gpt-4o-mini is the recommended budget option. + * + * IMPORTANT: Using LinkedHashMap to ensure deterministic iteration order. + * Entries are ordered from most specific to least specific (longest keys first) + * to ensure correct matching with model.contains() logic. + */ + private static final Map MODEL_PRICING; + + static { + MODEL_PRICING = new LinkedHashMap<>(); + MODEL_PRICING.put("gpt-4o-mini", new ModelPricing(0.150, 0.600, 0.25)); // Most specific - check first + MODEL_PRICING.put("gpt-4-turbo", new ModelPricing(10.00, 30.00, 0.25)); // More specific than gpt-4 + MODEL_PRICING.put(GPT_4_O, new ModelPricing(2.50, 10.00, 0.25)); // Current flagship model + MODEL_PRICING.put("gpt-4", new ModelPricing(30.00, 60.00, 0.25)); // Least specific - check last + } + + + public OpenAIService() throws IOException { + super(); + } + + /** + * Builds the request payload for OpenAI's Chat Completions API. + */ + @Override + protected String buildRequestPayload(QueryBatch batch) throws IOException { + String userContent = buildQueryDataArray(batch); + return buildOpenAIRequest(userContent); + } + + /** + * Builds the OpenAI Chat Completions API request structure. + * Separated from buildRequestPayload to follow Single Responsibility Principle. + * + * @param userContent The query data as JSON string + * @return Complete OpenAI API request as JSON string + * @throws IOException if JSON serialization fails + */ + private String buildOpenAIRequest(String userContent) throws IOException { + ObjectNode root = objectMapper.createObjectNode(); + + String model = getConfigString(MODEL, GPT_4_O); + root.put(MODEL, model); + + // Messages array + ArrayNode messages = root.putArray("messages"); + + // System message + ObjectNode systemMessage = messages.addObject(); + systemMessage.put("role", "system"); + systemMessage.put(CONTENT, systemPrompt); + + // User message + ObjectNode userMessage = messages.addObject(); + userMessage.put("role", "user"); + userMessage.put(CONTENT, userContent); + + // Note: We don't use json_object mode because it requires a JSON object at root, + // but we need a JSON array. Instead, we rely on the system prompt to ensure + // JSON array format. The prompt explicitly requests a JSON array. + // OpenAI will still return valid JSON, just not strictly enforced by json_object mode. + + return objectMapper.writeValueAsString(root); + } + + + @Override + protected String sendApiRequest(String payload, int retryCount) throws IOException, InterruptedException { + String apiEndpoint = getConfigString("api_endpoint", "https://api.openai.com/v1/chat/completions"); + String apiKey = getConfigString(API_KEY, null); + int timeoutSeconds = getConfigInt("timeout_seconds", 90); + int initialRetryCount = getConfigInt("initial_retry_count", 0); + + // If retryCount is less than initial, we're on a retry attempt + if (retryCount < initialRetryCount) { + timeoutSeconds += 30; + logger.info("Retrying API request with extra 30 seconds timeout (total: {}s)", timeoutSeconds); + } + + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(apiEndpoint)) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + apiKey) + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .timeout(Duration.ofSeconds(timeoutSeconds)) + .build(); + + return executeHttpRequest(request, payload, retryCount); + } + + /** + * Parses the OpenAI response and converts it to OptimizationIssue objects. + */ + @Override + protected List parseResponse(String responseBody, QueryBatch batch) throws IOException { + JsonNode root = objectMapper.readTree(responseBody); + JsonNode choices = root.path("choices"); + + if (choices.isArray() && !choices.isEmpty()) { + JsonNode firstChoice = choices.get(0); + JsonNode message = firstChoice.path("message"); + String content = message.path(CONTENT).asText(); + + return parseRecommendations(content, batch); + } + + return new ArrayList<>(); + } + + /** + * Extracts token usage information from the OpenAI API response. + */ + @Override + protected void extractTokenUsage(String responseBody) throws IOException { + JsonNode root = objectMapper.readTree(responseBody); + JsonNode usage = root.path("usage"); + + if (!usage.isMissingNode()) { + int inputTokens = usage.path("prompt_tokens").asInt(0); + int outputTokens = usage.path("completion_tokens").asInt(0); + int totalTokens = usage.path("total_tokens").asInt(inputTokens + outputTokens); + + // Extract cached token count from prompt_tokens_details + JsonNode promptTokensDetails = usage.path("prompt_tokens_details"); + int cachedContentTokenCount = promptTokensDetails.path("cached_tokens").asInt(0); + + String model = getConfigString(MODEL, GPT_4_O); + double inputCost = 0; + double outputCost = 0; + + ModelPricing pricing = MODEL_PRICING.entrySet().stream() + .filter(entry -> model.contains(entry.getKey())) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + + if (pricing != null) { + inputCost = pricing.calculateInputCost(inputTokens, cachedContentTokenCount); + outputCost = pricing.calculateOutputCost(inputTokens, outputTokens); + } else { + // Fallback to a default if model is unknown + double costPer1kTokens = getConfigDouble("cost_per_1k_tokens", 0.0015); + inputCost = (inputTokens / 1000.0) * costPer1kTokens; + outputCost = (outputTokens / 1000.0) * costPer1kTokens * 2; // Output typically costs 2x + } + + lastTokenUsage = new TokenUsage(inputTokens, outputTokens, totalTokens, cachedContentTokenCount, inputCost, outputCost); + + boolean trackUsage = getConfigBoolean("track_usage", true); + if (trackUsage) { + logger.info("Token usage: {}", lastTokenUsage.getFormattedReport()); + } + } else { + lastTokenUsage = new TokenUsage(); + } + } + + /** + * Validates the configuration to ensure required settings are present. + * Overrides base class for OpenAI-specific validation. + */ + @Override + protected void validateConfig() { + String apiKey = getConfigString(API_KEY, null); + if (apiKey == null || apiKey.trim().isEmpty()) { + throw new IllegalStateException( + "OpenAI API key is required. Set OPENAI_API_KEY environment variable or configure ai_service.api_key in generator.yml"); + } + } + + /** + * Gets a string configuration value with fallback to environment variables. + * Overrides base class to add OpenAI-specific environment variable support. + */ + @Override + protected String getConfigString(String key, String defaultValue) { + if (config == null) + return defaultValue; + + Object value = config.get(key); + if (value instanceof String str && !str.trim().isEmpty()) { + return str; + } + + // Fallback to environment variables + if (API_KEY.equals(key)) { + String envValue = System.getenv("OPENAI_API_KEY"); + if (envValue != null && !envValue.trim().isEmpty()) { + return envValue; + } + } else if ("api_endpoint".equals(key)) { + String envValue = System.getenv("OPENAI_API_ENDPOINT"); + if (envValue != null && !envValue.trim().isEmpty()) { + return envValue; + } + } + + return defaultValue; + } +} diff --git a/src/main/java/sa/com/cloudsolutions/antikythera/examples/QueryOptimizationChecker.java b/src/main/java/sa/com/cloudsolutions/antikythera/examples/QueryOptimizationChecker.java index 8ada960d..23440ef4 100644 --- a/src/main/java/sa/com/cloudsolutions/antikythera/examples/QueryOptimizationChecker.java +++ b/src/main/java/sa/com/cloudsolutions/antikythera/examples/QueryOptimizationChecker.java @@ -34,7 +34,7 @@ public class QueryOptimizationChecker { protected RepositoryParser repositoryParser; protected QueryAnalysisEngine analysisEngine; protected final File liquibaseXmlPath; - protected GeminiAIService aiService; + protected AbstractAIService aiService; protected LiquibaseGenerator liquibaseGenerator; protected int totalRecommendations = 0; @@ -85,11 +85,11 @@ public QueryOptimizationChecker(File liquibaseXmlPath) throws LiquibaseException this.repositoryParser = new RepositoryParser(); // Initialize AI service components - this.aiService = new GeminiAIService(); Map aiConfig = (Map) Settings.getProperty("ai_service"); if (aiConfig == null) { aiConfig = new HashMap<>(); } + this.aiService = AIServiceFactory.create(aiConfig); this.aiService.configure(aiConfig); this.liquibaseGenerator = new LiquibaseGenerator(LiquibaseGenerator.ChangesetConfig.fromConfiguration()); @@ -1285,7 +1285,7 @@ public void setAnalysisEngine(QueryAnalysisEngine analysisEngine) { this.analysisEngine = analysisEngine; } - public void setAiService(GeminiAIService aiService) { + public void setAiService(AbstractAIService aiService) { this.aiService = aiService; } @@ -1301,7 +1301,7 @@ public QueryAnalysisEngine getAnalysisEngine() { return analysisEngine; } - public GeminiAIService getAiService() { + public AbstractAIService getAiService() { return aiService; } diff --git a/src/main/java/sa/com/cloudsolutions/antikythera/examples/QueryOptimizer.java b/src/main/java/sa/com/cloudsolutions/antikythera/examples/QueryOptimizer.java index c1e58847..78cbed07 100644 --- a/src/main/java/sa/com/cloudsolutions/antikythera/examples/QueryOptimizer.java +++ b/src/main/java/sa/com/cloudsolutions/antikythera/examples/QueryOptimizer.java @@ -27,6 +27,7 @@ import sa.com.cloudsolutions.antikythera.generator.TypeWrapper; import sa.com.cloudsolutions.antikythera.parser.AbstractCompiler; import sa.com.cloudsolutions.antikythera.parser.Callable; +import sa.com.cloudsolutions.antikythera.parser.MavenHelper; import sa.com.cloudsolutions.antikythera.parser.converter.EntityMappingResolver; import java.io.File; @@ -46,6 +47,7 @@ public class QueryOptimizer extends QueryOptimizationChecker { private static final Logger logger = LoggerFactory.getLogger(QueryOptimizer.class); private boolean repositoryFileModified; + private final boolean supportsTextBlocks; private static final Set modifiedFiles = new java.util.HashSet<>(); private static final Set writtenFiles = new java.util.HashSet<>(); @@ -65,6 +67,23 @@ public QueryOptimizer(File liquibaseXmlPath) throws LiquibaseException, IOExcept super(liquibaseXmlPath); Fields.buildDependencies(); EntityMappingResolver.build(); + + int javaVersion = detectJavaVersion(); + supportsTextBlocks = javaVersion >= 15; + if (!supportsTextBlocks) { + logger.info("Java version {} detected — text blocks will not be used in @Query annotations", javaVersion); + } + } + + private static int detectJavaVersion() { + try { + MavenHelper helper = new MavenHelper(); + helper.readPomFile(); + return helper.getJavaVersion(); + } catch (Exception e) { + logger.warn("Could not detect Java version from pom.xml: {}. Defaulting to 21.", e.getMessage()); + return 21; + } } /** @@ -340,16 +359,20 @@ static String formatQueryForTextBlock(String query, int targetWidth, String inde * @param newStringValue the new query value */ private void updateAnnotationValueWithTextBlockSupport(MethodDeclaration method, String newStringValue) { - // Format long queries for readability using text blocks (break at ~80 chars at - // whitespace) - // Use 8 spaces for indentation to align with typical method annotation - // indentation - String indent = " "; - String formattedQuery = formatQueryForTextBlock(newStringValue, 80, indent); - // Prepend indent to first line and append newline + indent for closing - // delimiter alignment - String textBlockContent = indent + formattedQuery + "\n" + indent; - updateAnnotationValue(method, "Query", textBlockContent, true); + if (supportsTextBlocks) { + // Format long queries for readability using text blocks (break at ~80 chars at + // whitespace) + // Use 8 spaces for indentation to align with typical method annotation + // indentation + String indent = " "; + String formattedQuery = formatQueryForTextBlock(newStringValue, 80, indent); + // Prepend indent to first line and append newline + indent for closing + // delimiter alignment + String textBlockContent = indent + formattedQuery + "\n" + indent; + updateAnnotationValue(method, "Query", textBlockContent, true); + } else { + updateAnnotationValue(method, "Query", newStringValue, false); + } } /** @@ -734,13 +757,17 @@ private boolean writeFile(File f, String content) throws FileNotFoundException { * Uses direct parameter matching (type + name) which is more reliable than * column order mapping, as it handles cases like Pageable parameters correctly. */ - static void reorderMethodArguments(MethodCallExpr mce, OptimizationIssue issue) { + /** + * @return true if arguments were successfully reordered, false if mapping + * could not be built (caller should not rename the method in that case). + */ + static boolean reorderMethodArguments(MethodCallExpr mce, OptimizationIssue issue) { MethodDeclaration oldMethod = issue.query().getMethodDeclaration().asMethodDeclaration(); MethodDeclaration newMethod = issue.optimizedQuery().getMethodDeclaration().asMethodDeclaration(); NodeList args = mce.getArguments(); if (args.size() != newMethod.getParameters().size()) { - return; + return false; } // Build parameter mapping: newIndex -> oldIndex by matching parameter type+name @@ -757,9 +784,14 @@ static void reorderMethodArguments(MethodCallExpr mce, OptimizationIssue issue) } } - // If mapping is incomplete, don't reorder + // If type+name mapping is incomplete, fall back to column order mapping. + // This handles derived queries where the optimizer generates new parameter + // names that differ from the original (e.g., "hospital" vs "hospitalId"). if (map.size() != args.size()) { - return; + map = buildColumnOrderMapping(issue, args.size()); + if (map == null) { + return false; + } } // Reorder arguments in-place using array to avoid expensive NodeList operations @@ -776,6 +808,42 @@ static void reorderMethodArguments(MethodCallExpr mce, OptimizationIssue issue) args.add(argsArray[oldIdx]); } } + return true; + } + + /** + * Builds a newIndex→oldIndex mapping from the column order lists in the + * OptimizationIssue. Each entry in currentColumnOrder at position i maps + * to its position in recommendedColumnOrder, giving us the permutation + * without relying on parameter names. + * + * @return the mapping, or null if it cannot be built + */ + private static Map buildColumnOrderMapping(OptimizationIssue issue, int argCount) { + List currentCols = issue.currentColumnOrder(); + List recommendedCols = issue.recommendedColumnOrder(); + + if (currentCols == null || recommendedCols == null + || currentCols.size() != recommendedCols.size() + || currentCols.size() > argCount) { + return null; + } + + Map map = new HashMap<>(); + for (int i = 0; i < currentCols.size(); i++) { + int newIdx = recommendedCols.indexOf(currentCols.get(i)); + if (newIdx < 0) { + return null; + } + map.put(newIdx, i); + } + + // Extra parameters (e.g. Pageable) stay in their original positions + for (int i = currentCols.size(); i < argCount; i++) { + map.put(i, i); + } + + return map; } static class NameChangeVisitor extends ModifierVisitor { @@ -817,16 +885,16 @@ public MethodCallExpr visit(MethodCallExpr mce, QueryAnalysisResult update) { // Only count as an update if something actually changes boolean methodNameChanged = !originalMethodName.equals(newMethodName); - // Update method name if it changed + // Update method name and reorder arguments if changed if (methodNameChanged) { mce.setName(newMethodName); - reorderMethodArguments(mce, issue); - } - - // Only mark as modified and increment counter if actual changes were made - if (methodNameChanged) { - modified = true; - methodCallsUpdated++; + if (!reorderMethodArguments(mce, issue)) { + // Rollback — can't safely rename without reordering + mce.setName(originalMethodName); + } else { + modified = true; + methodCallsUpdated++; + } } } @@ -980,10 +1048,18 @@ void processMethodCall(MethodCallExpr mce) { .getMethodDeclaration().asMethodDeclaration(); if (callArity == newMethod.getParameters().size()) { + String originalName = mce.getNameAsString(); mce.setName(matchingRename.newMethodName()); - reorderMethodArguments(mce, matchingRename.issue()); - modified = true; - methodCallsUpdated++; + if (!reorderMethodArguments(mce, matchingRename.issue())) { + // Rollback the name change — we can't safely rename + // without also reordering the arguments + mce.setName(originalName); + logger.warn("Skipping rename of call {} — could not build argument mapping", + originalName); + } else { + modified = true; + methodCallsUpdated++; + } } } } diff --git a/src/main/resources/graph-age.yml.example b/src/main/resources/graph-age.yml.example new file mode 100644 index 00000000..3e49851c --- /dev/null +++ b/src/main/resources/graph-age.yml.example @@ -0,0 +1,16 @@ +# Knowledge Graph Configuration — Apache AGE Backend +# Copy to graph.yml and update with your credentials. +# The actual graph.yml file should NOT be committed to version control. + +base_path: /path/to/project/src/main/java # Can be overridden with --base-path +base_package: com.example.myproject + +graph: + type: age + batch_size: 1000 + + age: + url: jdbc:postgresql://localhost:5432/postgres + user: postgres + password: ${AGE_PASSWORD} # Set AGE_PASSWORD environment variable + graph_name: antikythera_graph diff --git a/src/main/resources/graph-neo4j.yml.example b/src/main/resources/graph-neo4j.yml.example new file mode 100644 index 00000000..7718472d --- /dev/null +++ b/src/main/resources/graph-neo4j.yml.example @@ -0,0 +1,16 @@ +# Knowledge Graph Configuration — Neo4j Backend +# Copy to graph.yml and update with your credentials. +# The actual graph.yml file should NOT be committed to version control. + +base_path: /path/to/project/src/main/java # Can be overridden with --base-path +base_package: com.example.myproject + +graph: + type: neo4j + batch_size: 1000 + + neo4j: + uri: bolt://localhost:7687 + username: neo4j + password: ${NEO4J_PASSWORD} # Set NEO4J_PASSWORD environment variable + database: neo4j diff --git a/src/main/resources/graph.yml.example b/src/main/resources/graph.yml.example deleted file mode 100644 index 8c86a846..00000000 --- a/src/main/resources/graph.yml.example +++ /dev/null @@ -1,10 +0,0 @@ -# Knowledge Graph Configuration Example -# Copy this file to graph.yml and update with your Neo4j credentials -# The actual graph.yml file should NOT be committed to version control - -neo4j: - uri: bolt://localhost:7687 - username: neo4j - password: ${NEO4J_PASSWORD} # Set NEO4J_PASSWORD environment variable - database: neo4j - batch_size: 1000 diff --git a/src/test/java/com/raditha/graph/GraphQueryTest.java b/src/test/java/com/raditha/graph/GraphQueryTest.java index 55d41ff7..2bd3457a 100644 --- a/src/test/java/com/raditha/graph/GraphQueryTest.java +++ b/src/test/java/com/raditha/graph/GraphQueryTest.java @@ -14,15 +14,12 @@ import org.neo4j.driver.SessionConfig; import org.neo4j.driver.Value; -import java.io.IOException; import java.util.List; import java.util.function.Function; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -41,7 +38,7 @@ class GraphQueryTest { @Mock private Result result; - private Neo4jGraphStore graphStore; + private GraphStore graphStore; @BeforeEach void setUp() { @@ -55,7 +52,7 @@ void setUp() { } @AfterEach - void tearDown() { + void tearDown() throws Exception { if (graphStore != null) { graphStore.close(); } @@ -84,7 +81,7 @@ void testFindCallers() { assertEquals("A", callers.get(0)); verify(session).run( - org.mockito.ArgumentMatchers.contains("MATCH (n)-[:CALLS]->(m {signature: $sig}) RETURN n.signature"), + org.mockito.ArgumentMatchers.contains("MATCH (n)-[:CALLS]->(m:CodeElement {signature: $sig}) RETURN n.signature"), any(org.neo4j.driver.Value.class) ); } @@ -112,7 +109,7 @@ void testFindCallees() { assertEquals("B", callees.get(0)); verify(session).run( - org.mockito.ArgumentMatchers.contains("MATCH (n {signature: $sig})-[:CALLS]->(m) RETURN m.signature"), + org.mockito.ArgumentMatchers.contains("MATCH (n:CodeElement {signature: $sig})-[:CALLS]->(m) RETURN m.signature"), any(org.neo4j.driver.Value.class) ); } @@ -140,7 +137,7 @@ void testFindUsages() { assertEquals("A", usages.get(0)); verify(session).run( - org.mockito.ArgumentMatchers.contains("MATCH (n)-[:USES]->(m {signature: $sig}) RETURN n.signature"), + org.mockito.ArgumentMatchers.contains("MATCH (n)-[:USES]->(m:CodeElement {signature: $sig}) RETURN n.signature"), any(org.neo4j.driver.Value.class) ); } diff --git a/src/test/java/com/raditha/graph/GraphStoreFactoryTest.java b/src/test/java/com/raditha/graph/GraphStoreFactoryTest.java new file mode 100644 index 00000000..eaf11015 --- /dev/null +++ b/src/test/java/com/raditha/graph/GraphStoreFactoryTest.java @@ -0,0 +1,111 @@ +package com.raditha.graph; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import sa.com.cloudsolutions.antikythera.configuration.Settings; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class GraphStoreFactoryTest { + + /** + * Provides test cases for Neo4j graph store creation scenarios. + * Each argument contains: test name, YAML configuration content, expected store class. + */ + static Stream neo4jStoreTestCases() { + return Stream.of( + Arguments.of( + "Neo4j store created from full config", + """ + graph: + type: neo4j + batch_size: 500 + neo4j: + uri: bolt://localhost:7687 + username: admin + password: secret + database: mydb + """, + Neo4jGraphStore.class + ), + Arguments.of( + "Defaults to Neo4j when no type specified", + """ + graph: + neo4j: + uri: bolt://localhost:7687 + password: secret + """, + Neo4jGraphStore.class + ), + Arguments.of( + "Defaults to Neo4j when no graph section", + """ + base_path: /tmp/test + """, + Neo4jGraphStore.class + ), + Arguments.of( + "Neo4j store from file overload", + """ + graph: + type: neo4j + batch_size: 750 + neo4j: + uri: bolt://localhost:7687 + password: p + """, + Neo4jGraphStore.class + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("neo4jStoreTestCases") + void testNeo4jStoreCreation(String testName, String yamlConfig, Class expectedClass) throws Exception { + Path config = Files.createTempFile("graph-config", ".yml"); + try { + Files.writeString(config, yamlConfig); + + // Neo4j driver creation succeeds without a running server (lazy connect) + try (GraphStore store = GraphStoreFactory.createGraphStore(config.toFile())) { + assertInstanceOf(expectedClass, store); + } + } finally { + Files.deleteIfExists(config); + } + } + + @Test + void testAgeStoreCreationAttempted() throws IOException { + Path config = Files.createTempFile("graph-config-age", ".yml"); + try { + Files.writeString(config, """ + graph: + type: age + batch_size: 2000 + age: + url: jdbc:postgresql://localhost:5432/postgres + user: postgres + password: pgpass + graph_name: code_graph + """); + + Settings.loadConfigMap(config.toFile()); + + // AGE connects eagerly in the constructor — no database means SQLException + assertThrows(SQLException.class, GraphStoreFactory::createGraphStore); + } finally { + Files.deleteIfExists(config); + } + } +} diff --git a/src/test/java/com/raditha/graph/KnowledgeGraphDataStructuresTest.java b/src/test/java/com/raditha/graph/KnowledgeGraphDataStructuresTest.java index 4cba1da7..46735459 100644 --- a/src/test/java/com/raditha/graph/KnowledgeGraphDataStructuresTest.java +++ b/src/test/java/com/raditha/graph/KnowledgeGraphDataStructuresTest.java @@ -5,6 +5,7 @@ import com.github.javaparser.ast.body.FieldDeclaration; import com.github.javaparser.ast.body.InitializerDeclaration; import com.github.javaparser.ast.body.MethodDeclaration; +import com.github.javaparser.ast.expr.LambdaExpr; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.DisplayName; @@ -104,6 +105,47 @@ void testNullNode() { assertNull(SignatureUtils.getSignature(null)); } + @Test + @DisplayName("Signature: enum constants are stable and unique") + void testEnumConstantSignature() { + String code = """ + package com.example; + enum Status { NEW, DONE } + """; + CompilationUnit cu = StaticJavaParser.parse(code); + var constants = cu.findAll(com.github.javaparser.ast.body.EnumConstantDeclaration.class); + + String sig1 = SignatureUtils.getEnumConstantSignature("com.example.Status", constants.get(0)); + String sig2 = SignatureUtils.getEnumConstantSignature("com.example.Status", constants.get(1)); + + assertEquals("com.example.Status#NEW", sig1); + assertEquals("com.example.Status#DONE", sig2); + assertNotEquals(sig1, sig2); + } + + @Test + @DisplayName("Signature: lambda signatures are deterministic and unique by ordinal") + void testLambdaSignature() { + String code = """ + package com.example; + class Service { + void run() { + Runnable a = () -> {}; + Runnable b = () -> {}; + } + } + """; + CompilationUnit cu = StaticJavaParser.parse(code); + var lambdas = cu.findAll(LambdaExpr.class); + + String sig1 = SignatureUtils.getLambdaSignature("com.example.Service#run()", lambdas.get(0), 1); + String sig2 = SignatureUtils.getLambdaSignature("com.example.Service#run()", lambdas.get(1), 2); + + assertTrue(sig1.startsWith("com.example.Service#run()$lambda_")); + assertTrue(sig2.startsWith("com.example.Service#run()$lambda_")); + assertNotEquals(sig1, sig2); + } + @Test @DisplayName("Edge: builder creates immutable edge with all attributes") void testEdgeBuilder() { diff --git a/src/test/java/com/raditha/graph/KnowledgeGraphIntegrationTest.java b/src/test/java/com/raditha/graph/KnowledgeGraphIntegrationTest.java index 593340da..ff537ee2 100644 --- a/src/test/java/com/raditha/graph/KnowledgeGraphIntegrationTest.java +++ b/src/test/java/com/raditha/graph/KnowledgeGraphIntegrationTest.java @@ -2,7 +2,6 @@ import com.github.javaparser.StaticJavaParser; import com.github.javaparser.ast.CompilationUnit; -import com.github.javaparser.ast.body.MethodDeclaration; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; @@ -22,6 +21,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.Set; import org.testcontainers.containers.Neo4jContainer; import org.codehaus.plexus.util.xml.pull.XmlPullParserException; @@ -42,7 +42,7 @@ class KnowledgeGraphIntegrationTest { @Mock - private Neo4jGraphStore graphStore; + private GraphStore graphStore; private Neo4jContainer neo4jContainer; @@ -100,18 +100,18 @@ void testSpringPetClinicAnalysis() throws IOException, XmlPullParserException { // Use Antikythera framework to pre-process (parse & resolve) the testbed AbstractCompiler.preProcess(); - List allMethods = new ArrayList<>(); + List units = new ArrayList<>(); // Retrieve parsed units directly from AntikytheraRunTime for (CompilationUnit cu : AntikytheraRunTime.getResolvedCompilationUnits().values()) { - allMethods.addAll(cu.findAll(MethodDeclaration.class)); + units.add(cu); } - assertTrue(allMethods.size() > 0, "Should have found methods in testbed"); + assertTrue(units.size() > 0, "Should have found compilation units in testbed"); // Build Graph - builder.build(allMethods); + builder.build(units); if (neo4jContainer != null) { // Live verification against Real DB @@ -126,6 +126,15 @@ void testSpringPetClinicAnalysis() throws IOException, XmlPullParserException { assertTrue(capturedEdges.size() > 100, "Should generate a significant number of edges for PetClinic"); + Set types = capturedEdges.stream() + .map(KnowledgeGraphEdge::type) + .collect(java.util.stream.Collectors.toSet()); + + assertTrue(types.contains(EdgeType.CONTAINS), "Should include structural CONTAINS edges"); + assertTrue(types.contains(EdgeType.CALLS), "Should include behavioral CALLS edges"); + assertTrue(types.contains(EdgeType.EXTENDS) || types.contains(EdgeType.IMPLEMENTS), + "Should include inheritance edges"); + // Check for specific expected patterns (OwnerController calling something) boolean foundControllerInteraction = capturedEdges.stream() .anyMatch(e -> e.sourceId().contains("OwnerController") && diff --git a/src/test/java/com/raditha/graph/RelationshipExtractionTest.java b/src/test/java/com/raditha/graph/RelationshipExtractionTest.java index eeefe949..e8ebff49 100644 --- a/src/test/java/com/raditha/graph/RelationshipExtractionTest.java +++ b/src/test/java/com/raditha/graph/RelationshipExtractionTest.java @@ -2,391 +2,177 @@ import com.github.javaparser.StaticJavaParser; import com.github.javaparser.ast.CompilationUnit; -import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; -import com.github.javaparser.ast.body.MethodDeclaration; -import com.github.javaparser.ast.expr.FieldAccessExpr; -import com.github.javaparser.ast.expr.MethodCallExpr; -import com.github.javaparser.ast.type.ClassOrInterfaceType; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import sa.com.cloudsolutions.antikythera.depsolver.Graph; -import sa.com.cloudsolutions.antikythera.depsolver.GraphNode; import java.util.List; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; -/** - * TDD Tests for Phase 4: Relationship Extraction. - * These tests are written FIRST (Red phase), then implementation follows. - */ class RelationshipExtractionTest { - private Neo4jGraphStore mockStore; + private GraphStore mockStore; private KnowledgeGraphBuilder builder; @BeforeEach void setUp() { Graph.getNodes().clear(); - mockStore = mock(Neo4jGraphStore.class); + mockStore = mock(GraphStore.class); builder = new KnowledgeGraphBuilder(mockStore); builder.setAutoClose(false); } - // ======================== - // Task 4.1: CALLS Edge Tests - // ======================== - - @Test - @DisplayName("CALLS: method calling another method creates CALLS edge") - void testCallsEdge_MethodToMethod() { - String code = """ - package com.example; - class Service { - void caller() { - helper(); - } - void helper() {} - } - """; - CompilationUnit cu = StaticJavaParser.parse(code); - MethodDeclaration caller = cu.findAll(MethodDeclaration.class).stream() - .filter(m -> m.getNameAsString().equals("caller")) - .findFirst().orElseThrow(); - MethodCallExpr mce = cu.findFirst(MethodCallExpr.class).orElseThrow(); - - GraphNode node = Graph.createGraphNode(caller); - - // Act: Simulate what happens when a method call is visited - builder.onMethodCalled(node, mce); - - // Assert: Verify CALLS edge was persisted - ArgumentCaptor captor = ArgumentCaptor.forClass(KnowledgeGraphEdge.class); - verify(mockStore).persistEdge(captor.capture()); - - KnowledgeGraphEdge edge = captor.getValue(); - assertEquals(EdgeType.CALLS, edge.type()); - assertTrue(edge.sourceId().contains("caller")); - assertTrue(edge.targetId().contains("helper")); - } - - // ======================== - // Task 4.2: ACCESSES Edge Tests - // ======================== - @Test - @DisplayName("ACCESSES: reading a field creates ACCESSES edge with READ type") - void testAccessesEdge_FieldRead() { - String code = """ + @DisplayName("Structural traversal emits EXTENDS, IMPLEMENTS, CONTAINS") + void testStructuralEdges() { + CompilationUnit cu = StaticJavaParser.parse(""" package com.example; - class Entity { - private String name; - String getName() { return this.name; } + interface Api {} + class Base {} + class Service extends Base implements Api { + private int count; + void work() {} } - """; - CompilationUnit cu = StaticJavaParser.parse(code); - MethodDeclaration getter = cu.findFirst(MethodDeclaration.class).orElseThrow(); - FieldAccessExpr fae = cu.findFirst(FieldAccessExpr.class).orElseThrow(); + """); - GraphNode node = Graph.createGraphNode(getter); - - // Act - builder.onFieldAccessed(node, fae); + builder.build(List.of(cu)); - // Assert - ArgumentCaptor captor = ArgumentCaptor.forClass(KnowledgeGraphEdge.class); - verify(mockStore).persistEdge(captor.capture()); - - KnowledgeGraphEdge edge = captor.getValue(); - assertEquals(EdgeType.ACCESSES, edge.type()); - assertEquals("READ", edge.attributes().get("accessType")); - assertTrue(edge.targetId().contains("name")); - } + List edges = captureEdges(); - // ======================== - // Task 4.3: CONTAINS Edge Tests - // ======================== + assertTrue(edges.stream().anyMatch(e -> + e.type() == EdgeType.EXTENDS + && e.sourceId().equals("com.example.Service") + && e.targetId().equals("com.example.Base"))); - @Test - @DisplayName("CONTAINS: class containing method creates CONTAINS edge") - void testContainsEdge_ClassToMethod() { - String code = """ - package com.example; - class UserService { - void findUser() {} - } - """; - CompilationUnit cu = StaticJavaParser.parse(code); - ClassOrInterfaceDeclaration clazz = cu.findFirst(ClassOrInterfaceDeclaration.class).orElseThrow(); - MethodDeclaration method = cu.findFirst(MethodDeclaration.class).orElseThrow(); + assertTrue(edges.stream().anyMatch(e -> + e.type() == EdgeType.IMPLEMENTS + && e.sourceId().equals("com.example.Service") + && e.targetId().equals("com.example.Api"))); - // Act: When processing a class, CONTAINS edges should be created for members - builder.onMemberDiscovered(clazz, method); + assertTrue(edges.stream().anyMatch(e -> + e.type() == EdgeType.CONTAINS + && e.sourceId().equals("com.example.Service") + && e.targetId().equals("com.example.Service#count"))); - // Assert - ArgumentCaptor captor = ArgumentCaptor.forClass(KnowledgeGraphEdge.class); - verify(mockStore).persistEdge(captor.capture()); - - KnowledgeGraphEdge edge = captor.getValue(); - assertEquals(EdgeType.CONTAINS, edge.type()); - assertTrue(edge.sourceId().contains("UserService")); - assertTrue(edge.targetId().contains("findUser")); + assertTrue(edges.stream().anyMatch(e -> + e.type() == EdgeType.CONTAINS + && e.sourceId().equals("com.example.Service") + && e.targetId().contains("work("))); } - // ======================== - // Task 4.4: EXTENDS/IMPLEMENTS Edge Tests - // ======================== - @Test - @DisplayName("EXTENDS: class extending another creates EXTENDS edge") - void testExtendsEdge() { - String code = """ + @DisplayName("Enum constants are emitted as distinct CONTAINS targets") + void testEnumConstantContainment() { + CompilationUnit cu = StaticJavaParser.parse(""" package com.example; - class Child extends Parent {} - """; - CompilationUnit cu = StaticJavaParser.parse(code); - ClassOrInterfaceDeclaration clazz = cu.findFirst(ClassOrInterfaceDeclaration.class).orElseThrow(); - ClassOrInterfaceType extendedType = clazz.getExtendedTypes().get(0); - - // Act - builder.onExtendsDiscovered(clazz, extendedType); + enum Status { NEW, DONE } + """); - // Assert - ArgumentCaptor captor = ArgumentCaptor.forClass(KnowledgeGraphEdge.class); - verify(mockStore).persistEdge(captor.capture()); - - KnowledgeGraphEdge edge = captor.getValue(); - assertEquals(EdgeType.EXTENDS, edge.type()); - assertTrue(edge.sourceId().contains("Child")); - assertTrue(edge.targetId().contains("Parent")); - } + builder.build(List.of(cu)); - @Test - @DisplayName("IMPLEMENTS: class implementing interface creates IMPLEMENTS edge") - void testImplementsEdge() { - String code = """ - package com.example; - class ServiceImpl implements Service {} - """; - CompilationUnit cu = StaticJavaParser.parse(code); - ClassOrInterfaceDeclaration clazz = cu.findFirst(ClassOrInterfaceDeclaration.class).orElseThrow(); - ClassOrInterfaceType implementedType = clazz.getImplementedTypes().get(0); + List edges = captureEdges(); - // Act - builder.onImplementsDiscovered(clazz, implementedType); + assertTrue(edges.stream().anyMatch(e -> + e.type() == EdgeType.CONTAINS + && e.sourceId().equals("com.example.Status") + && e.targetId().equals("com.example.Status#NEW"))); - // Assert - ArgumentCaptor captor = ArgumentCaptor.forClass(KnowledgeGraphEdge.class); - verify(mockStore).persistEdge(captor.capture()); - - KnowledgeGraphEdge edge = captor.getValue(); - assertEquals(EdgeType.IMPLEMENTS, edge.type()); - assertTrue(edge.sourceId().contains("ServiceImpl")); - assertTrue(edge.targetId().contains("Service")); + assertTrue(edges.stream().anyMatch(e -> + e.type() == EdgeType.CONTAINS + && e.sourceId().equals("com.example.Status") + && e.targetId().equals("com.example.Status#DONE"))); } - // ======================== - // Task 4.5: USES Edge Test - // ======================== - @Test - @DisplayName("USES: casting to a type creates USES edge") - void testUsesEdge_Cast() { - String code = """ - package com.example; - class Processor { - void process(Object obj) { - String s = (String) obj; - } - } - """; - CompilationUnit cu = StaticJavaParser.parse(code); - MethodDeclaration method = cu.findFirst(MethodDeclaration.class).orElseThrow(); - var castType = StaticJavaParser.parseType("String"); - - GraphNode node = Graph.createGraphNode(method); - - // Act - builder.onTypeUsed(node, castType); - - // Assert - ArgumentCaptor captor = ArgumentCaptor.forClass(KnowledgeGraphEdge.class); - verify(mockStore).persistEdge(captor.capture()); - - KnowledgeGraphEdge edge = captor.getValue(); - assertEquals(EdgeType.USES, edge.type()); - assertTrue(edge.targetId().contains("String")); - } - - @Test - @DisplayName("CALLS: method call captures local variable arguments in JSON format") - void testCallsEdge_CapturesArguments() { - String code = """ + @DisplayName("Lambda body edges use lambda node as source") + void testLambdaAttribution() { + CompilationUnit cu = StaticJavaParser.parse(""" package com.example; + import java.util.List; class Service { - void process() { - String localVar = "value"; - int num = 10; - helper(localVar, num); + void run(List values) { + values.forEach(v -> helper(v)); } - void helper(String s, int i) {} + void helper(String v) {} } - """; - CompilationUnit cu = StaticJavaParser.parse(code); - MethodDeclaration caller = cu.findAll(MethodDeclaration.class).stream() - .filter(m -> m.getNameAsString().equals("process")) - .findFirst().orElseThrow(); - MethodCallExpr mce = cu.findFirst(MethodCallExpr.class).orElseThrow(); - - GraphNode node = Graph.createGraphNode(caller); - - // Act - builder.onMethodCalled(node, mce); - - // Assert - ArgumentCaptor captor = ArgumentCaptor.forClass(KnowledgeGraphEdge.class); - verify(mockStore).persistEdge(captor.capture()); + """); - KnowledgeGraphEdge edge = captor.getValue(); - assertEquals(EdgeType.CALLS, edge.type()); + builder.build(List.of(cu)); - // Verify attributes - assertNotNull(edge.attributes(), "Attributes should not be null"); - assertTrue(edge.attributes().containsKey("parameterValues"), "Should contain parameterValues attribute"); + List edges = captureEdges(); - String paramValues = edge.attributes().get("parameterValues"); - // Check for JSON array structure - assertTrue(paramValues.startsWith("[") && paramValues.endsWith("]"), "Should be a JSON array"); - assertTrue(paramValues.contains("\"localVar\""), "Should contain 'localVar'"); - assertTrue(paramValues.contains("\"num\""), "Should contain 'num'"); + assertTrue(edges.stream().anyMatch(e -> + e.type() == EdgeType.CALLS + && e.targetId().equals("com.example.Service#helper()") + && e.sourceId().contains("$lambda_"))); } @Test - @DisplayName("CALLS: no arguments results in no parameterValues attribute") - void testCallsEdge_NoArguments() { - String code = """ + @DisplayName("Method references emit REFERENCES edges") + void testMethodReferenceExtraction() { + CompilationUnit cu = StaticJavaParser.parse(""" package com.example; + import java.util.List; class Service { - void process() { - noArgs(); + void run(List values) { + values.forEach(this::helper); } - void noArgs() {} + void helper(String v) {} } - """; - CompilationUnit cu = StaticJavaParser.parse(code); - MethodDeclaration caller = cu.findAll(MethodDeclaration.class).stream() - .filter(m -> m.getNameAsString().equals("process")) - .findFirst().orElseThrow(); - MethodCallExpr mce = cu.findFirst(MethodCallExpr.class).orElseThrow(); + """); - GraphNode node = Graph.createGraphNode(caller); + builder.build(List.of(cu)); - // Act - builder.onMethodCalled(node, mce); + List edges = captureEdges(); - // Assert - ArgumentCaptor captor = ArgumentCaptor.forClass(KnowledgeGraphEdge.class); - verify(mockStore).persistEdge(captor.capture()); - - KnowledgeGraphEdge edge = captor.getValue(); - assertEquals(EdgeType.CALLS, edge.type()); - - // Verify attributes - assertFalse(edge.attributes().containsKey("parameterValues"), "Should NOT contain parameterValues attribute for no-arg call"); + assertTrue(edges.stream().anyMatch(e -> + e.type() == EdgeType.REFERENCES + && e.sourceId().contains("run(") + && e.targetId().equals("com.example.Service#helper()"))); } @Test - @DisplayName("CALLS: handles complex arguments correctly") - void testCallsEdge_ComplexArguments() { - String code = """ + @DisplayName("Resolution quality marks unresolved calls as partial") + void testResolutionQuality() { + CompilationUnit cu = StaticJavaParser.parse(""" package com.example; + class Repo { void save() {} } class Service { - void process(int x, int y) { - helper("a,b", x + y); + void resolved(Repo repo) { + repo.save(); + } + void unresolved() { + mystery.save(); } - void helper(String s, int i) {} } - """; - CompilationUnit cu = StaticJavaParser.parse(code); - MethodDeclaration caller = cu.findAll(MethodDeclaration.class).stream() - .filter(m -> m.getNameAsString().equals("process")) - .findFirst().orElseThrow(); - MethodCallExpr mce = cu.findFirst(MethodCallExpr.class).orElseThrow(); + """); - GraphNode node = Graph.createGraphNode(caller); + builder.build(List.of(cu)); - // Act - builder.onMethodCalled(node, mce); + List edges = captureEdges(); - // Assert - ArgumentCaptor captor = ArgumentCaptor.forClass(KnowledgeGraphEdge.class); - verify(mockStore).persistEdge(captor.capture()); + assertTrue(edges.stream().anyMatch(e -> + e.type() == EdgeType.CALLS + && e.targetId().equals("com.example.Repo#save()"))); - KnowledgeGraphEdge edge = captor.getValue(); - assertEquals(EdgeType.CALLS, edge.type()); + KnowledgeGraphEdge partial = edges.stream() + .filter(e -> e.type() == EdgeType.CALLS && e.targetId().equals("mystery#save()")) + .findFirst() + .orElseThrow(); - String paramValues = edge.attributes().get("parameterValues"); - // Verify proper JSON escaping and separation - // Note: JavaParser might include quotes in the string representation of a StringLiteral - assertTrue(paramValues.contains("a,b"), "Should contain string literal with comma"); - assertTrue(paramValues.contains("x + y"), "Should contain binary expression"); + assertEquals("partial", partial.attributes().get("resolution")); } - @Test - @DisplayName("CALLS: method calling method on another type should use target type from scope") - void testCallsEdge_OtherType() { - String code = """ - package com.example; - class Controller { - private Repository repo; - void handle() { - repo.save(); - } - } - """; - CompilationUnit cu = StaticJavaParser.parse(code); - MethodDeclaration caller = cu.findAll(MethodDeclaration.class).stream() - .filter(m -> m.getNameAsString().equals("handle")) - .findFirst().orElseThrow(); - MethodCallExpr mce = cu.findFirst(MethodCallExpr.class).orElseThrow(); - - GraphNode node = Graph.createGraphNode(caller); - - builder.onMethodCalled(node, mce); - - ArgumentCaptor captor = ArgumentCaptor.forClass(KnowledgeGraphEdge.class); - verify(mockStore).persistEdge(captor.capture()); - - KnowledgeGraphEdge edge = captor.getValue(); - assertEquals("repo#save()", edge.targetId()); - } - - @Test - @DisplayName("ACCESSES: accessing field on another type should use target type from scope") - void testAccessesEdge_OtherType() { - String code = """ - package com.example; - class Service { - void process(User user) { - String name = user.name; - } - } - """; - CompilationUnit cu = StaticJavaParser.parse(code); - MethodDeclaration method = cu.findFirst(MethodDeclaration.class).orElseThrow(); - FieldAccessExpr fae = cu.findFirst(FieldAccessExpr.class).orElseThrow(); - - GraphNode node = Graph.createGraphNode(method); - - builder.onFieldAccessed(node, fae); - + private List captureEdges() { ArgumentCaptor captor = ArgumentCaptor.forClass(KnowledgeGraphEdge.class); - verify(mockStore).persistEdge(captor.capture()); - - KnowledgeGraphEdge edge = captor.getValue(); - assertEquals("user#name", edge.targetId()); + verify(mockStore, atLeastOnce()).persistEdge(captor.capture()); + return captor.getAllValues(); } } diff --git a/src/test/java/com/raditha/perf/JavaParserMethodFindingBenchmark.java b/src/test/java/com/raditha/perf/JavaParserMethodFindingBenchmark.java new file mode 100644 index 00000000..9ca7d9a2 --- /dev/null +++ b/src/test/java/com/raditha/perf/JavaParserMethodFindingBenchmark.java @@ -0,0 +1,164 @@ +package com.raditha.perf; + +import com.github.javaparser.ParserConfiguration; +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.MethodDeclaration; +import com.github.javaparser.ast.body.TypeDeclaration; +import com.github.javaparser.ast.visitor.VoidVisitorAdapter; + +import java.io.File; +import java.io.IOException; +import java.util.Optional; + +/** + * Benchmark to compare performance of two methods for finding a specific method declaration: + * 1. Using TypeDeclaration.findAll(MethodDeclaration.class) + * 2. Using a VoidVisitor pattern + */ +public class JavaParserMethodFindingBenchmark { + + private static final String FILE_PATH = "../antikythera/src/main/java/sa/com/cloudsolutions/antikythera/parser/AbstractCompiler.java"; + private static final String TARGET_METHOD_NAME = "camelToSnakeCase"; + private static final int ITERATIONS = 1000; + + private CompilationUnit cu; + private TypeDeclaration classDeclaration; + + /** + * Optimized inner class visitor for finding a method by name. + * Avoids anonymous class overhead and uses a simple field instead of AtomicReference. + */ + private static class MethodFinderVisitor extends VoidVisitorAdapter { + private MethodDeclaration foundMethod; + + @Override + public void visit(MethodDeclaration md, String targetMethodName) { + if (md.getNameAsString().equals(targetMethodName)) { + foundMethod = md; + } + super.visit(md, targetMethodName); + } + + public MethodDeclaration getFoundMethod() { + return foundMethod; + } + + public void reset() { + foundMethod = null; + } + } + + public static void main(String[] args) throws Exception { + JavaParserMethodFindingBenchmark benchmark = new JavaParserMethodFindingBenchmark(); + benchmark.setup(); + benchmark.runBenchmarks(); + } + + /** + * Setup: Load and parse the AbstractCompiler.java file + */ + public void setup() throws IOException { + System.out.println("Setting up benchmark..."); + System.out.println("Loading file: " + FILE_PATH); + + // Configure JavaParser to support Java 21 features + ParserConfiguration parserConfiguration = new ParserConfiguration(); + parserConfiguration.setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_21); + StaticJavaParser.setConfiguration(parserConfiguration); + + File file = new File(FILE_PATH); + cu = StaticJavaParser.parse(file); + + // Get the AbstractCompiler class declaration + Optional classOpt = cu.getClassByName("AbstractCompiler"); + if (classOpt.isEmpty()) { + throw new IllegalStateException("Could not find AbstractCompiler class in the parsed file"); + } + + classDeclaration = classOpt.get(); + System.out.println("Loaded class: " + classDeclaration.getNameAsString()); + System.out.println("Target method: " + TARGET_METHOD_NAME); + System.out.println("Iterations per test: " + ITERATIONS); + System.out.println(); + } + + /** + * Run both benchmarks and compare results + */ + public void runBenchmarks() { + // Warm up the JVM + System.out.println("Warming up JVM..."); + for (int i = 0; i < 100; i++) { + findMethodUsingFindAll(); + findMethodUsingVisitor(); + } + System.out.println("Warm up complete.\n"); + + // Benchmark 1: Using findAll + System.out.println("=== Benchmark 1: Using findAll() ==="); + long startTime1 = System.nanoTime(); + MethodDeclaration result1 = null; + for (int i = 0; i < ITERATIONS; i++) { + result1 = findMethodUsingFindAll(); + } + long endTime1 = System.nanoTime(); + long duration1 = endTime1 - startTime1; + + System.out.println("Found method: " + (result1 != null ? result1.getNameAsString() : "null")); + System.out.println("Total time: " + duration1 / 1_000_000.0 + " ms"); + System.out.println("Average time per iteration: " + duration1 / (ITERATIONS * 1_000_000.0) + " ms"); + System.out.println(); + + // Benchmark 2: Using Visitor + System.out.println("=== Benchmark 2: Using VoidVisitor ==="); + long startTime2 = System.nanoTime(); + MethodDeclaration result2 = null; + for (int i = 0; i < ITERATIONS; i++) { + result2 = findMethodUsingVisitor(); + } + long endTime2 = System.nanoTime(); + long duration2 = endTime2 - startTime2; + + System.out.println("Found method: " + (result2 != null ? result2.getNameAsString() : "null")); + System.out.println("Total time: " + duration2 / 1_000_000.0 + " ms"); + System.out.println("Average time per iteration: " + duration2 / (ITERATIONS * 1_000_000.0) + " ms"); + System.out.println(); + + // Comparison + System.out.println("=== Results Comparison ==="); + double speedup = (double) duration1 / duration2; + System.out.println("findAll() took: " + duration1 / 1_000_000.0 + " ms"); + System.out.println("Visitor took: " + duration2 / 1_000_000.0 + " ms"); + + if (speedup > 1.0) { + System.out.println("Visitor is " + String.format("%.2f", speedup) + "x FASTER than findAll()"); + } else { + System.out.println("findAll() is " + String.format("%.2f", 1.0 / speedup) + "x FASTER than Visitor"); + } + + System.out.println("\nVerification: Both methods found the same method? " + + (result1 != null && result2 != null && result1.equals(result2))); + } + + /** + * Method 1: Find method using findAll() + */ + private MethodDeclaration findMethodUsingFindAll() { + return classDeclaration.findAll(MethodDeclaration.class).stream() + .filter(md -> md.getNameAsString().equals(TARGET_METHOD_NAME)) + .findFirst() + .orElse(null); + } + + /** + * Method 2: Find method using VoidVisitor (optimized with inner class) + */ + private MethodDeclaration findMethodUsingVisitor() { + MethodFinderVisitor visitor = new MethodFinderVisitor(); + classDeclaration.accept(visitor, TARGET_METHOD_NAME); + return visitor.getFoundMethod(); + } +} + diff --git a/src/test/java/sa/com/cloudsolutions/antikythera/examples/AnnotationFinderTest.java b/src/test/java/sa/com/cloudsolutions/antikythera/examples/AnnotationFinderTest.java new file mode 100644 index 00000000..b9c3bb04 --- /dev/null +++ b/src/test/java/sa/com/cloudsolutions/antikythera/examples/AnnotationFinderTest.java @@ -0,0 +1,395 @@ +package sa.com.cloudsolutions.antikythera.examples; + +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.MethodDeclaration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Set; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for AnnotationFinder tool to verify it correctly identifies + * classes and methods with specific annotations, handling both simple + * and fully qualified annotation names. + */ +class AnnotationFinderTest { + + @Test + void testMatchesAnnotation_SimpleName() { + // Test that simple name "Service" matches both @Service and @org.springframework.stereotype.Service + assertTrue(AnnotationFinder.matchesAnnotation("Service", "Service", "Service")); + assertTrue(AnnotationFinder.matchesAnnotation("org.springframework.stereotype.Service", "Service", "Service")); + assertTrue(AnnotationFinder.matchesAnnotation("Service", "org.springframework.stereotype.Service", "Service")); + assertTrue(AnnotationFinder.matchesAnnotation("org.springframework.stereotype.Service", + "org.springframework.stereotype.Service", "Service")); + } + + @Test + void testMatchesAnnotation_FullyQualifiedName() { + // Test fully qualified name matching + assertTrue(AnnotationFinder.matchesAnnotation("org.springframework.stereotype.Service", + "org.springframework.stereotype.Service", "Service")); + assertTrue(AnnotationFinder.matchesAnnotation("Service", + "org.springframework.stereotype.Service", "Service")); + } + + @Test + void testMatchesAnnotation_NoMatch() { + // Test that non-matching annotations are not matched + assertFalse(AnnotationFinder.matchesAnnotation("Service", "Test", "Test")); + assertFalse(AnnotationFinder.matchesAnnotation("org.springframework.stereotype.Service", + "Test", "Test")); + assertFalse(AnnotationFinder.matchesAnnotation("Entity", "Service", "Service")); + } + + @Test + void testExtractSimpleName() { + assertEquals("Service", AnnotationFinder.extractSimpleName("Service")); + assertEquals("Service", AnnotationFinder.extractSimpleName("org.springframework.stereotype.Service")); + assertEquals("Test", AnnotationFinder.extractSimpleName("org.junit.jupiter.api.Test")); + assertEquals("Entity", AnnotationFinder.extractSimpleName("javax.persistence.Entity")); + } + + /** + * Provides test cases for hasAnnotation method. + */ + static Stream hasAnnotationTestCases() { + return Stream.of( + Arguments.of( + "With matching annotation", + """ + package test; + import org.springframework.stereotype.Service; + + @Service + class MyService { + } + """, + true + ), + Arguments.of( + "With fully qualified annotation", + """ + package test; + + @org.springframework.stereotype.Service + class MyService { + } + """, + true + ), + Arguments.of( + "No match", + """ + package test; + + @Entity + class MyEntity { + } + """, + false + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("hasAnnotationTestCases") + void testHasAnnotation(String testName, String code, boolean shouldMatch) { + CompilationUnit cu = StaticJavaParser.parse(code); + ClassOrInterfaceDeclaration classDecl = cu.findFirst(ClassOrInterfaceDeclaration.class).orElseThrow(); + + assertEquals(shouldMatch, AnnotationFinder.hasAnnotation(classDecl.getAnnotations(), "Service", "Service")); + if (shouldMatch) { + assertTrue(AnnotationFinder.hasAnnotation(classDecl.getAnnotations(), + "org.springframework.stereotype.Service", "Service")); + } + } + + /** + * Provides test cases for buildMethodSignature method. + */ + static Stream buildMethodSignatureTestCases() { + return Stream.of( + Arguments.of( + "No parameters", + """ + package test; + + class MyClass { + void print() { + } + } + """, + "print()" + ), + Arguments.of( + "With parameters", + """ + package test; + + class MyClass { + void print(String s) { + } + } + """, + "print(String s)" + ), + Arguments.of( + "Multiple parameters", + """ + package test; + + class MyClass { + void process(String name, int count, boolean active) { + } + } + """, + "process(String name, int count, boolean active)" + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("buildMethodSignatureTestCases") + void testBuildMethodSignature(String testName, String code, String expectedSignature) { + CompilationUnit cu = StaticJavaParser.parse(code); + MethodDeclaration method = cu.findFirst(MethodDeclaration.class).orElseThrow(); + + String signature = AnnotationFinder.buildMethodSignature(method); + assertEquals(expectedSignature, signature); + } + + @Test + void testBuildMethodSignature_WithComplexTypes() { + String code = """ + package test; + import java.util.List; + import java.util.Map; + + class MyClass { + void process(List items, Map counts) { + } + } + """; + + CompilationUnit cu = StaticJavaParser.parse(code); + MethodDeclaration method = cu.findFirst(MethodDeclaration.class).orElseThrow(); + + String signature = AnnotationFinder.buildMethodSignature(method); + assertTrue(signature.contains("process")); + // Verify both parameter name and type are present together + assertTrue(signature.contains("items") && signature.contains("List"), + "Expected signature to contain both 'List' and 'items', got: " + signature); + assertTrue(signature.contains("counts") && signature.contains("Map"), + "Expected signature to contain both 'Map' and 'counts', got: " + signature); + // Verify it has the method name and parameters + assertTrue(signature.startsWith("process(")); + assertTrue(signature.endsWith(")")); + } + + @Test + void testClassAnnotation_SimpleName() { + String code = """ + package com.example; + import org.springframework.stereotype.Service; + + @Service + class UserService { + } + """; + + CompilationUnit cu = StaticJavaParser.parse(code); + ClassOrInterfaceDeclaration classDecl = cu.findFirst(ClassOrInterfaceDeclaration.class).orElseThrow(); + + assertTrue(AnnotationFinder.hasAnnotation(classDecl.getAnnotations(), "Service", "Service")); + } + + @Test + void testClassAnnotation_FullyQualified() { + String code = """ + package com.example; + + @org.springframework.stereotype.Service + class UserService { + } + """; + + CompilationUnit cu = StaticJavaParser.parse(code); + ClassOrInterfaceDeclaration classDecl = cu.findFirst(ClassOrInterfaceDeclaration.class).orElseThrow(); + + assertTrue(AnnotationFinder.hasAnnotation(classDecl.getAnnotations(), "Service", "Service")); + assertTrue(AnnotationFinder.hasAnnotation(classDecl.getAnnotations(), + "org.springframework.stereotype.Service", "Service")); + } + + @Test + void testMethodAnnotation_SimpleName() { + String code = """ + package com.example; + import org.junit.jupiter.api.Test; + + class TestClass { + @Test + void testMethod() { + } + } + """; + + CompilationUnit cu = StaticJavaParser.parse(code); + MethodDeclaration method = cu.findFirst(MethodDeclaration.class).orElseThrow(); + + assertTrue(AnnotationFinder.hasAnnotation(method.getAnnotations(), "Test", "Test")); + } + + @Test + void testMethodAnnotation_FullyQualified() { + String code = """ + package com.example; + + class TestClass { + @org.junit.jupiter.api.Test + void testMethod() { + } + } + """; + + CompilationUnit cu = StaticJavaParser.parse(code); + MethodDeclaration method = cu.findFirst(MethodDeclaration.class).orElseThrow(); + + assertTrue(AnnotationFinder.hasAnnotation(method.getAnnotations(), "Test", "Test")); + assertTrue(AnnotationFinder.hasAnnotation(method.getAnnotations(), + "org.junit.jupiter.api.Test", "Test")); + } + + @Test + void testMultipleAnnotations() { + String code = """ + package com.example; + import org.springframework.stereotype.Service; + import org.springframework.transaction.annotation.Transactional; + + @Service + class UserService { + @Transactional + void save() { + } + } + """; + + CompilationUnit cu = StaticJavaParser.parse(code); + ClassOrInterfaceDeclaration classDecl = cu.findFirst(ClassOrInterfaceDeclaration.class).orElseThrow(); + MethodDeclaration method = cu.findFirst(MethodDeclaration.class).orElseThrow(); + + assertTrue(AnnotationFinder.hasAnnotation(classDecl.getAnnotations(), "Service", "Service")); + assertTrue(AnnotationFinder.hasAnnotation(method.getAnnotations(), "Transactional", "Transactional")); + assertFalse(AnnotationFinder.hasAnnotation(classDecl.getAnnotations(), "Transactional", "Transactional")); + assertFalse(AnnotationFinder.hasAnnotation(method.getAnnotations(), "Service", "Service")); + } + + @Test + void testSimpleModeOutput_JustMethodName() { + String code = """ + package com.example; + import org.junit.jupiter.api.Test; + + class TestClass { + @Test + void testMethod() { + } + + @Test + void testMethod(String param) { + } + } + """; + + CompilationUnit cu = StaticJavaParser.parse(code); + ClassOrInterfaceDeclaration classDecl = cu.findFirst(ClassOrInterfaceDeclaration.class).orElseThrow(); + String className = classDecl.getFullyQualifiedName().orElse(classDecl.getNameAsString()); + + // In simple mode, both overloaded methods should produce the same output + java.util.List methods = classDecl.getMethods(); + Set seen = new java.util.HashSet<>(); + + methods.forEach(method -> { + if (AnnotationFinder.hasAnnotation(method.getAnnotations(), "Test", "Test")) { + String output = className + "#" + method.getNameAsString(); + seen.add(output); + } + }); + + // Should only have one entry (duplicates eliminated) + assertEquals(1, seen.size()); + assertTrue(seen.contains(className + "#testMethod")); + } + + @Test + void testSimpleModeOutput_DifferentMethodNames() { + String code = """ + package com.example; + import org.junit.jupiter.api.Test; + + class TestClass { + @Test + void testMethod1() { + } + + @Test + void testMethod2() { + } + } + """; + + CompilationUnit cu = StaticJavaParser.parse(code); + ClassOrInterfaceDeclaration classDecl = cu.findFirst(ClassOrInterfaceDeclaration.class).orElseThrow(); + String className = classDecl.getFullyQualifiedName().orElse(classDecl.getNameAsString()); + + java.util.List methods = classDecl.getMethods(); + Set seen = new java.util.HashSet<>(); + + methods.forEach(method -> { + if (AnnotationFinder.hasAnnotation(method.getAnnotations(), "Test", "Test")) { + String output = className + "#" + method.getNameAsString(); + seen.add(output); + } + }); + + // Should have two entries (different method names) + assertEquals(2, seen.size()); + assertTrue(seen.contains(className + "#testMethod1")); + assertTrue(seen.contains(className + "#testMethod2")); + } + + @Test + void testSimpleModeOutput_NoParameters() { + String code = """ + package com.example; + import org.junit.jupiter.api.Test; + + class TestClass { + @Test + void testMethod() { + } + } + """; + + CompilationUnit cu = StaticJavaParser.parse(code); + MethodDeclaration method = cu.findFirst(MethodDeclaration.class).orElseThrow(); + + // In simple mode, output should be just className#methodName (no parameters) + String simpleOutput = "com.example.TestClass#" + method.getNameAsString(); + assertEquals("com.example.TestClass#testMethod", simpleOutput); + + // Detailed mode would include parameters + String detailedOutput = "com.example.TestClass#" + AnnotationFinder.buildMethodSignature(method); + assertEquals("com.example.TestClass#testMethod()", detailedOutput); + } +} + diff --git a/src/test/java/sa/com/cloudsolutions/antikythera/examples/GeminiAIServiceConfigTest.java b/src/test/java/sa/com/cloudsolutions/antikythera/examples/GeminiAIServiceConfigTest.java new file mode 100644 index 00000000..6ef72af0 --- /dev/null +++ b/src/test/java/sa/com/cloudsolutions/antikythera/examples/GeminiAIServiceConfigTest.java @@ -0,0 +1,230 @@ +package sa.com.cloudsolutions.antikythera.examples; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import uk.org.webcompere.systemstubs.environment.EnvironmentVariables; +import uk.org.webcompere.systemstubs.jupiter.SystemStub; +import uk.org.webcompere.systemstubs.jupiter.SystemStubsExtension; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Comprehensive tests for GeminiAIService.getConfigString method. + * Uses system-stubs to test environment variable fallback without global modifications. + */ +@ExtendWith(SystemStubsExtension.class) +class GeminiAIServiceConfigTest { + + @SystemStub + private EnvironmentVariables environmentVariables; + + private TestableGeminiAIService service; + + @BeforeEach + void setUp() throws IOException { + service = new TestableGeminiAIService(); + } + + @Test + void testGetConfigString_NullConfig() { + // When config is null, should return default value + service.setConfig(null); + + String result = service.getConfigString("api_key", "default-key"); + + assertEquals("default-key", result); + } + + @Test + void testGetConfigString_ConfigValuePresent() { + // When config has non-empty value, should return config value (ignoring env vars) + Map config = new HashMap<>(); + config.put("api_key", "config-api-key"); + service.setConfig(config); + + environmentVariables.set("GEMINI_API_KEY", "env-api-key"); + + String result = service.getConfigString("api_key", "default-key"); + + assertEquals("config-api-key", result, "Config value should take precedence over environment variable"); + } + + @Test + void testGetConfigString_ConfigValueEmpty() { + // When config value is empty string, should fall back to environment variable + Map config = new HashMap<>(); + config.put("api_key", ""); + service.setConfig(config); + + environmentVariables.set("GEMINI_API_KEY", "env-api-key"); + + String result = service.getConfigString("api_key", "default-key"); + + assertEquals("env-api-key", result, "Should fall back to env var when config value is empty"); + } + + @Test + void testGetConfigString_ConfigValueWhitespace() { + // When config value is whitespace only, should fall back to environment variable + Map config = new HashMap<>(); + config.put("api_key", " "); + service.setConfig(config); + + environmentVariables.set("GEMINI_API_KEY", "env-api-key"); + + String result = service.getConfigString("api_key", "default-key"); + + assertEquals("env-api-key", result, "Should fall back to env var when config value is whitespace"); + } + + @Test + void testGetConfigString_ApiKeyFromEnv() { + // When config doesn't have api_key, should fall back to GEMINI_API_KEY env var + Map config = new HashMap<>(); + service.setConfig(config); + + environmentVariables.set("GEMINI_API_KEY", "env-api-key-value"); + + String result = service.getConfigString("api_key", "default-key"); + + assertEquals("env-api-key-value", result, "Should use GEMINI_API_KEY environment variable"); + } + + @Test + void testGetConfigString_ApiEndpointFromEnv() { + // When config doesn't have api_endpoint, should fall back to AI_SERVICE_ENDPOINT env var + Map config = new HashMap<>(); + service.setConfig(config); + + environmentVariables.set("AI_SERVICE_ENDPOINT", "https://custom-endpoint.com"); + + String result = service.getConfigString("api_endpoint", "https://default-endpoint.com"); + + assertEquals("https://custom-endpoint.com", result, "Should use AI_SERVICE_ENDPOINT environment variable"); + } + + @Test + void testGetConfigString_OtherKeyNoEnvFallback() { + // For keys other than api_key/api_endpoint, should not use env vars + Map config = new HashMap<>(); + service.setConfig(config); + + environmentVariables.set("SOME_OTHER_VAR", "env-value"); + + String result = service.getConfigString("other_key", "default-value"); + + assertEquals("default-value", result, "Should not fall back to env vars for non-special keys"); + } + + @Test + void testGetConfigString_EnvVarEmpty() { + // When env var exists but is empty, should return default value + Map config = new HashMap<>(); + service.setConfig(config); + + environmentVariables.set("GEMINI_API_KEY", ""); + + String result = service.getConfigString("api_key", "default-key"); + + assertEquals("default-key", result, "Should return default when env var is empty"); + } + + @Test + void testGetConfigString_EnvVarWhitespace() { + // When env var exists but is whitespace, should return default value + Map config = new HashMap<>(); + service.setConfig(config); + + environmentVariables.set("GEMINI_API_KEY", " "); + + String result = service.getConfigString("api_key", "default-key"); + + assertEquals("default-key", result, "Should return default when env var is whitespace"); + } + + @Test + void testGetConfigString_NoConfigNoEnvUsesDefault() { + // When neither config nor env var exists, should return default value + Map config = new HashMap<>(); + service.setConfig(config); + + // No environment variable set + + String result = service.getConfigString("api_key", "default-key"); + + assertEquals("default-key", result, "Should return default when no config or env var"); + } + + @Test + void testGetConfigString_ConfigPrecedenceOverEnv() { + // Verify config always takes precedence over environment variables + Map config = new HashMap<>(); + config.put("api_endpoint", "https://config-endpoint.com"); + service.setConfig(config); + + environmentVariables.set("AI_SERVICE_ENDPOINT", "https://env-endpoint.com"); + + String result = service.getConfigString("api_endpoint", "https://default-endpoint.com"); + + assertEquals("https://config-endpoint.com", result, "Config should take precedence over env var"); + } + + @Test + void testGetConfigString_NullDefaultValue() { + // Should handle null default value gracefully + Map config = new HashMap<>(); + service.setConfig(config); + + String result = service.getConfigString("nonexistent_key", null); + + assertNull(result, "Should return null when default is null and no value found"); + } + + @Test + void testGetConfigString_ModelKey() { + // Test with a typical non-special key like "model" + Map config = new HashMap<>(); + config.put("model", "gemini-2.0-flash"); + service.setConfig(config); + + String result = service.getConfigString("model", "gemini-1.5-flash"); + + assertEquals("gemini-2.0-flash", result, "Should return config value for model key"); + } + + @Test + void testGetConfigString_NonStringConfigValue() { + // Test behavior when config contains non-String value + Map config = new HashMap<>(); + config.put("api_key", 12345); // Integer instead of String + service.setConfig(config); + + String result = service.getConfigString("api_key", "default-key"); + + assertEquals("default-key", result, "Should return default when config value is not a String"); + } + + /** + * Test subclass that exposes the protected getConfigString method. + */ + private static class TestableGeminiAIService extends GeminiAIService { + public TestableGeminiAIService() throws IOException { + super(); + } + + @Override + public String getConfigString(String key, String defaultValue) { + return super.getConfigString(key, defaultValue); + } + + public void setConfig(Map config) { + this.config = config; + } + } +} diff --git a/src/test/java/sa/com/cloudsolutions/antikythera/examples/GeminiAIServiceTest.java b/src/test/java/sa/com/cloudsolutions/antikythera/examples/GeminiAIServiceTest.java index ed7fb5c2..8f64b009 100644 --- a/src/test/java/sa/com/cloudsolutions/antikythera/examples/GeminiAIServiceTest.java +++ b/src/test/java/sa/com/cloudsolutions/antikythera/examples/GeminiAIServiceTest.java @@ -78,6 +78,7 @@ void setUp() throws IOException { config.put("timeout_seconds", 30); config.put("track_usage", true); config.put("cost_per_1k_tokens", 0.001); + config.put("initial_retry_count", 1); // Configure the service geminiAIService.configure(config); @@ -199,7 +200,7 @@ void testGetCacheEfficiency_NoTokens() { void testGetCacheEfficiency_WithTokens() throws Exception { // Use reflection to set token usage for testing TokenUsage tokenUsage = new TokenUsage(100, 50, 150, 0.15, 30); - java.lang.reflect.Field field = GeminiAIService.class.getDeclaredField("lastTokenUsage"); + java.lang.reflect.Field field = AbstractAIService.class.getDeclaredField("lastTokenUsage"); field.setAccessible(true); field.set(geminiAIService, tokenUsage); @@ -230,17 +231,7 @@ void testEscapeJsonString_Null() { assertEquals("", result); } - @ParameterizedTest - @CsvSource({ - "'Here is the JSON response:\n[{\"test\": \"value\"}]\nEnd of response.', '[{\"test\": \"value\"}]'", - "'```json\n[{\"test\": \"value\"}]\n```', '[{\"test\": \"value\"}]'", - "'No JSON here, just plain text.', ''" - }) - void testExtractJsonFromResponse(String input, String expected) { - String result = geminiAIService.extractJsonFromResponse(input); - assertEquals(expected, result); - } @Test void testParseRecommendations_InvalidJson() throws Exception { @@ -563,27 +554,7 @@ void testExtractTokenUsage_Gemini2_5_Pro_HighTier() throws IOException { assertEquals(2.25, tokenUsage.getEstimatedCost(), 0.000001); } - @Test - void testExtractJsonFromResponse_MultipleFormats() { - // Test with markdown code block - String codeBlockResponse = "```json\n[{\"test\": \"value\"}]\n```"; - String result1 = geminiAIService.extractJsonFromResponse(codeBlockResponse); - assertEquals("[{\"test\": \"value\"}]", result1); - - // Test with plain JSON - String plainResponse = "Here is the result: [{\"test\": \"value\"}] end"; - String result2 = geminiAIService.extractJsonFromResponse(plainResponse); - assertEquals("[{\"test\": \"value\"}]", result2); - - // Test with no JSON - String noJsonResponse = "No JSON content here"; - String result3 = geminiAIService.extractJsonFromResponse(noJsonResponse); - assertEquals("", result3); - - // Test with null input - String result4 = geminiAIService.extractJsonFromResponse(null); - assertNull(result4); - } + @Test void testExtractRecommendedColumnOrder_NestedClass() throws Exception { diff --git a/src/test/java/sa/com/cloudsolutions/antikythera/examples/OpenAIServiceTest.java b/src/test/java/sa/com/cloudsolutions/antikythera/examples/OpenAIServiceTest.java new file mode 100644 index 00000000..e333b9fc --- /dev/null +++ b/src/test/java/sa/com/cloudsolutions/antikythera/examples/OpenAIServiceTest.java @@ -0,0 +1,541 @@ +package sa.com.cloudsolutions.antikythera.examples; + +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.MethodDeclaration; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statement; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; +import sa.com.cloudsolutions.antikythera.configuration.Settings; +import sa.com.cloudsolutions.antikythera.evaluator.AntikytheraRunTime; +import sa.com.cloudsolutions.antikythera.generator.RepositoryQuery; +import sa.com.cloudsolutions.antikythera.parser.AbstractCompiler; +import sa.com.cloudsolutions.antikythera.parser.BaseRepositoryParser; +import sa.com.cloudsolutions.antikythera.parser.Callable; +import sa.com.cloudsolutions.antikythera.parser.converter.EntityMappingResolver; + +import java.io.File; +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.time.Duration; +import java.util.*; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Comprehensive tests for OpenAIService with mocked HTTP calls. + * Adapted from GeminiAIServiceTest to ensure consistent test coverage. + */ +class OpenAIServiceTest { + public static final String USER_REPO = "sa.com.cloudsolutions.antikythera.testhelper.repository.UserRepository"; + + private OpenAIService openAIService; + private Map config; + private static BaseRepositoryParser bp; + + @Mock + private HttpClient mockHttpClient; + + @Mock + private HttpResponse mockHttpResponse; + + @BeforeAll + static void setUpAll() throws IOException { + Settings.loadConfigMap(new File("src/test/resources/generator.yml")); + AbstractCompiler.reset(); + AbstractCompiler.preProcess(); + EntityMappingResolver.reset(); + EntityMappingResolver.build(); + CardinalityAnalyzer.setIndexMap(new HashMap<>()); + + bp = BaseRepositoryParser.create( + AntikytheraRunTime.getCompilationUnit(USER_REPO)); + bp.processTypes(); + bp.buildQueries(); + } + + @BeforeEach + void setUp() throws IOException { + MockitoAnnotations.openMocks(this); + + openAIService = new OpenAIService(); + + // Create a test configuration + config = new HashMap<>(); + config.put("api_key", "test-api-key"); + config.put("timeout_seconds", 30); + config.put("track_usage", true); + config.put("cost_per_1k_tokens", 0.001); + config.put("initial_retry_count", 1); + + // Configure the service + openAIService.configure(config); + } + + @Test + void testConstructor() throws IOException { + OpenAIService service = new OpenAIService(); + assertNotNull(service); + assertNotNull(service.getLastTokenUsage()); + assertEquals(0, service.getLastTokenUsage().getTotalTokens()); + } + + @Test + void testConfigure() throws IOException { + Map testConfig = new HashMap<>(); + testConfig.put("api_key", "test-key"); + testConfig.put("timeout_seconds", 60); + + OpenAIService service = new OpenAIService(); + assertDoesNotThrow(() -> service.configure(testConfig)); + } + + @Test + void testAnalyzeQueryBatch_SuccessfulResponse() throws Exception { + // Setup mock HTTP client and response + try (MockedStatic httpClientMock = mockStatic(HttpClient.class)) { + HttpClient.Builder mockBuilder = mock(HttpClient.Builder.class); + when(mockBuilder.connectTimeout(any())).thenReturn(mockBuilder); + when(mockBuilder.build()).thenReturn(mockHttpClient); + httpClientMock.when(HttpClient::newBuilder).thenReturn(mockBuilder); + + // Create a new service instance to use the mocked HttpClient + OpenAIService testService = new OpenAIService(); + testService.configure(config); + + // Mock successful HTTP response with OpenAI format + String mockResponseBody = """ + { + "choices": [ + { + "message": { + "role": "assistant", + "content": "[{\\"optimizedCodeElement\\": \\"User findByUsername(String username);\\", \\"notes\\": \\"Reordered parameters for better performance\\"}]" + } + } + ], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150, + "prompt_tokens_details": { + "cached_tokens": 20 + } + } + } + """; + + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn(mockResponseBody); + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockHttpResponse); + + // Create test batch + QueryBatch batch = createTestQueryBatch(); + + List result = testService.analyzeQueryBatch(batch); + + assertNotNull(result); + assertEquals(1, result.size()); + + // Verify token usage was extracted + TokenUsage tokenUsage = testService.getLastTokenUsage(); + assertEquals(100, tokenUsage.getInputTokens()); + assertEquals(50, tokenUsage.getOutputTokens()); + assertEquals(150, tokenUsage.getTotalTokens()); + assertEquals(20, tokenUsage.getCachedContentTokenCount()); + } + } + + @Test + void testAnalyzeQueryBatch_HttpError() throws Exception { + try (MockedStatic httpClientMock = mockStatic(HttpClient.class)) { + HttpClient.Builder mockBuilder = mock(HttpClient.Builder.class); + when(mockBuilder.connectTimeout(any())).thenReturn(mockBuilder); + when(mockBuilder.build()).thenReturn(mockHttpClient); + httpClientMock.when(HttpClient::newBuilder).thenReturn(mockBuilder); + + OpenAIService testService = new OpenAIService(); + testService.configure(config); + + // Mock HTTP error response + when(mockHttpResponse.statusCode()).thenReturn(400); + when(mockHttpResponse.body()).thenReturn("Bad Request"); + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockHttpResponse); + + QueryBatch batch = createTestQueryBatch(); + + IOException exception = assertThrows(IOException.class, + () -> testService.analyzeQueryBatch(batch)); + + assertTrue(exception.getMessage().contains("API request failed")); + assertTrue(exception.getMessage().contains("400")); + } + } + + @Test + void testGetLastTokenUsage() { + TokenUsage tokenUsage = openAIService.getLastTokenUsage(); + assertNotNull(tokenUsage); + assertEquals(0, tokenUsage.getTotalTokens()); + } + + + + + + @Test + void testAnalyzeQueryBatch_TimeoutRetrySuccess() throws Exception { + try (MockedStatic httpClientMock = mockStatic(HttpClient.class)) { + HttpClient.Builder mockBuilder = mock(HttpClient.Builder.class); + when(mockBuilder.connectTimeout(any())).thenReturn(mockBuilder); + when(mockBuilder.build()).thenReturn(mockHttpClient); + httpClientMock.when(HttpClient::newBuilder).thenReturn(mockBuilder); + + OpenAIService testService = new OpenAIService(); + testService.configure(config); + + // Mock timeout on first call, success on second + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new HttpTimeoutException("Timeout")) + .thenReturn(mockHttpResponse); + + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn("{\"choices\": []}"); + + QueryBatch batch = createTestQueryBatch(); + + List result = testService.analyzeQueryBatch(batch); + + assertNotNull(result); + // Verify send was called twice + verify(mockHttpClient, times(2)).send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)); + + // Verify the second request had increased timeout + verify(mockHttpClient).send(argThat(request -> + request.timeout().isPresent() && request.timeout().get().equals(Duration.ofSeconds(60))), + any(HttpResponse.BodyHandler.class)); + } + } + + @Test + void testAnalyzeQueryBatch_DoubleTimeoutFails() throws Exception { + try (MockedStatic httpClientMock = mockStatic(HttpClient.class)) { + HttpClient.Builder mockBuilder = mock(HttpClient.Builder.class); + when(mockBuilder.connectTimeout(any())).thenReturn(mockBuilder); + when(mockBuilder.build()).thenReturn(mockHttpClient); + httpClientMock.when(HttpClient::newBuilder).thenReturn(mockBuilder); + + OpenAIService testService = new OpenAIService(); + testService.configure(config); + + // Mock timeout on both calls + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new HttpTimeoutException("Timeout 1")) + .thenThrow(new HttpTimeoutException("Timeout 2")); + + QueryBatch batch = createTestQueryBatch(); + + assertThrows(HttpTimeoutException.class, () -> testService.analyzeQueryBatch(batch)); + + // Verify send was called exactly twice + verify(mockHttpClient, times(2)).send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)); + } + } + + private QueryBatch createTestQueryBatch() { + QueryBatch batch = new QueryBatch("UserRepository"); + MethodDeclaration md = bp.getCompilationUnit().findAll(MethodDeclaration.class).stream() + .filter(m -> m.getNameAsString().equals("findByUsername")) + .findFirst() + .orElseThrow(); + + RepositoryQuery mockQuery = bp.getQueryFromRepositoryMethod(new Callable(md, null)); + + batch.addQuery(mockQuery); + + // Add column cardinalities + Map cardinalities = new HashMap<>(); + cardinalities.put("username", CardinalityLevel.HIGH); + cardinalities.put("email", CardinalityLevel.MEDIUM); + cardinalities.put("age", CardinalityLevel.LOW); + batch.setColumnCardinalities(cardinalities); + + return batch; + } + + @Test + void testBuildTableSchemaString() { + QueryBatch batch = createTestQueryBatch(); + MethodDeclaration md = bp.getCompilationUnit().findAll(MethodDeclaration.class).stream() + .filter(m -> m.getNameAsString().equals("findByUsername")) + .findFirst() + .orElseThrow(); + + RepositoryQuery query = bp.getQueryFromRepositoryMethod(new Callable(md, null)); + + String result = openAIService.buildTableSchemaString(batch, query); + + assertNotNull(result); + // The table name should be "users" from the User entity @Table annotation + assertTrue(result.contains("users"), "Expected 'users' in: " + result); + assertTrue(result.contains("email:MEDIUM")); + assertTrue(result.contains("username:HIGH")); + } + + @Test + void testBuildTableSchemaString_NullTable() { + QueryBatch batch = new QueryBatch("TestRepository"); + Map cardinalities = new HashMap<>(); + cardinalities.put("id", CardinalityLevel.LOW); + batch.setColumnCardinalities(cardinalities); + + RepositoryQuery mockQuery = mock(RepositoryQuery.class); + when(mockQuery.getPrimaryTable()).thenReturn(null); + + String result = openAIService.buildTableSchemaString(batch, mockQuery); + + assertNotNull(result); + assertTrue(result.contains("UnknownTable")); + assertTrue(result.contains("id:LOW")); + } + + @Test + void testExtractTokenUsage_ValidResponse() throws IOException { + String responseBody = """ + { + "usage": { + "prompt_tokens": 1000000, + "completion_tokens": 1000000, + "total_tokens": 2000000, + "prompt_tokens_details": { + "cached_tokens": 0 + } + } + } + """; + + openAIService.extractTokenUsage(responseBody); + + TokenUsage tokenUsage = openAIService.getLastTokenUsage(); + assertEquals(1000000, tokenUsage.getInputTokens()); + assertEquals(1000000, tokenUsage.getOutputTokens()); + assertEquals(2000000, tokenUsage.getTotalTokens()); + // Default model is gpt-4o, pricing: $2.50/1M input, $10.00/1M output + assertEquals(2.50 + 10.00, tokenUsage.getEstimatedCost(), 0.000001); + } + + @Test + void testExtractTokenUsage_WithCaching() throws IOException { + String responseBody = """ + { + "usage": { + "prompt_tokens": 100000, + "completion_tokens": 100000, + "total_tokens": 200000, + "prompt_tokens_details": { + "cached_tokens": 50000 + } + } + } + """; + + openAIService.extractTokenUsage(responseBody); + + TokenUsage tokenUsage = openAIService.getLastTokenUsage(); + assertEquals(50000, tokenUsage.getCachedContentTokenCount()); + // Default model is gpt-4o, pricing: $2.50/1M input, cache ratio 0.25 + // inputCost = (50000/1M)*2.50 + (50000/1M)*0.625 = 0.125 + 0.03125 = 0.15625 + // outputCost = (100000/1M)*10.00 = 1.0 + // total = 1.15625 + assertEquals(1.15625, tokenUsage.getEstimatedCost(), 0.000001); + } + + @Test + void testExtractTokenUsage_NoMetadata() throws IOException { + String responseBody = """ + { + "choices": [ + { + "message": { + "content": "response" + } + } + ] + } + """; + + openAIService.extractTokenUsage(responseBody); + + TokenUsage tokenUsage = openAIService.getLastTokenUsage(); + assertEquals(0, tokenUsage.getTotalTokens()); + } + + @Test + void testParseResponse_ValidResponse() throws Exception { + String responseBody = """ + { + "choices": [ + { + "message": { + "role": "assistant", + "content": "[{\\"optimizedCodeElement\\": \\"User findByUsername(String username);\\", \\"notes\\": \\"Reordered parameters\\"}]" + } + } + ] + } + """; + + QueryBatch batch = createTestQueryBatch(); + + List result = openAIService.parseResponse(responseBody, batch); + + assertNotNull(result); + assertEquals(1, result.size()); + } + + @Test + void testParseResponse_EmptyResponse() throws Exception { + String responseBody = """ + { + "choices": [] + } + """; + + QueryBatch batch = createTestQueryBatch(); + + List result = openAIService.parseResponse(responseBody, batch); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void testExtractTokenUsage_GPT4oMini() throws IOException { + openAIService.configure(Map.of("api_key", "test-key", "model", "gpt-4o-mini")); + String responseBody = """ + { + "usage": { + "prompt_tokens": 1000000, + "completion_tokens": 1000000, + "total_tokens": 2000000 + } + } + """; + openAIService.extractTokenUsage(responseBody); + TokenUsage tokenUsage = openAIService.getLastTokenUsage(); + // gpt-4o-mini: $0.150/1M input, $0.600/1M output + assertEquals(0.150 + 0.600, tokenUsage.getEstimatedCost(), 0.000001); + } + + @Test + void testExtractTokenUsage_GPT4Turbo() throws IOException { + openAIService.configure(Map.of("api_key", "test-key", "model", "gpt-4-turbo")); + String responseBody = """ + { + "usage": { + "prompt_tokens": 1000000, + "completion_tokens": 1000000, + "total_tokens": 2000000 + } + } + """; + openAIService.extractTokenUsage(responseBody); + TokenUsage tokenUsage = openAIService.getLastTokenUsage(); + // gpt-4-turbo: $10.00/1M input, $30.00/1M output + assertEquals(10.00 + 30.00, tokenUsage.getEstimatedCost(), 0.000001); + } + + + + @Test + void testExtractRecommendedColumnOrder_NestedClass() throws Exception { + // Create a CompilationUnit with a nested class + String code = """ + package com.example; + public class Outer { + public interface InnerRepository { + User findByUsername(String username); + } + } + """; + CompilationUnit cu = StaticJavaParser.parse(code); + ClassOrInterfaceDeclaration inner = cu.findFirst(ClassOrInterfaceDeclaration.class, + c -> c.getNameAsString().equals("InnerRepository")).orElseThrow(); + MethodDeclaration md = inner.findFirst(MethodDeclaration.class).orElseThrow(); + + // Mock RepositoryQuery + RepositoryQuery mockQuery = mock(RepositoryQuery.class); + Callable callable = new Callable(md, null); + when(mockQuery.getMethodDeclaration()).thenReturn(callable); + Statement stmt = CCJSqlParserUtil.parse("SELECT * FROM users WHERE username = ?"); + when(mockQuery.getStatement()).thenReturn(stmt); + + String optimizedCode = "User findByUsername(String username);"; + + // We expect it to fallback to original if parsing fails, which is fine for this test + // as long as it doesn't throw because of the missing ancestor. + assertDoesNotThrow(() -> openAIService.extractRecommendedColumnOrder(optimizedCode, mockQuery)); + } + + @Test + void testExtractTokenUsage_MissingCachedTokensField() throws IOException { + // Test when prompt_tokens_details exists but cached_tokens is missing + String responseBody = """ + { + "usage": { + "prompt_tokens": 100000, + "completion_tokens": 50000, + "total_tokens": 150000, + "prompt_tokens_details": {} + } + } + """; + + openAIService.extractTokenUsage(responseBody); + + TokenUsage tokenUsage = openAIService.getLastTokenUsage(); + assertEquals(100000, tokenUsage.getInputTokens()); + assertEquals(50000, tokenUsage.getOutputTokens()); + assertEquals(150000, tokenUsage.getTotalTokens()); + assertEquals(0, tokenUsage.getCachedContentTokenCount()); + } + + @Test + void testExtractTokenUsage_MissingPromptTokensDetails() throws IOException { + // Test when prompt_tokens_details is completely missing + String responseBody = """ + { + "usage": { + "prompt_tokens": 100000, + "completion_tokens": 50000, + "total_tokens": 150000 + } + } + """; + + openAIService.extractTokenUsage(responseBody); + + TokenUsage tokenUsage = openAIService.getLastTokenUsage(); + assertEquals(100000, tokenUsage.getInputTokens()); + assertEquals(50000, tokenUsage.getOutputTokens()); + assertEquals(150000, tokenUsage.getTotalTokens()); + assertEquals(0, tokenUsage.getCachedContentTokenCount()); + } +} diff --git a/src/test/java/sa/com/cloudsolutions/antikythera/examples/QueryOptimizerArgumentReorderTest.java b/src/test/java/sa/com/cloudsolutions/antikythera/examples/QueryOptimizerArgumentReorderTest.java index a792a77c..a009a235 100644 --- a/src/test/java/sa/com/cloudsolutions/antikythera/examples/QueryOptimizerArgumentReorderTest.java +++ b/src/test/java/sa/com/cloudsolutions/antikythera/examples/QueryOptimizerArgumentReorderTest.java @@ -1,5 +1,7 @@ package sa.com.cloudsolutions.antikythera.examples; +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.CompilationUnit; import com.github.javaparser.ast.Modifier; import com.github.javaparser.ast.NodeList; import com.github.javaparser.ast.body.MethodDeclaration; @@ -8,10 +10,18 @@ import com.github.javaparser.ast.expr.MethodCallExpr; import com.github.javaparser.ast.expr.NameExpr; import com.github.javaparser.ast.type.ClassOrInterfaceType; +import liquibase.exception.LiquibaseException; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import sa.com.cloudsolutions.antikythera.configuration.Settings; import sa.com.cloudsolutions.antikythera.generator.RepositoryQuery; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Files; + import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; @@ -20,6 +30,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -309,4 +320,188 @@ void testBatchedNameChangeProcessor_DoReturnWhenPattern() { assertEquals("\"valY\"", call.getArgument(0).toString()); assertEquals("\"valX\"", call.getArgument(1).toString()); } + + @Test + void testReorderArguments_FallsBackToColumnOrderWhenNamesDiffer() { + // Reproduces a bug where old params have abbreviated names like "wallet"/"chain" + // but the AI-generated new params have "walletId"/"blockchainId". + // Type+name matching fails, so column order mapping must be used. + + // Old: existsByTxHashAndIsConfirmedAndWalletIdAndBlockchainIdAndUtxoCodeIn( + // Long txHash, boolean isConfirmed, Long wallet, Long chain, List utxoCodes) + MethodDeclaration oldMethod = new MethodDeclaration(); + oldMethod.addParameter(new Parameter(new ClassOrInterfaceType(null, "Long"), "txHash")); + oldMethod.addParameter(new Parameter(new ClassOrInterfaceType(null, "boolean"), "isConfirmed")); + oldMethod.addParameter(new Parameter(new ClassOrInterfaceType(null, "Long"), "wallet")); + oldMethod.addParameter(new Parameter(new ClassOrInterfaceType(null, "Long"), "chain")); + oldMethod.addParameter(new Parameter(new ClassOrInterfaceType(null, "List"), "utxoCodes")); + when(mockOldCallable.asMethodDeclaration()).thenReturn(oldMethod); + + // New: existsByTxHashAndBlockchainIdAndUtxoCodeInAndIsConfirmedAndWalletId( + // Long txHash, Long blockchainId, List utxoCodes, boolean isConfirmed, Long walletId) + MethodDeclaration newMethod = new MethodDeclaration(); + newMethod.addParameter(new Parameter(new ClassOrInterfaceType(null, "Long"), "txHash")); + newMethod.addParameter(new Parameter(new ClassOrInterfaceType(null, "Long"), "blockchainId")); + newMethod.addParameter(new Parameter(new ClassOrInterfaceType(null, "List"), "utxoCodes")); + newMethod.addParameter(new Parameter(new ClassOrInterfaceType(null, "boolean"), "isConfirmed")); + newMethod.addParameter(new Parameter(new ClassOrInterfaceType(null, "Long"), "walletId")); + when(mockNewCallable.asMethodDeclaration()).thenReturn(newMethod); + + // Column orders encode the positional permutation + when(mockIssue.currentColumnOrder()).thenReturn( + List.of("tx_hash", "is_confirmed", "wallet_id", "blockchain_id", "utxo_code")); + when(mockIssue.recommendedColumnOrder()).thenReturn( + List.of("tx_hash", "blockchain_id", "utxo_code", "is_confirmed", "wallet_id")); + + // Call: existsBy...(txHash, false, walletId, chainId, spentUtxoCodes) + MethodCallExpr call = new MethodCallExpr(); + call.addArgument(new NameExpr("txHash")); + call.addArgument(new NameExpr("false")); + call.addArgument(new NameExpr("walletId")); + call.addArgument(new NameExpr("chainId")); + call.addArgument(new NameExpr("spentUtxoCodes")); + + boolean result = QueryOptimizer.reorderMethodArguments(call, mockIssue); + + assertTrue(result, "Should succeed using column order fallback"); + assertEquals(5, call.getArguments().size()); + // Expected: txHash, chainId, spentUtxoCodes, false, walletId + assertEquals("txHash", call.getArgument(0).toString()); + assertEquals("chainId", call.getArgument(1).toString()); + assertEquals("spentUtxoCodes", call.getArgument(2).toString()); + assertEquals("false", call.getArgument(3).toString()); + assertEquals("walletId", call.getArgument(4).toString()); + } + + @Test + void testReorderArguments_ReturnsFalseWhenNoMappingPossible() { + // When both type+name and column order mapping fail, should return false + MethodDeclaration oldMethod = new MethodDeclaration(); + oldMethod.addParameter(new Parameter(new ClassOrInterfaceType(null, "Long"), "a")); + oldMethod.addParameter(new Parameter(new ClassOrInterfaceType(null, "Long"), "b")); + when(mockOldCallable.asMethodDeclaration()).thenReturn(oldMethod); + + MethodDeclaration newMethod = new MethodDeclaration(); + newMethod.addParameter(new Parameter(new ClassOrInterfaceType(null, "Long"), "x")); + newMethod.addParameter(new Parameter(new ClassOrInterfaceType(null, "Long"), "y")); + when(mockNewCallable.asMethodDeclaration()).thenReturn(newMethod); + + // No column orders available + when(mockIssue.currentColumnOrder()).thenReturn(null); + when(mockIssue.recommendedColumnOrder()).thenReturn(null); + + MethodCallExpr call = new MethodCallExpr(); + call.addArgument(new NameExpr("val1")); + call.addArgument(new NameExpr("val2")); + + boolean result = QueryOptimizer.reorderMethodArguments(call, mockIssue); + assertFalse(result, "Should fail when no mapping can be built"); + // Arguments should remain unchanged + assertEquals("val1", call.getArgument(0).toString()); + assertEquals("val2", call.getArgument(1).toString()); + } + + // --------------------------------------------------------------- + // formatQueryForTextBlock tests + // --------------------------------------------------------------- + + @Test + void testFormatQueryForTextBlock_ShortQuery() { + String query = "SELECT u FROM User u WHERE u.id = ?1"; + String result = QueryOptimizer.formatQueryForTextBlock(query, 80, " "); + assertEquals(query, result, "Short query should be returned unchanged"); + } + + @Test + void testFormatQueryForTextBlock_NullQuery() { + assertNull(QueryOptimizer.formatQueryForTextBlock(null, 80, " ")); + } + + @Test + void testFormatQueryForTextBlock_BreaksAtWhitespace() { + String query = "SELECT u FROM User u WHERE u.username = ?1 AND u.age = ?2 AND u.firstName = ?3 AND u.lastName = ?4"; + String result = QueryOptimizer.formatQueryForTextBlock(query, 40, " "); + // Should contain newlines since it's longer than 40 chars + assertTrue(result.contains("\n"), "Long query should be broken into multiple lines"); + // Each line (ignoring indent) should not exceed target width significantly + String[] lines = result.split("\n"); + assertTrue(lines.length > 1, "Should have multiple lines"); + } + + @Test + void testFormatQueryForTextBlock_NoWhitespace() { + // A very long word with no spaces — can't break at whitespace + String query = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + String result = QueryOptimizer.formatQueryForTextBlock(query, 40, " "); + // Should return the whole string since there's no whitespace to break at + assertEquals(query, result); + } + + // --------------------------------------------------------------- + // updateAnnotationValue tests + // --------------------------------------------------------------- + + @Test + void testUpdateAnnotationValue_SingleMember() throws Exception { + File settingsFile = new File("src/test/resources/generator.yml"); + if (settingsFile.exists()) { + Settings.loadConfigMap(settingsFile); + } + + File tempFile = Files.createTempFile("db-changelog", ".xml").toFile(); + try (FileWriter fw = new FileWriter(tempFile)) { + fw.write("\n"); + fw.write(""); + fw.write(""); + } + tempFile.deleteOnExit(); + + OptimizationStatsLogger.initialize("test"); + QueryOptimizer optimizer = new QueryOptimizer(tempFile); + CompilationUnit cu = StaticJavaParser.parse( + "import org.springframework.data.jpa.repository.Query;\n" + + "interface Repo { @Query(\"SELECT u FROM User u\") void findAll(); }"); + MethodDeclaration method = cu.findFirst(MethodDeclaration.class).orElseThrow(); + + optimizer.updateAnnotationValue(method, "Query", "SELECT u FROM User u WHERE u.active = true", false); + + String annotation = method.getAnnotationByName("Query").orElseThrow().toString(); + assertTrue(annotation.contains("u.active = true"), + "Annotation should contain updated query: " + annotation); + } + + @Test + void testUpdateAnnotationValue_NormalAnnotation() throws Exception { + File settingsFile = new File("src/test/resources/generator.yml"); + if (settingsFile.exists()) { + Settings.loadConfigMap(settingsFile); + } + + File tempFile = Files.createTempFile("db-changelog", ".xml").toFile(); + try (FileWriter fw = new FileWriter(tempFile)) { + fw.write("\n"); + fw.write(""); + fw.write(""); + } + tempFile.deleteOnExit(); + + OptimizationStatsLogger.initialize("test"); + QueryOptimizer optimizer = new QueryOptimizer(tempFile); + CompilationUnit cu = StaticJavaParser.parse( + "import org.springframework.data.jpa.repository.Query;\n" + + "interface Repo { @Query(value = \"SELECT u FROM User u\") void findAll(); }"); + MethodDeclaration method = cu.findFirst(MethodDeclaration.class).orElseThrow(); + + optimizer.updateAnnotationValue(method, "Query", "SELECT u FROM User u ORDER BY u.id", false); + + String annotation = method.getAnnotationByName("Query").orElseThrow().toString(); + assertTrue(annotation.contains("ORDER BY u.id"), + "Normal annotation should contain updated query: " + annotation); + } } diff --git a/src/test/java/sa/com/cloudsolutions/antikythera/examples/QueryOptimizerIntegrationTest.java b/src/test/java/sa/com/cloudsolutions/antikythera/examples/QueryOptimizerIntegrationTest.java new file mode 100644 index 00000000..8422e60d --- /dev/null +++ b/src/test/java/sa/com/cloudsolutions/antikythera/examples/QueryOptimizerIntegrationTest.java @@ -0,0 +1,370 @@ +package sa.com.cloudsolutions.antikythera.examples; + +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.MethodDeclaration; +import com.github.javaparser.ast.body.Parameter; +import com.github.javaparser.ast.expr.MethodCallExpr; +import com.github.javaparser.ast.type.ClassOrInterfaceType; +import liquibase.exception.LiquibaseException; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import sa.com.cloudsolutions.antikythera.configuration.Settings; +import sa.com.cloudsolutions.antikythera.evaluator.AntikytheraRunTime; +import sa.com.cloudsolutions.antikythera.generator.RepositoryQuery; +import sa.com.cloudsolutions.antikythera.parser.AbstractCompiler; +import sa.com.cloudsolutions.antikythera.parser.Callable; +import sa.com.cloudsolutions.antikythera.parser.converter.EntityMappingResolver; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Integration tests for QueryOptimizer that exercise the real Antikythera + * framework pipeline against the antikythera-test-helper testbed. + * + * These tests parse real repository interfaces and service classes, build + * real dependency graphs, and verify that method renames and argument + * reordering work end-to-end. + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class QueryOptimizerIntegrationTest { + + private static final String USER_REPO_FQN = + "sa.com.cloudsolutions.antikythera.testhelper.repository.UserRepository"; + private static final String USER_SERVICE_FQN = + "sa.com.cloudsolutions.antikythera.testhelper.service.UserService"; + + private static File tempLiquibaseFile; + + @BeforeAll + static void setUpAll() throws Exception { + Settings.loadConfigMap(new File("src/test/resources/generator.yml")); + AbstractCompiler.reset(); + AntikytheraRunTime.resetAll(); + AbstractCompiler.preProcess(); + + tempLiquibaseFile = Files.createTempFile("db-changelog", ".xml").toFile(); + try (FileWriter fw = new FileWriter(tempLiquibaseFile)) { + fw.write("\n"); + fw.write("\n"); + fw.write(""); + } + tempLiquibaseFile.deleteOnExit(); + } + + @BeforeEach + void setUp() { + EntityMappingResolver.reset(); + Fields.clearFieldDependencies(); + CardinalityAnalyzer.setIndexMap(new HashMap<>()); + OptimizationStatsLogger.initialize("test"); + } + + // --------------------------------------------------------------- + // 1. Field dependency resolution + // --------------------------------------------------------------- + + @Test + @Order(1) + void testFieldDependenciesResolvedForUserRepository() { + EntityMappingResolver.build(); + Fields.buildDependencies(); + Map> deps = Fields.getFieldDependencies(USER_REPO_FQN); + assertNotNull(deps, "UserRepository should have dependent classes"); + + Set fieldNames = deps.get(USER_SERVICE_FQN); + assertNotNull(fieldNames, "UserService should depend on UserRepository"); + assertTrue(fieldNames.contains("userRepository"), + "Field name should be 'userRepository'"); + } + + // --------------------------------------------------------------- + // 2. Method call index + // --------------------------------------------------------------- + + @Test + @Order(2) + void testMethodCallIndexBuiltForUserRepository() { + EntityMappingResolver.build(); + Fields.buildDependencies(); + Set callers = + Fields.getMethodCallers(USER_REPO_FQN, "findByFirstNameAndLastName"); + assertFalse(callers.isEmpty(), + "findByFirstNameAndLastName should have callers in UserService"); + + boolean foundUserService = callers.stream() + .anyMatch(c -> c.callerClass().equals(USER_SERVICE_FQN) + && c.fieldName().equals("userRepository")); + assertTrue(foundUserService, "UserService.userRepository should be a caller"); + } + + @Test + @Order(3) + void testMethodCallIndexContainsDerivedQueryMethods() { + EntityMappingResolver.build(); + Fields.buildDependencies(); + Set indexedMethods = Fields.getIndexedMethodNames(USER_REPO_FQN); + assertNotNull(indexedMethods); + // UserService calls several UserRepository methods + assertTrue(indexedMethods.contains("findByUsername"), + "findByUsername should be indexed"); + assertTrue(indexedMethods.contains("findByFirstNameAndLastName"), + "findByFirstNameAndLastName should be indexed"); + assertTrue(indexedMethods.contains("findByUsernameAndAge"), + "findByUsernameAndAge should be indexed"); + } + + // --------------------------------------------------------------- + // 3. Batch update: renames callers and reorders args + // --------------------------------------------------------------- + + @Test + @Order(4) + void testBatchUpdateMethodSignatures_RenamesCallersAndReordersArgs() throws Exception { + QueryOptimizer optimizer = new QueryOptimizer(tempLiquibaseFile); + + // Build a synthetic MethodRename: findByFirstNameAndLastName → findByLastNameAndFirstName + MethodDeclaration oldMd = new MethodDeclaration(); + oldMd.setName("findByFirstNameAndLastName"); + oldMd.addParameter(new Parameter(new ClassOrInterfaceType(null, "String"), "firstName")); + oldMd.addParameter(new Parameter(new ClassOrInterfaceType(null, "String"), "lastName")); + + MethodDeclaration newMd = new MethodDeclaration(); + newMd.setName("findByLastNameAndFirstName"); + newMd.addParameter(new Parameter(new ClassOrInterfaceType(null, "String"), "lastName")); + newMd.addParameter(new Parameter(new ClassOrInterfaceType(null, "String"), "firstName")); + + Callable mockOldCallable = mock(Callable.class); + when(mockOldCallable.asMethodDeclaration()).thenReturn(oldMd); + when(mockOldCallable.getNameAsString()).thenReturn("findByFirstNameAndLastName"); + + Callable mockNewCallable = mock(Callable.class); + when(mockNewCallable.asMethodDeclaration()).thenReturn(newMd); + when(mockNewCallable.getNameAsString()).thenReturn("findByLastNameAndFirstName"); + + RepositoryQuery mockOriginal = mock(RepositoryQuery.class); + when(mockOriginal.getMethodDeclaration()).thenReturn(mockOldCallable); + when(mockOriginal.getMethodName()).thenReturn("findByFirstNameAndLastName"); + + RepositoryQuery mockOptimized = mock(RepositoryQuery.class); + when(mockOptimized.getMethodDeclaration()).thenReturn(mockNewCallable); + when(mockOptimized.getMethodName()).thenReturn("findByLastNameAndFirstName"); + + OptimizationIssue issue = new OptimizationIssue( + mockOriginal, List.of("first_name", "last_name"), + List.of("last_name", "first_name"), + "Reorder for index", "Swap columns", + mockOptimized); + + QueryAnalysisResult result = mock(QueryAnalysisResult.class); + when(result.getOptimizationIssue()).thenReturn(issue); + when(result.getMethodName()).thenReturn("findByFirstNameAndLastName"); + + QueryOptimizer.MethodRename rename = new QueryOptimizer.MethodRename( + "findByFirstNameAndLastName", "findByLastNameAndFirstName", result, issue); + + optimizer.batchUpdateMethodSignatures(List.of(rename), USER_REPO_FQN); + + // Verify: UserService's CompilationUnit should now have renamed method calls + CompilationUnit serviceCu = AntikytheraRunTime.getCompilationUnit(USER_SERVICE_FQN); + assertNotNull(serviceCu); + + List calls = serviceCu.findAll(MethodCallExpr.class); + long renamedCount = calls.stream() + .filter(c -> c.getNameAsString().equals("findByLastNameAndFirstName")) + .count(); + + // UserService has 3 calls to findByFirstNameAndLastName (in getUsersByName + processUsers) + assertTrue(renamedCount >= 3, + "Expected at least 3 renamed calls, found " + renamedCount); + + // Verify arguments were swapped: first call should have args in reversed order + MethodCallExpr firstRenamed = calls.stream() + .filter(c -> c.getNameAsString().equals("findByLastNameAndFirstName")) + .findFirst() + .orElseThrow(); + assertEquals(2, firstRenamed.getArguments().size()); + + // No calls should remain with the old name + long oldNameCount = calls.stream() + .filter(c -> c.getNameAsString().equals("findByFirstNameAndLastName")) + .count(); + assertEquals(0, oldNameCount, + "No calls should remain with old name findByFirstNameAndLastName"); + } + + // --------------------------------------------------------------- + // 4. Rollback on mapping failure + // --------------------------------------------------------------- + + @Test + @Order(5) + void testBatchUpdateMethodSignatures_RollsBackOnMappingFailure() throws Exception { + QueryOptimizer optimizer = new QueryOptimizer(tempLiquibaseFile); + + // Build a rename with mismatched param names AND no column orders + MethodDeclaration oldMd = new MethodDeclaration(); + oldMd.setName("findByAge"); + oldMd.addParameter(new Parameter(new ClassOrInterfaceType(null, "Integer"), "yearsOld")); + + MethodDeclaration newMd = new MethodDeclaration(); + newMd.setName("findByAgeRenamed"); + newMd.addParameter(new Parameter(new ClassOrInterfaceType(null, "Integer"), "differentName")); + + Callable mockOldCallable = mock(Callable.class); + when(mockOldCallable.asMethodDeclaration()).thenReturn(oldMd); + when(mockOldCallable.getNameAsString()).thenReturn("findByAge"); + + Callable mockNewCallable = mock(Callable.class); + when(mockNewCallable.asMethodDeclaration()).thenReturn(newMd); + when(mockNewCallable.getNameAsString()).thenReturn("findByAgeRenamed"); + + RepositoryQuery mockOriginal = mock(RepositoryQuery.class); + when(mockOriginal.getMethodDeclaration()).thenReturn(mockOldCallable); + when(mockOriginal.getMethodName()).thenReturn("findByAge"); + + RepositoryQuery mockOptimized = mock(RepositoryQuery.class); + when(mockOptimized.getMethodDeclaration()).thenReturn(mockNewCallable); + when(mockOptimized.getMethodName()).thenReturn("findByAgeRenamed"); + + // No column orders → fallback mapping will fail too + OptimizationIssue issue = new OptimizationIssue( + mockOriginal, null, null, + "Test", "N/A", mockOptimized); + + QueryAnalysisResult result = mock(QueryAnalysisResult.class); + when(result.getOptimizationIssue()).thenReturn(issue); + when(result.getMethodName()).thenReturn("findByAge"); + + QueryOptimizer.MethodRename rename = new QueryOptimizer.MethodRename( + "findByAge", "findByAgeRenamed", result, issue); + + optimizer.batchUpdateMethodSignatures(List.of(rename), USER_REPO_FQN); + + // Verify: UserService should still have findByAge (rollback) + CompilationUnit serviceCu = AntikytheraRunTime.getCompilationUnit(USER_SERVICE_FQN); + List calls = serviceCu.findAll(MethodCallExpr.class); + + long oldNameCount = calls.stream() + .filter(c -> c.getNameAsString().equals("findByAge")) + .count(); + assertTrue(oldNameCount > 0, + "findByAge calls should remain unchanged after rollback"); + + long newNameCount = calls.stream() + .filter(c -> c.getNameAsString().equals("findByAgeRenamed")) + .count(); + assertEquals(0, newNameCount, + "No calls should have been renamed to findByAgeRenamed"); + } + + // --------------------------------------------------------------- + // 5. actOnAnalysisResult updates @Query annotation + // --------------------------------------------------------------- + + @Test + @Order(6) + void testActOnAnalysisResult_UpdatesQueryAnnotation() throws Exception { + QueryOptimizer optimizer = new QueryOptimizer(tempLiquibaseFile); + + // Get the real UserRepository CompilationUnit + CompilationUnit repoCu = AntikytheraRunTime.getCompilationUnit(USER_REPO_FQN); + assertNotNull(repoCu); + + // Find the findByUsernameAndAgeWithQuery method (has @Query annotation) + MethodDeclaration targetMethod = repoCu.findAll(MethodDeclaration.class).stream() + .filter(m -> m.getNameAsString().equals("findByUsernameAndAgeWithQuery")) + .findFirst() + .orElseThrow(() -> new AssertionError("findByUsernameAndAgeWithQuery not found")); + + // Build mocks for the analysis result + Callable mockCallable = mock(Callable.class); + when(mockCallable.asMethodDeclaration()).thenReturn(targetMethod); + when(mockCallable.getNameAsString()).thenReturn("findByUsernameAndAgeWithQuery"); + + RepositoryQuery mockOriginal = mock(RepositoryQuery.class); + when(mockOriginal.getMethodDeclaration()).thenReturn(mockCallable); + when(mockOriginal.getMethodName()).thenReturn("findByUsernameAndAgeWithQuery"); + + // Create optimized query with a new query string + String optimizedSql = "SELECT u FROM User u WHERE u.age = ?2 AND u.username = ?1"; + RepositoryQuery mockOptimized = mock(RepositoryQuery.class); + when(mockOptimized.getMethodDeclaration()).thenReturn(mockCallable); + when(mockOptimized.getMethodName()).thenReturn("findByUsernameAndAgeWithQuery"); + when(mockOptimized.getOriginalQuery()).thenReturn(optimizedSql); + when(mockOptimized.getQueryType()).thenReturn( + sa.com.cloudsolutions.antikythera.generator.QueryType.HQL); + + OptimizationIssue issue = new OptimizationIssue( + mockOriginal, + List.of("username", "age"), + List.of("age", "username"), + "Reorder columns", "Swap for index", + mockOptimized); + + QueryAnalysisResult analysisResult = mock(QueryAnalysisResult.class); + when(analysisResult.getOptimizationIssue()).thenReturn(issue); + when(analysisResult.getMethodName()).thenReturn("findByUsernameAndAgeWithQuery"); + + // Act + optimizer.actOnAnalysisResult(analysisResult, new ArrayList<>()); + + // Verify the @Query annotation was updated + String updatedAnnotation = targetMethod.getAnnotationByName("Query") + .orElseThrow() + .toString(); + assertTrue(updatedAnnotation.contains("u.age"), + "Updated annotation should contain reordered query: " + updatedAnnotation); + } + + // --------------------------------------------------------------- + // 6. reorderMethodParameters on real parsed method + // --------------------------------------------------------------- + + @Test + @Order(7) + void testReorderMethodParameters_WorksOnParsedMethod() throws LiquibaseException, IOException { + QueryOptimizer optimizer = new QueryOptimizer(tempLiquibaseFile); + + // Parse a simple method declaration + CompilationUnit cu = StaticJavaParser.parse( + "interface Repo { void findByAAndB(String a, int b); }"); + MethodDeclaration method = cu.findFirst(MethodDeclaration.class).orElseThrow(); + + assertEquals("a", method.getParameter(0).getNameAsString()); + assertEquals("b", method.getParameter(1).getNameAsString()); + + // Create a new method with swapped parameters + MethodDeclaration newMethod = new MethodDeclaration(); + newMethod.addParameter(new Parameter(new ClassOrInterfaceType(null, "int"), "b")); + newMethod.addParameter(new Parameter(new ClassOrInterfaceType(null, "String"), "a")); + + optimizer.reorderMethodParameters(method, newMethod); + + // Verify parameters were reordered + assertEquals("b", method.getParameter(0).getNameAsString()); + assertEquals("int", method.getParameter(0).getTypeAsString()); + assertEquals("a", method.getParameter(1).getNameAsString()); + assertEquals("String", method.getParameter(1).getTypeAsString()); + } +} diff --git a/src/test/java/sa/com/cloudsolutions/antikythera/examples/QueryOptimizerTest.java b/src/test/java/sa/com/cloudsolutions/antikythera/examples/QueryOptimizerTest.java index 67571e8e..c34c7db6 100644 --- a/src/test/java/sa/com/cloudsolutions/antikythera/examples/QueryOptimizerTest.java +++ b/src/test/java/sa/com/cloudsolutions/antikythera/examples/QueryOptimizerTest.java @@ -65,21 +65,27 @@ void setUp() throws Exception { when(mockResult.getMethodName()).thenReturn("oldMethod"); when(mockOptimizedQuery.getMethodName()).thenReturn("newMethod"); - // Mock original method + // Mock original method (with one parameter to match test call sites) sa.com.cloudsolutions.antikythera.parser.Callable mockOldCallable = mock( sa.com.cloudsolutions.antikythera.parser.Callable.class); MethodDeclaration oldMd = new MethodDeclaration(); oldMd.setName("oldMethod"); + oldMd.addParameter("String", "arg1"); when(mockOldCallable.asMethodDeclaration()).thenReturn(oldMd); when(mockOriginalQuery.getMethodDeclaration()).thenReturn(mockOldCallable); - // Mock optimized method + // Mock optimized method (same single parameter — no reordering needed) sa.com.cloudsolutions.antikythera.parser.Callable mockNewCallable = mock( sa.com.cloudsolutions.antikythera.parser.Callable.class); MethodDeclaration newMd = new MethodDeclaration(); newMd.setName("newMethod"); + newMd.addParameter("String", "arg1"); when(mockNewCallable.asMethodDeclaration()).thenReturn(newMd); when(mockOptimizedQuery.getMethodDeclaration()).thenReturn(mockNewCallable); + + // Stub column orders so the fallback mapping can also succeed + when(mockIssue.currentColumnOrder()).thenReturn(List.of("arg1")); + when(mockIssue.recommendedColumnOrder()).thenReturn(List.of("arg1")); } @ParameterizedTest(name = "{0}")