introduce autocomplete textfield with RSQL suggestion

Signed-off-by: Michael Hirsch <michael.hirsch@bosch-si.com>
This commit is contained in:
Michael Hirsch
2016-08-26 13:26:37 +02:00
parent 2f415c4839
commit 79b1ae8728
28 changed files with 1735 additions and 462 deletions

View File

@@ -52,6 +52,8 @@ import org.eclipse.hawkbit.repository.jpa.model.helper.SystemManagementHolder;
import org.eclipse.hawkbit.repository.jpa.model.helper.SystemSecurityContextHolder;
import org.eclipse.hawkbit.repository.jpa.model.helper.TenantAwareHolder;
import org.eclipse.hawkbit.repository.jpa.model.helper.TenantConfigurationManagementHolder;
import org.eclipse.hawkbit.repository.jpa.rsql.RsqlParserValidationOracle;
import org.eclipse.hawkbit.repository.rsql.RsqlValidationOracle;
import org.eclipse.hawkbit.security.SecurityTokenGenerator;
import org.eclipse.hawkbit.security.SystemSecurityContext;
import org.eclipse.hawkbit.tenancy.TenantAware;
@@ -92,6 +94,12 @@ public class RepositoryApplicationConfiguration extends JpaBaseConfiguration {
@Autowired
private EventBus eventBus;
@Bean
@ConditionalOnMissingBean
public RsqlValidationOracle rsqlValidationOracle() {
return new RsqlParserValidationOracle();
}
/**
* @return the {@link SystemSecurityContext} singleton bean which make it
* accessible in beans which cannot access the service directly,

View File

@@ -0,0 +1,187 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others.
*
* 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
* http://www.eclipse.org/legal/epl-v10.html
*/
package org.eclipse.hawkbit.repository.jpa.rsql;
import java.lang.reflect.Field;
import java.util.Arrays;
import com.google.common.base.Throwables;
import cz.jirutka.rsql.parser.ParseException;
/**
* A {@link ParseException} wrapper which allows to access the parsing
* information from the exception using reflection due there is no other access
* of this information. See issue for requesting feature
* {@link https://github.com/jirutka/rsql-parser/issues/22}
*/
public class ParseExceptionWrapper {
private static final String FIELD_EXPECTED_TOKEN_SEQ = "expectedTokenSequences";
private static final String FIELD_CURRENT_TOKEN = "currentToken";
private final ParseException parseException;
private final Class<? extends ParseException> parseExceptionClass;
private Field expectedTokenSequenceField;
private Field currentTokenField;
/**
* Constructor.
*
* @param parseException
* the original parsing exception object to access its field
* using reflection
*/
public ParseExceptionWrapper(final ParseException parseException) {
this.parseException = parseException;
parseExceptionClass = parseException.getClass();
try {
expectedTokenSequenceField = getAccessibleField(parseExceptionClass, FIELD_EXPECTED_TOKEN_SEQ);
} catch (@SuppressWarnings("squid:S1166") final NoSuchFieldException e) {
expectedTokenSequenceField = null;
}
try {
currentTokenField = getAccessibleField(parseExceptionClass, FIELD_CURRENT_TOKEN);
} catch (@SuppressWarnings("squid:S1166") final NoSuchFieldException e) {
currentTokenField = null;
}
}
public int[][] getExpectedTokenSequence() {
if (expectedTokenSequenceField == null) {
return new int[0][0];
}
return (int[][]) getValue(expectedTokenSequenceField, parseException);
}
public TokenWrapper getCurrentToken() {
if (currentTokenField == null) {
return null;
}
return new TokenWrapper(getValue(currentTokenField, parseException));
}
@Override
public String toString() {
return "ParseExceptionWrapper [getExpectedTokenSequence()=" + Arrays.toString(getExpectedTokenSequence())
+ ", getCurrentToken()=" + getCurrentToken() + "]";
}
private static Field getAccessibleField(final Class<?> clazz, final String field) throws NoSuchFieldException {
final Field declaredField = clazz.getDeclaredField(field);
declaredField.setAccessible(true);
return declaredField;
}
private static Object getValue(final Field field, final Object instance) {
try {
return field.get(instance);
} catch (IllegalArgumentException | IllegalAccessException e) {
throw Throwables.propagate(e);
}
}
/**
* A {@link TokenWrapper} which wraps the
* {@code cz.jirutka.rsql.parser.Token} class of the {@link ParseException}
* which otherwise is not accessible.
*/
public static final class TokenWrapper {
private static final String FIELD_NEXT = "next";
private static final String FIELD_KIND = "kind";
private static final String FIELD_IMAGE = "image";
private static final String FIELD_BEGIN_COL = "beginColumn";
private static final String FIELD_END_COL = "endColumn";
private final Object tokenInstance;
private Field nextTokenField;
private Field kindTokenField;
private Field imageTokenField;
private Field beginColumnTokenField;
private Field endColumnTokenField;
private TokenWrapper(final Object tokenField) {
this.tokenInstance = tokenField;
try {
nextTokenField = getAccessibleField(tokenField.getClass(), FIELD_NEXT);
} catch (@SuppressWarnings("squid:S1166") final NoSuchFieldException e) {
nextTokenField = null;
}
try {
kindTokenField = getAccessibleField(tokenField.getClass(), FIELD_KIND);
} catch (@SuppressWarnings("squid:S1166") final NoSuchFieldException e) {
kindTokenField = null;
}
try {
imageTokenField = getAccessibleField(tokenField.getClass(), FIELD_IMAGE);
} catch (@SuppressWarnings("squid:S1166") final NoSuchFieldException e) {
imageTokenField = null;
}
try {
beginColumnTokenField = getAccessibleField(tokenField.getClass(), FIELD_BEGIN_COL);
} catch (@SuppressWarnings("squid:S1166") final NoSuchFieldException e) {
beginColumnTokenField = null;
}
try {
endColumnTokenField = getAccessibleField(tokenField.getClass(), FIELD_END_COL);
} catch (@SuppressWarnings("squid:S1166") final NoSuchFieldException e) {
endColumnTokenField = null;
}
}
public TokenWrapper getNext() {
final Object nextToken = getValue(nextTokenField, tokenInstance);
return nextToken != null ? new TokenWrapper(nextToken) : null;
}
public int getKind() {
if (kindTokenField == null) {
return 0;
}
return (int) getValue(kindTokenField, tokenInstance);
}
public String getImage() {
if (imageTokenField == null) {
return null;
}
return (String) getValue(imageTokenField, tokenInstance);
}
public int getBeginColumn() {
if (beginColumnTokenField == null) {
return 0;
}
return (int) getValue(beginColumnTokenField, tokenInstance);
}
public int getEndColumn() {
if (endColumnTokenField == null) {
return 0;
}
return (int) getValue(endColumnTokenField, tokenInstance);
}
@Override
public String toString() {
return "TokenWrapper [tokenInstance=" + tokenInstance + ", getNext()=" + getNext() + ", getKind()="
+ getKind() + ", getImage()=" + getImage() + ", getBeginColumn()=" + getBeginColumn()
+ ", getEndColumn()=" + getEndColumn() + "]";
}
}
}

View File

@@ -0,0 +1,301 @@
package org.eclipse.hawkbit.repository.jpa.rsql;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import org.eclipse.hawkbit.repository.TargetFields;
import org.eclipse.hawkbit.repository.TargetManagement;
import org.eclipse.hawkbit.repository.exception.RSQLParameterSyntaxException;
import org.eclipse.hawkbit.repository.exception.RSQLParameterUnsupportedFieldException;
import org.eclipse.hawkbit.repository.jpa.rsql.ParseExceptionWrapper.TokenWrapper;
import org.eclipse.hawkbit.repository.rsql.RsqlValidationOracle;
import org.eclipse.hawkbit.repository.rsql.SuggestToken;
import org.eclipse.hawkbit.repository.rsql.SuggestionContext;
import org.eclipse.hawkbit.repository.rsql.SyntaxErrorContext;
import org.eclipse.hawkbit.repository.rsql.ValidationOracleContext;
import org.eclipse.persistence.exceptions.ConversionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.orm.jpa.JpaSystemException;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;
import cz.jirutka.rsql.parser.ParseException;
import cz.jirutka.rsql.parser.RSQLParserException;
/**
* An implementation of {@link RsqlValidationOracle} which retrieves the
* exception using the {@link ParseException} to retrieve the suggestions.
*
* The suggestion only works when there are syntax errors existing because the
* information about current and next tokens in the RSQL syntax are from the
* {@link ParseException}.
*
* There is a feature request on the GitHub project
* {@link https://github.com/jirutka/rsql-parser/issues/22}.
*/
public class RsqlParserValidationOracle implements RsqlValidationOracle {
private static final Logger LOGGER = LoggerFactory.getLogger(RsqlParserValidationOracle.class);
@Autowired
private TargetManagement targetManagement;
@Override
public ValidationOracleContext suggest(final String rsqlQuery, final int cursorPosition) {
final List<SuggestToken> expectedTokens = new ArrayList<>();
final ValidationOracleContext context = new ValidationOracleContext();
context.setSyntaxError(true);
final SuggestionContext suggestionContext = new SuggestionContext();
context.setSuggestionContext(suggestionContext);
final SyntaxErrorContext errorContext = new SyntaxErrorContext();
context.setSyntaxErrorContext(errorContext);
try {
targetManagement.findTargetsAll(rsqlQuery, new PageRequest(0, 1));
context.setSyntaxError(false);
suggestionContext.getSuggestions().addAll(getLogicalOperatorSuggestion(rsqlQuery));
} catch (final RSQLParameterSyntaxException | RSQLParserException ex) {
setExceptionDetails(new Exception(ex.getCause().getCause()), expectedTokens);
errorContext.setErrorMessage(getCustomMessage(ex.getCause().getMessage(), expectedTokens));
suggestionContext.setSuggestions(expectedTokens);
LOGGER.trace("Syntax exception on parsing :", ex);
} catch (final RSQLParameterUnsupportedFieldException | IllegalArgumentException ex) {
errorContext.setErrorMessage(getCustomMessage(ex.getMessage(), null));
LOGGER.trace("Illegal argument on parsing :", ex);
} catch (@SuppressWarnings("squid:S1166") final ConversionException | JpaSystemException e) {
// noop
}
return context;
}
private static Collection<? extends SuggestToken> getLogicalOperatorSuggestion(final String rsqlQuery) {
final int currentQueryLength = rsqlQuery.length();
// only return and/or suggestion when there is a space at the end
if (rsqlQuery.endsWith(" ")) {
final Collection<String> tokenImages = TokenDescription.getTokenImage(TokenDescription.LOGICAL_OP);
final List<SuggestToken> logicalOps = new ArrayList<>(tokenImages.size());
for (final String tokenImage : tokenImages) {
logicalOps.add(new SuggestToken(currentQueryLength, currentQueryLength + tokenImage.length(), null,
tokenImage));
}
return logicalOps;
}
return Collections.emptyList();
}
private static void setExceptionDetails(final Exception ex, final List<SuggestToken> expectedTokens) {
expectedTokens.addAll(getNextTokens(ex));
}
private static List<SuggestToken> getNextTokens(final Exception e) {
final ParseException parseException = findParseException(e);
if (parseException != null) {
final List<SuggestToken> listTokens = new ArrayList<>();
final ParseExceptionWrapper parseExceptionWrapper = new ParseExceptionWrapper(parseException);
final int[][] expectedTokenSequence = parseExceptionWrapper.getExpectedTokenSequence();
final TokenWrapper currentToken = parseExceptionWrapper.getCurrentToken();
final TokenWrapper nextToken = currentToken.getNext();
final int currentTokenKind = currentToken.getKind();
final String currentTokenImageName = currentToken.getImage();
final int nextTokenBeginColumn = nextToken.getBeginColumn();
final int currentTokenEndColumn = currentToken.getEndColumn();
// token == 5 is the field token, reverse engineering.
if (currentTokenKind == 5) {
final Optional<List<SuggestToken>> handleFieldTokenSuggestion = handleFieldTokenSuggestion(
currentTokenImageName, nextTokenBeginColumn, currentTokenEndColumn);
if (handleFieldTokenSuggestion.isPresent()) {
return handleFieldTokenSuggestion.get();
}
}
for (final int[] is : expectedTokenSequence) {
for (final int i : is) {
final Collection<String> tokenImage = TokenDescription.getTokenImage(i);
if (tokenImage != null && !tokenImage.isEmpty()) {
tokenImage.forEach(image -> listTokens.add(new SuggestToken(currentTokenEndColumn + 1,
nextTokenBeginColumn + image.length(), null, image)));
}
}
}
return listTokens;
}
return Collections.emptyList();
}
private static Optional<List<SuggestToken>> handleFieldTokenSuggestion(final String currentTokenImageName,
final int nextTokenBeginColumn, final int currentTokenEndColumn) {
final boolean containsDot = currentTokenImageName.indexOf('.') != -1;
if (shouldSuggestTopLevelFieldNames(currentTokenImageName, containsDot)) {
return Optional
.of(FieldNameDescription.toTopSuggestToken(nextTokenBeginColumn - currentTokenImageName.length(),
nextTokenBeginColumn + currentTokenImageName.length(), currentTokenImageName));
} else if (shouldSuggestDotToken(currentTokenImageName, containsDot)) {
return Optional.of(
Lists.newArrayList(new SuggestToken(currentTokenEndColumn, nextTokenBeginColumn + 1, null, ".")));
} else if (shouldSuggestSubTokenFieldNames(currentTokenImageName, containsDot)) {
return handleSubtokenSuggestion(currentTokenImageName, nextTokenBeginColumn);
}
return Optional.empty();
}
private static boolean shouldSuggestSubTokenFieldNames(final String currentTokenImageName,
final boolean containsDot) {
return containsDot && !FieldNameDescription.containsValue(currentTokenImageName);
}
private static boolean shouldSuggestDotToken(final String currentTokenImageName, final boolean containsDot) {
return !containsDot && FieldNameDescription.hasSubEntries(currentTokenImageName);
}
private static boolean shouldSuggestTopLevelFieldNames(final String currentTokenImageName,
final boolean containsDot) {
return !containsDot && !FieldNameDescription.containsValue(currentTokenImageName)
&& !FieldNameDescription.hasSubEntries(currentTokenImageName);
}
private static Optional<List<SuggestToken>> handleSubtokenSuggestion(final String currentTokenImageName,
final int nextTokenBeginColumn) {
final String[] split = currentTokenImageName.split("\\.");
for (final String string : split) {
if (FieldNameDescription.containsValue(string)) {
final String subTokenImage = split.length > 1 ? split[1] : null;
final int subTokenBegin = nextTokenBeginColumn + currentTokenImageName.indexOf('.') + 1;
return Optional.of(FieldNameDescription.toSubSuggestToken(subTokenBegin, subTokenBegin + 1, string,
subTokenImage));
}
}
return Optional.empty();
}
private static ParseException findParseException(final Throwable e) {
if (e != null && e instanceof ParseException) {
return (ParseException) e;
} else if (e != null && e.getCause() != null) {
return findParseException(e.getCause());
}
return null;
}
private static String getCustomMessage(final String message, final List<SuggestToken> expectedTokens) {
String builder = message;
if (message.contains(":")) {
builder = message.substring(message.indexOf(':') + 1, message.length());
if (builder.indexOf("Was expecting") != -1) {
builder = builder.substring(0, builder.lastIndexOf("Was expecting"));
}
if (null != expectedTokens && !expectedTokens.isEmpty()) {
final StringBuilder tokens = new StringBuilder();
expectedTokens.stream().forEach(value -> tokens.append(value.getSuggestion() + ","));
builder = builder.concat("Was expecting :" + tokens.toString().substring(0, tokens.length() - 1));
}
builder = builder.replace('\r', ' ');
builder = builder.replace('\n', ' ');
builder = builder.replaceAll(">", " ");
builder = builder.replaceAll("<", " ");
}
return builder;
}
private static final class TokenDescription {
private static final Multimap<Integer, String> TOKEN_MAP = ArrayListMultimap.create();
private static final int LOGICAL_OP = 8;
private static final int COMPARATOR = 12;
static {
TOKEN_MAP.put(LOGICAL_OP, "and");
TOKEN_MAP.put(LOGICAL_OP, "or");
TOKEN_MAP.put(COMPARATOR, "==");
TOKEN_MAP.put(COMPARATOR, "!=");
TOKEN_MAP.put(COMPARATOR, "=ge=");
TOKEN_MAP.put(COMPARATOR, "=le=");
TOKEN_MAP.put(COMPARATOR, "=gt=");
TOKEN_MAP.put(COMPARATOR, "=lt=");
TOKEN_MAP.put(COMPARATOR, "=in=");
TOKEN_MAP.put(COMPARATOR, "=out=");
}
private TokenDescription() {
}
private static Collection<String> getTokenImage(final int tokenIndex) {
return TOKEN_MAP.get(tokenIndex);
}
}
private static final class FieldNameDescription {
private static final Set<String> FIELD_NAMES = Arrays.stream(TargetFields.values())
.map(field -> field.toString().toLowerCase()).collect(Collectors.toSet());
private static final Map<String, List<String>> SUB_NAMES = Arrays.stream(TargetFields.values()).collect(
Collectors.toMap(field -> field.toString().toLowerCase(), field -> field.getSubEntityAttributes()));
private FieldNameDescription() {
}
private static boolean hasSubEntries(final String tokenImageName) {
final String tmpTokenName;
if (tokenImageName.contains(".")) {
final String[] split = tokenImageName.split("\\.");
if (split.length > 0) {
tmpTokenName = split[0];
} else {
return false;
}
} else {
tmpTokenName = tokenImageName;
}
return Arrays.stream(TargetFields.values()).filter(field -> field.toString().equalsIgnoreCase(tmpTokenName))
.map(field -> field.getSubEntityAttributes()).flatMap(subentities -> subentities.stream())
.count() > 0;
}
private static List<SuggestToken> toTopSuggestToken(final int beginToken, final int endToken,
final String tokenImageName) {
return FIELD_NAMES.stream()
.map(field -> new SuggestToken(beginToken, endToken, tokenImageName, field.toLowerCase()))
.collect(Collectors.toList());
}
private static List<SuggestToken> toSubSuggestToken(final int beginToken, final int endToken,
final String topToken, final String tokenImageName) {
return Arrays.stream(TargetFields.values()).filter(field -> field.toString().equalsIgnoreCase(topToken))
.map(field -> field.getSubEntityAttributes()).flatMap(list -> list.stream())
.map(subentity -> new SuggestToken(beginToken, endToken, tokenImageName, subentity))
.collect(Collectors.toList());
}
private static boolean containsValue(final String imageName) {
if (imageName.contains(".")) {
final String[] split = imageName.split("\\.");
if (split.length > 1 && FIELD_NAMES.contains(split[0].toLowerCase())) {
return SUB_NAMES.get(split[0].toLowerCase()).stream()
.filter(subname -> new String(split[0] + "." + subname).equalsIgnoreCase(imageName))
.count() > 0;
}
}
return FIELD_NAMES.stream().filter(value -> value.equalsIgnoreCase(imageName)).count() > 0;
}
}
}

View File

@@ -0,0 +1,81 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others.
*
* 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
* http://www.eclipse.org/legal/epl-v10.html
*/
package org.eclipse.hawkbit.repository.jpa.rsql;
import static org.fest.assertions.api.Assertions.assertThat;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import org.eclipse.hawkbit.repository.TargetFields;
import org.eclipse.hawkbit.repository.jpa.AbstractJpaIntegrationTest;
import org.eclipse.hawkbit.repository.rsql.RsqlValidationOracle;
import org.eclipse.hawkbit.repository.rsql.ValidationOracleContext;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import ru.yandex.qatools.allure.annotations.Description;
import ru.yandex.qatools.allure.annotations.Features;
import ru.yandex.qatools.allure.annotations.Stories;
@Features("Component Tests - Repository")
@Stories("RSQL filter suggestion")
public class RsqlParserValidationOracleTest extends AbstractJpaIntegrationTest {
@Autowired
private RsqlValidationOracle rsqlValidationOracle;
private static final String[] OP_SUGGESTIONS = new String[] { "==", "!=", "=ge=", "=le=", "=gt=", "=lt=", "=in=",
"=out=" };
private static final String[] FIELD_SUGGESTIONS = Arrays.stream(TargetFields.values())
.map(field -> field.name().toLowerCase()).toArray(size -> new String[size]);
private static final String[] AND_OR_SUGGESTIONS = new String[] { "and", "or" };
private static final String[] NAME_VERSION_SUGGESTIONS = new String[] { "name", "version" };
@Test
@Description("Verifies that suggestions contains all possible field names")
public void suggestionContainsAllFieldNames() {
final String rsqlQuery = "na";
final List<String> currentSuggestions = getSuggestions(rsqlQuery);
assertThat(currentSuggestions).containsOnly(FIELD_SUGGESTIONS);
}
@Test
@Description("Verifies that suggestions only contains the allowed operators")
public void suggestionContainsOnlyOperators() {
final String rsqlQuery = "name";
final List<String> currentSuggestions = getSuggestions(rsqlQuery);
assertThat(currentSuggestions).containsOnly(OP_SUGGESTIONS);
}
@Test
@Description("Verifies that suggestions only contains operator to combine RSQL filters (and, or)")
public void suggestionContainsOnlyAndOrOperator() {
final String rsqlQuery = "name==a ";
final List<String> currentSuggestions = getSuggestions(rsqlQuery);
assertThat(currentSuggestions).containsOnly(AND_OR_SUGGESTIONS);
}
@Test
@Description("Verifies that sub suggestions are shown")
public void suggestionContainsSubFieldSuggestions() {
final String rsqlQuery = "assignedds.";
final List<String> currentSuggestions = getSuggestions(rsqlQuery);
assertThat(currentSuggestions).containsOnly(NAME_VERSION_SUGGESTIONS);
}
private List<String> getSuggestions(final String rsqlQuery) {
final ValidationOracleContext suggest = rsqlValidationOracle.suggest(rsqlQuery, -1);
final List<String> currentSuggestions = suggest.getSuggestionContext().getSuggestions().stream()
.map(suggestion -> suggestion.getSuggestion()).collect(Collectors.toList());
return currentSuggestions;
}
}