diff --git a/README.md b/README.md index 93ec06b..f18f2d9 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ type Employee { firstName: String! lastName: String! age: Int! + birthDate: DateTime! } # Define filter input @@ -81,6 +82,7 @@ input Filter { firstName: StringExpression lastName: StringExpression age: IntExpression + birthDate: DateExpression and: [Filter!] or: [Filter!] @@ -91,6 +93,9 @@ input Filter { input StringExpression { equals: String contains: String + starts: String + ends: String + in: [String!] } # Define Int Expression @@ -100,6 +105,16 @@ input IntExpression { gte: Int lt: Int lte: Int + in: [Int!] +} + +# Define Date Expression +input DateExpression { + eq: DateTime + gt: DateTime + gte: DateTime + lt: DateTime + lte: DateTime } ``` @@ -173,6 +188,47 @@ private String getExpression(DataFetchingEnvironment env) { ``` WHERE ((lastName = 'Jaiswal') OR (firstName LIKE '%Saurabh%')) ``` + +### DynamoDB FilterExpression +Generates a DynamoDB `FilterExpression` string along with an `ExpressionAttributeValues` map +that can be passed directly to a DynamoDB `ScanRequest` or `QueryRequest`. + +```java +private void queryDynamoDB(DataFetchingEnvironment env, DynamoDBMapper mapper) { + FilterExpression.FilterExpressionBuilder builder = FilterExpression.newFilterExpressionBuilder(); + FilterExpression filterExpression = builder.field(env.getField()) + .args(env.getArguments()) + .build(); + + DynamoDBExpressionVisitor visitor = new DynamoDBExpressionVisitor(null, null); + String filterExpressionStr = filterExpression.getExpression(ExpressionFormat.DYNAMODB); + Map expressionValues = visitor.getExpressionValues(); + + // Convert expressionValues to Map and use with DynamoDB SDK +} +``` + +#### Expression output +``` +(contains(firstName, :firstName)) +``` +#### ExpressionAttributeValues output +``` +{ ":firstName": "Saurabh" } +``` + +#### Operator mapping +| GraphQL operator | DynamoDB expression | Parameter name | +|-----------------|---------------------|----------------| +| `contains` | `contains(field, :field)` | `:field` | +| `starts` | `begins_with(field, :field)` | `:field` | +| `equals` / `eq` | `field = :field` | `:field` | +| `gt` | `field > :min_field` | `:min_field` | +| `gte` | `field >= :min_field` | `:min_field` | +| `lt` | `field < :max_field` | `:max_field` | +| `lte` | `field <= :max_field` | `:max_field` | +| `in` | `field IN (:field_0, :field_1, ...)` | `:field_N` | +| `between` | `field BETWEEN :min_field AND :max_field` | `:min_field`, `:max_field` | ## How it works? When graphql-java receives and parses the source filter expression, it creates an AST in memory which contains all the fields, operators and values supplied in the source filter. The problem is the generated AST does not know about the valid rules of a correct logical expression with multiple filter criteria. In order to get a meaningful expression out of the source @@ -189,6 +245,9 @@ After this step, the generated AST looks as shown below in memory. - Infix String - SQL WHERE clause - JPA Specification +- MongoDB Criteria +- Elasticsearch Criteria +- DynamoDB FilterExpression ## Supported Operators ### Relational @@ -197,10 +256,13 @@ After this step, the generated AST looks as shown below in memory. * **Range** (IN, BETWEEN) ### Logical * **AND** - * **OR** + * **OR** * **NOT** -## Supported Database -- MySQL +## Supported Databases +- MySQL (via JPA Specification or SQL WHERE clause) +- MongoDB (via Criteria) +- Elasticsearch (via Criteria) +- DynamoDB (via FilterExpression) ## Complete GraphQL JPA Example [GraphQL Java Filtering With JPA Specification](/example/) diff --git a/src/main/java/com/intuit/graphql/filter/client/ExpressionFormat.java b/src/main/java/com/intuit/graphql/filter/client/ExpressionFormat.java index 25177e8..5a09142 100644 --- a/src/main/java/com/intuit/graphql/filter/client/ExpressionFormat.java +++ b/src/main/java/com/intuit/graphql/filter/client/ExpressionFormat.java @@ -26,7 +26,8 @@ public enum ExpressionFormat { INFIX("INFIX"), JPA("JPA"), MONGO("MONGO"), - ELASTICSEARCH("ELASTICSEARCH"); + ELASTICSEARCH("ELASTICSEARCH"), + DYNAMODB("DYNAMODB"); private String type; ExpressionFormat(String type) { diff --git a/src/main/java/com/intuit/graphql/filter/client/ExpressionVisitorFactory.java b/src/main/java/com/intuit/graphql/filter/client/ExpressionVisitorFactory.java index 753a0aa..08c9494 100644 --- a/src/main/java/com/intuit/graphql/filter/client/ExpressionVisitorFactory.java +++ b/src/main/java/com/intuit/graphql/filter/client/ExpressionVisitorFactory.java @@ -15,6 +15,7 @@ */ package com.intuit.graphql.filter.client; +import com.intuit.graphql.filter.visitors.DynamoDBExpressionVisitor; import com.intuit.graphql.filter.visitors.ElasticsearchCriteriaExpressionVisitor; import com.intuit.graphql.filter.visitors.ExpressionVisitor; import com.intuit.graphql.filter.visitors.InfixExpressionVisitor; @@ -61,6 +62,9 @@ static ExpressionVisitor getExpressionVisitor(ExpressionFormat format, case ELASTICSEARCH: expressionVisitor = new ElasticsearchCriteriaExpressionVisitor(fieldMap, fieldValueTransformer); break; + case DYNAMODB: + expressionVisitor = new DynamoDBExpressionVisitor(fieldMap, fieldValueTransformer); + break; } } return expressionVisitor; diff --git a/src/main/java/com/intuit/graphql/filter/visitors/DynamoDBExpressionVisitor.java b/src/main/java/com/intuit/graphql/filter/visitors/DynamoDBExpressionVisitor.java new file mode 100644 index 0000000..d5638ae --- /dev/null +++ b/src/main/java/com/intuit/graphql/filter/visitors/DynamoDBExpressionVisitor.java @@ -0,0 +1,294 @@ +/* + Copyright 2020 Intuit Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +package com.intuit.graphql.filter.visitors; + +import com.intuit.graphql.filter.ast.BinaryExpression; +import com.intuit.graphql.filter.ast.CompoundExpression; +import com.intuit.graphql.filter.ast.Expression; +import com.intuit.graphql.filter.ast.ExpressionField; +import com.intuit.graphql.filter.ast.ExpressionValue; +import com.intuit.graphql.filter.ast.Operator; +import com.intuit.graphql.filter.ast.UnaryExpression; +import com.intuit.graphql.filter.client.FieldValuePair; +import com.intuit.graphql.filter.client.FieldValueTransformer; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * This class is responsible for traversing + * the expression tree and generating a + * DynamoDB filter expression string from it with correct + * precedence order marked by parentheses. + * + *

The visitor produces two outputs: + *

+ * + * @author sjaiswal + */ +public class DynamoDBExpressionVisitor implements ExpressionVisitor { + + private final Deque operatorStack; + private final Map fieldMap; + private final Deque fieldStack; + private final FieldValueTransformer fieldValueTransformer; + private final Map expressionValues; + private final Map expressionNames; + + public DynamoDBExpressionVisitor(Map fieldMap, + FieldValueTransformer fieldValueTransformer) { + this.operatorStack = new ArrayDeque<>(); + this.fieldMap = fieldMap; + this.fieldStack = new ArrayDeque<>(); + this.fieldValueTransformer = fieldValueTransformer; + this.expressionValues = new HashMap<>(); + this.expressionNames = new HashMap<>(); + } + + /** + * Entry point — initiates AST traversal and returns the complete + * DynamoDB filter expression string. + */ + @Override + public String expression(Expression expression) { + String expressionString = ""; + if (expression != null) { + expressionString = expression.accept(this, expressionString); + } + return expressionString; + } + + /** + * Handles AND / OR compound expressions. + * Wraps both operands in parentheses to preserve precedence. + */ + @Override + public String visitCompoundExpression(CompoundExpression compoundExpression, String data) { + StringBuilder expressionBuilder = new StringBuilder(data); + expressionBuilder.append("(") + .append(compoundExpression.getLeftOperand().accept(this, "")) + .append(" ").append(compoundExpression.getOperator().getName().toUpperCase()).append(" ") + .append(compoundExpression.getRightOperand().accept(this, "")) + .append(")"); + return expressionBuilder.toString(); + } + + /** + * Handles binary field-value comparisons. + * CONTAINS and STARTS use DynamoDB function syntax and are handled + * specially in {@link #visitExpressionValue}. + */ + @Override + public String visitBinaryExpression(BinaryExpression binaryExpression, String data) { + StringBuilder expressionBuilder = new StringBuilder(data); + Operator operator = binaryExpression.getOperator(); + + if (operator == Operator.STARTS || operator == Operator.CONTAINS) { + // Function-style operators: field name is embedded in visitExpressionValue + operatorStack.push(operator); + binaryExpression.getLeftOperand().accept(this, ""); + expressionBuilder.append(binaryExpression.getRightOperand().accept(this, "")); + } else { + expressionBuilder.append("(") + .append(binaryExpression.getLeftOperand().accept(this, "")) + .append(" ").append(resolveOperator(operator)).append(" "); + operatorStack.push(operator); + expressionBuilder.append(binaryExpression.getRightOperand().accept(this, "")) + .append(")"); + } + return expressionBuilder.toString(); + } + + /** + * Handles NOT unary expressions. + */ + @Override + public String visitUnaryExpression(UnaryExpression unaryExpression, String data) { + StringBuilder expressionBuilder = new StringBuilder(data); + expressionBuilder.append("(") + .append(" ").append(resolveOperator(unaryExpression.getOperator())).append(" ") + .append(unaryExpression.getLeftOperand().accept(this, "")) + .append(")"); + return expressionBuilder.toString(); + } + + /** + * Handles field name resolution including fieldMap and custom transformer. + * For STARTS/CONTAINS the field name is not appended here — it is used + * inside {@link #visitExpressionValue} to build the function call. + */ + @Override + public String visitExpressionField(ExpressionField field, String data) { + StringBuilder expressionBuilder = new StringBuilder(data); + String fieldName = field.infix(); + + if (fieldMap != null && fieldMap.get(fieldName) != null) { + fieldName = fieldMap.get(fieldName); + } else if (fieldValueTransformer != null + && fieldValueTransformer.transformField(fieldName) != null) { + fieldName = fieldValueTransformer.transformField(fieldName); + } + fieldStack.push(field); + + Operator operator = operatorStack.peek(); + if (operator == Operator.STARTS || operator == Operator.CONTAINS) { + // Field name is written inside visitExpressionValue for function calls + return expressionBuilder.toString(); + } + expressionBuilder.append(fieldName); + return expressionBuilder.toString(); + } + + /** + * Handles value nodes — generates the expression-attribute placeholder name, + * stores the value in {@link #expressionValues}, and builds the final expression + * fragment for the current operator. + */ + @Override + public String visitExpressionValue(ExpressionValue value, String data) { + StringBuilder expressionBuilder = new StringBuilder(data); + Operator operator = operatorStack.pop(); + ExpressionField field = fieldStack.pop(); + String fieldName = resolveFieldName(field.infix()); + + if (!fieldStack.isEmpty() && fieldValueTransformer != null) { + FieldValuePair fieldValuePair = fieldValueTransformer.transformValue(field.infix(), value.value()); + if (fieldValuePair != null && fieldValuePair.getValue() != null) { + value = new ExpressionValue(fieldValuePair.getValue()); + } + } + + String expressionValue = deriveValueParameterName(operator, fieldName); + expressionValues.put(expressionValue, value.value()); + + if (operator == Operator.STARTS) { + expressionBuilder.append("(begins_with(") + .append(fieldName).append(", ").append(expressionValue).append("))"); + } else if (operator == Operator.CONTAINS) { + expressionBuilder.append("(contains(") + .append(fieldName).append(", ").append(expressionValue).append("))"); + } else if (operator == Operator.BETWEEN) { + List values = (List) value.value(); + String minValue = ":min_" + fieldName; + String maxValue = ":max_" + fieldName; + this.expressionValues.put(minValue, values.get(0)); + this.expressionValues.put(maxValue, values.get(1)); + expressionBuilder.append(minValue).append(" AND ").append(maxValue); + } else if (operator == Operator.IN) { + List values = (List) value.value(); + expressionBuilder.append("("); + for (int i = 0; i < values.size(); i++) { + String inValue = ":" + fieldName + "_" + i; + this.expressionValues.put(inValue, values.get(i)); + expressionBuilder.append(inValue); + if (i < values.size() - 1) { + expressionBuilder.append(", "); + } + } + expressionBuilder.append(")"); + } else { + expressionBuilder.append(expressionValue); + } + return expressionBuilder.toString(); + } + + /** + * Returns the expression-attribute values map, e.g. + * {@code {":firstName": "Saurabh", ":min_age": 25}}. + * Use this to populate {@code ExpressionAttributeValues} on the DynamoDB request + * after converting each value to an {@code AttributeValue}. + */ + public Map getExpressionValues() { + return expressionValues; + } + + /** + * Returns the expression-attribute names map for aliasing reserved words. + * Currently unused but available for callers that need {@code ExpressionAttributeNames}. + */ + public Map getExpressionNames() { + return expressionNames; + } + + private String resolveFieldName(String fieldName) { + if (fieldMap != null && fieldMap.get(fieldName) != null) { + return fieldMap.get(fieldName); + } + if (fieldValueTransformer != null + && fieldValueTransformer.transformField(fieldName) != null) { + return fieldValueTransformer.transformField(fieldName); + } + return fieldName; + } + + /** + * Derives the placeholder parameter name for a given operator and field. + * Range-lower operators use {@code :min_field}; range-upper operators use + * {@code :max_field}; all others use {@code :field}. + */ + private String deriveValueParameterName(Operator operator, String fieldName) { + switch (operator) { + case GT: + case GTE: + return ":min_" + fieldName; + case LT: + case LTE: + return ":max_" + fieldName; + default: + return ":" + fieldName; + } + } + + private String resolveOperator(Operator operator) { + switch (operator) { + case AND: + case OR: + case NOT: + return operator.getName().toUpperCase(); + case CONTAINS: + return "contains"; + case STARTS: + return "begins_with"; + case LT: + return "<"; + case GT: + return ">"; + case EQ: + case EQUALS: + return "="; + case GTE: + return ">="; + case LTE: + return "<="; + case IN: + return "IN"; + case BETWEEN: + return "BETWEEN"; + default: + return ""; + } + } +} diff --git a/src/test/java/com/intuit/graphql/filter/common/EmployeeDataFetcher.java b/src/test/java/com/intuit/graphql/filter/common/EmployeeDataFetcher.java index e536345..ea5fa6b 100644 --- a/src/test/java/com/intuit/graphql/filter/common/EmployeeDataFetcher.java +++ b/src/test/java/com/intuit/graphql/filter/common/EmployeeDataFetcher.java @@ -35,6 +35,7 @@ public class EmployeeDataFetcher { private Specification specification; private Criteria mongoCriteria; private org.springframework.data.elasticsearch.core.query.Criteria elasticsearchCriteria; + private String dynamoDBExpression; public EmployeeDataFetcher() { @@ -136,4 +137,22 @@ public org.springframework.data.elasticsearch.core.query.Criteria getElasticsear return elasticsearchCriteria; } + public DataFetcher searchEmployeesDynamoDB() { + return new DataFetcher() { + @Override + public Object get(DataFetchingEnvironment dataFetchingEnvironment) throws Exception { + FilterExpression.FilterExpressionBuilder builder = FilterExpression.newFilterExpressionBuilder(); + FilterExpression filterExpression = builder.field(dataFetchingEnvironment.getField()) + .args(dataFetchingEnvironment.getArguments()) + .build(); + dynamoDBExpression = filterExpression.getExpression(ExpressionFormat.DYNAMODB); + return null; + } + }; + } + + public String getDynamoDBExpression() { + return dynamoDBExpression; + } + } diff --git a/src/test/java/com/intuit/graphql/filter/visitors/DynamoDBExpressionTest.java b/src/test/java/com/intuit/graphql/filter/visitors/DynamoDBExpressionTest.java new file mode 100644 index 0000000..abf0577 --- /dev/null +++ b/src/test/java/com/intuit/graphql/filter/visitors/DynamoDBExpressionTest.java @@ -0,0 +1,193 @@ +/* + Copyright 2020 Intuit Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +package com.intuit.graphql.filter.visitors; + +import com.intuit.graphql.filter.common.TestConstants; +import graphql.ExecutionResult; +import graphql.scalars.ExtendedScalars; +import graphql.schema.idl.RuntimeWiring; +import org.junit.Assert; +import org.junit.Test; + +import static graphql.schema.idl.TypeRuntimeWiring.newTypeWiring; + +/** + * @author sjaiswal + */ +public class DynamoDBExpressionTest extends BaseFilterExpressionTest { + + @Override + public RuntimeWiring buildWiring() { + return RuntimeWiring.newRuntimeWiring() + .scalar(ExtendedScalars.DateTime) + .type(newTypeWiring("Query") + .dataFetcher("searchEmployees", getEmployeeDataFetcher().searchEmployeesDynamoDB())) + .build(); + } + + @Test + public void filterExpressionWithContains() { + getGraphQL().execute(TestConstants.BINARY_FILER); + + String expectedExpression = "(contains(firstName, :firstName))"; + + Assert.assertEquals(expectedExpression, getEmployeeDataFetcher().getDynamoDBExpression()); + } + + @Test + public void filterExpressionWithOR() { + getGraphQL().execute(TestConstants.COMPOUND_FILER_WITH_OR); + + String expectedExpression = "((contains(firstName, :firstName)) OR (lastName = :lastName))"; + + Assert.assertEquals(expectedExpression, getEmployeeDataFetcher().getDynamoDBExpression()); + } + + @Test + public void filterExpressionWithAND() { + getGraphQL().execute(TestConstants.COMPOUND_FILER_WITH_AND); + + String expectedExpression = "((contains(firstName, :firstName)) AND (lastName = :lastName))"; + + Assert.assertEquals(expectedExpression, getEmployeeDataFetcher().getDynamoDBExpression()); + } + + @Test + public void filterExpressionORWithAND() { + getGraphQL().execute(TestConstants.COMPOUND_FILER_WITH_OR_AND); + + String expectedExpression = "((contains(firstName, :firstName)) OR ((lastName = :lastName) AND (age >= :min_age)))"; + + Assert.assertEquals(expectedExpression, getEmployeeDataFetcher().getDynamoDBExpression()); + } + + @Test + public void filterExpressionANDWithOR() { + getGraphQL().execute(TestConstants.COMPOUND_FILER_WITH_AND_OR); + + String expectedExpression = "((contains(firstName, :firstName)) AND ((lastName = :lastName) OR (age >= :min_age)))"; + + Assert.assertEquals(expectedExpression, getEmployeeDataFetcher().getDynamoDBExpression()); + } + + @Test + public void filterExpressionANDWithMultipleOR() { + getGraphQL().execute(TestConstants.COMPOUND_FILER_WITH_OR_OR_AND); + + String expectedExpression = "(((contains(firstName, :firstName)) OR (lastName = :lastName)) OR ((firstName = :firstName) AND (age >= :min_age)))"; + + Assert.assertEquals(expectedExpression, getEmployeeDataFetcher().getDynamoDBExpression()); + } + + @Test + public void filterExpressionORWithMultipleAND() { + getGraphQL().execute(TestConstants.COMPOUND_FILER_WITH_AND_AND_OR); + + String expectedExpression = "(((contains(firstName, :firstName)) AND (lastName = :lastName)) AND ((firstName = :firstName) OR (age >= :min_age)))"; + + Assert.assertEquals(expectedExpression, getEmployeeDataFetcher().getDynamoDBExpression()); + } + + @Test + public void filterExpressionWithOtherArgs() { + getGraphQL().execute(TestConstants.FILTER_WITH_OTHER_ARGS); + + String expectedExpression = "(firstName = :firstName)"; + + Assert.assertEquals(expectedExpression, getEmployeeDataFetcher().getDynamoDBExpression()); + } + + @Test + public void filterExpressionWithVariables() { + getGraphQL().execute(TestConstants.FILTER_WITH_VARIABLE); + + String expectedExpression = "((contains(firstName, :firstName)) AND (lastName = :lastName))"; + + Assert.assertEquals(expectedExpression, getEmployeeDataFetcher().getDynamoDBExpression()); + } + + @Test + public void filterExpressionWithLastNameIn() { + getGraphQL().execute(TestConstants.LAST_NAME_IN); + + String expectedExpression = "(lastName IN (:lastName_0, :lastName_1, :lastName_2))"; + + Assert.assertEquals(expectedExpression, getEmployeeDataFetcher().getDynamoDBExpression()); + } + + @Test + public void filterExpressionWithAgeIn() { + getGraphQL().execute(TestConstants.AGE_IN); + + String expectedExpression = "(age IN (:age_0, :age_1, :age_2))"; + + Assert.assertEquals(expectedExpression, getEmployeeDataFetcher().getDynamoDBExpression()); + } + + @Test + public void invalidFilterExpression() { + getGraphQL().execute(TestConstants.INVALID_FILTER); + + String expectedExpression = null; + + Assert.assertEquals(expectedExpression, getEmployeeDataFetcher().getDynamoDBExpression()); + } + + @Test + public void notFilterExpression() { + getGraphQL().execute(TestConstants.NOT_FILTER); + + String expectedExpression = "( NOT (firstName = :firstName))"; + + Assert.assertEquals(expectedExpression, getEmployeeDataFetcher().getDynamoDBExpression()); + } + + @Test + public void notCompoundFilterExpression() { + getGraphQL().execute(TestConstants.NOT_COMPOUND_FILTER); + + String expectedExpression = "( NOT ((firstName = :firstName) AND (contains(lastName, :lastName))))"; + + Assert.assertEquals(expectedExpression, getEmployeeDataFetcher().getDynamoDBExpression()); + } + + @Test + public void compoundFilterExpressionWithNot() { + getGraphQL().execute(TestConstants.COMPOUND_NOT_FILTER); + + String expectedExpression = "((firstName = :firstName) AND ( NOT (contains(lastName, :lastName))))"; + + Assert.assertEquals(expectedExpression, getEmployeeDataFetcher().getDynamoDBExpression()); + } + + @Test + public void filterExpressionWithStarts() { + getGraphQL().execute(TestConstants.FIRST_NAME_STARTS); + + String expectedExpression = "(begins_with(firstName, :firstName))"; + + Assert.assertEquals(expectedExpression, getEmployeeDataFetcher().getDynamoDBExpression()); + } + + @Test + public void filterExpressionWithContainsSubstring() { + getGraphQL().execute(TestConstants.FIRST_NAME_CONTAINS); + + String expectedExpression = "(contains(firstName, :firstName))"; + + Assert.assertEquals(expectedExpression, getEmployeeDataFetcher().getDynamoDBExpression()); + } +}