Repository: dapr/python-sdk

Issue:
add support for statestore query API
This feature is implemented in dapr runtime: dapr/dapr#3662

It should be available via the SDK


Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/dapr/python-sdk/pull/325?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dapr) Report
> Merging [#325](https://codecov.io/gh/dapr/python-sdk/pull/325?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dapr) (d651c28) into [master](https://codecov.io/gh/dapr/python-sdk/commit/bba469a2ee46c38b6ab523bd3b575c9407438937?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dapr) (bba469a) will **decrease** coverage by `0.14%`.
> The diff coverage is `84.00%`.

> :exclamation: Current head d651c28 differs from pull request most recent head 82f10d5. Consider uploading reports for the commit 82f10d5 to get more accurate results
[![Impacted file tree graph](https://codecov.io/gh/dapr/python-sdk/pull/325/graphs/tree.svg?width=650&height=150&src=pr&token=JTTIYXA9JP&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dapr)](https://codecov.io/gh/dapr/python-sdk/pull/325?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dapr)

```diff
@@            Coverage Diff             @@
##           master     #325      +/-   ##
==========================================
- Coverage   89.48%   89.33%   -0.15%     
==========================================
  Files          52       52              
  Lines        2196     2242      +46     
==========================================
+ Hits         1965     2003      +38     
- Misses        231      239       +8     
```


| [Impacted Files](https://codecov.io/gh/dapr/python-sdk/pull/325?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dapr) | Coverage Δ | |
|---|---|---|
| [dapr/clients/grpc/\_response.py](https://codecov.io/gh/dapr/python-sdk/pull/325/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dapr#diff-ZGFwci9jbGllbnRzL2dycGMvX3Jlc3BvbnNlLnB5) | `92.55% <81.08%> (-2.39%)` | :arrow_down: |
| [dapr/clients/grpc/client.py](https://codecov.io/gh/dapr/python-sdk/pull/325/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dapr#diff-ZGFwci9jbGllbnRzL2dycGMvY2xpZW50LnB5) | `91.01% <92.30%> (-0.12%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/dapr/python-sdk/pull/325?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dapr).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dapr)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/dapr/python-sdk/pull/325?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dapr). Last update [bba469a...82f10d5](https://codecov.io/gh/dapr/python-sdk/pull/325?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dapr). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dapr).

berndverst: @dmitsh can you review to make sure this looks good from a Query API perspective? I followed the Go SDK implementation closely.
berndverst: FYI @wcs1only  -- validation tests are expected to fail in GitHub Actions because the 1.5.1 sidecar does not return the correct data. The sidecar needs to be manually built from master at this time.
wcs1only: > FYI @wcs1only -- validation tests are expected to fail in GitHub Actions because the 1.5.1 sidecar does not return the correct data. The sidecar needs to be manually built from master at this time. This is expected to work once the 1.6.0 rc is live.

This might be a good case for having beta builds.
berndverst: FYI nothing changed here -- I just rebased against master so that the Protobuff changes were no longer included in this PR

Patch:
diff --git a/dapr/clients/grpc/_response.py b/dapr/clients/grpc/_response.py
index 00ac970..e32c901 100644
--- a/dapr/clients/grpc/_response.py
+++ b/dapr/clients/grpc/_response.py
@@ -469,6 +469,110 @@ class BulkStatesResponse(DaprResponse):
         return self._items
 
 
+class QueryResponseItem:
+    """A query response item from state store query API.
+
+    Attributes:
+        key (str): query reponse item's key.
+        value (bytes): query reponse item's data.
+        etag (str): query reponse item's etag.
+        error (str): error when state was retrieved
+    """
+
+    def __init__(
+            self,
+            key: str,
+            value: bytes,
+            etag: str = '',
+            error: str = ''):
+        """Initializes QueryResponseItem item from :obj:`runtime_v1.QueryStateItem`.
+
+        Args:
+            key (str): query response item's key.
+            value (bytes): query response item's data.
+            etag (str): query response item's etag.
+            error (str): error when state was retrieved
+        """
+        self._key = key
+        self._value = value
+        self._etag = etag
+        self._error = error
+
+    def text(self) -> str:
+        """Gets value as str."""
+        return to_str(self._value)
+
+    def json(self) -> Dict[str, object]:
+        """Gets value as deserialized JSON dictionary."""
+        return json.loads(to_str(self._value))
+
+    @property
+    def key(self) -> str:
+        """Gets key."""
+        return self._key
+
+    @property
+    def value(self) -> bytes:
+        """Gets raw value."""
+        return self._value
+
+    @property
+    def etag(self) -> str:
+        """Gets etag."""
+        return self._etag
+
+    @property
+    def error(self) -> str:
+        """Gets error."""
+        return self._error
+
+
+class QueryResponse(DaprResponse):
+    """The response of state store query API.
+
+    This inherits from DaprResponse
+
+    Attributes:
+        results (Sequence[QueryResponseItem]): the query results.
+        token (str): query response token for pagination.
+        metadata (Dict[str, str]): query response metadata.
+    """
+
+    def __init__(
+            self,
+            results: Sequence[QueryResponseItem],
+            token: str = '',
+            metadata: Dict[str, str] = dict(),
+            headers: MetadataTuple = ()):
+        """Initializes QueryResponse from :obj:`runtime_v1.QueryStateResponse`.
+
+        Args:
+            results (Sequence[QueryResponseItem]): the query results.
+            token (str): query response token for pagination.
+            metadata (Dict[str, str]): query response metadata.
+            headers (Tuple, optional): the headers from Dapr gRPC response.
+        """
+        super(QueryResponse, self).__init__(headers)
+        self._metadata = metadata
+        self._results = results
+        self._token = token
+
+    @property
+    def results(self) -> Sequence[QueryResponseItem]:
+        """Gets the query results."""
+        return self._results
+
+    @property
+    def token(self) -> str:
+        """Gets the query pagination token."""
+        return self._token
+
+    @property
+    def metadata(self) -> Dict[str, str]:
+        """Gets the query response metadata."""
+        return self._metadata
+
+
 class ConfigurationItem:
     """A config item from get_configuration API.
 
diff --git a/dapr/clients/grpc/client.py b/dapr/clients/grpc/client.py
index dfe592d..fca2a10 100644
--- a/dapr/clients/grpc/client.py
+++ b/dapr/clients/grpc/client.py
@@ -16,6 +16,15 @@ limitations under the License.
 import time
 import socket
 
+from urllib.parse import urlencode
+
+from warnings import warn
+
+from typing import Dict, Optional, Union, Sequence, List
+
+from google.protobuf.message import Message as GrpcMessage
+from google.protobuf.empty_pb2 import Empty as GrpcEmpty
+
 import grpc  # type: ignore
 from grpc import (  # type: ignore
     UnaryUnaryClientInterceptor,
@@ -25,12 +34,6 @@ from grpc import (  # type: ignore
 )
 
 from dapr.clients.grpc._state import StateOptions, StateItem
-
-from typing import Dict, Optional, Union, Sequence, List
-
-from google.protobuf.message import Message as GrpcMessage
-from google.protobuf.empty_pb2 import Empty as GrpcEmpty
-
 from dapr.conf import settings
 from dapr.proto import api_v1, api_service_v1, common_v1
 
@@ -38,7 +41,7 @@ from dapr.clients.grpc._helpers import MetadataTuple, DaprClientInterceptor, to_
 from dapr.clients.grpc._request import (
     InvokeMethodRequest,
     BindingRequest,
-    TransactionalStateOperation,
+    TransactionalStateOperation
 )
 from dapr.clients.grpc._response import (
     BindingResponse,
@@ -50,13 +53,11 @@ from dapr.clients.grpc._response import (
     BulkStatesResponse,
     BulkStateItem,
     ConfigurationResponse,
-    ConfigurationItem
+    ConfigurationItem,
+    QueryResponse,
+    QueryResponseItem
 )
 
-from urllib.parse import urlencode
-
-from warnings import warn
-
 
 class DaprGrpcClient:
     """The convenient layer implementation of Dapr gRPC APIs.
@@ -459,6 +460,75 @@ class DaprGrpcClient:
             items=items,
             headers=call.initial_metadata())
 
+    def query_state(
+            self,
+            store_name: str,
+            query: str,
+            states_metadata: Optional[Dict[str, str]] = dict()) -> QueryResponse:
+        """Queries a statestore with a query
+
+        For details on supported queries see https://docs.dapr.io/
+
+        This example queries a statestore:
+            from dapr import DaprClient
+
+            query = '''
+            {
+                "filter": {
+                    "EQ": { "value.state": "CA" }
+                },
+                "sort": [
+                    {
+                        "key": "value.person.id",
+                        "order": "DESC"
+                    }
+                ]
+            }
+            '''
+
+            with DaprClient() as d:
+                resp = d.query_state(
+                    store_name='state_store',
+                    query=query,
+                    states_metadata={"metakey": "metavalue"},
+                )
+
+        Args:
+            store_name (str): the state store name to query
+            query (str): the query to be executed
+            states_metadata (Dict[str, str], optional): custom metadata for state request
+
+        Returns:
+            :class:`QueryStateResponse` gRPC metadata returned from callee,
+                pagination token and results of the query
+        """
+        warn('The State Store Query API is an Alpha version and is subject to change.',
+             UserWarning, stacklevel=2)
+
+        if not store_name or len(store_name) == 0 or len(store_name.strip()) == 0:
+            raise ValueError("State store name cannot be empty")
+        req = api_v1.QueryStateRequest(
+            store_name=store_name,
+            query=query,
+            metadata=states_metadata)
+        response, call = self._stub.QueryStateAlpha1.with_call(req)
+
+        results = []
+        for item in response.results:
+            results.append(
+                QueryResponseItem(
+                    key=item.key,
+                    value=item.data,
+                    etag=item.etag,
+                    error=item.error)
+            )
+
+        return QueryResponse(
+            token=response.token,
+            results=results,
+            metadata=response.metadata,
+            headers=call.initial_metadata())
+
     def save_state(
             self,
             store_name: str,
diff --git a/examples/state_store_query/README.md b/examples/state_store_query/README.md
new file mode 100644
index 0000000..b1708da
--- /dev/null
+++ b/examples/state_store_query/README.md
@@ -0,0 +1,100 @@
+# Example - Query State Store
+
+This example demonstrates the State Store Query Alpha API in Dapr.
+It demonstrates the following APIs:
+- **querystatealpha1**: Queries a compatible Dapr state store
+
+> **Note:** Make sure to use the latest proto bindings
+
+## Pre-requisites
+
+- [Dapr CLI and initialized environment](https://docs.dapr.io/getting-started)
+- [Install Python 3.7+](https://www.python.org/downloads/)
+
+## Install Dapr python-SDK
+
+<!-- Our CI/CD pipeline automatically installs the correct version, so we can skip this step in the automation -->
+```bash
+pip3 install dapr dapr-ext-grpc
+```
+
+## Store the configuration in configurationstore 
+<!-- STEP
+name: Set configuration value
+timeout_seconds: 120
+-->
+
+```bash
+docker run -d --rm -p 27017:27017 --name mongodb mongo:5
+```
+
+<!-- END_STEP -->
+
+## Run the example
+
+Change directory to this folder:
+```bash
+cd examples/state_store_query
+```
+
+To run this example, start by importing the test data
+
+<!-- STEP
+name: Import test data
+timeout_seconds: 10
+-->
+
+```bash
+# Import the example dataset
+dapr run --app-id demo --dapr-http-port 3500 --components-path components -- curl -X POST -H "Content-Type: application/json" -d @dataset.json http://localhost:3500/v1.0/state/statestore
+```
+<!-- END_STEP -->
+
+
+Now run the app
+
+<!-- STEP
+name: Run get query example
+expected_stdout_lines:
+- '== APP == 1 {"city": "Seattle", "person": {"id": {"$numberDouble": "1036.0"}, "org": "Dev Ops"}, "state": "WA"}'
+- '== APP == 4 {"city": "Spokane", "person": {"id": {"$numberDouble": "1042.0"}, "org": "Dev Ops"}, "state": "WA"}'
+- '== APP == 10 {"city": "New York", "person": {"id": {"$numberDouble": "1054.0"}, "org": "Dev Ops"}, "state": "NY"}'
+- '== APP == Token: 3'
+- '== APP == 9 {"city": "San Diego", "person": {"id": {"$numberDouble": "1002.0"}, "org": "Finance"}, "state": "CA"}'
+- '== APP == 7 {"city": "San Francisco", "person": {"id": {"$numberDouble": "1015.0"}, "org": "Dev Ops"}, "state": "CA"}'
+- '== APP == 3 {"city": "Sacramento", "person": {"id": {"$numberDouble": "1071.0"}, "org": "Finance"}, "state": "CA"}'
+- '== APP == Token: 6'
+timeout_seconds: 5
+-->
+
+```bash
+dapr run --app-id queryexample --components-path components/ -- python3 state_store_query.py
+```
+<!-- END_STEP -->
+
+You should be able to see the following output:
+```
+== APP == 1 {"city": "Seattle", "person": {"id": {"$numberDouble": "1036.0"}, "org": "Dev Ops"}, "state": "WA"}
+== APP == 4 {"city": "Spokane", "person": {"id": {"$numberDouble": "1042.0"}, "org": "Dev Ops"}, "state": "WA"}
+== APP == 10 {"city": "New York", "person": {"id": {"$numberDouble": "1054.0"}, "org": "Dev Ops"}, "state": "NY"}
+== APP == Token: 3
+== APP == 9 {"city": "San Diego", "person": {"id": {"$numberDouble": "1002.0"}, "org": "Finance"}, "state": "CA"}
+== APP == 7 {"city": "San Francisco", "person": {"id": {"$numberDouble": "1015.0"}, "org": "Dev Ops"}, "state": "CA"}
+== APP == 3 {"city": "Sacramento", "person": {"id": {"$numberDouble": "1071.0"}, "org": "Finance"}, "state": "CA"}
+== APP == Token: 6
+```
+
+Cleanup
+
+<!-- STEP
+name: Cleanup
+expected_stdout_lines:
+- "mongodb"
+timeout_seconds: 5
+-->
+
+```bash
+docker kill mongodb
+```
+
+<!-- END_STEP -->
\ No newline at end of file
diff --git a/examples/state_store_query/components/mongodb.yml b/examples/state_store_query/components/mongodb.yml
new file mode 100644
index 0000000..82c96d1
--- /dev/null
+++ b/examples/state_store_query/components/mongodb.yml
@@ -0,0 +1,10 @@
+apiVersion: dapr.io/v1alpha1
+kind: Component
+metadata:
+  name: statestore
+spec:
+  type: state.mongodb
+  version: v1
+  metadata:
+  - name: host
+    value: localhost:27017
diff --git a/examples/state_store_query/dataset.json b/examples/state_store_query/dataset.json
new file mode 100644
index 0000000..69935f9
--- /dev/null
+++ b/examples/state_store_query/dataset.json
@@ -0,0 +1,112 @@
+[
+  {
+    "key": "1",
+    "value": {
+      "person": {
+        "org": "Dev Ops",
+        "id": 1036
+      },
+      "city": "Seattle",
+      "state": "WA"
+    }
+  },
+  {
+    "key": "2",
+    "value": {
+      "person": {
+        "org": "Hardware",
+        "id": 1028
+      },
+      "city": "Portland",
+      "state": "OR"
+    }
+  },
+  {
+    "key": "3",
+    "value": {
+      "person": {
+        "org": "Finance",
+        "id": 1071
+      },
+      "city": "Sacramento",
+      "state": "CA"
+    }
+  },
+  {
+    "key": "4",
+    "value": {
+      "person": {
+        "org": "Dev Ops",
+        "id": 1042
+      },
+      "city": "Spokane",
+      "state": "WA"
+    }
+  },
+  {
+    "key": "5",
+    "value": {
+      "person": {
+        "org": "Hardware",
+        "id": 1007
+      },
+      "city": "Los Angeles",
+      "state": "CA"
+    }
+  },
+  {
+    "key": "6",
+    "value": {
+      "person": {
+        "org": "Finance",
+        "id": 1094
+      },
+      "city": "Eugene",
+      "state": "OR"
+    }
+  },
+  {
+    "key": "7",
+    "value": {
+      "person": {
+        "org": "Dev Ops",
+        "id": 1015
+      },
+      "city": "San Francisco",
+      "state": "CA"
+    }
+  },
+  {
+    "key": "8",
+    "value": {
+      "person": {
+        "org": "Hardware",
+        "id": 1077
+      },
+      "city": "Redmond",
+      "state": "WA"
+    }
+  },
+  {
+    "key": "9",
+    "value": {
+      "person": {
+        "org": "Finance",
+        "id": 1002
+      },
+      "city": "San Diego",
+      "state": "CA"
+    }
+  },
+  {
+    "key": "10",
+    "value": {
+      "person": {
+        "org": "Dev Ops",
+        "id": 1054
+      },
+      "city": "New York",
+      "state": "NY"
+    }
+  }
+]
diff --git a/examples/state_store_query/query-token.json b/examples/state_store_query/query-token.json
new file mode 100644
index 0000000..87a2e55
--- /dev/null
+++ b/examples/state_store_query/query-token.json
@@ -0,0 +1,32 @@
+{
+    "filter": {
+        "OR": [
+            {
+                "EQ": { "value.person.org": "Dev Ops" }
+            },
+            {
+                "AND": [
+                    {
+                        "EQ": { "value.person.org": "Finance" }
+                    },
+                    {
+                        "IN": { "value.state": [ "CA", "WA" ] }
+                    }
+                ]
+            }
+        ]
+    },
+    "sort": [
+        {
+            "key": "value.state",
+            "order": "DESC"
+        },
+        {
+            "key": "value.person.id"
+        }
+    ],
+    "page": {
+        "limit": 3,
+        "token": "3"
+    }
+}
diff --git a/examples/state_store_query/query.json b/examples/state_store_query/query.json
new file mode 100644
index 0000000..5a8b7ff
--- /dev/null
+++ b/examples/state_store_query/query.json
@@ -0,0 +1,31 @@
+{
+    "filter": {
+        "OR": [
+            {
+                "EQ": { "value.person.org": "Dev Ops" }
+            },
+            {
+                "AND": [
+                    {
+                        "EQ": { "value.person.org": "Finance" }
+                    },
+                    {
+                        "IN": { "value.state": [ "CA", "WA" ] }
+                    }
+                ]
+            }
+        ]
+    },
+    "sort": [
+        {
+            "key": "value.state",
+            "order": "DESC"
+        },
+        {
+            "key": "value.person.id"
+        }
+    ],
+    "page": {
+        "limit": 3
+    }
+}
diff --git a/examples/state_store_query/state_store_query.py b/examples/state_store_query/state_store_query.py
new file mode 100644
index 0000000..eaa5a2f
--- /dev/null
+++ b/examples/state_store_query/state_store_query.py
@@ -0,0 +1,31 @@
+
+"""
+dapr run python3 state_store_query.py
+"""
+
+from dapr.clients import DaprClient
+from dapr.clients.grpc._state import StateItem
+
+import json
+
+with DaprClient() as d:
+    storeName = 'statestore'
+
+    # Wait for sidecar to be up within 5 seconds.
+    d.wait(5)
+
+    # Query the state store
+
+    query = open('query.json', 'r').read()
+    res = d.query_state(store_name=storeName, query=query)
+    for r in res.results:
+        print(r.key, json.dumps(json.loads(str(r.value, 'UTF-8')), sort_keys=True))
+    print("Token:", res.token)
+
+    # Get more results using a pagination token
+
+    query = open('query-token.json', 'r').read()
+    res = d.query_state(store_name=storeName, query=query)
+    for r in res.results:
+        print(r.key, json.dumps(json.loads(str(r.value, 'UTF-8')), sort_keys=True))
+    print("Token:", res.token)
\ No newline at end of file
diff --git a/tox.ini b/tox.ini
index 193f991..456779c 100644
--- a/tox.ini
+++ b/tox.ini
@@ -37,6 +37,7 @@ commands =
     ./validate.sh configuration
     ./validate.sh pubsub-simple
     ./validate.sh state_store
+    ./validate.sh state_store_query
     ./validate.sh secret_store
     ./validate.sh invoke-simple
     ./validate.sh invoke-custom-data


Test patch:
diff --git a/tests/clients/fake_dapr_server.py b/tests/clients/fake_dapr_server.py
index 4601bd6..322c1cb 100644
--- a/tests/clients/fake_dapr_server.py
+++ b/tests/clients/fake_dapr_server.py
@@ -1,10 +1,12 @@
 import grpc
+import json
 
 from concurrent import futures
 from google.protobuf.any_pb2 import Any as GrpcAny
 from google.protobuf import empty_pb2
 from dapr.clients.grpc._helpers import to_bytes
 from dapr.proto import api_service_v1, common_v1, api_v1
+from dapr.proto.runtime.v1.dapr_pb2 import QueryStateItem
 
 
 class FakeDaprSidecar(api_service_v1.DaprServicer):
@@ -189,6 +191,25 @@ class FakeDaprSidecar(api_service_v1.DaprServicer):
             items.append(item)
         return api_v1.GetConfigurationResponse(items=items)
 
+    def QueryStateAlpha1(self, request, context):
+        items = [QueryStateItem(
+            key=str(key), data=bytes('value of ' + str(key), 'UTF-8')) for key in range(1, 11)]
+        query = json.loads(request.query)
+
+        tokenIndex = 1
+        if 'page' in query:
+            if 'token' in query['page']:
+                # For testing purposes, we return a token that is the same as the key
+                tokenIndex = int(query['page']['token'])
+                items = items[tokenIndex - 1:]
+            if 'limit' in query['page']:
+                limit = int(query['page']['limit'])
+                if len(items) > limit:
+                    items = items[:limit]
+                tokenIndex = tokenIndex + len(items)
+
+        return api_v1.QueryStateResponse(results=items, token=str(tokenIndex))
+
     def Shutdown(self, request, context):
         self.shutdown_received = True
         return empty_pb2.Empty()
diff --git a/tests/clients/test_dapr_grpc_client.py b/tests/clients/test_dapr_grpc_client.py
index 06a3d53..e0b429c 100644
--- a/tests/clients/test_dapr_grpc_client.py
+++ b/tests/clients/test_dapr_grpc_client.py
@@ -13,6 +13,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 """
 
+import json
 import socket
 import unittest
 import uuid
@@ -428,6 +429,23 @@ class DaprGrpcClientTests(unittest.TestCase):
         self.assertEqual(resp.items[0].version, version)
         self.assertEqual(resp.items[0].metadata, metadata)
 
+    def test_query_state(self):
+        dapr = DaprGrpcClient(f'localhost:{self.server_port}')
+
+        resp = dapr.query_state(
+            store_name="statestore",
+            query=json.dumps({"filter": {}, "page": {"limit": 2}}),
+        )
+        self.assertEqual(resp.results[0].key, "1")
+        self.assertEqual(len(resp.results), 2)
+
+        resp = dapr.query_state(
+            store_name="statestore",
+            query=json.dumps({"filter": {}, "page": {"limit": 3, "token": "3"}}),
+        )
+        self.assertEqual(resp.results[0].key, "3")
+        self.assertEqual(len(resp.results), 3)
+
     def test_shutdown(self):
         dapr = DaprGrpcClient(f'localhost:{self.server_port}')
         dapr.shutdown()