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()); }