From a880498109601c9492a58430e13ae582a56eb421 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 20 Feb 2023 15:20:15 +0100 Subject: [PATCH] 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 {