feat: Where condition helper library using H2 in memory database (#7592)

This commit is contained in:
Trisha Anand 2021-09-19 20:33:47 +05:30 committed by GitHub
parent eaa9d783df
commit 659d7c3866
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 784 additions and 5 deletions

View File

@ -126,6 +126,12 @@
<version>2.4.7</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.200</version>
</dependency>
</dependencies>

View File

@ -1,6 +1,6 @@
package com.external.plugins;
package com.appsmith.external.constants;
public enum Op {
public enum ConditionalOperator {
LT,
LTE,
EQ,

View File

@ -0,0 +1,61 @@
package com.appsmith.external.models;
import com.appsmith.external.constants.ConditionalOperator;
import com.appsmith.external.constants.DataType;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.commons.lang3.StringUtils;
import java.util.List;
import java.util.stream.Collectors;
import static com.appsmith.external.helpers.DataTypeStringUtils.stringToKnownDataTypeConverter;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class Condition {
String path;
ConditionalOperator operator;
String value;
@JsonIgnore
DataType valueDataType;
public Condition(String path, String operator, String value) {
this.path = path;
this.operator = ConditionalOperator.valueOf(operator);
this.value = value;
}
public static List<Condition> addValueDataType(List<Condition> conditionList) {
return conditionList
.stream()
.map(condition -> {
String value = condition.getValue();
DataType dataType = stringToKnownDataTypeConverter(value);
condition.setValueDataType(dataType);
return condition;
})
.collect(Collectors.toList());
}
public static Boolean isValid(Condition condition) {
if (StringUtils.isEmpty(condition.getPath()) ||
(condition.getOperator() == null) ||
StringUtils.isEmpty(condition.getValue())) {
return false;
}
return true;
}
}

View File

@ -0,0 +1,431 @@
package com.appsmith.external.services;
import com.appsmith.external.constants.ConditionalOperator;
import com.appsmith.external.constants.DataType;
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError;
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException;
import com.appsmith.external.models.Condition;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static com.appsmith.external.helpers.DataTypeStringUtils.stringToKnownDataTypeConverter;
import static com.appsmith.external.models.Condition.addValueDataType;
@Component
@Slf4j
public class FilterDataService {
private static FilterDataService instance = null;
private ObjectMapper objectMapper;
private Connection connection;
private static final String URL = "jdbc:h2:mem:filterDb";
private static final Map<DataType, String> SQL_DATATYPE_MAP = Map.of(
DataType.INTEGER, "INT",
DataType.LONG, "BIGINT",
DataType.FLOAT, "REAL",
DataType.DOUBLE, "DOUBLE",
DataType.BOOLEAN, "BOOLEAN",
DataType.STRING, "VARCHAR"
);
private static final Map<ConditionalOperator, String> SQL_OPERATOR_MAP = Map.of(
ConditionalOperator.LT, "<",
ConditionalOperator.LTE, "<=",
ConditionalOperator.EQ, "=",
ConditionalOperator.NOT_EQ, "<>",
ConditionalOperator.GT, ">",
ConditionalOperator.GTE, ">=",
ConditionalOperator.IN, "IN",
ConditionalOperator.NOT_IN, "NOT IN"
);
private FilterDataService() {
objectMapper = new ObjectMapper();
try {
connection = DriverManager.getConnection(URL);
} catch (SQLException e) {
log.error(e.getMessage());
throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Failed to connect to the in memory database. Unable to perform filtering");
}
}
public static FilterDataService getInstance() {
if (instance == null) {
instance = new FilterDataService();
}
return instance;
}
public ArrayNode filterData(ArrayNode items, List<Condition> conditionList) {
if (items == null || items.size() == 0) {
return items;
}
if (!validConditionList(conditionList)) {
throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, "Conditions for filtering were incomplete or incorrect.");
}
List<Condition> conditions = addValueDataType(conditionList);
// Generate the schema of the table using the first object
JsonNode jsonNode = items.get(0);
Map<String, DataType> schema = generateSchema(jsonNode);
String tableName = generateTable(schema);
// insert the data
insertData(tableName, items, schema);
// Filter the data
List<Map<String, Object>> finalResults = executeFilterQuery(tableName, conditions);
// Now that the data has been filtered. Clean Up. Drop the table
dropTable(tableName);
ArrayNode finalResultsNode = objectMapper.valueToTree(finalResults);
return finalResultsNode;
}
public List<Map<String, Object>> executeFilterQuery(String tableName, List<Condition> conditions) {
StringBuilder sb = new StringBuilder("SELECT * FROM " + tableName);
String whereClause = generateWhereClause(conditions);
sb.append(whereClause);
sb.append(";");
String selectQuery = sb.toString();
log.debug("{} : Executing Query on H2 : {}", Thread.currentThread().getName(), selectQuery);
List<Map<String, Object>> rowsList = new ArrayList<>(50);
Connection conn = checkAndGetConnection();
try {
Statement statement = conn.createStatement();
ResultSet resultSet = statement.executeQuery(selectQuery);
ResultSetMetaData metaData = resultSet.getMetaData();
int colCount = metaData.getColumnCount();
while (resultSet.next()) {
Map<String, Object> row = new LinkedHashMap<>(colCount);
for (int i = 1; i <= colCount; i++) {
row.put(metaData.getColumnName(i), resultSet.getObject(i));
}
rowsList.add(row);
}
} catch (SQLException e) {
// Getting a SQL Exception here means that our generated query is incorrect. Raise an alarm!
log.error(e.getMessage());
throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Filtering failure seen : " + e.getMessage());
}
return rowsList;
}
private String generateWhereClause(List<Condition> conditions) {
StringBuilder sb = new StringBuilder();
Boolean firstCondition = true;
for (Condition condition : conditions) {
if (firstCondition) {
// Append the WHERE keyword before adding the conditions
sb.append(" WHERE ");
firstCondition = false;
} else {
// This is not the first condition. Append an `AND` before adding the next condition
sb.append(" AND ");
}
String path = condition.getPath();
ConditionalOperator operator = condition.getOperator();
String value = condition.getValue();
String sqlOp = SQL_OPERATOR_MAP.get(operator);
if (sqlOp == null) {
throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR,
operator.toString() + " is not supported currently for filtering.");
}
sb.append(path);
sb.append(" ");
sb.append(sqlOp);
sb.append(" ");
// These are array operations. Convert value into appropriate format and then append
if (operator == ConditionalOperator.IN || operator == ConditionalOperator.NOT_IN) {
StringBuilder valueBuilder = new StringBuilder("(");
// The array could be an array of Strings
if (value.contains("\"")) {
try {
List<String> stringValues = objectMapper.readValue(value, List.class);
List<String> updatedStringValues = stringValues.stream().map(stringValue -> "\'" + stringValue + "\'").collect(Collectors.toList());
String finalValues = String.join(",", updatedStringValues);
valueBuilder.append(finalValues);
} catch (IOException e) {
log.error(e.getMessage());
}
} else {
// Removes the outer square brackets from the string to leave behind just the values separated by comma
String trimmedValue = value.replaceAll("^\\[|]$", "");
valueBuilder.append(trimmedValue);
}
valueBuilder.append(")");
value = valueBuilder.toString();
sb.append(value);
} else {
// Since the value is not an array, surround the same with single quotes and append
sb.append("'");
sb.append(value);
sb.append("'");
}
}
return sb.toString();
}
// INSERT INTO tableName (columnName1, columnName2) VALUES (data1, data2)
public void insertData(String tableName, ArrayNode items, Map<String, DataType> schema) {
List<String> columnNames = schema.keySet().stream().collect(Collectors.toList());
StringBuilder insertQueryBuilder = new StringBuilder("INSERT INTO ");
insertQueryBuilder.append(tableName);
StringBuilder columnNamesBuilder = new StringBuilder("(");
columnNamesBuilder.append(String.join(", ", columnNames));
columnNamesBuilder.append(")");
insertQueryBuilder.append(columnNamesBuilder);
insertQueryBuilder.append(" VALUES ");
StringBuilder valuesMasterBuilder = new StringBuilder();
int counter = 0;
for (JsonNode item : items) {
// If the number of values inserted is greater than 1000, the insert would fail. Once we have reached 1000
// rows, execute the insert for rows so far and start afresh for the rest of the rows
if (counter == 1000) {
String insertQueryString = finalInsertQueryString(insertQueryBuilder.toString(), valuesMasterBuilder);
executeDbQuery(insertQueryString);
// Reset the values builder and counter for new insert queries.
valuesMasterBuilder = new StringBuilder();
counter = 0;
}
StringBuilder valuesBuilder = new StringBuilder();
if (counter != 0) {
// If not the first row, add a separator between rows
valuesBuilder.append(",");
}
// Start the row
valuesBuilder.append("(");
Boolean firstEntry = true;
for (String columnName : columnNames) {
if (!firstEntry) {
// Add a separator before adding a new entry
valuesBuilder.append(",");
} else {
// For future iterations, set flag to false
firstEntry = false;
}
JsonNode fieldNode = item.get(columnName);
if (fieldNode != null) {
valuesBuilder.append("'");
valuesBuilder.append(fieldNode.asText());
valuesBuilder.append("'");
}
}
// End the row
valuesBuilder.append(")");
valuesMasterBuilder.append(valuesBuilder);
counter++;
}
if (valuesMasterBuilder.length() > 0) {
String insertQueryString = finalInsertQueryString(insertQueryBuilder.toString(), valuesMasterBuilder);
executeDbQuery(insertQueryString);
}
}
private void executeDbQuery(String query) {
Connection conn = checkAndGetConnection();
try {
conn.createStatement().execute(query);
} catch (SQLException e) {
log.error(e.getMessage());
// Getting a SQL Exception here means that our generated query is incorrect. Raise an alarm!
throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Filtering failure seen during insertion of data : " + e.getMessage());
}
}
private String finalInsertQueryString(String partialInsertQuery, StringBuilder valuesBuilder) {
StringBuilder insertQueryBuilder = new StringBuilder(partialInsertQuery);
insertQueryBuilder.append(valuesBuilder);
insertQueryBuilder.append(";");
String finalInsertQuery = insertQueryBuilder.toString();
return finalInsertQuery;
}
private Connection checkAndGetConnection() {
try {
if (connection == null || connection.isClosed() || !connection.isValid(5)) {
connection = DriverManager.getConnection(URL);
}
} catch (SQLException e) {
throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Failed to connect to the in memory database. Unable to perform filtering");
}
return connection;
}
public String generateTable(Map<String, DataType> schema) {
// Generate table name
String generateUniqueId = new ObjectId().toString().toUpperCase();
// Appending tbl_ before the generated unique id since using the string directly was throwing a SQL error
// which I couldnt solve. Just appending a string to it though works perfectly.
String tableName = new StringBuilder("tbl_").append(generateUniqueId).toString();
StringBuilder sb = new StringBuilder("CREATE TABLE ");
sb.append(tableName);
sb.append(" (");
Boolean columnsAdded = false;
for (Map.Entry<String, DataType> entry : schema.entrySet()) {
if (columnsAdded) {
// If columns have been added before, add a separator
sb.append(",");
}
String fieldName = entry.getKey();
DataType dataType = entry.getValue();
String sqlDataType = SQL_DATATYPE_MAP.get(dataType);
if (sqlDataType == null) {
// the data type recognized does not have a native support in appsmith right now
// default to String
sqlDataType = SQL_DATATYPE_MAP.get(DataType.STRING);
}
columnsAdded = true;
sb.append(fieldName);
sb.append(" ");
sb.append(sqlDataType);
}
sb.append(");");
String createTableQuery = sb.toString();
executeDbQuery(createTableQuery);
return tableName;
}
public void dropTable(String tableName) {
String dropTableQuery = "DROP TABLE " + tableName + ";";
executeDbQuery(dropTableQuery);
}
private Map<String, DataType> generateSchema(JsonNode jsonNode) {
Iterator<String> fieldNamesIterator = jsonNode.fieldNames();
/*
* For an object of the following type :
* {
* "field1" : "stringValue",
* "field2" : "true",
* "field3" : "12"
* }
*
* The schema generated would be a Map as follows :
* {
* field1 : DataType.STRING
* field2 : DataType.BOOLEAN
* field3 : DataType.INTEGER
* }
*/
Map<String, DataType> schema = Stream.generate(() -> null)
.takeWhile(x -> fieldNamesIterator.hasNext())
.map(n -> fieldNamesIterator.next())
.collect(Collectors.toMap(
Function.identity(),
name -> {
String value = jsonNode.get(name).asText();
DataType dataType = stringToKnownDataTypeConverter(value);
return dataType;
}));
return schema;
}
public boolean validConditionList(List<Condition> conditionList) {
return conditionList
.stream()
.allMatch(condition -> Condition.isValid(condition));
}
}

View File

@ -0,0 +1,281 @@
package com.appsmith.external.services;
import com.appsmith.external.constants.DataType;
import com.appsmith.external.models.Condition;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import org.junit.Test;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.junit.Assert.assertEquals;
public class FilterDataServiceTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final FilterDataService filterDataService = FilterDataService.getInstance();
@Test
public void testGenerateTable() {
Map<String, DataType> schema = Map.of(
"id", DataType.INTEGER,
"name", DataType.STRING,
"status", DataType.BOOLEAN
);
String table = filterDataService.generateTable(schema);
assertThat(table).isNotNull();
}
@Test
public void testFilterSingleCondition() {
String data = "[\n" +
" {\n" +
" \"id\": 2381224,\n" +
" \"email\": \"michael.lawson@reqres.in\",\n" +
" \"userName\": \"Michael Lawson\",\n" +
" \"productName\": \"Chicken Sandwich\",\n" +
" \"orderAmount\": 4.99,\n" +
" \"orderStatus\": \"READY\"\n" +
" },\n" +
" {\n" +
" \"id\": 2736212,\n" +
" \"email\": \"lindsay.ferguson@reqres.in\",\n" +
" \"userName\": \"Lindsay Ferguson\",\n" +
" \"productName\": \"Tuna Salad\",\n" +
" \"orderAmount\": 9.99,\n" +
" \"orderStatus\": \"READY\"\n" +
" },\n" +
" {\n" +
" \"id\": 6788734,\n" +
" \"email\": \"tobias.funke@reqres.in\",\n" +
" \"userName\": \"Tobias Funke\",\n" +
" \"productName\": \"Beef steak\",\n" +
" \"orderAmount\": 19.99,\n" +
" \"orderStatus\": \"READY\"\n" +
" }\n" +
"]";
try {
ArrayNode items = (ArrayNode) objectMapper.readTree(data);
List<Condition> conditionList = new ArrayList<>();
Condition condition = new Condition("orderAmount", "LT", "15");
conditionList.add(condition);
ArrayNode filteredData = filterDataService.filterData(items, conditionList);
assertEquals(filteredData.size(), 2);
} catch (IOException e) {
e.printStackTrace();
}
}
@Test
public void testFilterMultipleConditions() {
String data = "[\n" +
" {\n" +
" \"id\": 2381224,\n" +
" \"email\": \"michael.lawson@reqres.in\",\n" +
" \"userName\": \"Michael Lawson\",\n" +
" \"productName\": \"Chicken Sandwich\",\n" +
" \"orderAmount\": 4.99,\n" +
" \"orderStatus\": \"READY\"\n" +
" },\n" +
" {\n" +
" \"id\": 2736212,\n" +
" \"email\": \"lindsay.ferguson@reqres.in\",\n" +
" \"userName\": \"Lindsay Ferguson\",\n" +
" \"productName\": \"Tuna Salad\",\n" +
" \"orderAmount\": 9.99,\n" +
" \"orderStatus\": \"NOT READY\"\n" +
" },\n" +
" {\n" +
" \"id\": 6788734,\n" +
" \"email\": \"tobias.funke@reqres.in\",\n" +
" \"userName\": \"Tobias Funke\",\n" +
" \"productName\": \"Beef steak\",\n" +
" \"orderAmount\": 19.99,\n" +
" \"orderStatus\": \"READY\"\n" +
" }\n" +
"]";
try {
ArrayNode items = (ArrayNode) objectMapper.readTree(data);
List<Condition> conditionList = new ArrayList<>();
Condition condition = new Condition("orderAmount", "LT", "15");
conditionList.add(condition);
Condition condition1 = new Condition("orderStatus", "EQ", "READY");
conditionList.add(condition1);
ArrayNode filteredData = filterDataService.filterData(items, conditionList);
assertEquals(filteredData.size(), 1);
} catch (IOException e) {
e.printStackTrace();
}
}
@Test
public void testFilterInConditionForStrings() {
String data = "[\n" +
" {\n" +
" \"id\": 2381224,\n" +
" \"email\": \"michael.lawson@reqres.in\",\n" +
" \"userName\": \"Michael Lawson\",\n" +
" \"productName\": \"Chicken Sandwich\",\n" +
" \"orderAmount\": 4.99,\n" +
" \"orderStatus\": \"READY\"\n" +
" },\n" +
" {\n" +
" \"id\": 2736212,\n" +
" \"email\": \"lindsay.ferguson@reqres.in\",\n" +
" \"userName\": \"Lindsay Ferguson\",\n" +
" \"productName\": \"Tuna Salad\",\n" +
" \"orderAmount\": 9.99,\n" +
" \"orderStatus\": \"NOT READY\"\n" +
" },\n" +
" {\n" +
" \"id\": 6788734,\n" +
" \"email\": \"tobias.funke@reqres.in\",\n" +
" \"userName\": \"Tobias Funke\",\n" +
" \"productName\": \"Beef steak\",\n" +
" \"orderAmount\": 19.99,\n" +
" \"orderStatus\": \"READY\"\n" +
" }\n" +
"]";
try {
ArrayNode items = (ArrayNode) objectMapper.readTree(data);
List<Condition> conditionList = new ArrayList<>();
Condition condition = new Condition("orderAmount", "LT", "15");
conditionList.add(condition);
Condition condition1 = new Condition("orderStatus", "IN", "[\"READY\", \"NOT READY\"]");
conditionList.add(condition1);
ArrayNode filteredData = filterDataService.filterData(items, conditionList);
assertEquals(filteredData.size(), 2);
} catch (IOException e) {
e.printStackTrace();
}
}
@Test
public void testFilterInConditionForNumbers() {
String data = "[\n" +
" {\n" +
" \"id\": 2381224,\n" +
" \"email\": \"michael.lawson@reqres.in\",\n" +
" \"userName\": \"Michael Lawson\",\n" +
" \"productName\": \"Chicken Sandwich\",\n" +
" \"orderAmount\": 4.99,\n" +
" \"orderStatus\": \"READY\"\n" +
" },\n" +
" {\n" +
" \"id\": 2736212,\n" +
" \"email\": \"lindsay.ferguson@reqres.in\",\n" +
" \"userName\": \"Lindsay Ferguson\",\n" +
" \"productName\": \"Tuna Salad\",\n" +
" \"orderAmount\": 9.99,\n" +
" \"orderStatus\": \"NOT READY\"\n" +
" },\n" +
" {\n" +
" \"id\": 6788734,\n" +
" \"email\": \"tobias.funke@reqres.in\",\n" +
" \"userName\": \"Tobias Funke\",\n" +
" \"productName\": \"Beef steak\",\n" +
" \"orderAmount\": 19.99,\n" +
" \"orderStatus\": \"READY\"\n" +
" }\n" +
"]";
try {
ArrayNode items = (ArrayNode) objectMapper.readTree(data);
List<Condition> conditionList = new ArrayList<>();
Condition condition = new Condition("orderAmount", "LT", "15");
conditionList.add(condition);
Condition condition1 = new Condition("orderAmount", "IN", "[4.99, 19.99]");
conditionList.add(condition1);
ArrayNode filteredData = filterDataService.filterData(items, conditionList);
assertEquals(filteredData.size(), 1);
} catch (IOException e) {
e.printStackTrace();
}
}
@Test
public void testFilterNotInConditionForNumbers() {
String data = "[\n" +
" {\n" +
" \"id\": 2381224,\n" +
" \"email\": \"michael.lawson@reqres.in\",\n" +
" \"userName\": \"Michael Lawson\",\n" +
" \"productName\": \"Chicken Sandwich\",\n" +
" \"orderAmount\": 4.99,\n" +
" \"orderStatus\": \"READY\"\n" +
" },\n" +
" {\n" +
" \"id\": 2736212,\n" +
" \"email\": \"lindsay.ferguson@reqres.in\",\n" +
" \"userName\": \"Lindsay Ferguson\",\n" +
" \"productName\": \"Tuna Salad\",\n" +
" \"orderAmount\": 9.99,\n" +
" \"orderStatus\": \"NOT READY\"\n" +
" },\n" +
" {\n" +
" \"id\": 6788734,\n" +
" \"email\": \"tobias.funke@reqres.in\",\n" +
" \"userName\": \"Tobias Funke\",\n" +
" \"productName\": \"Beef steak\",\n" +
" \"orderAmount\": 19.99,\n" +
" \"orderStatus\": \"READY\"\n" +
" }\n" +
"]";
try {
ArrayNode items = (ArrayNode) objectMapper.readTree(data);
List<Condition> conditionList = new ArrayList<>();
Condition condition = new Condition("orderAmount", "LT", "15");
conditionList.add(condition);
Condition condition1 = new Condition("orderAmount", "NOT_IN", "[5.99, 19.00]");
conditionList.add(condition1);
ArrayNode filteredData = filterDataService.filterData(items, conditionList);
assertEquals(filteredData.size(), 2);
} catch (IOException e) {
e.printStackTrace();
}
}
}

View File

@ -2,7 +2,7 @@ package com.external.utils;
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError;
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException;
import com.external.plugins.Op;
import com.appsmith.external.constants.ConditionalOperator;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.cloud.firestore.FieldPath;
import com.google.cloud.firestore.Query;
@ -26,9 +26,9 @@ public class WhereConditionUtils {
);
}
Op operator;
ConditionalOperator operator;
try {
operator = StringUtils.isEmpty(operatorString) ? null : Op.valueOf(operatorString);
operator = StringUtils.isEmpty(operatorString) ? null : ConditionalOperator.valueOf(operatorString);
} catch (IllegalArgumentException e) {
throw new AppsmithPluginException(
AppsmithPluginError.PLUGIN_ERROR,