From 366f7159b55303782fdad91a0a02d8c75ba75f9a Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 20 Feb 2023 15:20:15 +0100 Subject: [PATCH 1/8] Spring Data JPA Content Assist Fixes gh-107 --- .../DataRepositoryCompletionProcessor.java | 23 +- ...toryPrefixSensitiveCompletionProvider.java | 357 ++++++++++++++++++ .../ide/vscode/boot/java/data/DomainType.java | 20 +- ...DataRepositoryCompletionProcessorTest.java | 81 +++- .../src/main/java/org/test/Application.java | 32 +- .../src/main/java/org/test/Customer.java | 46 ++- .../src/main/java/org/test/Employee.java | 47 +++ .../TestCustomerRepositoryForCompletions.java | 2 - 8 files changed, 552 insertions(+), 56 deletions(-) create mode 100644 headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java create mode 100644 headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Employee.java diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryCompletionProcessor.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryCompletionProcessor.java index 7f65893e06..410e6c9174 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryCompletionProcessor.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryCompletionProcessor.java @@ -57,6 +57,7 @@ public void provideCompletions(ASTNode node, int offset, IDocument doc, Collecti for (DomainProperty property : properties) { completions.add(generateCompletionProposal(offset, prefix, repo, property)); } + DataRepositoryPrefixSensitiveCompletionProvider.addPrefixSensitiveProposals(completions, offset, prefix, repo); } } } @@ -71,7 +72,6 @@ protected ICompletionProposal generateCompletionProposal(int offset, String pref label.append(StringUtils.uncapitalize(domainProperty.getName())); label.append(");"); - DocumentEdits edits = new DocumentEdits(null, false); StringBuilder completion = new StringBuilder(); completion.append("List<"); @@ -84,20 +84,25 @@ protected ICompletionProposal generateCompletionProposal(int offset, String pref completion.append(StringUtils.uncapitalize(domainProperty.getName())); completion.append(");"); - String filter = label.toString(); - if (prefix != null && label.toString().startsWith(prefix)) { - edits.replace(offset - prefix.length(), offset, completion.toString()); + return createProposal(offset, CompletionItemKind.Method, prefix, label.toString(), completion.toString()); + } + + static ICompletionProposal createProposal(int offset, CompletionItemKind completionItemKind, String prefix, String label, String completion) { + DocumentEdits edits = new DocumentEdits(null, false); + String filter = label; + if (prefix != null && label.startsWith(prefix)) { + edits.replace(offset - prefix.length(), offset, completion); } - else if (prefix != null && completion.toString().startsWith(prefix)) { - edits.replace(offset - prefix.length(), offset, completion.toString()); - filter = completion.toString(); + else if (prefix != null && completion.startsWith(prefix)) { + edits.replace(offset - prefix.length(), offset, completion); + filter = completion; } else { - edits.insert(offset, completion.toString()); + edits.insert(offset, completion); } DocumentEdits additionalEdits = new DocumentEdits(null, false); - return new FindByCompletionProposal(label.toString(), CompletionItemKind.Method, edits, null, null, Optional.of(additionalEdits), filter); + return new FindByCompletionProposal(label, completionItemKind, edits, null, null, Optional.of(additionalEdits), filter); } private DataRepositoryDefinition getDataRepositoryDefinition(TypeDeclaration type) { diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java new file mode 100644 index 0000000000..3a2497c972 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java @@ -0,0 +1,357 @@ +/******************************************************************************* + * Copyright (c) 2023 Pivotal, 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: + * Pivotal, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.java.data; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.eclipse.lsp4j.CompletionItemKind; +import org.springframework.ide.vscode.commons.languageserver.completion.DocumentEdits; +import org.springframework.ide.vscode.commons.languageserver.completion.ICompletionProposal; +import org.springframework.util.StringUtils; + +/** + * @author danthe1st + */ +class DataRepositoryPrefixSensitiveCompletionProvider { + private static final List QUERY_METHOD_SUBJECTS = List.of( + QueryMethodSubject.createCollectionSubject("find", "List"), + QueryMethodSubject.createCollectionSubject("read", "List"), + QueryMethodSubject.createCollectionSubject("get", "List"), + QueryMethodSubject.createCollectionSubject("query", "List"), + QueryMethodSubject.createCollectionSubject("search", "List"), + QueryMethodSubject.createCollectionSubject("stream", "Streamable"), + QueryMethodSubject.createPrimitiveSubject("exists", "boolean"), + QueryMethodSubject.createPrimitiveSubject("count", "long"), + QueryMethodSubject.createPrimitiveSubject("delete", "void"), + QueryMethodSubject.createPrimitiveSubject("remove", "void") + ); + + private static final List PREDICATE_KEYWORDS = List.of( + new KeywordInfo("And", DataRepositoryMethodKeywordType.COMBINE_CONDITIONS), + new KeywordInfo("Or", DataRepositoryMethodKeywordType.COMBINE_CONDITIONS), + new KeywordInfo("After", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("IsAfter", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("Before", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("IsBefore", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("Containing", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("IsContaining", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("Contains", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("Between", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("IsBetween", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("EndingWith", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("IsEndingWith", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("EndsWith", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("Exists", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new KeywordInfo("False", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new KeywordInfo("IsFalse", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new KeywordInfo("GreaterThan", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("IsGreaterThan", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("GreaterThanEqual", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("IsGreaterThanEqual", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("In", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("IsIn", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("Is", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("Equals", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("Empty", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new KeywordInfo("IsEmpty", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new KeywordInfo("NotEmpty", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new KeywordInfo("IsNotEmpty", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new KeywordInfo("NotNull", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new KeywordInfo("IsNotNull", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new KeywordInfo("Null", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new KeywordInfo("IsNull", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new KeywordInfo("LessThan", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("IsLessThan", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("LessThanEqual", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("IsLessThanEqual", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("Like", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("IsLike", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("Near", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("IsNear", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("Not", DataRepositoryMethodKeywordType.IGNORE), + new KeywordInfo("IsNot", DataRepositoryMethodKeywordType.IGNORE), + new KeywordInfo("NotIn", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("IsNotIn", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("NotLike", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("IsNotLike", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("Regex", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("MatchesRegex", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("Matches", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("StartingWith", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("IsStartingWith", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("StartsWith", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("True", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new KeywordInfo("IsTrue", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new KeywordInfo("Within", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("IsWithin", DataRepositoryMethodKeywordType.COMPARE), + new KeywordInfo("IgnoreCase", DataRepositoryMethodKeywordType.IGNORE), + new KeywordInfo("IgnoringCase", DataRepositoryMethodKeywordType.IGNORE), + new KeywordInfo("AllIgnoreCase", DataRepositoryMethodKeywordType.IGNORE), + new KeywordInfo("AllIgnoringCase", DataRepositoryMethodKeywordType.IGNORE), + new KeywordInfo("OrderBy", DataRepositoryMethodKeywordType.COMBINE_CONDITIONS) + ); + private static final Map> PREDICATE_KEYWORDS_GROUPED_BY_FIRST_WORD = PREDICATE_KEYWORDS + .stream() + .collect(Collectors.groupingBy(info->{ + return findFirstWord(info.keyword()); + })); + + private static String findFirstWord(String expression) { + int firstWordEnd; + for (firstWordEnd = 1; + firstWordEnd < expression.length() + && Character.isLowerCase(expression.charAt(firstWordEnd)); + firstWordEnd++) { + //search is done in loop condition + } + return expression.substring(0, firstWordEnd); + } + + static void addPrefixSensitiveProposals(Collection completions, int offset, String prefix, DataRepositoryDefinition repoDef){ + String localPrefix = findJavaIdentifierPart(prefix); + addQueryStartProposals(completions, localPrefix, offset); + if (localPrefix == null) { + return; + } + DataRepositoryMethodNameParseResult parseResult = parseLocalPrefixForCompletion(localPrefix, repoDef); + if (parseResult != null) { + if(parseResult.performFullCompletion()) { + String methodName=localPrefix; + DocumentEdits edits = new DocumentEdits(null, false); + String signature = parseResult + .parameters() + .stream() + .map(param -> { + DomainProperty[] properties = repoDef.getDomainType().getProperties(); + for(DomainProperty domainProperty : properties){ + if(domainProperty.getName().equalsIgnoreCase(param)) { + return domainProperty.getType().getSimpleName() + " " + StringUtils.uncapitalize(param); + } + } + return "Object " + StringUtils.uncapitalize(param); + }) + .collect(Collectors.joining(", ", methodName + "(",")")); + StringBuilder newText = new StringBuilder(); + newText.append(parseResult.subjectType().returnType()); + if (parseResult.subjectType().isTyped()) { + newText.append("<"); + newText.append(repoDef.getDomainType().getSimpleName()); + newText.append(">"); + } + newText.append(" "); + newText.append(signature); + newText.append(";"); + edits.replace(offset - localPrefix.length(), offset, newText.toString()); + DocumentEdits additionalEdits = new DocumentEdits(null, false); + ICompletionProposal proposal = new FindByCompletionProposal(methodName, CompletionItemKind.Method, edits, null, null, Optional.of(additionalEdits), signature); + completions.add(proposal); + } + } + } + + private static void addQueryStartProposals(Collection completions, String prefix, int offset) { + for(QueryMethodSubject queryMethodSubject : QUERY_METHOD_SUBJECTS){ + String toInsert = queryMethodSubject.key() + "By"; + completions.add(DataRepositoryCompletionProcessor.createProposal(offset, CompletionItemKind.Text, prefix, toInsert, toInsert)); + } + } + + private static String findJavaIdentifierPart(String prefix) { + if (prefix == null) { + return null; + } + int lastNonIdentifierPartIndex; + for (lastNonIdentifierPartIndex = prefix.length() - 1; lastNonIdentifierPartIndex >= 0 && Character.isJavaIdentifierPart(prefix.charAt(lastNonIdentifierPartIndex)); lastNonIdentifierPartIndex--) { + // search done using loop condition + } + return prefix.substring(lastNonIdentifierPartIndex + 1); + } + + private static DataRepositoryMethodNameParseResult parseLocalPrefixForCompletion(String localPrefix, DataRepositoryDefinition repoDef) { + Map> propertiesGroupedByFirstWord = groupPropertiesByFirstWord(repoDef); + + propertiesGroupedByFirstWord.toString(); + int subjectPredicateSplitIndex = localPrefix.indexOf("By"); + if (subjectPredicateSplitIndex == -1) { + return null; + } + String subject=localPrefix.substring(0,subjectPredicateSplitIndex); + QueryMethodSubject subjectType = null; + for(QueryMethodSubject queryMethodSubject : QUERY_METHOD_SUBJECTS){ + if(subject.startsWith(queryMethodSubject.key())) { + subjectType = queryMethodSubject; + } + } + if (subjectType == null) { + return null; + } + String predicate = localPrefix.substring(subjectPredicateSplitIndex + 2); + List parameters=new ArrayList<>(); + String previousExpression = null; + int lastWordEnd = 0; + String expectedNextType = null;//the expected type as string if a type is expected, if the type cannot be found, the user should supply it + + boolean performFullCompletion = true;//if some invalid text is detected, do not complete the whole method + for (int i = 1; i <= predicate.length(); i++) { + if(i == predicate.length() || Character.isUpperCase(predicate.charAt(i))) {//word ends on uppercase letter or end of string + String word = predicate.substring(lastWordEnd, i); + KeywordInfo keyword = findByLargestFirstWord(PREDICATE_KEYWORDS_GROUPED_BY_FIRST_WORD, KeywordInfo::keyword, predicate, lastWordEnd, word); + if (keyword != null) {//word is keyword + i += keyword.keyword().length()-word.length(); + switch(keyword.type()) { + case TERMINATE_EXPRESSION: {//e.g. IsTrue + if (expectedNextType == null) { + //if no next type/expression is expected (which should not happen), do not complete the full method (parameters) + performFullCompletion = false; + } + expectedNextType = null; + + break; + } + case COMBINE_CONDITIONS: {//e.g. And + //if an expression is expected, it is added to the parameters + if (expectedNextType != null) { + parameters.add(expectedNextType); + } + expectedNextType = null; + break; + } + case COMPARE: {//e.g. GreaterThan + if (expectedNextType == null) { + //nothing to compare, e.g. And directly followed by GreaterThan + performFullCompletion = false; + } + expectedNextType = previousExpression; + break; + } + case IGNORE:{ + //ignore + break; + } + default: + throw new IllegalArgumentException("Unexpected value: " + keyword.type()); + } + previousExpression = null; + } else { + DomainProperty preferredWord = findByLargestFirstWord(propertiesGroupedByFirstWord, DomainProperty::getName, predicate, lastWordEnd, word); + if (preferredWord != null) { + i += preferredWord.getName().length()-word.length(); + word=preferredWord.getName(); + } + if (previousExpression == null){ + previousExpression = word; + //non-keywords just invert the status + //if an expression is expected, the word is the expression + //if not, some expression is required after the word + if (expectedNextType == null) { + expectedNextType = word; + } else { + expectedNextType = null; + } + } else { + //combine multiple words that are not keywords + previousExpression += word; + if (expectedNextType != null) { + expectedNextType = previousExpression; + } + } + } + lastWordEnd = i; + } + } + if (expectedNextType != null) { + parameters.add(expectedNextType); + } + + EnumSet allowedKeywordTypes = EnumSet.allOf(DataRepositoryMethodKeywordType.class); + if (expectedNextType == null) { + allowedKeywordTypes.remove(DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION); + allowedKeywordTypes.remove(DataRepositoryMethodKeywordType.COMPARE); + } + return new DataRepositoryMethodNameParseResult(subjectType, parameters, performFullCompletion, previousExpression, allowedKeywordTypes); + } + + private static T findByLargestFirstWord(Map> toSearch, Function expressionExtractor, String predicate, int lastWordEnd, String word) { + T ret = null; + if (toSearch.containsKey(word)) { + for(T possibleKeyword : toSearch.get(word)){ + int endPosition = lastWordEnd + expressionExtractor.apply(possibleKeyword).length(); + if (predicate.length() >= endPosition + && expressionExtractor.apply(possibleKeyword).equals(predicate.substring(lastWordEnd, endPosition)) + && (ret == null || expressionExtractor.apply(possibleKeyword).length() > expressionExtractor.apply(possibleKeyword).length())) {//find largest valid keyword + ret = possibleKeyword; + } + } + } + return ret; + } + + private static Map> groupPropertiesByFirstWord(DataRepositoryDefinition repoDef) { + Map> propertiesGroupedByFirstWord = new HashMap<>(); + for(DomainProperty property : repoDef.getDomainType().getProperties()){ + String firstWord = findFirstWord(property.getName()); + propertiesGroupedByFirstWord.putIfAbsent(firstWord, new ArrayList<>()); + propertiesGroupedByFirstWord.get(firstWord).add(property); + } + return propertiesGroupedByFirstWord; + } +} +record QueryMethodSubject(String key, String returnType, boolean isTyped) { + static QueryMethodSubject createPrimitiveSubject(String key, String primitive) { + return new QueryMethodSubject(key, primitive, false); + } + static QueryMethodSubject createCollectionSubject(String key, String collectionType) { + return new QueryMethodSubject(key, collectionType, true); + } + +} + +record DataRepositoryMethodNameParseResult( + /** + * Information about the subject of the method + */ + QueryMethodSubject subjectType, + /** + * parameters required for calling the method + */ + List parameters, + /** + * {@code true} if the whole method shall be replaced including parameters, else false + */ + boolean performFullCompletion, + /** + * the last entered word, which completion options should be shown for + */ + String lastWord, + /** + * types of keywords that can be completed with + */ + Set allowedKeywordTypes) { + +} + +enum DataRepositoryMethodKeywordType { + TERMINATE_EXPRESSION,//e.g. IsTrue + COMBINE_CONDITIONS,//e.g. AND + COMPARE,//needs expression left and right OR expression left and parameter, e.g. Equals or NOT + IGNORE;//NOT + //TODO In/IsIn keyword etc +} +record KeywordInfo(String keyword, DataRepositoryMethodKeywordType type) {} \ No newline at end of file diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DomainType.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DomainType.java index 17081a704a..7c88481a70 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DomainType.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DomainType.java @@ -36,7 +36,11 @@ public DomainType(String packageName, String fullName, String simpleName) { } public DomainType(ITypeBinding typeBinding) { - this.packageName = typeBinding.getPackage().getName(); + if (typeBinding.getPackage() == null) { + this.packageName = ""; + } else { + this.packageName = typeBinding.getPackage().getName(); + } this.fullName = typeBinding.getQualifiedName(); this.simpleName = typeBinding.getName(); @@ -48,9 +52,17 @@ public DomainType(ITypeBinding typeBinding) { for (IMethodBinding method : methods) { String methodName = method.getName(); - if (methodName != null && methodName.startsWith("get")) { - String propertyName = methodName.substring(3); - properties.add(new DomainProperty(propertyName, new DomainType(method.getReturnType()))); + if (methodName != null) { + String propertyName = null; + if (methodName.startsWith("get")) { + propertyName = methodName.substring(3); + } + else if (methodName.startsWith("is")) { + propertyName = methodName.substring(2); + } + if (propertyName != null) { + properties.add(new DomainProperty(propertyName, new DomainType(method.getReturnType()))); + } } } return (DomainProperty[]) properties.toArray(new DomainProperty[properties.size()]); diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java index adaffd1d2e..b0dcbbbfc7 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2018 Pivotal, Inc. + * Copyright (c) 2018, 2023 Pivotal, 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 @@ -11,6 +11,7 @@ package org.springframework.ide.vscode.boot.java.data.test; import java.io.InputStream; +import java.util.Arrays; import java.util.List; import org.apache.commons.io.IOUtils; @@ -27,13 +28,13 @@ import org.springframework.ide.vscode.commons.java.IJavaProject; import org.springframework.ide.vscode.commons.util.text.LanguageId; import org.springframework.ide.vscode.languageserver.testharness.Editor; -import org.springframework.ide.vscode.languageserver.testharness.TestAsserts; import org.springframework.ide.vscode.project.harness.BootLanguageServerHarness; import org.springframework.ide.vscode.project.harness.ProjectsHarness; import org.springframework.test.context.junit.jupiter.SpringExtension; /** * @author Martin Lippert + * @author danthe1st */ @ExtendWith(SpringExtension.class) @BootLanguageServerTest @@ -53,11 +54,78 @@ public void setup() throws Exception { @Test void testStandardFindByCompletions() throws Exception { prepareCase("{", "{<*>"); - assertContainsAnnotationCompletions( + assertStandardCompletions(); + } + + @Test + void testPrefixSensitiveCompletionsNoPrefix() throws Exception { + prepareCase("{\n}", "{\n<*>"); + assertStandardCompletions(); + } + + private void assertStandardCompletions() throws Exception { + assertContainsAnnotationCompletions( + "countBy", + "deleteBy", + "existsBy", + "findBy", "List findByFirstName(String firstName);", - "List findByLastName(String lastName);"); + "List findById(Long id);", + "List findByLastName(String lastName);", + "List findByResponsibleEmployee(Employee responsibleEmployee);", + "getBy", + "queryBy", + "readBy", + "removeBy", + "searchBy", + "streamBy"); + } + + @Test + void testPrefixSensitiveCompletionsCompleteMethod() throws Exception { + checkCompletions("findByFirstNameAndLastName", "List findByFirstNameAndLastName(String firstName, String lastName);"); } + @Test + void testAttributeComparison() throws Exception { + checkCompletions("findByFirstNameIsGreaterThanLastName", "List findByFirstNameIsGreaterThanLastName();"); + } + + @Test + void testTerminatingKeyword() throws Exception { + checkCompletions("findByFirstNameNull", "List findByFirstNameNull();"); + checkCompletions("findByFirstNameNotNull", "List findByFirstNameNotNull();"); + } + + @Test + void testNewConditionAfterTerminatedExpression() throws Exception { + checkCompletions("findByFirstNameNullAndLastName", "List findByFirstNameNullAndLastName(String lastName);"); + checkCompletions("findByNotFirstNameNullAndNotLastName", "List findByNotFirstNameNullAndNotLastName(String lastName);"); + } + + @Test + void testDifferentSubjectTypes() throws Exception { + checkCompletions("existsByFirstName", "boolean existsByFirstName(String firstName);"); + checkCompletions("countByFirstName", "long countByFirstName(String firstName);"); + checkCompletions("streamByFirstName", "Streamable streamByFirstName(String firstName);"); + checkCompletions("removeByFirstName", "void removeByFirstName(String firstName);"); + } + + @Test + void testUnknownAttribute() throws Exception { + checkCompletions("findByUnknownObject", "List findByUnknownObject(Object unknownObject);"); + } + + @Test + void testKeywordInPredicate() throws Exception { + checkCompletions("findByThisCustomerIsSpecial", "List findByThisCustomerIsSpecial(boolean thisCustomerIsSpecial);"); + } + + private void checkCompletions(String alredyPresent, String... expectedCompletions) throws Exception { + prepareCase("{\n}", "{\n\t" + alredyPresent + "<*>"); + assertContainsAnnotationCompletions(Arrays.stream(expectedCompletions).map(expected -> expected + "<*>").toArray(String[]::new)); + } + private void prepareCase(String selectedAnnotation, String annotationStatementBeforeTest) throws Exception { InputStream resource = this.getClass().getResourceAsStream("/test-projects/test-spring-data-symbols/src/main/java/org/test/TestCustomerRepositoryForCompletions.java"); String content = IOUtils.toString(resource); @@ -74,13 +142,10 @@ private void assertContainsAnnotationCompletions(String... expectedResultsFromCo Editor clonedEditor = editor.clone(); clonedEditor.apply(foundCompletion); - if (clonedEditor.getText().contains(expectedResultsFromCompletion[i])) { + if (i < expectedResultsFromCompletion.length && clonedEditor.getText().contains(expectedResultsFromCompletion[i])) { i++; } } - assertEquals(expectedResultsFromCompletion.length, i); } - - } diff --git a/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Application.java b/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Application.java index 409755db65..88c7a6e830 100644 --- a/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Application.java +++ b/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Application.java @@ -2,7 +2,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -10,46 +9,47 @@ @SpringBootApplication public class Application { - + private static final Logger log = LoggerFactory.getLogger(Application.class); - + public static void main(String[] args) { SpringApplication.run(Application.class); } - + @Bean public CommandLineRunner demo(CustomerRepository repository) { - return (args) -> { + return args -> { + Employee employee = new Employee("Margot", "Al-Harazi"); // save a couple of customers - repository.save(new Customer("Jack", "Bauer")); - repository.save(new Customer("Chloe", "O'Brian")); - repository.save(new Customer("Kim", "Bauer")); - repository.save(new Customer("David", "Palmer")); - repository.save(new Customer("Michelle", "Dessler")); - + repository.save(new Customer("Jack", "Bauer", employee)); + repository.save(new Customer("Chloe", "O'Brian", employee)); + repository.save(new Customer("Kim", "Bauer", employee)); + repository.save(new Customer("David", "Palmer", employee)); + repository.save(new Customer("Michelle", "Dessler", employee)); + // fetch all customers log.info("Customers found with findAll():"); log.info("-------------------------------"); - for (Customer customer : repository.findAll()) { + for(Customer customer : repository.findAll()){ log.info(customer.toString()); } log.info(""); - + // fetch an individual customer by ID Customer customer = repository.findOne(1L); log.info("Customer found with findOne(1L):"); log.info("--------------------------------"); log.info(customer.toString()); log.info(""); - + // fetch customers by last name log.info("Customer found with findByLastName('Bauer'):"); log.info("--------------------------------------------"); - for (Customer bauer : repository.findByLastName("Bauer")) { + for(Customer bauer : repository.findByLastName("Bauer")){ log.info(bauer.toString()); } log.info(""); }; } - + } diff --git a/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Customer.java b/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Customer.java index 4f093c01fb..92d59745d9 100644 --- a/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Customer.java +++ b/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Customer.java @@ -5,29 +5,34 @@ import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; +import javax.persistence.ManyToOne; @Entity public class Customer { - @Id - @GeneratedValue(strategy=GenerationType.AUTO) - private Long id; - private String firstName; - private String lastName; + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + private String firstName; + private String lastName; + private boolean thisCustomerIsSpecial;//contains keyword in name - protected Customer() {} + @ManyToOne + private Employee responsibleEmployee; - public Customer(String firstName, String lastName) { - this.firstName = firstName; - this.lastName = lastName; - } + protected Customer() {} - @Override - public String toString() { - return String.format( - "Customer[id=%d, firstName='%s', lastName='%s']", - id, firstName, lastName); - } + public Customer(String firstName, String lastName, Employee responsibleEmployee) { + this.firstName = firstName; + this.lastName = lastName; + this.responsibleEmployee = responsibleEmployee; + } + + @Override + public String toString() { + return "Customer [id=" + id + ", firstName=" + firstName + ", lastName=" + lastName + ", responsibleEmployee=" + + responsibleEmployee + "]"; + } // end::sample[] @@ -42,5 +47,12 @@ public String getFirstName() { public String getLastName() { return lastName; } -} + public boolean isThisCustomerIsSpecial() { + return thisCustomerIsSpecial; + } + + public Employee getResponsibleEmployee() { + return responsibleEmployee; + } +} diff --git a/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Employee.java b/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Employee.java new file mode 100644 index 0000000000..84c1d66fa9 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Employee.java @@ -0,0 +1,47 @@ +// tag::sample[] +package org.test; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; + +@Entity +public class Employee { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + private String firstName; + private String lastName; + + protected Employee() { + } + + public Employee(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } + + @Override + public String toString() { + return String.format( + "Employee[id=%d, firstName='%s', lastName='%s']", + id, firstName, lastName + ); + } + +// end::sample[] + + public Long getId() { + return id; + } + + public String getFirstName() { + return firstName; + } + + public String getLastName() { + return lastName; + } +} diff --git a/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/TestCustomerRepositoryForCompletions.java b/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/TestCustomerRepositoryForCompletions.java index 1da41bae84..9e9d574609 100644 --- a/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/TestCustomerRepositoryForCompletions.java +++ b/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/TestCustomerRepositoryForCompletions.java @@ -1,7 +1,5 @@ package org.test; -import java.util.List; - import org.springframework.data.repository.CrudRepository; public interface TestCustomerRepositoryForCompletions extends CrudRepository { From 76ec713ad4c1949ab2a163352c2a79ca8ac8714c Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 20 Feb 2023 15:52:10 +0100 Subject: [PATCH 2/8] move data types used for JPA content assist into separate files --- .../data/DataRepositoryMethodKeywordType.java | 42 ++++++ .../DataRepositoryMethodNameParseResult.java | 42 ++++++ ...toryPrefixSensitiveCompletionProvider.java | 132 ++---------------- .../boot/java/data/QueryMethodSubject.java | 44 ++++++ .../java/data/QueryPredicateKeywordInfo.java | 87 ++++++++++++ 5 files changed, 224 insertions(+), 123 deletions(-) create mode 100644 headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryMethodKeywordType.java create mode 100644 headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryMethodNameParseResult.java create mode 100644 headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryMethodSubject.java create mode 100644 headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryPredicateKeywordInfo.java diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryMethodKeywordType.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryMethodKeywordType.java new file mode 100644 index 0000000000..4a89bb0dcf --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryMethodKeywordType.java @@ -0,0 +1,42 @@ +/******************************************************************************* + * Copyright (c) 2023 Pivotal, 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: + * Pivotal, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.java.data; + +/** + * Types of predicate keywords Spring JPA repository method names + * @author danthe1st + */ +enum DataRepositoryMethodKeywordType { + /** + * A keyword that terminates an expression. + * + * e.g. {@code isTrue} in {@code findBySomeBooleanIsTrue} + */ + TERMINATE_EXPRESSION, + /** + * An operator combining two conditions. + * + * e.g. {@code And} in {@code findBySomeAttributeAndAnotherAttribute} + */ + COMBINE_CONDITIONS, + /** + * A keyword requiring an expression on both sides or an expression on one side and a parameter. + * + * e.g. {@code Equals} in {@code findBySomeAttributeEquals} or {@code findBySomeAttributeEqualsAnotherAttribute} + */ + COMPARE, + /** + * Keywords that can be ignored for content assist. + * + * e.g. {@code Not} in {@code findByNotSomeBooleanAttribute} + */ + IGNORE; +} \ No newline at end of file diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryMethodNameParseResult.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryMethodNameParseResult.java new file mode 100644 index 0000000000..86895e0509 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryMethodNameParseResult.java @@ -0,0 +1,42 @@ +/******************************************************************************* + * Copyright (c) 2023 Pivotal, 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: + * Pivotal, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.java.data; + +import java.util.List; +import java.util.Set; + +/** + * Represents the result of parsing a Spring JPA repository query method + * @author danthe1st + */ +record DataRepositoryMethodNameParseResult( + /** + * Information about the subject of the method + */ + QueryMethodSubject subjectType, + /** + * parameters required for calling the method + */ + List parameters, + /** + * {@code true} if the whole method shall be replaced including parameters, else false + */ + boolean performFullCompletion, + /** + * the last entered word, which completion options should be shown for + */ + String lastWord, + /** + * types of keywords that can be completed with + */ + Set allowedKeywordTypes) { + +} \ No newline at end of file diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java index 3a2497c972..223750ae39 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java @@ -17,7 +17,6 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -27,92 +26,21 @@ import org.springframework.util.StringUtils; /** + * This utility class provides content assist proposals for Spring JPA query methods. * @author danthe1st */ class DataRepositoryPrefixSensitiveCompletionProvider { - private static final List QUERY_METHOD_SUBJECTS = List.of( - QueryMethodSubject.createCollectionSubject("find", "List"), - QueryMethodSubject.createCollectionSubject("read", "List"), - QueryMethodSubject.createCollectionSubject("get", "List"), - QueryMethodSubject.createCollectionSubject("query", "List"), - QueryMethodSubject.createCollectionSubject("search", "List"), - QueryMethodSubject.createCollectionSubject("stream", "Streamable"), - QueryMethodSubject.createPrimitiveSubject("exists", "boolean"), - QueryMethodSubject.createPrimitiveSubject("count", "long"), - QueryMethodSubject.createPrimitiveSubject("delete", "void"), - QueryMethodSubject.createPrimitiveSubject("remove", "void") - ); - private static final List PREDICATE_KEYWORDS = List.of( - new KeywordInfo("And", DataRepositoryMethodKeywordType.COMBINE_CONDITIONS), - new KeywordInfo("Or", DataRepositoryMethodKeywordType.COMBINE_CONDITIONS), - new KeywordInfo("After", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("IsAfter", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("Before", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("IsBefore", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("Containing", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("IsContaining", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("Contains", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("Between", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("IsBetween", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("EndingWith", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("IsEndingWith", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("EndsWith", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("Exists", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), - new KeywordInfo("False", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), - new KeywordInfo("IsFalse", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), - new KeywordInfo("GreaterThan", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("IsGreaterThan", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("GreaterThanEqual", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("IsGreaterThanEqual", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("In", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("IsIn", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("Is", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("Equals", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("Empty", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), - new KeywordInfo("IsEmpty", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), - new KeywordInfo("NotEmpty", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), - new KeywordInfo("IsNotEmpty", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), - new KeywordInfo("NotNull", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), - new KeywordInfo("IsNotNull", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), - new KeywordInfo("Null", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), - new KeywordInfo("IsNull", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), - new KeywordInfo("LessThan", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("IsLessThan", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("LessThanEqual", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("IsLessThanEqual", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("Like", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("IsLike", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("Near", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("IsNear", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("Not", DataRepositoryMethodKeywordType.IGNORE), - new KeywordInfo("IsNot", DataRepositoryMethodKeywordType.IGNORE), - new KeywordInfo("NotIn", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("IsNotIn", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("NotLike", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("IsNotLike", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("Regex", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("MatchesRegex", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("Matches", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("StartingWith", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("IsStartingWith", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("StartsWith", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("True", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), - new KeywordInfo("IsTrue", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), - new KeywordInfo("Within", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("IsWithin", DataRepositoryMethodKeywordType.COMPARE), - new KeywordInfo("IgnoreCase", DataRepositoryMethodKeywordType.IGNORE), - new KeywordInfo("IgnoringCase", DataRepositoryMethodKeywordType.IGNORE), - new KeywordInfo("AllIgnoreCase", DataRepositoryMethodKeywordType.IGNORE), - new KeywordInfo("AllIgnoringCase", DataRepositoryMethodKeywordType.IGNORE), - new KeywordInfo("OrderBy", DataRepositoryMethodKeywordType.COMBINE_CONDITIONS) - ); - private static final Map> PREDICATE_KEYWORDS_GROUPED_BY_FIRST_WORD = PREDICATE_KEYWORDS + private static final Map> PREDICATE_KEYWORDS_GROUPED_BY_FIRST_WORD = QueryPredicateKeywordInfo.PREDICATE_KEYWORDS .stream() .collect(Collectors.groupingBy(info->{ return findFirstWord(info.keyword()); })); + private DataRepositoryPrefixSensitiveCompletionProvider() { + //prevent instantiation + } + private static String findFirstWord(String expression) { int firstWordEnd; for (firstWordEnd = 1; @@ -167,7 +95,7 @@ static void addPrefixSensitiveProposals(Collection completi } private static void addQueryStartProposals(Collection completions, String prefix, int offset) { - for(QueryMethodSubject queryMethodSubject : QUERY_METHOD_SUBJECTS){ + for(QueryMethodSubject queryMethodSubject : QueryMethodSubject.QUERY_METHOD_SUBJECTS){ String toInsert = queryMethodSubject.key() + "By"; completions.add(DataRepositoryCompletionProcessor.createProposal(offset, CompletionItemKind.Text, prefix, toInsert, toInsert)); } @@ -194,7 +122,7 @@ private static DataRepositoryMethodNameParseResult parseLocalPrefixForCompletion } String subject=localPrefix.substring(0,subjectPredicateSplitIndex); QueryMethodSubject subjectType = null; - for(QueryMethodSubject queryMethodSubject : QUERY_METHOD_SUBJECTS){ + for(QueryMethodSubject queryMethodSubject : QueryMethodSubject.QUERY_METHOD_SUBJECTS){ if(subject.startsWith(queryMethodSubject.key())) { subjectType = queryMethodSubject; } @@ -212,7 +140,7 @@ private static DataRepositoryMethodNameParseResult parseLocalPrefixForCompletion for (int i = 1; i <= predicate.length(); i++) { if(i == predicate.length() || Character.isUpperCase(predicate.charAt(i))) {//word ends on uppercase letter or end of string String word = predicate.substring(lastWordEnd, i); - KeywordInfo keyword = findByLargestFirstWord(PREDICATE_KEYWORDS_GROUPED_BY_FIRST_WORD, KeywordInfo::keyword, predicate, lastWordEnd, word); + QueryPredicateKeywordInfo keyword = findByLargestFirstWord(PREDICATE_KEYWORDS_GROUPED_BY_FIRST_WORD, QueryPredicateKeywordInfo::keyword, predicate, lastWordEnd, word); if (keyword != null) {//word is keyword i += keyword.keyword().length()-word.length(); switch(keyword.type()) { @@ -313,45 +241,3 @@ private static Map> groupPropertiesByFirstWord(Data return propertiesGroupedByFirstWord; } } -record QueryMethodSubject(String key, String returnType, boolean isTyped) { - static QueryMethodSubject createPrimitiveSubject(String key, String primitive) { - return new QueryMethodSubject(key, primitive, false); - } - static QueryMethodSubject createCollectionSubject(String key, String collectionType) { - return new QueryMethodSubject(key, collectionType, true); - } - -} - -record DataRepositoryMethodNameParseResult( - /** - * Information about the subject of the method - */ - QueryMethodSubject subjectType, - /** - * parameters required for calling the method - */ - List parameters, - /** - * {@code true} if the whole method shall be replaced including parameters, else false - */ - boolean performFullCompletion, - /** - * the last entered word, which completion options should be shown for - */ - String lastWord, - /** - * types of keywords that can be completed with - */ - Set allowedKeywordTypes) { - -} - -enum DataRepositoryMethodKeywordType { - TERMINATE_EXPRESSION,//e.g. IsTrue - COMBINE_CONDITIONS,//e.g. AND - COMPARE,//needs expression left and right OR expression left and parameter, e.g. Equals or NOT - IGNORE;//NOT - //TODO In/IsIn keyword etc -} -record KeywordInfo(String keyword, DataRepositoryMethodKeywordType type) {} \ No newline at end of file diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryMethodSubject.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryMethodSubject.java new file mode 100644 index 0000000000..786d5e7b93 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryMethodSubject.java @@ -0,0 +1,44 @@ +/******************************************************************************* + * Copyright (c) 2023 Pivotal, 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: + * Pivotal, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.java.data; + +import java.util.List; + +/** + * Represents information about the subject of a JPA query method. + * + * See https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#appendix.query.method.subject + * @author danthe1st + */ +record QueryMethodSubject( + String key, String returnType, boolean isTyped) { + + static final List QUERY_METHOD_SUBJECTS = List.of( + QueryMethodSubject.createCollectionSubject("find", "List"), + QueryMethodSubject.createCollectionSubject("read", "List"), + QueryMethodSubject.createCollectionSubject("get", "List"), + QueryMethodSubject.createCollectionSubject("query", "List"), + QueryMethodSubject.createCollectionSubject("search", "List"), + QueryMethodSubject.createCollectionSubject("stream", "Streamable"), + QueryMethodSubject.createPrimitiveSubject("exists", "boolean"), + QueryMethodSubject.createPrimitiveSubject("count", "long"), + QueryMethodSubject.createPrimitiveSubject("delete", "void"), + QueryMethodSubject.createPrimitiveSubject("remove", "void") + ); + + private static QueryMethodSubject createPrimitiveSubject(String key, String primitive) { + return new QueryMethodSubject(key, primitive, false); + } + private static QueryMethodSubject createCollectionSubject(String key, String collectionType) { + return new QueryMethodSubject(key, collectionType, true); + } + +} \ No newline at end of file diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryPredicateKeywordInfo.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryPredicateKeywordInfo.java new file mode 100644 index 0000000000..daf4ed85b2 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/QueryPredicateKeywordInfo.java @@ -0,0 +1,87 @@ +/******************************************************************************* + * Copyright (c) 2023 Pivotal, 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: + * Pivotal, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.java.data; + +import java.util.List; + +/** + * Represents information about the predicate in Spring JPA repository query methods. + * + * See https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#appendix.query.method.predicate + * + * @author danthe1st + */ +record QueryPredicateKeywordInfo(String keyword, DataRepositoryMethodKeywordType type) { + static final List PREDICATE_KEYWORDS = List.of( + new QueryPredicateKeywordInfo("And", DataRepositoryMethodKeywordType.COMBINE_CONDITIONS), + new QueryPredicateKeywordInfo("Or", DataRepositoryMethodKeywordType.COMBINE_CONDITIONS), + new QueryPredicateKeywordInfo("After", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("IsAfter", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("Before", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("IsBefore", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("Containing", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("IsContaining", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("Contains", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("Between", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("IsBetween", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("EndingWith", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("IsEndingWith", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("EndsWith", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("Exists", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new QueryPredicateKeywordInfo("False", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new QueryPredicateKeywordInfo("IsFalse", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new QueryPredicateKeywordInfo("GreaterThan", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("IsGreaterThan", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("GreaterThanEqual", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("IsGreaterThanEqual", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("In", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("IsIn", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("Is", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("Equals", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("Empty", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new QueryPredicateKeywordInfo("IsEmpty", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new QueryPredicateKeywordInfo("NotEmpty", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new QueryPredicateKeywordInfo("IsNotEmpty", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new QueryPredicateKeywordInfo("NotNull", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new QueryPredicateKeywordInfo("IsNotNull", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new QueryPredicateKeywordInfo("Null", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new QueryPredicateKeywordInfo("IsNull", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new QueryPredicateKeywordInfo("LessThan", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("IsLessThan", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("LessThanEqual", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("IsLessThanEqual", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("Like", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("IsLike", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("Near", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("IsNear", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("Not", DataRepositoryMethodKeywordType.IGNORE), + new QueryPredicateKeywordInfo("IsNot", DataRepositoryMethodKeywordType.IGNORE), + new QueryPredicateKeywordInfo("NotIn", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("IsNotIn", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("NotLike", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("IsNotLike", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("Regex", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("MatchesRegex", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("Matches", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("StartingWith", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("IsStartingWith", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("StartsWith", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("True", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new QueryPredicateKeywordInfo("IsTrue", DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION), + new QueryPredicateKeywordInfo("Within", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("IsWithin", DataRepositoryMethodKeywordType.COMPARE), + new QueryPredicateKeywordInfo("IgnoreCase", DataRepositoryMethodKeywordType.IGNORE), + new QueryPredicateKeywordInfo("IgnoringCase", DataRepositoryMethodKeywordType.IGNORE), + new QueryPredicateKeywordInfo("AllIgnoreCase", DataRepositoryMethodKeywordType.IGNORE), + new QueryPredicateKeywordInfo("AllIgnoringCase", DataRepositoryMethodKeywordType.IGNORE), + new QueryPredicateKeywordInfo("OrderBy", DataRepositoryMethodKeywordType.COMBINE_CONDITIONS) + ); +} \ No newline at end of file From 46641fb8b7fa0f47502f08ff8eae76134effe952 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 20 Feb 2023 16:21:17 +0100 Subject: [PATCH 3/8] outsource JPA query repository method parsing to different class --- ...toryPrefixSensitiveCompletionProvider.java | 221 ++++-------------- .../java/data/JPARepositoryMethodParser.java | 200 ++++++++++++++++ 2 files changed, 246 insertions(+), 175 deletions(-) create mode 100644 headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/JPARepositoryMethodParser.java diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java index 223750ae39..cfe8daca1a 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java @@ -31,67 +31,65 @@ */ class DataRepositoryPrefixSensitiveCompletionProvider { - private static final Map> PREDICATE_KEYWORDS_GROUPED_BY_FIRST_WORD = QueryPredicateKeywordInfo.PREDICATE_KEYWORDS - .stream() - .collect(Collectors.groupingBy(info->{ - return findFirstWord(info.keyword()); - })); - private DataRepositoryPrefixSensitiveCompletionProvider() { //prevent instantiation } - private static String findFirstWord(String expression) { - int firstWordEnd; - for (firstWordEnd = 1; - firstWordEnd < expression.length() - && Character.isLowerCase(expression.charAt(firstWordEnd)); - firstWordEnd++) { - //search is done in loop condition - } - return expression.substring(0, firstWordEnd); - } - static void addPrefixSensitiveProposals(Collection completions, int offset, String prefix, DataRepositoryDefinition repoDef){ String localPrefix = findJavaIdentifierPart(prefix); addQueryStartProposals(completions, localPrefix, offset); if (localPrefix == null) { return; } - DataRepositoryMethodNameParseResult parseResult = parseLocalPrefixForCompletion(localPrefix, repoDef); - if (parseResult != null) { - if(parseResult.performFullCompletion()) { - String methodName=localPrefix; - DocumentEdits edits = new DocumentEdits(null, false); - String signature = parseResult - .parameters() - .stream() - .map(param -> { - DomainProperty[] properties = repoDef.getDomainType().getProperties(); - for(DomainProperty domainProperty : properties){ - if(domainProperty.getName().equalsIgnoreCase(param)) { - return domainProperty.getType().getSimpleName() + " " + StringUtils.uncapitalize(param); - } - } - return "Object " + StringUtils.uncapitalize(param); - }) - .collect(Collectors.joining(", ", methodName + "(",")")); - StringBuilder newText = new StringBuilder(); - newText.append(parseResult.subjectType().returnType()); - if (parseResult.subjectType().isTyped()) { - newText.append("<"); - newText.append(repoDef.getDomainType().getSimpleName()); - newText.append(">"); + DataRepositoryMethodNameParseResult parseResult = new JPARepositoryMethodParser(localPrefix, repoDef).parseLocalPrefixForCompletion(); + if(parseResult != null && parseResult.performFullCompletion()){ + String methodName=localPrefix; + DocumentEdits edits = new DocumentEdits(null, false); + String signature = buildSignature(methodName, repoDef, parseResult); + StringBuilder newText = new StringBuilder(); + newText.append(parseResult.subjectType().returnType()); + if (parseResult.subjectType().isTyped()) { + newText.append("<"); + newText.append(repoDef.getDomainType().getSimpleName()); + newText.append(">"); + } + newText.append(" "); + newText.append(signature); + newText.append(";"); + edits.replace(offset - localPrefix.length(), offset, newText.toString()); + DocumentEdits additionalEdits = new DocumentEdits(null, false); + ICompletionProposal proposal = new FindByCompletionProposal(methodName, CompletionItemKind.Method, edits, null, null, Optional.of(additionalEdits), signature); + completions.add(proposal); + } + } + + private static String buildSignature(String methodName, DataRepositoryDefinition repoDef, DataRepositoryMethodNameParseResult parseResult) { + StringBuilder signatureBuilder = new StringBuilder(); + signatureBuilder.append(methodName); + signatureBuilder.append("("); + List parameters = parseResult.parameters(); + for(int i = 0; i < parameters.size(); i++){ + String param = parameters.get(i); + DomainProperty[] properties = repoDef.getDomainType().getProperties(); + boolean found = false; + for(int j = 0; j < properties.length; j++){ + DomainProperty prop = properties[j]; + if(prop.getName().equalsIgnoreCase(param)) { + signatureBuilder.append(prop.getType().getSimpleName()); + found = true; } - newText.append(" "); - newText.append(signature); - newText.append(";"); - edits.replace(offset - localPrefix.length(), offset, newText.toString()); - DocumentEdits additionalEdits = new DocumentEdits(null, false); - ICompletionProposal proposal = new FindByCompletionProposal(methodName, CompletionItemKind.Method, edits, null, null, Optional.of(additionalEdits), signature); - completions.add(proposal); + } + if (!found) { + signatureBuilder.append("Object"); + } + signatureBuilder.append(" "); + signatureBuilder.append(StringUtils.uncapitalize(param)); + if (i + 1 < parameters.size()) { + signatureBuilder.append(", "); } } + signatureBuilder.append(")"); + return signatureBuilder.toString(); } private static void addQueryStartProposals(Collection completions, String prefix, int offset) { @@ -112,132 +110,5 @@ private static String findJavaIdentifierPart(String prefix) { return prefix.substring(lastNonIdentifierPartIndex + 1); } - private static DataRepositoryMethodNameParseResult parseLocalPrefixForCompletion(String localPrefix, DataRepositoryDefinition repoDef) { - Map> propertiesGroupedByFirstWord = groupPropertiesByFirstWord(repoDef); - - propertiesGroupedByFirstWord.toString(); - int subjectPredicateSplitIndex = localPrefix.indexOf("By"); - if (subjectPredicateSplitIndex == -1) { - return null; - } - String subject=localPrefix.substring(0,subjectPredicateSplitIndex); - QueryMethodSubject subjectType = null; - for(QueryMethodSubject queryMethodSubject : QueryMethodSubject.QUERY_METHOD_SUBJECTS){ - if(subject.startsWith(queryMethodSubject.key())) { - subjectType = queryMethodSubject; - } - } - if (subjectType == null) { - return null; - } - String predicate = localPrefix.substring(subjectPredicateSplitIndex + 2); - List parameters=new ArrayList<>(); - String previousExpression = null; - int lastWordEnd = 0; - String expectedNextType = null;//the expected type as string if a type is expected, if the type cannot be found, the user should supply it - - boolean performFullCompletion = true;//if some invalid text is detected, do not complete the whole method - for (int i = 1; i <= predicate.length(); i++) { - if(i == predicate.length() || Character.isUpperCase(predicate.charAt(i))) {//word ends on uppercase letter or end of string - String word = predicate.substring(lastWordEnd, i); - QueryPredicateKeywordInfo keyword = findByLargestFirstWord(PREDICATE_KEYWORDS_GROUPED_BY_FIRST_WORD, QueryPredicateKeywordInfo::keyword, predicate, lastWordEnd, word); - if (keyword != null) {//word is keyword - i += keyword.keyword().length()-word.length(); - switch(keyword.type()) { - case TERMINATE_EXPRESSION: {//e.g. IsTrue - if (expectedNextType == null) { - //if no next type/expression is expected (which should not happen), do not complete the full method (parameters) - performFullCompletion = false; - } - expectedNextType = null; - - break; - } - case COMBINE_CONDITIONS: {//e.g. And - //if an expression is expected, it is added to the parameters - if (expectedNextType != null) { - parameters.add(expectedNextType); - } - expectedNextType = null; - break; - } - case COMPARE: {//e.g. GreaterThan - if (expectedNextType == null) { - //nothing to compare, e.g. And directly followed by GreaterThan - performFullCompletion = false; - } - expectedNextType = previousExpression; - break; - } - case IGNORE:{ - //ignore - break; - } - default: - throw new IllegalArgumentException("Unexpected value: " + keyword.type()); - } - previousExpression = null; - } else { - DomainProperty preferredWord = findByLargestFirstWord(propertiesGroupedByFirstWord, DomainProperty::getName, predicate, lastWordEnd, word); - if (preferredWord != null) { - i += preferredWord.getName().length()-word.length(); - word=preferredWord.getName(); - } - if (previousExpression == null){ - previousExpression = word; - //non-keywords just invert the status - //if an expression is expected, the word is the expression - //if not, some expression is required after the word - if (expectedNextType == null) { - expectedNextType = word; - } else { - expectedNextType = null; - } - } else { - //combine multiple words that are not keywords - previousExpression += word; - if (expectedNextType != null) { - expectedNextType = previousExpression; - } - } - } - lastWordEnd = i; - } - } - if (expectedNextType != null) { - parameters.add(expectedNextType); - } - - EnumSet allowedKeywordTypes = EnumSet.allOf(DataRepositoryMethodKeywordType.class); - if (expectedNextType == null) { - allowedKeywordTypes.remove(DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION); - allowedKeywordTypes.remove(DataRepositoryMethodKeywordType.COMPARE); - } - return new DataRepositoryMethodNameParseResult(subjectType, parameters, performFullCompletion, previousExpression, allowedKeywordTypes); - } - - private static T findByLargestFirstWord(Map> toSearch, Function expressionExtractor, String predicate, int lastWordEnd, String word) { - T ret = null; - if (toSearch.containsKey(word)) { - for(T possibleKeyword : toSearch.get(word)){ - int endPosition = lastWordEnd + expressionExtractor.apply(possibleKeyword).length(); - if (predicate.length() >= endPosition - && expressionExtractor.apply(possibleKeyword).equals(predicate.substring(lastWordEnd, endPosition)) - && (ret == null || expressionExtractor.apply(possibleKeyword).length() > expressionExtractor.apply(possibleKeyword).length())) {//find largest valid keyword - ret = possibleKeyword; - } - } - } - return ret; - } - private static Map> groupPropertiesByFirstWord(DataRepositoryDefinition repoDef) { - Map> propertiesGroupedByFirstWord = new HashMap<>(); - for(DomainProperty property : repoDef.getDomainType().getProperties()){ - String firstWord = findFirstWord(property.getName()); - propertiesGroupedByFirstWord.putIfAbsent(firstWord, new ArrayList<>()); - propertiesGroupedByFirstWord.get(firstWord).add(property); - } - return propertiesGroupedByFirstWord; - } } diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/JPARepositoryMethodParser.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/JPARepositoryMethodParser.java new file mode 100644 index 0000000000..da39cdf25a --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/JPARepositoryMethodParser.java @@ -0,0 +1,200 @@ +/******************************************************************************* + * Copyright (c) 2023 Pivotal, 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: + * Pivotal, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.ide.vscode.boot.java.data; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Class responsible for parsing Spring JPA Repository query methods. + * @author danthe1st + */ +class JPARepositoryMethodParser { + + private static final Map> PREDICATE_KEYWORDS_GROUPED_BY_FIRST_WORD = QueryPredicateKeywordInfo.PREDICATE_KEYWORDS + .stream() + .collect(Collectors.groupingBy(info->{ + return findFirstWord(info.keyword()); + })); + + private final String prefix; + private final Map> propertiesGroupedByFirstWord; + private String expectedNextType = null;//the expected type as string if a type is expected, if the type cannot be found, the user should supply it + private boolean performFullCompletion = true;//if some invalid text is detected, do not complete the whole method + private String previousExpression = null; + + public JPARepositoryMethodParser(String localPrefix, DataRepositoryDefinition repoDef) { + prefix = localPrefix; + propertiesGroupedByFirstWord = groupPropertiesByFirstWord(repoDef); + } + + DataRepositoryMethodNameParseResult parseLocalPrefixForCompletion() { + int subjectPredicateSplitIndex = prefix.indexOf("By"); + if (subjectPredicateSplitIndex == -1) { + return null; + } + QueryMethodSubject subjectType = parseSubject(subjectPredicateSplitIndex); + if (subjectType == null) { + return null; + } + String predicate = prefix.substring(subjectPredicateSplitIndex + 2); + List parameters=new ArrayList<>(); + + parsePredicate(predicate, parameters); + + EnumSet allowedKeywordTypes = findAllowedKeywordTypesAtEnd(); + return new DataRepositoryMethodNameParseResult(subjectType, parameters, performFullCompletion, previousExpression, allowedKeywordTypes); + } + + private void parsePredicate(String predicate, List parameters) { + int lastWordEnd = 0; + + for (int i = 1; i <= predicate.length(); i++) { + if(i == predicate.length() || Character.isUpperCase(predicate.charAt(i))) {//word ends on uppercase letter or end of string + String word = predicate.substring(lastWordEnd, i); + QueryPredicateKeywordInfo keyword = findByLargestFirstWord(PREDICATE_KEYWORDS_GROUPED_BY_FIRST_WORD, QueryPredicateKeywordInfo::keyword, predicate, lastWordEnd, word); + if (keyword == null){ + DomainProperty preferredWord = findByLargestFirstWord(propertiesGroupedByFirstWord, DomainProperty::getName, predicate, lastWordEnd, word); + if (preferredWord != null) { + i += preferredWord.getName().length()-word.length(); + word=preferredWord.getName(); + } + parseNonKeyword(word); + } else { + i += keyword.keyword().length() - word.length(); + parseKeyword(parameters, keyword); + } + lastWordEnd = i; + } + } + if (expectedNextType != null) { + parameters.add(expectedNextType); + } + } + + private QueryMethodSubject parseSubject(int subjectPredicateSplitIndex) { + String subject = prefix.substring(0,subjectPredicateSplitIndex); + QueryMethodSubject subjectType = null; + for(QueryMethodSubject queryMethodSubject : QueryMethodSubject.QUERY_METHOD_SUBJECTS){ + if(subject.startsWith(queryMethodSubject.key())) { + subjectType = queryMethodSubject; + } + } + return subjectType; + } + + private EnumSet findAllowedKeywordTypesAtEnd() { + EnumSet allowedKeywordTypes = EnumSet.allOf(DataRepositoryMethodKeywordType.class); + if (expectedNextType == null) { + allowedKeywordTypes.remove(DataRepositoryMethodKeywordType.TERMINATE_EXPRESSION); + allowedKeywordTypes.remove(DataRepositoryMethodKeywordType.COMPARE); + } + return allowedKeywordTypes; + } + + private void parseNonKeyword(String word) { + if (previousExpression == null) { + previousExpression = word; + //non-keywords just invert the status + //if an expression is expected, the word is the expression + //if not, some expression is required after the word + if (expectedNextType == null) { + expectedNextType = word; + } else { + expectedNextType = null; + } + } else { + //combine multiple words that are not keywords + previousExpression += word; + if (expectedNextType != null) { + expectedNextType = previousExpression; + } + } + } + + private void parseKeyword(List parameters, QueryPredicateKeywordInfo keyword) { + switch(keyword.type()) { + case TERMINATE_EXPRESSION: {//e.g. IsTrue + if (expectedNextType == null) { + //if no next type/expression is expected (which should not happen), do not complete the full method (parameters) + performFullCompletion = false; + } + expectedNextType = null; + + break; + } + case COMBINE_CONDITIONS: {//e.g. And + //if an expression is expected, it is added to the parameters + if (expectedNextType != null) { + parameters.add(expectedNextType); + } + expectedNextType = null; + break; + } + case COMPARE: {//e.g. GreaterThan + if (expectedNextType == null) { + //nothing to compare, e.g. And directly followed by GreaterThan + performFullCompletion = false; + } + expectedNextType = previousExpression; + break; + } + case IGNORE:{ + //ignore + break; + } + default: + throw new IllegalArgumentException("Unexpected value: " + keyword.type()); + } + previousExpression = null; + } + + private T findByLargestFirstWord(Map> toSearch, Function expressionExtractor, String predicate, int lastWordEnd, String word) { + T ret = null; + if (toSearch.containsKey(word)) { + for(T possibleKeyword : toSearch.get(word)){ + int endPosition = lastWordEnd + expressionExtractor.apply(possibleKeyword).length(); + if (predicate.length() >= endPosition + && expressionExtractor.apply(possibleKeyword).equals(predicate.substring(lastWordEnd, endPosition)) + && (ret == null || expressionExtractor.apply(possibleKeyword).length() > expressionExtractor.apply(possibleKeyword).length())) {//find largest valid keyword + ret = possibleKeyword; + } + } + } + return ret; + } + + private Map> groupPropertiesByFirstWord(DataRepositoryDefinition repoDef) { + Map> propertiesGroupedByFirstWord = new HashMap<>(); + for(DomainProperty property : repoDef.getDomainType().getProperties()){ + String firstWord = findFirstWord(property.getName()); + propertiesGroupedByFirstWord.putIfAbsent(firstWord, new ArrayList<>()); + propertiesGroupedByFirstWord.get(firstWord).add(property); + } + return propertiesGroupedByFirstWord; + } + + private static String findFirstWord(String expression) { + int firstWordEnd; + for (firstWordEnd = 1; + firstWordEnd < expression.length() + && Character.isLowerCase(expression.charAt(firstWordEnd)); + firstWordEnd++) { + //search is done in loop condition + } + return expression.substring(0, firstWordEnd); + } +} From f025bf6f89766a9f32c3f3f03e9ae224b13cb061 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 20 Feb 2023 17:14:21 +0100 Subject: [PATCH 4/8] property completion in JPA repository methods --- .../DataRepositoryMethodNameParseResult.java | 4 +- ...toryPrefixSensitiveCompletionProvider.java | 84 ++++++++++++------- .../java/data/JPARepositoryMethodParser.java | 20 ++--- ...DataRepositoryCompletionProcessorTest.java | 9 ++ 4 files changed, 75 insertions(+), 42 deletions(-) diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryMethodNameParseResult.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryMethodNameParseResult.java index 86895e0509..574ac365bc 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryMethodNameParseResult.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryMethodNameParseResult.java @@ -31,7 +31,9 @@ record DataRepositoryMethodNameParseResult( */ boolean performFullCompletion, /** - * the last entered word, which completion options should be shown for + * the last entered word, which completion options should be used for completing the expression. + * + * e.g. {@code First} in {@code findByFirst} which could be completed to {@code findByFirstName} */ String lastWord, /** diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java index cfe8daca1a..b24e444d21 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java @@ -10,15 +10,11 @@ *******************************************************************************/ package org.springframework.ide.vscode.boot.java.data; -import java.util.ArrayList; import java.util.Collection; -import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.function.Function; -import java.util.stream.Collectors; import org.eclipse.lsp4j.CompletionItemKind; import org.springframework.ide.vscode.commons.languageserver.completion.DocumentEdits; @@ -43,43 +39,69 @@ static void addPrefixSensitiveProposals(Collection completi } DataRepositoryMethodNameParseResult parseResult = new JPARepositoryMethodParser(localPrefix, repoDef).parseLocalPrefixForCompletion(); if(parseResult != null && parseResult.performFullCompletion()){ - String methodName=localPrefix; - DocumentEdits edits = new DocumentEdits(null, false); - String signature = buildSignature(methodName, repoDef, parseResult); - StringBuilder newText = new StringBuilder(); - newText.append(parseResult.subjectType().returnType()); - if (parseResult.subjectType().isTyped()) { - newText.append("<"); - newText.append(repoDef.getDomainType().getSimpleName()); - newText.append(">"); + Map propertiesByName = getPropertiesByName(repoDef.getDomainType().getProperties()); + addMethodCompletionProposal(completions, offset, repoDef, localPrefix, parseResult, propertiesByName); + + if (parseResult.lastWord() == null || !propertiesByName.containsKey(parseResult.lastWord())) { + addPropertyProposals(completions, offset, repoDef, parseResult); } - newText.append(" "); - newText.append(signature); - newText.append(";"); - edits.replace(offset - localPrefix.length(), offset, newText.toString()); - DocumentEdits additionalEdits = new DocumentEdits(null, false); - ICompletionProposal proposal = new FindByCompletionProposal(methodName, CompletionItemKind.Method, edits, null, null, Optional.of(additionalEdits), signature); - completions.add(proposal); } } - private static String buildSignature(String methodName, DataRepositoryDefinition repoDef, DataRepositoryMethodNameParseResult parseResult) { + private static void addPropertyProposals(Collection completions, int offset, DataRepositoryDefinition repoDef, DataRepositoryMethodNameParseResult parseResult) { + for(DomainProperty property : repoDef.getDomainType().getProperties()){ + String lastWord = parseResult.lastWord(); + if (lastWord == null) { + lastWord = ""; + } + if (property.getName().startsWith(lastWord)) { + DocumentEdits edits = new DocumentEdits(null, false); + edits.replace(offset - lastWord.length(), offset, property.getName()); + DocumentEdits additionalEdits = new DocumentEdits(null, false); + ICompletionProposal proposal = new FindByCompletionProposal(property.getName(), CompletionItemKind.Text, edits, "property " + property.getName(), null, Optional.of(additionalEdits), lastWord); + completions.add(proposal); + } + } + } + + private static void addMethodCompletionProposal(Collection completions, int offset, DataRepositoryDefinition repoDef, String localPrefix, DataRepositoryMethodNameParseResult parseResult, Map propertiesByName) { + String methodName = localPrefix; + DocumentEdits edits = new DocumentEdits(null, false); + String signature = buildSignature(methodName, propertiesByName, parseResult); + StringBuilder newText = new StringBuilder(); + newText.append(parseResult.subjectType().returnType()); + if (parseResult.subjectType().isTyped()) { + newText.append("<"); + newText.append(repoDef.getDomainType().getSimpleName()); + newText.append(">"); + } + newText.append(" "); + newText.append(signature); + newText.append(";"); + edits.replace(offset - localPrefix.length(), offset, newText.toString()); + DocumentEdits additionalEdits = new DocumentEdits(null, false); + ICompletionProposal proposal = new FindByCompletionProposal(methodName, CompletionItemKind.Method, edits, null, null, Optional.of(additionalEdits), signature); + completions.add(proposal); + } + + private static Map getPropertiesByName(DomainProperty[] properties) { + Map propertiesByName = new HashMap<>(); + for(DomainProperty prop : properties){ + propertiesByName.put(prop.getName(), prop); + } + return propertiesByName; + } + + private static String buildSignature(String methodName, Map properties, DataRepositoryMethodNameParseResult parseResult) { StringBuilder signatureBuilder = new StringBuilder(); signatureBuilder.append(methodName); signatureBuilder.append("("); List parameters = parseResult.parameters(); for(int i = 0; i < parameters.size(); i++){ String param = parameters.get(i); - DomainProperty[] properties = repoDef.getDomainType().getProperties(); - boolean found = false; - for(int j = 0; j < properties.length; j++){ - DomainProperty prop = properties[j]; - if(prop.getName().equalsIgnoreCase(param)) { - signatureBuilder.append(prop.getType().getSimpleName()); - found = true; - } - } - if (!found) { + if (properties.containsKey(param)) { + signatureBuilder.append(properties.get(param).getType().getSimpleName()); + } else { signatureBuilder.append("Object"); } signatureBuilder.append(" "); diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/JPARepositoryMethodParser.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/JPARepositoryMethodParser.java index da39cdf25a..4c826640f2 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/JPARepositoryMethodParser.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/JPARepositoryMethodParser.java @@ -41,6 +41,16 @@ public JPARepositoryMethodParser(String localPrefix, DataRepositoryDefinition re propertiesGroupedByFirstWord = groupPropertiesByFirstWord(repoDef); } + private Map> groupPropertiesByFirstWord(DataRepositoryDefinition repoDef) { + Map> grouped = new HashMap<>(); + for(DomainProperty property : repoDef.getDomainType().getProperties()){ + String firstWord = findFirstWord(property.getName()); + grouped.putIfAbsent(firstWord, new ArrayList<>()); + grouped.get(firstWord).add(property); + } + return grouped; + } + DataRepositoryMethodNameParseResult parseLocalPrefixForCompletion() { int subjectPredicateSplitIndex = prefix.indexOf("By"); if (subjectPredicateSplitIndex == -1) { @@ -177,16 +187,6 @@ private T findByLargestFirstWord(Map> toSearch, Function> groupPropertiesByFirstWord(DataRepositoryDefinition repoDef) { - Map> propertiesGroupedByFirstWord = new HashMap<>(); - for(DomainProperty property : repoDef.getDomainType().getProperties()){ - String firstWord = findFirstWord(property.getName()); - propertiesGroupedByFirstWord.putIfAbsent(firstWord, new ArrayList<>()); - propertiesGroupedByFirstWord.get(firstWord).add(property); - } - return propertiesGroupedByFirstWord; - } - private static String findFirstWord(String expression) { int firstWordEnd; for (firstWordEnd = 1; diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java index b0dcbbbfc7..b2cd76f739 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java @@ -121,6 +121,15 @@ void testKeywordInPredicate() throws Exception { checkCompletions("findByThisCustomerIsSpecial", "List findByThisCustomerIsSpecial(boolean thisCustomerIsSpecial);"); } + @Test + void testPropertyProposals() throws Exception { + checkCompletions("findByFirst", "findByFirstName"); + checkCompletions("findByFirstNameAndL", "findByFirstNameAndLastName"); + checkCompletions("findBy", + "findByFirstName", + "findByLastName"); + } + private void checkCompletions(String alredyPresent, String... expectedCompletions) throws Exception { prepareCase("{\n}", "{\n\t" + alredyPresent + "<*>"); assertContainsAnnotationCompletions(Arrays.stream(expectedCompletions).map(expected -> expected + "<*>").toArray(String[]::new)); From 49a9ef4a23570ea812e0c251a59239152983b377 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 20 Feb 2023 21:01:55 +0100 Subject: [PATCH 5/8] replace return type as well --- ...itoryPrefixSensitiveCompletionProvider.java | 18 +++++++++++++++--- .../DataRepositoryCompletionProcessorTest.java | 7 ++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java index b24e444d21..e2926bf3dc 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java @@ -40,7 +40,7 @@ static void addPrefixSensitiveProposals(Collection completi DataRepositoryMethodNameParseResult parseResult = new JPARepositoryMethodParser(localPrefix, repoDef).parseLocalPrefixForCompletion(); if(parseResult != null && parseResult.performFullCompletion()){ Map propertiesByName = getPropertiesByName(repoDef.getDomainType().getProperties()); - addMethodCompletionProposal(completions, offset, repoDef, localPrefix, parseResult, propertiesByName); + addMethodCompletionProposal(completions, offset, repoDef, localPrefix, prefix, parseResult, propertiesByName); if (parseResult.lastWord() == null || !propertiesByName.containsKey(parseResult.lastWord())) { addPropertyProposals(completions, offset, repoDef, parseResult); @@ -64,7 +64,7 @@ private static void addPropertyProposals(Collection complet } } - private static void addMethodCompletionProposal(Collection completions, int offset, DataRepositoryDefinition repoDef, String localPrefix, DataRepositoryMethodNameParseResult parseResult, Map propertiesByName) { + private static void addMethodCompletionProposal(Collection completions, int offset, DataRepositoryDefinition repoDef, String localPrefix, String fullPrefix, DataRepositoryMethodNameParseResult parseResult, Map propertiesByName) { String methodName = localPrefix; DocumentEdits edits = new DocumentEdits(null, false); String signature = buildSignature(methodName, propertiesByName, parseResult); @@ -75,15 +75,27 @@ private static void addMethodCompletionProposal(Collection newText.append(repoDef.getDomainType().getSimpleName()); newText.append(">"); } + String returnType = newText.toString(); newText.append(" "); newText.append(signature); newText.append(";"); - edits.replace(offset - localPrefix.length(), offset, newText.toString()); + int replaceStart = calculateReplaceOffset(offset, localPrefix, fullPrefix, returnType); + edits.replace(replaceStart, offset, newText.toString()); DocumentEdits additionalEdits = new DocumentEdits(null, false); ICompletionProposal proposal = new FindByCompletionProposal(methodName, CompletionItemKind.Method, edits, null, null, Optional.of(additionalEdits), signature); completions.add(proposal); } + private static int calculateReplaceOffset(int offset, String localPrefix, String fullPrefix, String returnType) { + int replaceStart = offset - localPrefix.length(); + String beforeLocalPrefix = fullPrefix.substring(0, fullPrefix.length()-localPrefix.length()); + String trimmed = beforeLocalPrefix.trim(); + if(trimmed.endsWith(returnType)) { + replaceStart -= (beforeLocalPrefix.length() - trimmed.length()) + returnType.length(); + } + return replaceStart; + } + private static Map getPropertiesByName(DomainProperty[] properties) { Map propertiesByName = new HashMap<>(); for(DomainProperty prop : properties){ diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java index b2cd76f739..2ac914b8ad 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java @@ -86,6 +86,11 @@ void testPrefixSensitiveCompletionsCompleteMethod() throws Exception { checkCompletions("findByFirstNameAndLastName", "List findByFirstNameAndLastName(String firstName, String lastName);"); } + @Test + void testPrefixSensitiveCompletionsCompleteMethodReturnTypePresent() throws Exception { + checkCompletions("List findByFirstNameAndLastName", "List findByFirstNameAndLastName(String firstName, String lastName);"); + } + @Test void testAttributeComparison() throws Exception { checkCompletions("findByFirstNameIsGreaterThanLastName", "List findByFirstNameIsGreaterThanLastName();"); @@ -132,7 +137,7 @@ void testPropertyProposals() throws Exception { private void checkCompletions(String alredyPresent, String... expectedCompletions) throws Exception { prepareCase("{\n}", "{\n\t" + alredyPresent + "<*>"); - assertContainsAnnotationCompletions(Arrays.stream(expectedCompletions).map(expected -> expected + "<*>").toArray(String[]::new)); + assertContainsAnnotationCompletions(Arrays.stream(expectedCompletions).map(expected -> "\t" + expected + "<*>").toArray(String[]::new)); } private void prepareCase(String selectedAnnotation, String annotationStatementBeforeTest) throws Exception { From 4d2edf9acaa559d015b0d5bdb281d7d6faef0d4e Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 20 Feb 2023 21:50:47 +0100 Subject: [PATCH 6/8] allow querying complex expressions in JPA content assist --- ...toryPrefixSensitiveCompletionProvider.java | 28 +++++++++++++++-- ...DataRepositoryCompletionProcessorTest.java | 7 +++++ .../src/main/java/org/test/Application.java | 16 +++++----- .../src/main/java/org/test/Employee.java | 30 +++++++++---------- 4 files changed, 55 insertions(+), 26 deletions(-) diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java index e2926bf3dc..ee9ecd2c45 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java @@ -111,10 +111,11 @@ private static String buildSignature(String methodName, Map parameters = parseResult.parameters(); for(int i = 0; i < parameters.size(); i++){ String param = parameters.get(i); - if (properties.containsKey(param)) { - signatureBuilder.append(properties.get(param).getType().getSimpleName()); - } else { + DomainType type = findExpressionType(properties, param); + if (type == null) { signatureBuilder.append("Object"); + }else { + signatureBuilder.append(type.getSimpleName()); } signatureBuilder.append(" "); signatureBuilder.append(StringUtils.uncapitalize(param)); @@ -126,6 +127,27 @@ private static String buildSignature(String methodName, Map properties, String param) { + String[] splitByUnderscore = param.split("_"); + if(properties.containsKey(splitByUnderscore[0])) { + DomainType type = properties.get(splitByUnderscore[0]).getType(); + for (int j = 1; j < splitByUnderscore.length && type != null; j++) { + type = findMatchingParameter(splitByUnderscore[j], type); + } + return type; + } + return null; + } + + private static DomainType findMatchingParameter(String name, DomainType type) { + for(DomainProperty prop : type.getProperties()){ + if (prop.getName().equals(name)) { + return prop.getType(); + } + } + return null; + } + private static void addQueryStartProposals(Collection completions, String prefix, int offset) { for(QueryMethodSubject queryMethodSubject : QueryMethodSubject.QUERY_METHOD_SUBJECTS){ String toInsert = queryMethodSubject.key() + "By"; diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java index 2ac914b8ad..7c0ba5d8d1 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java @@ -89,6 +89,7 @@ void testPrefixSensitiveCompletionsCompleteMethod() throws Exception { @Test void testPrefixSensitiveCompletionsCompleteMethodReturnTypePresent() throws Exception { checkCompletions("List findByFirstNameAndLastName", "List findByFirstNameAndLastName(String firstName, String lastName);"); + checkCompletions("boolean existsByFirstNameAndLastName", "boolean existsByFirstNameAndLastName(String firstName, String lastName);"); } @Test @@ -135,6 +136,12 @@ void testPropertyProposals() throws Exception { "findByLastName"); } + @Test + void findByComplexExpression() throws Exception { + checkCompletions("findByResponsibleEmployee", "List findByResponsibleEmployee(Employee responsibleEmployee);"); + checkCompletions("findByResponsibleEmployee_SocialSecurityNumber", "List findByResponsibleEmployee_SocialSecurityNumber(Long responsibleEmployee_SocialSecurityNumber);"); + } + private void checkCompletions(String alredyPresent, String... expectedCompletions) throws Exception { prepareCase("{\n}", "{\n\t" + alredyPresent + "<*>"); assertContainsAnnotationCompletions(Arrays.stream(expectedCompletions).map(expected -> "\t" + expected + "<*>").toArray(String[]::new)); diff --git a/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Application.java b/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Application.java index 88c7a6e830..a1c94d8699 100644 --- a/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Application.java +++ b/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Application.java @@ -9,24 +9,24 @@ @SpringBootApplication public class Application { - + private static final Logger log = LoggerFactory.getLogger(Application.class); - + public static void main(String[] args) { SpringApplication.run(Application.class); } - + @Bean public CommandLineRunner demo(CustomerRepository repository) { return args -> { - Employee employee = new Employee("Margot", "Al-Harazi"); + Employee employee = new Employee(1234, "Margot", "Al-Harazi"); // save a couple of customers repository.save(new Customer("Jack", "Bauer", employee)); repository.save(new Customer("Chloe", "O'Brian", employee)); repository.save(new Customer("Kim", "Bauer", employee)); repository.save(new Customer("David", "Palmer", employee)); repository.save(new Customer("Michelle", "Dessler", employee)); - + // fetch all customers log.info("Customers found with findAll():"); log.info("-------------------------------"); @@ -34,14 +34,14 @@ public CommandLineRunner demo(CustomerRepository repository) { log.info(customer.toString()); } log.info(""); - + // fetch an individual customer by ID Customer customer = repository.findOne(1L); log.info("Customer found with findOne(1L):"); log.info("--------------------------------"); log.info(customer.toString()); log.info(""); - + // fetch customers by last name log.info("Customer found with findByLastName('Bauer'):"); log.info("--------------------------------------------"); @@ -51,5 +51,5 @@ public CommandLineRunner demo(CustomerRepository repository) { log.info(""); }; } - + } diff --git a/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Employee.java b/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Employee.java index 84c1d66fa9..9e6ceb8afd 100644 --- a/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Employee.java +++ b/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-spring-data-symbols/src/main/java/org/test/Employee.java @@ -8,39 +8,39 @@ @Entity public class Employee { - + @Id - @GeneratedValue(strategy = GenerationType.AUTO) - private Long id; + private Long socialSecurityNumber; private String firstName; private String lastName; - + protected Employee() { } - - public Employee(String firstName, String lastName) { + + public Employee(long socialSecurityNumber, String firstName, String lastName) { + this.socialSecurityNumber = socialSecurityNumber; this.firstName = firstName; this.lastName = lastName; } - + @Override public String toString() { return String.format( - "Employee[id=%d, firstName='%s', lastName='%s']", - id, firstName, lastName + "Employee[socialSecurityNumber=%d, firstName='%s', lastName='%s']", + socialSecurityNumber, firstName, lastName ); } - + // end::sample[] - - public Long getId() { - return id; + + public Long getSocialSecurityNumber() { + return socialSecurityNumber; } - + public String getFirstName() { return firstName; } - + public String getLastName() { return lastName; } From a9689e3d8dce1dd2ad3a8fd0a16a063a8510e15d Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 20 Feb 2023 21:58:36 +0100 Subject: [PATCH 7/8] rename JPARepositoryMethodParser to DataRepositoryMethodParser for consistency --- ...yMethodParser.java => DataRepositoryMethodParser.java} | 8 ++++---- .../DataRepositoryPrefixSensitiveCompletionProvider.java | 2 +- .../data/test/DataRepositoryCompletionProcessorTest.java | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) rename headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/{JPARepositoryMethodParser.java => DataRepositoryMethodParser.java} (96%) diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/JPARepositoryMethodParser.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryMethodParser.java similarity index 96% rename from headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/JPARepositoryMethodParser.java rename to headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryMethodParser.java index 4c826640f2..106af45402 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/JPARepositoryMethodParser.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryMethodParser.java @@ -22,7 +22,7 @@ * Class responsible for parsing Spring JPA Repository query methods. * @author danthe1st */ -class JPARepositoryMethodParser { +class DataRepositoryMethodParser { private static final Map> PREDICATE_KEYWORDS_GROUPED_BY_FIRST_WORD = QueryPredicateKeywordInfo.PREDICATE_KEYWORDS .stream() @@ -36,7 +36,7 @@ class JPARepositoryMethodParser { private boolean performFullCompletion = true;//if some invalid text is detected, do not complete the whole method private String previousExpression = null; - public JPARepositoryMethodParser(String localPrefix, DataRepositoryDefinition repoDef) { + public DataRepositoryMethodParser(String localPrefix, DataRepositoryDefinition repoDef) { prefix = localPrefix; propertiesGroupedByFirstWord = groupPropertiesByFirstWord(repoDef); } @@ -79,7 +79,7 @@ private void parsePredicate(String predicate, List parameters) { if (keyword == null){ DomainProperty preferredWord = findByLargestFirstWord(propertiesGroupedByFirstWord, DomainProperty::getName, predicate, lastWordEnd, word); if (preferredWord != null) { - i += preferredWord.getName().length()-word.length(); + i += preferredWord.getName().length() - word.length(); word=preferredWord.getName(); } parseNonKeyword(word); @@ -96,7 +96,7 @@ private void parsePredicate(String predicate, List parameters) { } private QueryMethodSubject parseSubject(int subjectPredicateSplitIndex) { - String subject = prefix.substring(0,subjectPredicateSplitIndex); + String subject = prefix.substring(0, subjectPredicateSplitIndex); QueryMethodSubject subjectType = null; for(QueryMethodSubject queryMethodSubject : QueryMethodSubject.QUERY_METHOD_SUBJECTS){ if(subject.startsWith(queryMethodSubject.key())) { diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java index ee9ecd2c45..5bf40a1e8b 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java @@ -37,7 +37,7 @@ static void addPrefixSensitiveProposals(Collection completi if (localPrefix == null) { return; } - DataRepositoryMethodNameParseResult parseResult = new JPARepositoryMethodParser(localPrefix, repoDef).parseLocalPrefixForCompletion(); + DataRepositoryMethodNameParseResult parseResult = new DataRepositoryMethodParser(localPrefix, repoDef).parseLocalPrefixForCompletion(); if(parseResult != null && parseResult.performFullCompletion()){ Map propertiesByName = getPropertiesByName(repoDef.getDomainType().getProperties()); addMethodCompletionProposal(completions, offset, repoDef, localPrefix, prefix, parseResult, propertiesByName); diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java index 7c0ba5d8d1..2a088cf106 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/data/test/DataRepositoryCompletionProcessorTest.java @@ -95,6 +95,7 @@ void testPrefixSensitiveCompletionsCompleteMethodReturnTypePresent() throws Exce @Test void testAttributeComparison() throws Exception { checkCompletions("findByFirstNameIsGreaterThanLastName", "List findByFirstNameIsGreaterThanLastName();"); + checkCompletions("findByFirstNameIsLastName", "List findByFirstNameIsLastName();"); } @Test From c0f1662d2d29a624e1e449042037345df4c6cca2 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Tue, 21 Feb 2023 12:50:35 +0100 Subject: [PATCH 8/8] only show query start proposals if applicable --- .../DataRepositoryCompletionProcessor.java | 4 ++- ...toryPrefixSensitiveCompletionProvider.java | 34 +++++++++++++------ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryCompletionProcessor.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryCompletionProcessor.java index 410e6c9174..142bb1aafd 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryCompletionProcessor.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryCompletionProcessor.java @@ -57,11 +57,13 @@ public void provideCompletions(ASTNode node, int offset, IDocument doc, Collecti for (DomainProperty property : properties) { completions.add(generateCompletionProposal(offset, prefix, repo, property)); } - DataRepositoryPrefixSensitiveCompletionProvider.addPrefixSensitiveProposals(completions, offset, prefix, repo); + DataRepositoryPrefixSensitiveCompletionProvider.addPrefixSensitiveProposals(completions, doc, offset, prefix, repo); } } } + + protected ICompletionProposal generateCompletionProposal(int offset, String prefix, DataRepositoryDefinition repoDef, DomainProperty domainProperty) { StringBuilder label = new StringBuilder(); label.append("findBy"); diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java index 5bf40a1e8b..5bfc2038b3 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java @@ -19,6 +19,8 @@ import org.eclipse.lsp4j.CompletionItemKind; import org.springframework.ide.vscode.commons.languageserver.completion.DocumentEdits; import org.springframework.ide.vscode.commons.languageserver.completion.ICompletionProposal; +import org.springframework.ide.vscode.commons.util.BadLocationException; +import org.springframework.ide.vscode.commons.util.text.IDocument; import org.springframework.util.StringUtils; /** @@ -31,9 +33,8 @@ private DataRepositoryPrefixSensitiveCompletionProvider() { //prevent instantiation } - static void addPrefixSensitiveProposals(Collection completions, int offset, String prefix, DataRepositoryDefinition repoDef){ - String localPrefix = findJavaIdentifierPart(prefix); - addQueryStartProposals(completions, localPrefix, offset); + static void addPrefixSensitiveProposals(Collection completions, IDocument doc, int offset, String prefix, DataRepositoryDefinition repoDef) { + String localPrefix = findLastJavaIdentifierPart(prefix); if (localPrefix == null) { return; } @@ -46,6 +47,24 @@ static void addPrefixSensitiveProposals(Collection completi addPropertyProposals(completions, offset, repoDef, parseResult); } } + addQueryStartProposals(completions, localPrefix, doc, offset); + } + + private static void addQueryStartProposals(Collection completions, String prefix, IDocument doc, int offset) { + for(QueryMethodSubject queryMethodSubject : QueryMethodSubject.QUERY_METHOD_SUBJECTS){ + String toInsert = queryMethodSubject.key() + "By"; + if(toInsert.startsWith(prefix)||isOffsetAfterWhitespace(doc, offset)) { + completions.add(DataRepositoryCompletionProcessor.createProposal(offset, CompletionItemKind.Text, prefix, toInsert, toInsert)); + } + } + } + + private static boolean isOffsetAfterWhitespace(IDocument doc, int offset) { + try { + return offset > 0 && Character.isWhitespace(doc.getChar(offset-1)); + }catch (BadLocationException e) { + return false; + } } private static void addPropertyProposals(Collection completions, int offset, DataRepositoryDefinition repoDef, DataRepositoryMethodNameParseResult parseResult) { @@ -148,14 +167,7 @@ private static DomainType findMatchingParameter(String name, DomainType type) { return null; } - private static void addQueryStartProposals(Collection completions, String prefix, int offset) { - for(QueryMethodSubject queryMethodSubject : QueryMethodSubject.QUERY_METHOD_SUBJECTS){ - String toInsert = queryMethodSubject.key() + "By"; - completions.add(DataRepositoryCompletionProcessor.createProposal(offset, CompletionItemKind.Text, prefix, toInsert, toInsert)); - } - } - - private static String findJavaIdentifierPart(String prefix) { + private static String findLastJavaIdentifierPart(String prefix) { if (prefix == null) { return null; }