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..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,10 +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, doc, offset, prefix, repo); } } } + + protected ICompletionProposal generateCompletionProposal(int offset, String prefix, DataRepositoryDefinition repoDef, DomainProperty domainProperty) { StringBuilder label = new StringBuilder(); label.append("findBy"); @@ -71,7 +74,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 +86,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/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..574ac365bc --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryMethodNameParseResult.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; +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 used for completing the expression. + * + * e.g. {@code First} in {@code findByFirst} which could be completed to {@code findByFirstName} + */ + 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/DataRepositoryMethodParser.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryMethodParser.java new file mode 100644 index 0000000000..106af45402 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryMethodParser.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 DataRepositoryMethodParser { + + 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 DataRepositoryMethodParser(String localPrefix, DataRepositoryDefinition repoDef) { + prefix = localPrefix; + 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) { + 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 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); + } +} 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..5bfc2038b3 --- /dev/null +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/data/DataRepositoryPrefixSensitiveCompletionProvider.java @@ -0,0 +1,182 @@ +/******************************************************************************* + * 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.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +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; + +/** + * This utility class provides content assist proposals for Spring JPA query methods. + * @author danthe1st + */ +class DataRepositoryPrefixSensitiveCompletionProvider { + + private DataRepositoryPrefixSensitiveCompletionProvider() { + //prevent instantiation + } + + static void addPrefixSensitiveProposals(Collection completions, IDocument doc, int offset, String prefix, DataRepositoryDefinition repoDef) { + String localPrefix = findLastJavaIdentifierPart(prefix); + if (localPrefix == null) { + return; + } + 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); + + if (parseResult.lastWord() == null || !propertiesByName.containsKey(parseResult.lastWord())) { + 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) { + 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, String fullPrefix, 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(">"); + } + String returnType = newText.toString(); + newText.append(" "); + newText.append(signature); + newText.append(";"); + 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){ + 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); + DomainType type = findExpressionType(properties, param); + if (type == null) { + signatureBuilder.append("Object"); + }else { + signatureBuilder.append(type.getSimpleName()); + } + signatureBuilder.append(" "); + signatureBuilder.append(StringUtils.uncapitalize(param)); + if (i + 1 < parameters.size()) { + signatureBuilder.append(", "); + } + } + signatureBuilder.append(")"); + return signatureBuilder.toString(); + } + + private static DomainType findExpressionType(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 String findLastJavaIdentifierPart(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); + } + + +} 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/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 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..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 @@ -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,100 @@ 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 testPrefixSensitiveCompletionsCompleteMethodReturnTypePresent() throws Exception { + checkCompletions("List findByFirstNameAndLastName", "List findByFirstNameAndLastName(String firstName, String lastName);"); + checkCompletions("boolean existsByFirstNameAndLastName", "boolean existsByFirstNameAndLastName(String firstName, String lastName);"); + } + + @Test + void testAttributeComparison() throws Exception { + checkCompletions("findByFirstNameIsGreaterThanLastName", "List findByFirstNameIsGreaterThanLastName();"); + checkCompletions("findByFirstNameIsLastName", "List findByFirstNameIsLastName();"); + } + + @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);"); + } + + @Test + void testPropertyProposals() throws Exception { + checkCompletions("findByFirst", "findByFirstName"); + checkCompletions("findByFirstNameAndL", "findByFirstNameAndLastName"); + checkCompletions("findBy", + "findByFirstName", + "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)); + } + 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 +164,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..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 @@ -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; @@ -19,18 +18,19 @@ public static void main(String[] args) { @Bean public CommandLineRunner demo(CustomerRepository repository) { - return (args) -> { + return args -> { + Employee employee = new Employee(1234, "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(""); @@ -45,7 +45,7 @@ public CommandLineRunner demo(CustomerRepository repository) { // 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..9e6ceb8afd --- /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 + private Long socialSecurityNumber; + private String firstName; + private String lastName; + + protected Employee() { + } + + 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[socialSecurityNumber=%d, firstName='%s', lastName='%s']", + socialSecurityNumber, firstName, lastName + ); + } + +// end::sample[] + + public Long getSocialSecurityNumber() { + return socialSecurityNumber; + } + + 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 {