From 8a3e1c6c94de1d0abe493caec5bb483f4f9dad83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A4=80=ED=99=98?= Date: Tue, 20 Jan 2026 23:52:28 +0900 Subject: [PATCH 1/3] Use Java text blocks for query conversion in Java 15+ Signed-off-by: Junhwan Kim --- ...RepositoryAotMetadataCodeLensProvider.java | 47 ++++++++++---- .../data/QueryMethodCodeActionProvider.java | 6 +- ...ryAotMetadataCodeLensProviderJdbcTest.java | 65 ++++++++++++++++++- ...oryAotMetadataCodeLensProviderJpaTest.java | 62 ++++++++++++++++++ ...otMetadataCodeLensProviderMongoDbTest.java | 62 ++++++++++++++++++ ...ryMethodCodeActionProviderMongoDbTest.java | 19 +++++- 6 files changed, 239 insertions(+), 22 deletions(-) diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryAotMetadataCodeLensProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryAotMetadataCodeLensProvider.java index 1ef8312394..15751c7d55 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryAotMetadataCodeLensProvider.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryAotMetadataCodeLensProvider.java @@ -156,7 +156,7 @@ private List createCodeLenses(IJavaProject project, MethodDeclaration || hierarchyAnnot.isAnnotatedWith(mb, Annotations.DATA_JDBC_QUERY); if (!isQueryAnnotated) { - codeLenses.add(new CodeLens(range, refactorings.createFixCommand(COVERT_TO_QUERY_LABEL, createFixDescriptor(mb, document.getUri(), metadata.module(), methodMetadata)), null)); + codeLenses.add(new CodeLens(range, refactorings.createFixCommand(COVERT_TO_QUERY_LABEL, createFixDescriptor(mb, document.getUri(), metadata.module(), methodMetadata, project)), null)); } Command impl = new Command("Go To Implementation", GenAotQueryMethodImplProvider.CMD_NAVIGATE_TO_IMPL, List.of(new GenAotQueryMethodImplProvider.GoToImplParams( @@ -197,28 +197,47 @@ private Optional createRefreshCodeLens(IJavaProject project, String ti }); } - static FixDescriptor createFixDescriptor(IMethodBinding mb, String docUri, DataRepositoryModule module, IDataRepositoryAotMethodMetadata methodMetadata) { + static FixDescriptor createFixDescriptor(IMethodBinding mb, String docUri, DataRepositoryModule module, IDataRepositoryAotMethodMetadata methodMetadata, IJavaProject project) { return new FixDescriptor(AddAnnotationOverMethod.class.getName(), List.of(docUri), "Turn into `@Query`") - .withRecipeScope(RecipeScope.FILE) - .withParameters(Map.of( "annotationType", moduleToQueryMapping.get(module), "method", "%s %s(%s)".formatted(mb.getDeclaringClass().getQualifiedName(), mb.getName(), Arrays.stream(mb.getParameterTypes()) - .map(pt -> pt.getQualifiedName()) - .collect(Collectors.joining(", "))), - "attributes", createAttributeList(methodMetadata.getAttributesMap()))); + .map(pt -> pt.getQualifiedName()) + .collect(Collectors.joining(", "))), + "attributes", createAttributeList(methodMetadata.getAttributesMap(), project))); } - - private static List createAttributeList(Map attributes) { + + private static List createAttributeList(Map attributes, IJavaProject project) { List result = new ArrayList<>(); - - Set keys = attributes.keySet(); - for (String key : keys) { - result.add(new AddAnnotationOverMethod.Attribute(key, "\"%s\"".formatted(StringEscapeUtils.escapeJava(attributes.get(key))))); + int javaVersion = 8; + try { + String versionStr = project.getClasspath().getJre().version(); + if (versionStr != null) { + if (versionStr.startsWith("1.")) { + javaVersion = Integer.parseInt(versionStr.substring(2, 3)); + } else { + javaVersion = Integer.parseInt(versionStr.split("\\.")[0]); + } + } + } catch (Exception e) { + // fallback to 8 + } + for (Map.Entry entry : attributes.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + if (value == null) continue; + String escaped = org.apache.commons.text.StringEscapeUtils.escapeJava(value); + boolean containsQuote = value.contains("\""); + if (javaVersion >= 15 && containsQuote) { + // Use text block + result.add(new AddAnnotationOverMethod.Attribute(key, "\"\"\"\n" + value + "\n\"\"\"")); + } else { + // Use standard string + result.add(new AddAnnotationOverMethod.Attribute(key, "\"%s\"".formatted(escaped))); + } } - return result; } diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryMethodCodeActionProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryMethodCodeActionProvider.java index f0e51cad8d..9287de0975 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryMethodCodeActionProvider.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryMethodCodeActionProvider.java @@ -80,7 +80,7 @@ public boolean visit(MethodDeclaration node) { DataRepositoryAotMetadataCodeLensProvider .getMetadata(repositoryMetadataService, project, binding) .ifPresent(metadata -> metadata.findMethod(binding) - .map(method -> createCodeAction(binding, docURI, metadata, method)) + .map(method -> createCodeAction(binding, docURI, metadata, method, project)) .ifPresent(collector::accept)); } } @@ -92,9 +92,9 @@ public boolean visit(MethodDeclaration node) { }; } - private CodeAction createCodeAction(IMethodBinding mb, URI docUri, DataRepositoryAotMetadata metadata, IDataRepositoryAotMethodMetadata method) { + private CodeAction createCodeAction(IMethodBinding mb, URI docUri, DataRepositoryAotMetadata metadata, IDataRepositoryAotMethodMetadata method, IJavaProject project) { CodeAction ca = new CodeAction(); - ca.setCommand(refactorings.createFixCommand(TITLE, DataRepositoryAotMetadataCodeLensProvider.createFixDescriptor(mb, docUri.toASCIIString(), metadata.module(), method))); + ca.setCommand(refactorings.createFixCommand(TITLE, DataRepositoryAotMetadataCodeLensProvider.createFixDescriptor(mb, docUri.toASCIIString(), metadata.module(), method, project))); ca.setTitle(TITLE); ca.setKind(CodeActionKind.Refactor); return ca; diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJdbcTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJdbcTest.java index 48cfeac86f..d005288cef 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJdbcTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJdbcTest.java @@ -10,8 +10,6 @@ *******************************************************************************/ package org.springframework.ide.vscode.boot.java.data.test; -import static org.junit.jupiter.api.Assertions.assertEquals; - import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -21,6 +19,9 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import org.eclipse.lsp4j.CodeLens; import org.eclipse.lsp4j.TextDocumentIdentifier; import org.junit.jupiter.api.BeforeEach; @@ -42,6 +43,8 @@ import com.google.gson.Gson; +import static org.junit.jupiter.api.Assertions.*; + @ExtendWith(SpringExtension.class) @BootLanguageServerTest @Import(SymbolProviderTestConf.class) @@ -94,4 +97,62 @@ void noCodeLensOverMethodWithQueryAnnotation() throws Exception { assertEquals("Refresh AOT Metadata", cls.get(1).getCommand().getTitle()); assertEquals(2, cls.get(1).getCommand().getArguments().size()); } + + /** + * Verify that text blocks are generated when the query string contains quotes on Java 15 or above. + */ + @Test + void turnIntoQueryUsesTextBlockWhenQuotesPresentAndJava15OrAbove() throws Exception { + Path filePath = Paths.get(testProject.getLocationUri()) + .resolve("src/main/java/example/springdata/aot/CategoryRepository.java"); + + Editor editor = harness.newEditor( + LanguageId.JAVA, + new String(Files.readAllBytes(filePath), StandardCharsets.UTF_8), + filePath.toUri().toASCIIString() + ); + + List cls = editor.getCodeLenses("findAllByNameContaining", 1); + + String queryValue = extractValueFromAttributes(cls.get(0)); + assertNotNull(queryValue, "Query value should not be null"); + + int javaVersion = 8; + String versionStr = testProject.getClasspath().getJre().version(); + if (versionStr.startsWith("1.")) { + javaVersion = Integer.parseInt(versionStr.substring(2, 3)); + } else { + javaVersion = Integer.parseInt(versionStr.split("\\.")[0]); + } + + // JDBC query has quotes: SELECT "CATEGORY"."ID" ... + if (javaVersion >= 15) { + assertTrue(queryValue.startsWith("\"\"\""), "Text block must be used for Java >= 15 when quotes are present"); + } else { + assertFalse(queryValue.startsWith("\"\"\""), "Text block must NOT be used for Java < 15"); + } + } + + + private String extractValueFromAttributes(CodeLens codeLens) { + Object args = codeLens.getCommand().getArguments().get(1); + if (args instanceof JsonObject) { + JsonObject params = (JsonObject) args; + if (params.has("parameters") && params.get("parameters").isJsonObject()) { + JsonObject parameters = params.getAsJsonObject("parameters"); + if (parameters.has("attributes") && parameters.get("attributes").isJsonArray()) { + JsonArray attributes = parameters.getAsJsonArray("attributes"); + for (JsonElement element : attributes) { + if (element.isJsonObject()) { + JsonObject attr = element.getAsJsonObject(); + if (attr.has("name") && "value".equals(attr.get("name").getAsString())) { + return attr.get("value").getAsString(); + } + } + } + } + } + } + return null; + } } diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJpaTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJpaTest.java index 6f978dbedc..846b5140bb 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJpaTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJpaTest.java @@ -11,6 +11,9 @@ package org.springframework.ide.vscode.boot.java.data.test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -94,4 +97,63 @@ void noCodeLensOverMethodWithQueryAnnotation() throws Exception { assertEquals("Refresh AOT Metadata", cls.get(1).getCommand().getTitle()); assertEquals(2, cls.get(1).getCommand().getArguments().size()); } + + /** + * Verify that text blocks are generated when the query string contains quotes on Java 15 or above. + */ + @Test + void turnIntoQueryUsesTextBlockWhenQuotesPresentAndJava15OrAbove() throws Exception { + Path filePath = Paths.get(testProject.getLocationUri()) + .resolve("src/main/java/example/springdata/aot/UserRepository.java"); + + Editor editor = harness.newEditor( + LanguageId.JAVA, + new String(Files.readAllBytes(filePath), StandardCharsets.UTF_8), + filePath.toUri().toASCIIString() + ); + + List cls = editor.getCodeLenses("findUserByUsername", 1); + String queryValue = extractValueFromAttributes(cls.get(0)); + assertNotNull(queryValue, "Query value should not be null"); + System.out.println("Extracted query value: " + queryValue); + + // JPA findUserByUsername query (SELECT u FROM users u ...) does not contain quotes. + boolean containsQuote = false; + + int javaVersion = 8; + String versionStr = testProject.getClasspath().getJre().version(); + if (versionStr.startsWith("1.")) { + javaVersion = Integer.parseInt(versionStr.substring(2, 3)); + } else { + javaVersion = Integer.parseInt(versionStr.split("\\.")[0]); + } + + if (javaVersion >= 15 && containsQuote) { + assertTrue(queryValue.startsWith("\"\"\""), "Text block must be used for Java >= 15 when quotes are present"); + } else { + assertFalse(queryValue.startsWith("\"\"\""), "Text block must NOT be used if Java < 15 or if quotes are missing"); + } + } + + private String extractValueFromAttributes(CodeLens codeLens) { + Object args = codeLens.getCommand().getArguments().get(1); + if (args instanceof com.google.gson.JsonObject) { + com.google.gson.JsonObject params = (com.google.gson.JsonObject) args; + if (params.has("parameters") && params.get("parameters").isJsonObject()) { + com.google.gson.JsonObject parameters = params.getAsJsonObject("parameters"); + if (parameters.has("attributes") && parameters.get("attributes").isJsonArray()) { + com.google.gson.JsonArray attributes = parameters.getAsJsonArray("attributes"); + for (com.google.gson.JsonElement element : attributes) { + if (element.isJsonObject()) { + com.google.gson.JsonObject attr = element.getAsJsonObject(); + if (attr.has("name") && "value".equals(attr.get("name").getAsString())) { + return attr.get("value").getAsString(); + } + } + } + } + } + } + return null; + } } diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderMongoDbTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderMongoDbTest.java index 11a0847264..ee5e277717 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderMongoDbTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderMongoDbTest.java @@ -11,6 +11,9 @@ package org.springframework.ide.vscode.boot.java.data.test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -106,4 +109,63 @@ void noCodeLensOverMethodWithQueryAnnotation() throws Exception { assertEquals("Refresh AOT Metadata", cls.get(1).getCommand().getTitle()); assertEquals(2, cls.get(1).getCommand().getArguments().size()); } + + /** + * Verify that text blocks are generated when the query string contains quotes on Java 15 or above. + */ + @Test + void turnIntoQueryUsesTextBlockWhenQuotesPresentAndJava15OrAbove() throws Exception { + Path filePath = Paths.get(testProject.getLocationUri()) + .resolve("src/main/java/example/springdata/aot/UserRepository.java"); + + Editor editor = harness.newEditor( + LanguageId.JAVA, + new String(Files.readAllBytes(filePath), StandardCharsets.UTF_8), + filePath.toUri().toASCIIString() + ); + + List cls = editor.getCodeLenses("findUserByUsername", 1); + String queryValue = extractValueFromAttributes(cls.get(0)); + + System.out.println("Extracted query value: " + queryValue); + + assertNotNull(queryValue, "Query value should not be null"); + + int javaVersion = 8; + String versionStr = testProject.getClasspath().getJre().version(); + if (versionStr.startsWith("1.")) { + javaVersion = Integer.parseInt(versionStr.substring(2, 3)); + } else { + javaVersion = Integer.parseInt(versionStr.split("\\.")[0]); + } + + // MongoDB findUserByUsername query {"username": ?0} contains quotes. + if (javaVersion >= 15) { + assertTrue(queryValue.startsWith("\"\"\""), "Text block must be used for Java >= 15 when quotes are present"); + } else { + assertFalse(queryValue.startsWith("\"\"\""), "Text block must NOT be used if Java < 15"); + } + } + + private String extractValueFromAttributes(CodeLens codeLens) { + Object args = codeLens.getCommand().getArguments().get(1); + if (args instanceof com.google.gson.JsonObject) { + com.google.gson.JsonObject params = (com.google.gson.JsonObject) args; + if (params.has("parameters") && params.get("parameters").isJsonObject()) { + com.google.gson.JsonObject parameters = params.getAsJsonObject("parameters"); + if (parameters.has("attributes") && parameters.get("attributes").isJsonArray()) { + com.google.gson.JsonArray attributes = parameters.getAsJsonArray("attributes"); + for (com.google.gson.JsonElement element : attributes) { + if (element.isJsonObject()) { + com.google.gson.JsonObject attr = element.getAsJsonObject(); + if (attr.has("name") && "value".equals(attr.get("name").getAsString())) { + return attr.get("value").getAsString(); + } + } + } + } + } + } + return null; + } } diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderMongoDbTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderMongoDbTest.java index 4cf49b1fcb..316b3935e4 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderMongoDbTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderMongoDbTest.java @@ -84,9 +84,22 @@ void convertToQueryCodeAction() throws Exception { assertEquals(RewriteRefactorings.REWRITE_RECIPE_QUICKFIX, cmd.getArguments().get(0)); WorkspaceEdit edit = refactorings.createEdit((JsonElement) cmd.getArguments().get(1)).get(5, TimeUnit.SECONDS); TextDocumentEdit docEdit = edit.getDocumentChanges().get(0).getLeft(); - assertEquals( - "@Query(\"{\\\"lastname\\\": /^\\\\Q?0\\\\E/}\")", - docEdit.getEdits().get(0).getNewText().trim()); + String rawNewText = docEdit.getEdits().get(0).getNewText().trim(); + + if (rawNewText.startsWith("@Query(\"\"\"")) { + // Java 15+ behavior (Text Block) + assertEquals( + "@Query(\"\"\"\n" + + "{\"lastname\": /^\\Q?0\\E/}\n" + + "\"\"\")\n" + + " Page findUserByLastnameStartingWith(String lastname, Pageable page)", + rawNewText.replace("\r\n", "\n")); + } else { + // Java <15 behavior (String literal) + assertEquals( + "@Query(\"{\\\"lastname\\\": /^\\\\Q?0\\\\E/}\")", + rawNewText); + } assertEquals(filePath.toUri().toASCIIString(), docEdit.getTextDocument().getUri()); } From 6b62d907ec298e2fe97255ad6631c914fdac9a93 Mon Sep 17 00:00:00 2001 From: Junhwan Kim Date: Wed, 21 Jan 2026 16:04:22 +0900 Subject: [PATCH 2/3] refactor use Java text blocks when generating Query annotations Signed-off-by: Junhwan Kim --- ...RepositoryAotMetadataCodeLensProvider.java | 33 ++-------- .../data/QueryMethodCodeActionProvider.java | 6 +- ...ryAotMetadataCodeLensProviderJdbcTest.java | 20 +----- ...oryAotMetadataCodeLensProviderJpaTest.java | 61 ------------------- ...otMetadataCodeLensProviderMongoDbTest.java | 61 ------------------- .../QueryMethodCodeActionProviderJpaTest.java | 6 +- ...ryMethodCodeActionProviderMongoDbTest.java | 24 +++----- 7 files changed, 23 insertions(+), 188 deletions(-) diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryAotMetadataCodeLensProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryAotMetadataCodeLensProvider.java index 15751c7d55..65c5a1a2bc 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryAotMetadataCodeLensProvider.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryAotMetadataCodeLensProvider.java @@ -18,7 +18,6 @@ import java.util.Set; import java.util.stream.Collectors; -import org.apache.commons.text.StringEscapeUtils; import org.eclipse.jdt.core.dom.ASTVisitor; import org.eclipse.jdt.core.dom.CompilationUnit; import org.eclipse.jdt.core.dom.IMethodBinding; @@ -156,7 +155,7 @@ private List createCodeLenses(IJavaProject project, MethodDeclaration || hierarchyAnnot.isAnnotatedWith(mb, Annotations.DATA_JDBC_QUERY); if (!isQueryAnnotated) { - codeLenses.add(new CodeLens(range, refactorings.createFixCommand(COVERT_TO_QUERY_LABEL, createFixDescriptor(mb, document.getUri(), metadata.module(), methodMetadata, project)), null)); + codeLenses.add(new CodeLens(range, refactorings.createFixCommand(COVERT_TO_QUERY_LABEL, createFixDescriptor(mb, document.getUri(), metadata.module(), methodMetadata)), null)); } Command impl = new Command("Go To Implementation", GenAotQueryMethodImplProvider.CMD_NAVIGATE_TO_IMPL, List.of(new GenAotQueryMethodImplProvider.GoToImplParams( @@ -197,7 +196,7 @@ private Optional createRefreshCodeLens(IJavaProject project, String ti }); } - static FixDescriptor createFixDescriptor(IMethodBinding mb, String docUri, DataRepositoryModule module, IDataRepositoryAotMethodMetadata methodMetadata, IJavaProject project) { + static FixDescriptor createFixDescriptor(IMethodBinding mb, String docUri, DataRepositoryModule module, IDataRepositoryAotMethodMetadata methodMetadata) { return new FixDescriptor(AddAnnotationOverMethod.class.getName(), List.of(docUri), "Turn into `@Query`") .withRecipeScope(RecipeScope.FILE) .withParameters(Map.of( @@ -206,37 +205,17 @@ static FixDescriptor createFixDescriptor(IMethodBinding mb, String docUri, DataR Arrays.stream(mb.getParameterTypes()) .map(pt -> pt.getQualifiedName()) .collect(Collectors.joining(", "))), - "attributes", createAttributeList(methodMetadata.getAttributesMap(), project))); + "attributes", createAttributeList(methodMetadata.getAttributesMap()))); } - private static List createAttributeList(Map attributes, IJavaProject project) { + private static List createAttributeList(Map attributes) { List result = new ArrayList<>(); - int javaVersion = 8; - try { - String versionStr = project.getClasspath().getJre().version(); - if (versionStr != null) { - if (versionStr.startsWith("1.")) { - javaVersion = Integer.parseInt(versionStr.substring(2, 3)); - } else { - javaVersion = Integer.parseInt(versionStr.split("\\.")[0]); - } - } - } catch (Exception e) { - // fallback to 8 - } for (Map.Entry entry : attributes.entrySet()) { String key = entry.getKey(); String value = entry.getValue(); if (value == null) continue; - String escaped = org.apache.commons.text.StringEscapeUtils.escapeJava(value); - boolean containsQuote = value.contains("\""); - if (javaVersion >= 15 && containsQuote) { - // Use text block - result.add(new AddAnnotationOverMethod.Attribute(key, "\"\"\"\n" + value + "\n\"\"\"")); - } else { - // Use standard string - result.add(new AddAnnotationOverMethod.Attribute(key, "\"%s\"".formatted(escaped))); - } + + result.add(new AddAnnotationOverMethod.Attribute(key, "\"\"\"\n" + value + "\n\"\"\"")); } return result; } diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryMethodCodeActionProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryMethodCodeActionProvider.java index 9287de0975..f0e51cad8d 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryMethodCodeActionProvider.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryMethodCodeActionProvider.java @@ -80,7 +80,7 @@ public boolean visit(MethodDeclaration node) { DataRepositoryAotMetadataCodeLensProvider .getMetadata(repositoryMetadataService, project, binding) .ifPresent(metadata -> metadata.findMethod(binding) - .map(method -> createCodeAction(binding, docURI, metadata, method, project)) + .map(method -> createCodeAction(binding, docURI, metadata, method)) .ifPresent(collector::accept)); } } @@ -92,9 +92,9 @@ public boolean visit(MethodDeclaration node) { }; } - private CodeAction createCodeAction(IMethodBinding mb, URI docUri, DataRepositoryAotMetadata metadata, IDataRepositoryAotMethodMetadata method, IJavaProject project) { + private CodeAction createCodeAction(IMethodBinding mb, URI docUri, DataRepositoryAotMetadata metadata, IDataRepositoryAotMethodMetadata method) { CodeAction ca = new CodeAction(); - ca.setCommand(refactorings.createFixCommand(TITLE, DataRepositoryAotMetadataCodeLensProvider.createFixDescriptor(mb, docUri.toASCIIString(), metadata.module(), method, project))); + ca.setCommand(refactorings.createFixCommand(TITLE, DataRepositoryAotMetadataCodeLensProvider.createFixDescriptor(mb, docUri.toASCIIString(), metadata.module(), method))); ca.setTitle(TITLE); ca.setKind(CodeActionKind.Refactor); return ca; diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJdbcTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJdbcTest.java index d005288cef..d966f74552 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJdbcTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJdbcTest.java @@ -98,11 +98,8 @@ void noCodeLensOverMethodWithQueryAnnotation() throws Exception { assertEquals(2, cls.get(1).getCommand().getArguments().size()); } - /** - * Verify that text blocks are generated when the query string contains quotes on Java 15 or above. - */ @Test - void turnIntoQueryUsesTextBlockWhenQuotesPresentAndJava15OrAbove() throws Exception { + void turnIntoQueryUsesTextBlock() throws Exception { Path filePath = Paths.get(testProject.getLocationUri()) .resolve("src/main/java/example/springdata/aot/CategoryRepository.java"); @@ -117,20 +114,7 @@ void turnIntoQueryUsesTextBlockWhenQuotesPresentAndJava15OrAbove() throws Except String queryValue = extractValueFromAttributes(cls.get(0)); assertNotNull(queryValue, "Query value should not be null"); - int javaVersion = 8; - String versionStr = testProject.getClasspath().getJre().version(); - if (versionStr.startsWith("1.")) { - javaVersion = Integer.parseInt(versionStr.substring(2, 3)); - } else { - javaVersion = Integer.parseInt(versionStr.split("\\.")[0]); - } - - // JDBC query has quotes: SELECT "CATEGORY"."ID" ... - if (javaVersion >= 15) { - assertTrue(queryValue.startsWith("\"\"\""), "Text block must be used for Java >= 15 when quotes are present"); - } else { - assertFalse(queryValue.startsWith("\"\"\""), "Text block must NOT be used for Java < 15"); - } + assertTrue(queryValue.startsWith("\"\"\""), "Query should be generated as a text block"); } diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJpaTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJpaTest.java index 846b5140bb..9ee01ee1cd 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJpaTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJpaTest.java @@ -11,9 +11,6 @@ package org.springframework.ide.vscode.boot.java.data.test; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -98,62 +95,4 @@ void noCodeLensOverMethodWithQueryAnnotation() throws Exception { assertEquals(2, cls.get(1).getCommand().getArguments().size()); } - /** - * Verify that text blocks are generated when the query string contains quotes on Java 15 or above. - */ - @Test - void turnIntoQueryUsesTextBlockWhenQuotesPresentAndJava15OrAbove() throws Exception { - Path filePath = Paths.get(testProject.getLocationUri()) - .resolve("src/main/java/example/springdata/aot/UserRepository.java"); - - Editor editor = harness.newEditor( - LanguageId.JAVA, - new String(Files.readAllBytes(filePath), StandardCharsets.UTF_8), - filePath.toUri().toASCIIString() - ); - - List cls = editor.getCodeLenses("findUserByUsername", 1); - String queryValue = extractValueFromAttributes(cls.get(0)); - assertNotNull(queryValue, "Query value should not be null"); - System.out.println("Extracted query value: " + queryValue); - - // JPA findUserByUsername query (SELECT u FROM users u ...) does not contain quotes. - boolean containsQuote = false; - - int javaVersion = 8; - String versionStr = testProject.getClasspath().getJre().version(); - if (versionStr.startsWith("1.")) { - javaVersion = Integer.parseInt(versionStr.substring(2, 3)); - } else { - javaVersion = Integer.parseInt(versionStr.split("\\.")[0]); - } - - if (javaVersion >= 15 && containsQuote) { - assertTrue(queryValue.startsWith("\"\"\""), "Text block must be used for Java >= 15 when quotes are present"); - } else { - assertFalse(queryValue.startsWith("\"\"\""), "Text block must NOT be used if Java < 15 or if quotes are missing"); - } - } - - private String extractValueFromAttributes(CodeLens codeLens) { - Object args = codeLens.getCommand().getArguments().get(1); - if (args instanceof com.google.gson.JsonObject) { - com.google.gson.JsonObject params = (com.google.gson.JsonObject) args; - if (params.has("parameters") && params.get("parameters").isJsonObject()) { - com.google.gson.JsonObject parameters = params.getAsJsonObject("parameters"); - if (parameters.has("attributes") && parameters.get("attributes").isJsonArray()) { - com.google.gson.JsonArray attributes = parameters.getAsJsonArray("attributes"); - for (com.google.gson.JsonElement element : attributes) { - if (element.isJsonObject()) { - com.google.gson.JsonObject attr = element.getAsJsonObject(); - if (attr.has("name") && "value".equals(attr.get("name").getAsString())) { - return attr.get("value").getAsString(); - } - } - } - } - } - } - return null; - } } diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderMongoDbTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderMongoDbTest.java index ee5e277717..c28d076c5d 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderMongoDbTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderMongoDbTest.java @@ -11,9 +11,6 @@ package org.springframework.ide.vscode.boot.java.data.test; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -110,62 +107,4 @@ void noCodeLensOverMethodWithQueryAnnotation() throws Exception { assertEquals(2, cls.get(1).getCommand().getArguments().size()); } - /** - * Verify that text blocks are generated when the query string contains quotes on Java 15 or above. - */ - @Test - void turnIntoQueryUsesTextBlockWhenQuotesPresentAndJava15OrAbove() throws Exception { - Path filePath = Paths.get(testProject.getLocationUri()) - .resolve("src/main/java/example/springdata/aot/UserRepository.java"); - - Editor editor = harness.newEditor( - LanguageId.JAVA, - new String(Files.readAllBytes(filePath), StandardCharsets.UTF_8), - filePath.toUri().toASCIIString() - ); - - List cls = editor.getCodeLenses("findUserByUsername", 1); - String queryValue = extractValueFromAttributes(cls.get(0)); - - System.out.println("Extracted query value: " + queryValue); - - assertNotNull(queryValue, "Query value should not be null"); - - int javaVersion = 8; - String versionStr = testProject.getClasspath().getJre().version(); - if (versionStr.startsWith("1.")) { - javaVersion = Integer.parseInt(versionStr.substring(2, 3)); - } else { - javaVersion = Integer.parseInt(versionStr.split("\\.")[0]); - } - - // MongoDB findUserByUsername query {"username": ?0} contains quotes. - if (javaVersion >= 15) { - assertTrue(queryValue.startsWith("\"\"\""), "Text block must be used for Java >= 15 when quotes are present"); - } else { - assertFalse(queryValue.startsWith("\"\"\""), "Text block must NOT be used if Java < 15"); - } - } - - private String extractValueFromAttributes(CodeLens codeLens) { - Object args = codeLens.getCommand().getArguments().get(1); - if (args instanceof com.google.gson.JsonObject) { - com.google.gson.JsonObject params = (com.google.gson.JsonObject) args; - if (params.has("parameters") && params.get("parameters").isJsonObject()) { - com.google.gson.JsonObject parameters = params.getAsJsonObject("parameters"); - if (parameters.has("attributes") && parameters.get("attributes").isJsonArray()) { - com.google.gson.JsonArray attributes = parameters.getAsJsonArray("attributes"); - for (com.google.gson.JsonElement element : attributes) { - if (element.isJsonObject()) { - com.google.gson.JsonObject attr = element.getAsJsonObject(); - if (attr.has("name") && "value".equals(attr.get("name").getAsString())) { - return attr.get("value").getAsString(); - } - } - } - } - } - } - return null; - } } diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderJpaTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderJpaTest.java index 32a8a1e0d9..2e85a8a8b3 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderJpaTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderJpaTest.java @@ -84,9 +84,11 @@ void convertToQueryCodeAction() throws Exception { assertEquals(RewriteRefactorings.REWRITE_RECIPE_QUICKFIX, cmd.getArguments().get(0)); WorkspaceEdit edit = refactorings.createEdit((JsonElement) cmd.getArguments().get(1)).get(5, TimeUnit.SECONDS); TextDocumentEdit docEdit = edit.getDocumentChanges().get(0).getLeft(); + String rawText = docEdit.getEdits().get(0).getNewText(); + assertEquals( - "@Query(\"SELECT u FROM users u WHERE u.lastname LIKE :lastname ESCAPE '\\\\' ORDER BY u.firstname asc\")", - docEdit.getEdits().get(0).getNewText().trim()); + "@Query(\"\"\"\nSELECT u FROM users u WHERE u.lastname LIKE :lastname ESCAPE '\\' ORDER BY u.firstname asc\n\"\"\")", + rawText.trim()); assertEquals(filePath.toUri().toASCIIString(), docEdit.getTextDocument().getUri()); } diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderMongoDbTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderMongoDbTest.java index 316b3935e4..27f468748b 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderMongoDbTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderMongoDbTest.java @@ -84,22 +84,14 @@ void convertToQueryCodeAction() throws Exception { assertEquals(RewriteRefactorings.REWRITE_RECIPE_QUICKFIX, cmd.getArguments().get(0)); WorkspaceEdit edit = refactorings.createEdit((JsonElement) cmd.getArguments().get(1)).get(5, TimeUnit.SECONDS); TextDocumentEdit docEdit = edit.getDocumentChanges().get(0).getLeft(); - String rawNewText = docEdit.getEdits().get(0).getNewText().trim(); - - if (rawNewText.startsWith("@Query(\"\"\"")) { - // Java 15+ behavior (Text Block) - assertEquals( - "@Query(\"\"\"\n" + - "{\"lastname\": /^\\Q?0\\E/}\n" + - "\"\"\")\n" + - " Page findUserByLastnameStartingWith(String lastname, Pageable page)", - rawNewText.replace("\r\n", "\n")); - } else { - // Java <15 behavior (String literal) - assertEquals( - "@Query(\"{\\\"lastname\\\": /^\\\\Q?0\\\\E/}\")", - rawNewText); - } + String rawText = docEdit.getEdits().get(0).getNewText().trim(); + + assertEquals( + "@Query(\"\"\"\n" + + "{\"lastname\": /^\\Q?0\\E/}\n" + + "\"\"\")\n" + + " Page findUserByLastnameStartingWith(String lastname, Pageable page)", + rawText.replace("\r\n", "\n")); assertEquals(filePath.toUri().toASCIIString(), docEdit.getTextDocument().getUri()); } From 96e35c2e80c473d825cc9f7f36393c93ddf60e80 Mon Sep 17 00:00:00 2001 From: Junhwan Kim Date: Fri, 23 Jan 2026 23:57:33 +0900 Subject: [PATCH 3/3] Add support for multiline query formatting in query annotations - Introduce QueryFormatter interface and implementations for JPQL and MongoDB queries - Add BootJavaConfig option to select query style (compact or multiline) - Update code lens and code action providers to use configurable query formatting - Enhance tests to verify multiline and compact query styles Signed-off-by: Junhwan Kim --- .../ide/vscode/boot/app/BootJavaConfig.java | 5 ++ .../ide/vscode/boot/app/RewriteConfig.java | 4 +- ...RepositoryAotMetadataCodeLensProvider.java | 35 ++++++++-- .../data/QueryMethodCodeActionProvider.java | 7 +- .../data/formatter/JpqlQueryFormatter.java | 68 +++++++++++++++++++ .../data/formatter/MongoQueryFormatter.java | 30 ++++++++ .../java/data/formatter/QueryFormatter.java | 20 ++++++ ...ryAotMetadataCodeLensProviderJdbcTest.java | 34 ++++++++++ .../QueryMethodCodeActionProviderJpaTest.java | 2 +- ...ryMethodCodeActionProviderMongoDbTest.java | 7 +- 10 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/formatter/JpqlQueryFormatter.java create mode 100644 headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/formatter/MongoQueryFormatter.java create mode 100644 headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/formatter/QueryFormatter.java diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootJavaConfig.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootJavaConfig.java index 167e5b8c21..5f61cf10b4 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootJavaConfig.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/BootJavaConfig.java @@ -229,6 +229,11 @@ public boolean isEnabledCodeLensOverDataQueryMethods() { return Boolean.TRUE.equals(b); } + public String getDataQueryStyle() { + String style = settings.getString("boot-java", "code-action", "data-query-style"); + return style == null ? "compact" : style; + } + public boolean isEnabledCodeLensForWebConfigs() { Boolean b = settings.getBoolean("boot-java", "java", "codelens-web-configs-on-controller-classes"); return Boolean.TRUE.equals(b); diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/RewriteConfig.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/RewriteConfig.java index 6d94fdb080..a5ba748a1c 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/RewriteConfig.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/RewriteConfig.java @@ -46,8 +46,8 @@ public class RewriteConfig { } @ConditionalOnBean(RewriteRefactorings.class) - @Bean QueryMethodCodeActionProvider queryMethodCodeActionProvider(DataRepositoryAotMetadataService dataRepoAotService, RewriteRefactorings refactorings) { - return new QueryMethodCodeActionProvider(dataRepoAotService, refactorings); + @Bean QueryMethodCodeActionProvider queryMethodCodeActionProvider(DataRepositoryAotMetadataService dataRepoAotService, RewriteRefactorings refactorings, BootJavaConfig config) { + return new QueryMethodCodeActionProvider(dataRepoAotService, refactorings, config); } } diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryAotMetadataCodeLensProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryAotMetadataCodeLensProvider.java index 65c5a1a2bc..5a7d3cb221 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryAotMetadataCodeLensProvider.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryAotMetadataCodeLensProvider.java @@ -18,6 +18,7 @@ import java.util.Set; import java.util.stream.Collectors; +import org.apache.commons.text.StringEscapeUtils; import org.eclipse.jdt.core.dom.ASTVisitor; import org.eclipse.jdt.core.dom.CompilationUnit; import org.eclipse.jdt.core.dom.IMethodBinding; @@ -32,6 +33,9 @@ import org.springframework.ide.vscode.boot.app.BootJavaConfig; import org.springframework.ide.vscode.boot.java.Annotations; import org.springframework.ide.vscode.boot.java.annotations.AnnotationHierarchies; +import org.springframework.ide.vscode.boot.java.data.formatter.JpqlQueryFormatter; +import org.springframework.ide.vscode.boot.java.data.formatter.MongoQueryFormatter; +import org.springframework.ide.vscode.boot.java.data.formatter.QueryFormatter; import org.springframework.ide.vscode.boot.java.handlers.CodeLensProvider; import org.springframework.ide.vscode.boot.java.rewrite.RewriteRefactorings; import org.springframework.ide.vscode.boot.java.utils.ASTUtils; @@ -56,6 +60,9 @@ public class DataRepositoryAotMetadataCodeLensProvider implements CodeLensProvid DataRepositoryModule.JDBC, Annotations.DATA_JDBC_QUERY, DataRepositoryModule.MONGODB, Annotations.DATA_MONGODB_QUERY ); + + private static final QueryFormatter JPQL_FORMATTER = new JpqlQueryFormatter(); + private static final QueryFormatter MONGO_FORMATTER = new MongoQueryFormatter(); private static final Logger log = LoggerFactory.getLogger(DataRepositoryAotMetadataCodeLensProvider.class); @@ -155,7 +162,7 @@ private List createCodeLenses(IJavaProject project, MethodDeclaration || hierarchyAnnot.isAnnotatedWith(mb, Annotations.DATA_JDBC_QUERY); if (!isQueryAnnotated) { - codeLenses.add(new CodeLens(range, refactorings.createFixCommand(COVERT_TO_QUERY_LABEL, createFixDescriptor(mb, document.getUri(), metadata.module(), methodMetadata)), null)); + codeLenses.add(new CodeLens(range, refactorings.createFixCommand(COVERT_TO_QUERY_LABEL, createFixDescriptor(mb, document.getUri(), metadata.module(), methodMetadata, config)), null)); } Command impl = new Command("Go To Implementation", GenAotQueryMethodImplProvider.CMD_NAVIGATE_TO_IMPL, List.of(new GenAotQueryMethodImplProvider.GoToImplParams( @@ -196,7 +203,7 @@ private Optional createRefreshCodeLens(IJavaProject project, String ti }); } - static FixDescriptor createFixDescriptor(IMethodBinding mb, String docUri, DataRepositoryModule module, IDataRepositoryAotMethodMetadata methodMetadata) { + static FixDescriptor createFixDescriptor(IMethodBinding mb, String docUri, DataRepositoryModule module, IDataRepositoryAotMethodMetadata methodMetadata, BootJavaConfig config) { return new FixDescriptor(AddAnnotationOverMethod.class.getName(), List.of(docUri), "Turn into `@Query`") .withRecipeScope(RecipeScope.FILE) .withParameters(Map.of( @@ -205,19 +212,33 @@ static FixDescriptor createFixDescriptor(IMethodBinding mb, String docUri, DataR Arrays.stream(mb.getParameterTypes()) .map(pt -> pt.getQualifiedName()) .collect(Collectors.joining(", "))), - "attributes", createAttributeList(methodMetadata.getAttributesMap()))); + "attributes", createAttributeList(methodMetadata.getAttributesMap(), module, config))); } - private static List createAttributeList(Map attributes) { + private static List createAttributeList(Map attributes, DataRepositoryModule module, BootJavaConfig config) { List result = new ArrayList<>(); + String style = config.getDataQueryStyle(); + boolean isMultiline = "multiline".equalsIgnoreCase(style); + + if (style != null && !"compact".equalsIgnoreCase(style) && !isMultiline) { + log.warn("Unknown data-query-style: '{}'. Falling back to 'compact'.", style); + } + for (Map.Entry entry : attributes.entrySet()) { String key = entry.getKey(); String value = entry.getValue(); - if (value == null) continue; - result.add(new AddAnnotationOverMethod.Attribute(key, "\"\"\"\n" + value + "\n\"\"\"")); + if (isMultiline) { + QueryFormatter formatter = (module == DataRepositoryModule.MONGODB) ? MONGO_FORMATTER : JPQL_FORMATTER; + value = formatter.format(value); + value = "\"\"\"" + value + "\n \"\"\""; + } else { + value = "\"" + StringEscapeUtils.escapeJava(value).trim() + "\""; + } + + result.add(new AddAnnotationOverMethod.Attribute(key, value)); } return result; } - } + diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryMethodCodeActionProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryMethodCodeActionProvider.java index 4b65a84bc0..3c75952354 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryMethodCodeActionProvider.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryMethodCodeActionProvider.java @@ -19,6 +19,7 @@ import org.eclipse.lsp4j.CodeAction; import org.eclipse.lsp4j.CodeActionKind; import org.eclipse.lsp4j.jsonrpc.CancelChecker; +import org.springframework.ide.vscode.boot.app.BootJavaConfig; import org.springframework.ide.vscode.boot.java.Annotations; import org.springframework.ide.vscode.boot.java.annotations.AnnotationHierarchies; import org.springframework.ide.vscode.boot.java.codeaction.JdtAstCodeActionProvider; @@ -36,10 +37,12 @@ public class QueryMethodCodeActionProvider implements JdtAstCodeActionProvider { private final DataRepositoryAotMetadataService repositoryMetadataService; private final RewriteRefactorings refactorings; + private final BootJavaConfig config; - public QueryMethodCodeActionProvider(DataRepositoryAotMetadataService repositoryMetadataService, RewriteRefactorings refactorings) { + public QueryMethodCodeActionProvider(DataRepositoryAotMetadataService repositoryMetadataService, RewriteRefactorings refactorings, BootJavaConfig config) { this.repositoryMetadataService = repositoryMetadataService; this.refactorings = refactorings; + this.config = config; } @Override @@ -94,7 +97,7 @@ public boolean visit(MethodDeclaration node) { private CodeAction createCodeAction(IMethodBinding mb, URI docUri, DataRepositoryAotMetadata metadata, IDataRepositoryAotMethodMetadata method) { CodeAction ca = new CodeAction(); - ca.setCommand(refactorings.createFixCommand(TITLE, DataRepositoryAotMetadataCodeLensProvider.createFixDescriptor(mb, docUri.toASCIIString(), metadata.module(), method))); + ca.setCommand(refactorings.createFixCommand(TITLE, DataRepositoryAotMetadataCodeLensProvider.createFixDescriptor(mb, docUri.toASCIIString(), metadata.module(), method, config))); ca.setTitle(TITLE); ca.setKind(CodeActionKind.Refactor); return ca; diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/formatter/JpqlQueryFormatter.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/formatter/JpqlQueryFormatter.java new file mode 100644 index 0000000000..2a773e24f5 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/formatter/JpqlQueryFormatter.java @@ -0,0 +1,68 @@ +/******************************************************************************* + * Copyright (c) 2026 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + ******************************************************************************/ +package org.springframework.ide.vscode.boot.java.data.formatter; + +import org.antlr.v4.runtime.CharStreams; +import org.antlr.v4.runtime.ConsoleErrorListener; +import org.antlr.v4.runtime.Token; +import org.springframework.ide.vscode.parser.jpql.JpqlLexer; +import org.springframework.ide.vscode.parser.jpql.JpqlParser; + +public class JpqlQueryFormatter implements QueryFormatter { + + @Override + public String format(String query) { + try { + JpqlLexer lexer = new JpqlLexer(CharStreams.fromString(query)); + lexer.removeErrorListener(ConsoleErrorListener.INSTANCE); + + StringBuilder sb = new StringBuilder(); + + int lastStopIndex = -1; + for (Token token = lexer.nextToken(); token.getType() != Token.EOF; token = lexer.nextToken()) { + boolean newlineAdded = false; + if (isNewlineKeyword(token)) { + sb.append("\n "); + newlineAdded = true; + } + + if (!newlineAdded && lastStopIndex != -1 && token.getStartIndex() > lastStopIndex + 1) { + sb.append(" "); + } else if (!newlineAdded && sb.length() == 0) { + sb.append("\n "); + } + + sb.append(token.getText()); + lastStopIndex = token.getStopIndex(); + } + return sb.toString(); + } catch (Exception e) { + return "\n " + query; + } + } + + private boolean isNewlineKeyword(Token token) { + int type = token.getType(); + if (type == JpqlParser.SELECT || type == JpqlParser.FROM || type == JpqlParser.WHERE + || type == JpqlParser.ORDER || type == JpqlParser.GROUP || type == JpqlParser.HAVING + || type == JpqlParser.UPDATE || type == JpqlParser.DELETE + || type == JpqlParser.SET + || type == JpqlParser.JOIN || type == JpqlParser.LEFT || type == JpqlParser.INNER) { + return true; + } + String text = token.getText(); + if ("INSERT".equalsIgnoreCase(text) || "VALUES".equalsIgnoreCase(text) || "RIGHT".equalsIgnoreCase(text)) { + return true; + } + return false; + } + +} diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/formatter/MongoQueryFormatter.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/formatter/MongoQueryFormatter.java new file mode 100644 index 0000000000..3b0c8457ec --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/formatter/MongoQueryFormatter.java @@ -0,0 +1,30 @@ +/******************************************************************************* + * Copyright (c) 2026 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + ******************************************************************************/ +package org.springframework.ide.vscode.boot.java.data.formatter; + +import org.json.JSONObject; + +public class MongoQueryFormatter implements QueryFormatter { + + @Override + public String format(String query) { + try { + // Try to format as JSON + String formattedJson = new JSONObject(query).toString(4); + // Indent the formatted JSON to align with Java code (8 spaces) + return "\n " + formattedJson.replace("\n", "\n "); + } catch (Exception e) { + // Fallback: just indent + return "\n " + query; + } + } + +} diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/formatter/QueryFormatter.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/formatter/QueryFormatter.java new file mode 100644 index 0000000000..80a85c88f2 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/formatter/QueryFormatter.java @@ -0,0 +1,20 @@ +/******************************************************************************* + * Copyright (c) 2026 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.java.data.formatter; + +/** + * Interface for formatting data repository queries. + */ +public interface QueryFormatter { + + String format(String query); + +} diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJdbcTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJdbcTest.java index d966f74552..fbc40bdfa4 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJdbcTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryAotMetadataCodeLensProviderJdbcTest.java @@ -100,6 +100,12 @@ void noCodeLensOverMethodWithQueryAnnotation() throws Exception { @Test void turnIntoQueryUsesTextBlock() throws Exception { + harness.changeConfiguration(new Settings(new Gson() + .toJsonTree(Map.of("boot-java", Map.of( + "java", Map.of("codelens-over-query-methods", true), + "code-action", Map.of("data-query-style", "multiline") + ))))); + Path filePath = Paths.get(testProject.getLocationUri()) .resolve("src/main/java/example/springdata/aot/CategoryRepository.java"); @@ -115,6 +121,34 @@ void turnIntoQueryUsesTextBlock() throws Exception { assertNotNull(queryValue, "Query value should not be null"); assertTrue(queryValue.startsWith("\"\"\""), "Query should be generated as a text block"); + assertTrue(queryValue.contains("\n"), "Query should be split into multiple lines"); + assertTrue(queryValue.contains("\n SELECT"), "Should have newline and 8 spaces before SELECT"); + assertTrue(queryValue.contains("\n FROM"), "Should have newline and 8 spaces before FROM"); + assertTrue(queryValue.contains("\n WHERE"), "Should have newline and 8 spaces before WHERE"); + assertTrue(queryValue.endsWith("\n \"\"\""), "Should end with newline and 4 spaces before closing triple quotes"); + } + + @Test + void turnIntoQueryUsesCompactStyleByDefault() throws Exception { + Path filePath = Paths.get(testProject.getLocationUri()) + .resolve("src/main/java/example/springdata/aot/CategoryRepository.java"); + + Editor editor = harness.newEditor( + LanguageId.JAVA, + new String(Files.readAllBytes(filePath), StandardCharsets.UTF_8), + filePath.toUri().toASCIIString() + ); + + List cls = editor.getCodeLenses("findAllByNameContaining", 1); + + String queryValue = extractValueFromAttributes(cls.get(0)); + + System.out.println("Extracted query value: " + queryValue); + assertNotNull(queryValue, "Query value should not be null"); + + assertTrue(queryValue.startsWith("\""), "Query should be generated as a string literal"); + assertFalse(queryValue.startsWith("\"\"\""), "Query should NOT be generated as a text block"); + assertFalse(queryValue.contains("\n SELECT"), "Should NOT have formatted newline before SELECT"); } diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderJpaTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderJpaTest.java index 2e85a8a8b3..7a3b2cc275 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderJpaTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderJpaTest.java @@ -87,7 +87,7 @@ void convertToQueryCodeAction() throws Exception { String rawText = docEdit.getEdits().get(0).getNewText(); assertEquals( - "@Query(\"\"\"\nSELECT u FROM users u WHERE u.lastname LIKE :lastname ESCAPE '\\' ORDER BY u.firstname asc\n\"\"\")", + "@Query(\"SELECT u FROM users u WHERE u.lastname LIKE :lastname ESCAPE '\\\\' ORDER BY u.firstname asc\")", rawText.trim()); assertEquals(filePath.toUri().toASCIIString(), docEdit.getTextDocument().getUri()); } diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderMongoDbTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderMongoDbTest.java index 27f468748b..67e3d01fad 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderMongoDbTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/QueryMethodCodeActionProviderMongoDbTest.java @@ -87,11 +87,8 @@ void convertToQueryCodeAction() throws Exception { String rawText = docEdit.getEdits().get(0).getNewText().trim(); assertEquals( - "@Query(\"\"\"\n" + - "{\"lastname\": /^\\Q?0\\E/}\n" + - "\"\"\")\n" - + " Page findUserByLastnameStartingWith(String lastname, Pageable page)", - rawText.replace("\r\n", "\n")); + "@Query(\"{\\\"lastname\\\": /^\\\\Q?0\\\\E/}\")", + rawText); assertEquals(filePath.toUri().toASCIIString(), docEdit.getTextDocument().getUri()); }