Repository: getmoto/moto

Issue:
Inconsistency between moto and boto when DynamoDb Query FilterExpression is empty
I have a conditional DynamoDB `Query` where, depending on the parameters, I may want to pass a `FilterExpression` or not.

Initially I wrote it like this:

```
filter_condition = Attr('permissions').contains('admin') if admins_only else ''
results = table.query(
    KeyConditionExpression=key_condition,
    FilterExpression=filter_condition
)
```

The tests were all fine, but then when I deployed it, the query failed because:

```
botocore.exceptions.ClientError: An error occurred (ValidationException) when calling the Query operation: Invalid FilterExpression: The expression can not be empty;
```

So I had to rewrite my code as:

```
if admins_only:
    results = table.query(
        KeyConditionExpression=key_condition,
        FilterExpression=Attr('permissions').contains('admin')
    )
else:
    results = table.query(
        KeyConditionExpression=key_condition,
    )
```

I also tried to pass `None` instead of the empty string, but then the tests break in moto.

It would be nice if this inconsistency was resolved in moto, it's not the first time I was hit by this.

Thanks.


Discussion:
Thanks for raising this @luisabtrace - marking it as an enhancement to add this validation.

Resolving patch:
diff --git a/moto/dynamodb/models/__init__.py b/moto/dynamodb/models/__init__.py
--- a/moto/dynamodb/models/__init__.py
+++ b/moto/dynamodb/models/__init__.py
@@ -316,7 +316,7 @@ def query(
         limit: int,
         exclusive_start_key: Dict[str, Any],
         scan_index_forward: bool,
-        projection_expression: str,
+        projection_expression: Optional[str],
         index_name: Optional[str] = None,
         expr_names: Optional[Dict[str, str]] = None,
         expr_values: Optional[Dict[str, str]] = None,
@@ -351,11 +351,11 @@ def scan(
         filters: Dict[str, Any],
         limit: int,
         exclusive_start_key: Dict[str, Any],
-        filter_expression: str,
+        filter_expression: Optional[str],
         expr_names: Dict[str, Any],
         expr_values: Dict[str, Any],
         index_name: str,
-        projection_expression: str,
+        projection_expression: Optional[str],
     ) -> Tuple[List[Item], int, Optional[Dict[str, Any]]]:
         table = self.get_table(table_name)
 
diff --git a/moto/dynamodb/models/table.py b/moto/dynamodb/models/table.py
--- a/moto/dynamodb/models/table.py
+++ b/moto/dynamodb/models/table.py
@@ -637,7 +637,7 @@ def query(
         limit: int,
         exclusive_start_key: Dict[str, Any],
         scan_index_forward: bool,
-        projection_expression: str,
+        projection_expression: Optional[str],
         index_name: Optional[str] = None,
         filter_expression: Any = None,
         **filter_kwargs: Any,
diff --git a/moto/dynamodb/responses.py b/moto/dynamodb/responses.py
--- a/moto/dynamodb/responses.py
+++ b/moto/dynamodb/responses.py
@@ -351,6 +351,22 @@ def dump_list(list_: List[str]) -> str:
                     + dump_list(actual_attrs)
                 )
 
+    def _get_filter_expression(self) -> Optional[str]:
+        filter_expression = self.body.get("FilterExpression")
+        if filter_expression == "":
+            raise MockValidationException(
+                "Invalid FilterExpression: The expression can not be empty;"
+            )
+        return filter_expression
+
+    def _get_projection_expression(self) -> Optional[str]:
+        expression = self.body.get("ProjectionExpression")
+        if expression == "":
+            raise MockValidationException(
+                "Invalid ProjectionExpression: The expression can not be empty;"
+            )
+        return expression
+
     def delete_table(self) -> str:
         name = self.body["TableName"]
         table = self.dynamodb_backend.delete_table(name)
@@ -521,7 +537,7 @@ def get_item(self) -> str:
                 f"empty string value. Key: {empty_keys[0]}"
             )
 
-        projection_expression = self.body.get("ProjectionExpression")
+        projection_expression = self._get_projection_expression()
         attributes_to_get = self.body.get("AttributesToGet")
         if projection_expression and attributes_to_get:
             raise MockValidationException(
@@ -631,9 +647,9 @@ def _contains_duplicates(self, keys: List[str]) -> bool:
     def query(self) -> str:
         name = self.body["TableName"]
         key_condition_expression = self.body.get("KeyConditionExpression")
-        projection_expression = self.body.get("ProjectionExpression")
+        projection_expression = self._get_projection_expression()
         expression_attribute_names = self.body.get("ExpressionAttributeNames", {})
-        filter_expression = self.body.get("FilterExpression")
+        filter_expression = self._get_filter_expression()
         expression_attribute_values = self.body.get("ExpressionAttributeValues", {})
 
         projection_expression = self._adjust_projection_expression(
@@ -726,8 +742,8 @@ def query(self) -> str:
         return dynamo_json_dump(result)
 
     def _adjust_projection_expression(
-        self, projection_expression: str, expr_attr_names: Dict[str, str]
-    ) -> str:
+        self, projection_expression: Optional[str], expr_attr_names: Dict[str, str]
+    ) -> Optional[str]:
         def _adjust(expression: str) -> str:
             return (
                 expr_attr_names[expression]
@@ -762,10 +778,10 @@ def scan(self) -> str:
             comparison_values = scan_filter.get("AttributeValueList", [])
             filters[attribute_name] = (comparison_operator, comparison_values)
 
-        filter_expression = self.body.get("FilterExpression")
+        filter_expression = self._get_filter_expression()
         expression_attribute_values = self.body.get("ExpressionAttributeValues", {})
         expression_attribute_names = self.body.get("ExpressionAttributeNames", {})
-        projection_expression = self.body.get("ProjectionExpression", "")
+        projection_expression = self._get_projection_expression()
         exclusive_start_key = self.body.get("ExclusiveStartKey")
         limit = self.body.get("Limit")
         index_name = self.body.get("IndexName")


Test patch:
diff --git a/tests/test_dynamodb/exceptions/test_dynamodb_exceptions.py b/tests/test_dynamodb/exceptions/test_dynamodb_exceptions.py
--- a/tests/test_dynamodb/exceptions/test_dynamodb_exceptions.py
+++ b/tests/test_dynamodb/exceptions/test_dynamodb_exceptions.py
@@ -144,7 +144,7 @@ def test_empty_expressionattributenames_with_empty_projection():
     table = ddb.Table("test-table")
     with pytest.raises(ClientError) as exc:
         table.get_item(
-            Key={"id": "my_id"}, ProjectionExpression="", ExpressionAttributeNames={}
+            Key={"id": "my_id"}, ProjectionExpression="a", ExpressionAttributeNames={}
         )
     err = exc.value.response["Error"]
     assert err["Code"] == "ValidationException"
@@ -1067,3 +1067,30 @@ def test_list_append_errors_for_unknown_attribute_value():
         ExpressionAttributeValues={":i": {"L": [{"S": "bar2"}]}},
         ReturnValues="UPDATED_NEW",
     )
+
+
+@mock_dynamodb
+def test_query_with_empty_filter_expression():
+    ddb = boto3.resource("dynamodb", region_name="us-east-1")
+    ddb.create_table(
+        TableName="test-table", BillingMode="PAY_PER_REQUEST", **table_schema
+    )
+    table = ddb.Table("test-table")
+    with pytest.raises(ClientError) as exc:
+        table.query(
+            KeyConditionExpression="partitionKey = sth", ProjectionExpression=""
+        )
+    err = exc.value.response["Error"]
+    assert err["Code"] == "ValidationException"
+    assert (
+        err["Message"]
+        == "Invalid ProjectionExpression: The expression can not be empty;"
+    )
+
+    with pytest.raises(ClientError) as exc:
+        table.query(KeyConditionExpression="partitionKey = sth", FilterExpression="")
+    err = exc.value.response["Error"]
+    assert err["Code"] == "ValidationException"
+    assert (
+        err["Message"] == "Invalid FilterExpression: The expression can not be empty;"
+    )