Repository: k2bd/awstin

Issue:
DynamoDB service that maps tables to python data models

Discussion:
codecov-io: # [Codecov](https://codecov.io/gh/k2bd/awstin/pull/42?src=pr&el=h1) Report
> Merging [#42](https://codecov.io/gh/k2bd/awstin/pull/42?src=pr&el=desc) (62f0bdf) into [develop](https://codecov.io/gh/k2bd/awstin/commit/601a3eea1affaa1771e1e6b18ceae10763a550d3?el=desc) (601a3ee) will **decrease** coverage by `0.41%`.
> The diff coverage is `98.47%`.

[![Impacted file tree graph](https://codecov.io/gh/k2bd/awstin/pull/42/graphs/tree.svg?width=650&height=150&src=pr&token=YMC864VLJ4)](https://codecov.io/gh/k2bd/awstin/pull/42?src=pr&el=tree)

```diff
@@             Coverage Diff             @@
##           develop      #42      +/-   ##
===========================================
- Coverage    99.13%   98.71%   -0.42%     
===========================================
  Files           20       22       +2     
  Lines          464      858     +394     
===========================================
+ Hits           460      847     +387     
- Misses           4       11       +7     
```


| [Impacted Files](https://codecov.io/gh/k2bd/awstin/pull/42?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [awstin/apigateway/tests/test\_auth.py](https://codecov.io/gh/k2bd/awstin/pull/42/diff?src=pr&el=tree#diff-YXdzdGluL2FwaWdhdGV3YXkvdGVzdHMvdGVzdF9hdXRoLnB5) | `100.00% <ø> (ø)` | |
| [awstin/apigateway/websocket.py](https://codecov.io/gh/k2bd/awstin/pull/42/diff?src=pr&el=tree#diff-YXdzdGluL2FwaWdhdGV3YXkvd2Vic29ja2V0LnB5) | `100.00% <ø> (ø)` | |
| [awstin/awslambda/api.py](https://codecov.io/gh/k2bd/awstin/pull/42/diff?src=pr&el=tree#diff-YXdzdGluL2F3c2xhbWJkYS9hcGkucHk=) | `100.00% <ø> (ø)` | |
| [awstin/sns/api.py](https://codecov.io/gh/k2bd/awstin/pull/42/diff?src=pr&el=tree#diff-YXdzdGluL3Nucy9hcGkucHk=) | `100.00% <ø> (ø)` | |
| [awstin/tests/test\_environment.py](https://codecov.io/gh/k2bd/awstin/pull/42/diff?src=pr&el=tree#diff-YXdzdGluL3Rlc3RzL3Rlc3RfZW52aXJvbm1lbnQucHk=) | `100.00% <ø> (ø)` | |
| [awstin/dynamodb/tests/test\_testing.py](https://codecov.io/gh/k2bd/awstin/pull/42/diff?src=pr&el=tree#diff-YXdzdGluL2R5bmFtb2RiL3Rlc3RzL3Rlc3RfdGVzdGluZy5weQ==) | `95.00% <95.00%> (-1.56%)` | :arrow_down: |
| [awstin/dynamodb/orm.py](https://codecov.io/gh/k2bd/awstin/pull/42/diff?src=pr&el=tree#diff-YXdzdGluL2R5bmFtb2RiL29ybS5weQ==) | `95.83% <95.83%> (ø)` | |
| [awstin/dynamodb/tests/test\_dynamodb.py](https://codecov.io/gh/k2bd/awstin/pull/42/diff?src=pr&el=tree#diff-YXdzdGluL2R5bmFtb2RiL3Rlc3RzL3Rlc3RfZHluYW1vZGIucHk=) | `99.39% <99.32%> (-0.61%)` | :arrow_down: |
| [awstin/apigateway/auth.py](https://codecov.io/gh/k2bd/awstin/pull/42/diff?src=pr&el=tree#diff-YXdzdGluL2FwaWdhdGV3YXkvYXV0aC5weQ==) | `100.00% <100.00%> (ø)` | |
| [awstin/apigateway/tests/test\_websocket.py](https://codecov.io/gh/k2bd/awstin/pull/42/diff?src=pr&el=tree#diff-YXdzdGluL2FwaWdhdGV3YXkvdGVzdHMvdGVzdF93ZWJzb2NrZXQucHk=) | `100.00% <100.00%> (ø)` | |
| ... and [12 more](https://codecov.io/gh/k2bd/awstin/pull/42/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/k2bd/awstin/pull/42?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/k2bd/awstin/pull/42?src=pr&el=footer). Last update [601a3ee...62f0bdf](https://codecov.io/gh/k2bd/awstin/pull/42?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000..8dd399a
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,3 @@
+[flake8]
+max-line-length = 88
+extend-ignore = E203
diff --git a/.gitignore b/.gitignore
index a9f4179..5d57cd8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
 env/
 .vscode/
+playground/
 
 # Byte-compiled / optimized / DLL files
 __pycache__/
diff --git a/README.md b/README.md
index 1966bbc..acfd02d 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,33 @@ High-level utilities for building and testing AWS applications in Python.
 
 ### Production
 
-The `DynamoDB` class currently gives dict-like access to boto3 `Table`s and their contents.
-This requires either the `TEST_DYNAMODB_ENDPOINT` (for integration testing) or `AWS_REGION` (for production) environment variable to be set.
+To use DynamoDB either the `TEST_DYNAMODB_ENDPOINT` (for integration
+testing) or `AWS_REGION` (for production) environment variable must be set.
+
+DynamoDB is accessed through Python data models that users define to represent
+structured data in tables.
+
+```python
+from awstin.dynamodb import Attr, DynamoModel, Key
+
+
+class User(DynamoModel):
+    # Name of the DynamoDB table (required!)
+    _table_name_ = "Users"
+
+    # Sort or hash keys are marked with Key
+    user_id = Key()
+
+    # Other attributes are marked with Attr
+    favorite_color = Attr()
+
+    # The names of attributes and keys can differ from the names on the data
+    # model - the name of the attribute in DynamoDB should be passed to Attr
+    account_age = Attr("ageDays")
+```
+
+Tables are tied to these data models. They'll be returned when items are 
+retrieved from the table. Also, `put_item` takes instances of this data model class.
 
 ```python
 from awstin.dynamodb import DynamoDB
@@ -23,22 +48,70 @@ dynamodb = DynamoDB()
 # List of available tables
 tables = dynamodb.list_tables()
 
-# Access a table by name
-table1 = dynamodb["my_table"]
+# Access a table by model
+users_table = dynamodb[User]
+
+# Put an item into the table
+user = User(
+    user_id="user123",
+    favorite_color="Blue",
+    account_age=120,
+)
+users_table.put_item(user)
 
 # Tables that only have a partition key can be accessed directly by their
 # partition key
-item1 = table1["an_item"]
+item1 = users_table["user123"]
 
 # Tables that have partition and sort keys can be accessed by a tuple
-table2 = dynamodb["another_table"]
+table2 = dynamodb[AnotherTableModel]
 item2 = table2[("hashval", 123)]
 
 # Full primary key access is also available
 item3 = table2[{"hashkey_name": "hashval", "sortkey_name": 123}]
 ```
 
-Tables can be scanned without worrying about pagination. `Table.scan` yields items, requesting another page of items lazily only when it's out of items in a page.
+Query and scan filters can be built up using these data models as well. Results can be iterated without worrying about pagination. `Table.scan` and `Table.query` yield items, requesting another page of items lazily only when it's out of items in a page.
+
+```python
+scan_filter = (
+    (User.account_age > 30)
+    & (User.favorite_color.in_(["Blue", "Green"]))
+)
+
+for user in users_table.scan(scan_filter):
+    ban_user(user)
+```
+
+Queries must be given a query expression and can optionally be given a filter expression. Query expressions must represent valid DynamoDB queries.
+
+```python
+class Student(DynamoModel):
+    _table_name_ = "Students"
+
+    # Hash key
+    name = Key()
+
+    # Sort key
+    year = Key()
+
+    homeroom = Attr()
+
+
+students_table = dynamodb[Student]
+
+query_expression = (Student.name == "John") & (Student.year >= 10)
+filter_expression = Student.homeroom == "Smith"
+
+results = students_table.query(
+    query_expression=query_expression,
+    filter_expression=filter_expression,
+)
+```
+
+**Float and Decimal**
+
+Floats should be used when working with DynamoDB through `awstin`. Conversions between float and Decimal is done internally.
 
 
 ### Testing
@@ -51,14 +124,19 @@ Hashkey and sortkey info can be provided.
 from awstin.dynamodb.testing import temporary_dynamodb_table
 
 
-with temporary_dynamodb_table("table_name", "hashkey_name") as table:
-    item = {
-        "hashkey_name": "test_value",
-        "another_key": 5,
-    }
+with temporary_dynamodb_table(User, "hashkey_name") as table:
+    item = User(
+        user_id="user456",
+        favorite_color="Green",
+        account_age=333,
+    )
     table.put_item(item)
 ```
 
+### Future Work
+
+See the [Milestone](https://github.com/k2bd/awstin/milestone/1) for more information on what's currently planned for DynamoDB support. Feel free to open an issue for anything that's missing.
+
 
 ## Lambdas
 
diff --git a/awstin/apigateway/auth.py b/awstin/apigateway/auth.py
index 37779f2..1ae24e8 100644
--- a/awstin/apigateway/auth.py
+++ b/awstin/apigateway/auth.py
@@ -1,4 +1,3 @@
-
 def accept(principal_id, resource_arn):
     """
     Return an auth lambda response granting access to the given resource ARN
@@ -39,7 +38,7 @@ def reject(principal_id, resource_arn):
     return _auth_response(principal_id, resource_arn, "Deny")
 
 
-def unauthorized(body='Unauthorized'):
+def unauthorized(body="Unauthorized"):
     """
     Return an auth lambda response indicating the requester is unauthorized.
 
@@ -54,12 +53,12 @@ def unauthorized(body='Unauthorized'):
         Auth lambda response
     """
     return {
-        'statusCode': 401,
-        'body': body,
+        "statusCode": 401,
+        "body": body,
     }
 
 
-def invalid(body='Invalid'):
+def invalid(body="Invalid"):
     """
     Return an auth lambda response indicating the request is invalid.
 
@@ -74,8 +73,8 @@ def invalid(body='Invalid'):
         Auth lambda response
     """
     return {
-        'statusCode': 500,
-        'body': body,
+        "statusCode": 500,
+        "body": body,
     }
 
 
@@ -92,6 +91,6 @@ def _auth_response(principal_id, resource_arn, effect):
                     "Effect": effect,
                     "Resource": [resource_arn],
                 }
-            ]
+            ],
         },
     }
diff --git a/awstin/apigateway/websocket.py b/awstin/apigateway/websocket.py
index d73e5a0..5d9ccff 100644
--- a/awstin/apigateway/websocket.py
+++ b/awstin/apigateway/websocket.py
@@ -5,6 +5,7 @@ class Websocket:
     """
     Serverless-to-client push via websocket
     """
+
     def __init__(self, domain_name, stage=None):
         """
         Parameters
@@ -16,7 +17,7 @@ class Websocket:
         if stage:
             endpoint_url += f"/{stage}"
         self.api_client = boto3.client(
-            'apigatewaymanagementapi',
+            "apigatewaymanagementapi",
             endpoint_url=endpoint_url,
         )
 
diff --git a/awstin/awslambda/api.py b/awstin/awslambda/api.py
index 930fb3f..6dad48f 100644
--- a/awstin/awslambda/api.py
+++ b/awstin/awslambda/api.py
@@ -34,5 +34,7 @@ def lambda_handler(event_parser):
                 result = func(args)
             logger.info(f"Result: {result!r}")
             return result
+
         return wrapped
+
     return handler
diff --git a/awstin/config.py b/awstin/config.py
index 1207fb9..dd49af8 100644
--- a/awstin/config.py
+++ b/awstin/config.py
@@ -12,7 +12,7 @@ def aws_config(timeout=5.0, max_retries=3, endpoint=None):
     Parameters
     ----------
     timeout : float, optional
-        Timeout for establishing a conneciton to DynamoDB (default 5.0)
+        Timeout for establishing a connection to DynamoDB (default 5.0)
     max_retries : int, optional
         Max retries for establishing a connection to DynamoDB (default 3)
     endpoint : str or None, optional
@@ -42,10 +42,7 @@ def aws_config(timeout=5.0, max_retries=3, endpoint=None):
 
     config = Config(
         connect_timeout=timeout,
-        retries={'max_attempts': max_retries},
+        retries={"max_attempts": max_retries},
     )
 
-    return {
-        "config": config,
-        **kwargs
-    }
+    return {"config": config, **kwargs}
diff --git a/awstin/dynamodb/__init__.py b/awstin/dynamodb/__init__.py
index c0d0882..5d3276e 100644
--- a/awstin/dynamodb/__init__.py
+++ b/awstin/dynamodb/__init__.py
@@ -1,1 +1,2 @@
-from .api import *  # noqa
+from .orm import NOT_SET, Attr, DynamoModel, Key  # noqa
+from .table import DynamoDB  # noqa
diff --git a/awstin/dynamodb/orm.py b/awstin/dynamodb/orm.py
new file mode 100644
index 0000000..8788073
--- /dev/null
+++ b/awstin/dynamodb/orm.py
@@ -0,0 +1,235 @@
+from typing import Union
+
+from boto3.dynamodb.conditions import Attr as BotoAttr
+from boto3.dynamodb.conditions import Key as BotoKey
+
+from awstin.dynamodb.utils import from_decimal, to_decimal
+
+
+class NotSet:
+    """
+    A value of an attribute on a data model is not present in a DynamoDB result
+    """
+
+    def __str__(self):
+        return "<<Attribute not set>>"
+
+    def __repr__(self):
+        return "<<Attribute not set>>"
+
+
+NOT_SET = NotSet()
+
+
+class BaseAttribute:
+    def __init__(self, attribute_name: Union[str, None] = None):
+        # Set by user
+        self._attribute_name = attribute_name
+
+        # Set by Model
+        self._name_on_model = None
+
+    @property
+    def name(self):
+        if self._attribute_name is not None:
+            return self._attribute_name
+        else:
+            return self._name_on_model
+
+
+class Key(BaseAttribute):
+    """
+    Used to define and query hash and sort key attributes on a dynamodb table
+    data model
+    """
+
+    _query_type = BotoKey
+
+    def begins_with(self, value):
+        """
+        Filter results by a key or attribute beginning with a value
+
+        Parameters
+        ----------
+        value : str
+            Starting string for returned results
+        """
+        return self._query_type(self.name).begins_with(to_decimal(value))
+
+    def between(self, low, high):
+        """
+        Filter results by range (inclusive)
+
+        Parameters
+        ----------
+        low : Any
+            Low end of the range
+        high : Any
+            High end of the range
+        """
+        return self._query_type(self.name).between(
+            to_decimal(low),
+            to_decimal(high),
+        )
+
+    def __eq__(self, value):
+        return self._query_type(self.name).eq(to_decimal(value))
+
+    def __gt__(self, value):
+        return self._query_type(self.name).gt(to_decimal(value))
+
+    def __ge__(self, value):
+        return self._query_type(self.name).gte(to_decimal(value))
+
+    def __lt__(self, value):
+        return self._query_type(self.name).lt(to_decimal(value))
+
+    def __le__(self, value):
+        return self._query_type(self.name).lte(to_decimal(value))
+
+    def attribute_type(self, value):
+        """
+        Filter results by attribute type
+
+        Parameters
+        ----------
+        value : str
+            Index for a DynamoDB attribute type (e.g. "N" for Number)
+        """
+        return BotoAttr(self.name).attribute_type(to_decimal(value))
+
+    def contains(self, value):
+        """
+        Filter results by attributes that are containers and contain the target
+        value
+
+        Parameters
+        ----------
+        values : Any
+            Result must contain this item
+        """
+        return BotoAttr(self.name).contains(to_decimal(value))
+
+    def exists(self):
+        """
+        Filter results by existence of an attribute
+        """
+        return BotoAttr(self.name).exists()
+
+    def in_(self, values):
+        """
+        Filter results by existence in a set
+
+        Parameters
+        ----------
+        values : list of Any
+            Allowed values of returned results
+        """
+        in_values = [to_decimal(value) for value in values]
+        return BotoAttr(self.name).is_in(in_values)
+
+    def __ne__(self, value):
+        return BotoAttr(self.name).ne(to_decimal(value))
+
+    def not_exists(self):
+        """
+        Filter results by non-existence of an attribute
+        """
+        return BotoAttr(self.name).not_exists()
+
+
+class Attr(Key):
+    """
+    Used to define and query non-key attributes on a dynamodb table data model
+    """
+
+    _query_type = BotoAttr
+
+
+class DynamoModelMeta(type):
+    def __getattribute__(self, name):
+        attr = super().__getattribute__(name)
+        if isinstance(attr, BaseAttribute):
+            attr._name_on_model = name
+            return attr
+        else:
+            return attr
+
+    def _dynamodb_attributes(self):
+        result = {
+            getattr(self, attr).name: attr
+            for attr in dir(self)
+            if isinstance(getattr(self, attr), BaseAttribute)
+        }
+        return result
+
+
+class DynamoModel(metaclass=DynamoModelMeta):
+    """
+    Abstract class for defining DynamoDB data models.
+
+    For example:
+    ```python
+    class MyDataModel(DynamoModel):
+        hashkey_name = HashKey()
+
+        # The name of the DynamoDB attribute differs from the
+        # name on the data model
+        sortkey = SortKey("sortkeyName")
+
+        an_attribute = Attr()
+
+        another_attribute = Attr("attributeName")
+    ```
+    """
+
+    def __init__(self, **kwargs):
+        model_attrs = type(self)._dynamodb_attributes().values()
+
+        for name in model_attrs:
+            setattr(self, name, NOT_SET)
+
+        for name, value in kwargs.items():
+            if name not in model_attrs:
+                msg = f"{type(self)!r} has no attribute {name!r}"
+                raise AttributeError(msg)
+            setattr(self, name, value)
+
+    @classmethod
+    def _from_dynamodb(cls, data):
+        model_attrs = cls._dynamodb_attributes()
+
+        result = cls()
+
+        for attr in model_attrs.values():
+            setattr(result, attr, NOT_SET)
+
+        for db_attr, value in data.items():
+            if db_attr in model_attrs.keys():
+                if type(value) in [list, set, tuple]:
+                    value = type(value)(from_decimal(v) for v in value)
+                elif type(value) is dict:
+                    value = {from_decimal(k): from_decimal(v) for k, v in value.items()}
+                else:
+                    value = from_decimal(value)
+                setattr(result, model_attrs[db_attr], value)
+
+        return result
+
+    def _to_dynamodb(self):
+        model_attrs = type(self)._dynamodb_attributes()
+
+        result = {}
+
+        for dynamo_name, model_name in model_attrs.items():
+            value = getattr(self, model_name)
+            if value is not NOT_SET:
+                if type(value) in [list, set, tuple]:
+                    value = type(value)(to_decimal(v) for v in value)
+                elif type(value) is dict:
+                    value = {to_decimal(k): to_decimal(v) for k, v in value.items()}
+                else:
+                    value = to_decimal(value)
+                result[dynamo_name] = value
+
+        return result
diff --git a/awstin/dynamodb/api.py b/awstin/dynamodb/table.py
similarity index 59%
rename from awstin/dynamodb/api.py
rename to awstin/dynamodb/table.py
index a2e29e3..66f701f 100644
--- a/awstin/dynamodb/api.py
+++ b/awstin/dynamodb/table.py
@@ -4,6 +4,7 @@ import boto3
 
 from awstin.config import aws_config
 from awstin.constants import TEST_DYNAMODB_ENDPOINT
+from awstin.dynamodb.utils import to_decimal
 
 # Testing parameter to change table listing page size
 _PAGE_SIZE = 100
@@ -11,14 +12,17 @@ _PAGE_SIZE = 100
 
 class DynamoDB:
     """
-    A client for typical use of DynamoDB.
+    A client for use of DynamoDB via awstin.
+
+    Tables are accessed via data models. See documentation for details.
     """
+
     def __init__(self, timeout=5.0, max_retries=3):
         """
         Parameters
         ----------
         timeout : float, optional
-            Timeout for establishing a conneciton to DynamoDB (default 5.0)
+            Timeout for establishing a connection to DynamoDB (default 5.0)
         max_retries : int, optional
             Max retries for establishing a connection to DynamoDB (default 3)
 
@@ -37,8 +41,8 @@ class DynamoDB:
             max_retries=max_retries,
             endpoint=test_endpoint,
         )
-        self.client = boto3.client('dynamodb', **self.config)
-        self.resource = boto3.resource('dynamodb', **self.config)
+        self.client = boto3.client("dynamodb", **self.config)
+        self.resource = boto3.resource("dynamodb", **self.config)
 
     def list_tables(self):
         """
@@ -55,22 +59,22 @@ class DynamoDB:
         while "LastEvaluatedTableName" in response:
             response = self.client.list_tables(
                 Limit=_PAGE_SIZE,
-                ExclusiveStartTableName=response["LastEvaluatedTableName"]
+                ExclusiveStartTableName=response["LastEvaluatedTableName"],
             )
             tables.extend(response["TableNames"])
 
         return tables
 
-    def __getitem__(self, key):
+    def __getitem__(self, data_model):
         """
-        Indexed access to DynamoDB tables.
+        Indexed access to DynamoDB tables via Python data models.
 
         Returns
         -------
         Table
             the dynamodb table, if it exists.
         """
-        return Table(self, key)
+        return Table(self, data_model)
 
 
 class Table:
@@ -79,56 +83,62 @@ class Table:
 
     Items can be retrieved in a dict-like way:
     ```
-    Table("table_name")[{"HashKeyName": "hashval", "SortKeyName": 123}]
+    DynamoDB()[TableModel][{"HashKeyName": "hashval", "SortKeyName": 123}]
     ```
 
     Items can also be retrieved from the table by a shorthand depending on the
     primary key. If it's only a partition key, items can be retrieved by the
     value of the partition key:
     ```
-    Table("table_name")["hashval"]
+    DynamoDB()[TableModel]["hashval"]
     ```
     If it's a partition and sort key, items can be retrived by a hashkey,
     sortkey tuple:
     ```
-    Table("table_name")[("hashval", 123)]
+    DynamoDB()[TableModel][("hashval", 123)]
     ```
     """
-    def __init__(self, dynamodb_client, table_name):
+
+    def __init__(self, dynamodb_client, data_model):
         """
         Paramters
         ---------
         client : DynamoDB
             DynamoDB client
-        table_name : DynamoDB Table
-            Table to convert into an awstin Table
+        data_model : DynamoDB Table
+            Data model for interfacing with the table's contents
         """
-        self.name = table_name
+        self.data_model = data_model
+        self.name = data_model._table_name_
 
         self._dynamodb = dynamodb_client
-        self._boto3_table = dynamodb_client.resource.Table(table_name)
+        self._boto3_table = dynamodb_client.resource.Table(self.name)
 
     def _get_primary_key(self, key):
         if isinstance(key, dict):
+            key = {k: to_decimal(v) for k, v in key.items()}
             primary_key = key
         else:
             table_description = self._dynamodb.client.describe_table(
                 TableName=self.name,
             )
-            partition_key, = [
+            (partition_key,) = [
                 entry["AttributeName"]
                 for entry in table_description["Table"]["KeySchema"]
                 if entry["KeyType"] == "HASH"
             ]
             if isinstance(key, tuple):
-                sort_key, = [
+                (sort_key,) = [
                     entry["AttributeName"]
                     for entry in table_description["Table"]["KeySchema"]
                     if entry["KeyType"] == "RANGE"
                 ]
-                primary_key = {partition_key: key[0], sort_key: key[1]}
+                primary_key = {
+                    partition_key: to_decimal(key[0]),
+                    sort_key: to_decimal(key[1]),
+                }
             else:
-                primary_key = {partition_key: key}
+                primary_key = {partition_key: to_decimal(key)}
         return primary_key
 
     def __getitem__(self, key):
@@ -137,13 +147,15 @@ class Table:
         value of the partition key if there is no sort key
         """
         primary_key = self._get_primary_key(key)
-        return self._boto3_table.get_item(Key=primary_key)["Item"]
+        item = self._boto3_table.get_item(Key=primary_key)["Item"]
+        return self.data_model._from_dynamodb(item)
 
     def put_item(self, item):
         """
         Direct exposure of put_item
         """
-        return self._boto3_table.put_item(Item=item)
+        data = item._to_dynamodb()
+        return self._boto3_table.put_item(Item=data)
 
     def update_item(self, *args, **kwargs):
         """
@@ -159,28 +171,56 @@ class Table:
         primary_key = self._get_primary_key(key)
         self._boto3_table.delete_item(Key=primary_key)
 
-    def scan(self):
+    def scan(self, scan_filter=None):
         """
         Generate all items in the table, one at a time.
 
         Lazily queries for more items if needed.
 
+        Parameters
+        ----------
+        scan_filter : Query
+            An optional query constructed with awstin's query framework
+
         Yields
         ------
-        dict
+        DynamoModel
             an item in the table
         """
-        # TODO: scan filters
+        filter_kwargs = {}
 
-        results = self._boto3_table.scan()
-        items = results["Items"]
+        if scan_filter is not None:
+            filter_kwargs["FilterExpression"] = scan_filter
+
+        results = self._boto3_table.scan(**filter_kwargs)
+        items = [self.data_model._from_dynamodb(item) for item in results["Items"]]
         yield from items
 
         while "LastEvaluatedKey" in results:
             results = self._boto3_table.scan(
                 ExclusiveStartKey=results["LastEvaluatedKey"],
+                **filter_kwargs,
+            )
+            items = [self.data_model._from_dynamodb(item) for item in results["Items"]]
+            yield from items
+
+    def query(self, query_expression, filter_expression=None):
+        query_kwargs = {}
+
+        query_kwargs["KeyConditionExpression"] = query_expression
+        if filter_expression is not None:
+            query_kwargs["FilterExpression"] = filter_expression
+
+        results = self._boto3_table.query(**query_kwargs)
+        items = [self.data_model._from_dynamodb(item) for item in results["Items"]]
+        yield from items
+
+        while "LastEvaluatedKey" in results:
+            results = self._boto3_table.query(
+                ExclusiveStartKey=results["LastEvaluatedKey"],
+                **query_kwargs,
             )
-            items = results["Items"]
+            items = [self.data_model._from_dynamodb(item) for item in results["Items"]]
             yield from items
 
     # TODO: Batch Update
diff --git a/awstin/dynamodb/utils.py b/awstin/dynamodb/utils.py
new file mode 100644
index 0000000..1943fa1
--- /dev/null
+++ b/awstin/dynamodb/utils.py
@@ -0,0 +1,15 @@
+from decimal import Decimal
+
+
+def to_decimal(value):
+    if isinstance(value, float):
+        return Decimal(str(value))
+    return value
+
+
+def from_decimal(value):
+    if isinstance(value, Decimal):
+        value = float(value)
+        if int(value) == value:
+            value = int(value)
+    return value
diff --git a/awstin/sns/api.py b/awstin/sns/api.py
index 2b81fa9..dc4f82c 100644
--- a/awstin/sns/api.py
+++ b/awstin/sns/api.py
@@ -11,6 +11,7 @@ class SNSTopic:
     """
     A client for typical use of an SNS topic
     """
+
     def __init__(self, topic_name):
         """
         Parameters


Test patch:
diff --git a/awstin/apigateway/tests/test_auth.py b/awstin/apigateway/tests/test_auth.py
index 87cbe04..e53a64d 100644
--- a/awstin/apigateway/tests/test_auth.py
+++ b/awstin/apigateway/tests/test_auth.py
@@ -48,7 +48,7 @@ class TestAuthHandler(unittest.TestCase):
                         "Effect": "Allow",
                         "Resource": ["arn123"],
                     }
-                ]
+                ],
             },
         }
         self.assertEqual(result, expected_result)
@@ -76,7 +76,7 @@ class TestAuthHandler(unittest.TestCase):
                         "Effect": "Deny",
                         "Resource": ["arn123"],
                     }
-                ]
+                ],
             },
         }
         self.assertEqual(result, expected_result)
@@ -93,8 +93,8 @@ class TestAuthHandler(unittest.TestCase):
         result = token_auth(event, context)
 
         expected_result = {
-            'statusCode': 401,
-            'body': 'Unauthorized',
+            "statusCode": 401,
+            "body": "Unauthorized",
         }
         self.assertEqual(result, expected_result)
 
@@ -110,7 +110,7 @@ class TestAuthHandler(unittest.TestCase):
         result = token_auth(event, context)
 
         expected_result = {
-            'statusCode': 500,
-            'body': 'Invalid',
+            "statusCode": 500,
+            "body": "Invalid",
         }
         self.assertEqual(result, expected_result)
diff --git a/awstin/apigateway/tests/test_websocket.py b/awstin/apigateway/tests/test_websocket.py
index 99be780..6fa4b4a 100644
--- a/awstin/apigateway/tests/test_websocket.py
+++ b/awstin/apigateway/tests/test_websocket.py
@@ -1,7 +1,8 @@
 import unittest
 from unittest import mock
 
-from awstin.apigateway.websocket import __name__ as WS_NAME, Websocket
+from awstin.apigateway.websocket import Websocket
+from awstin.apigateway.websocket import __name__ as WS_NAME
 
 
 class TestWebsocket(unittest.TestCase):
@@ -16,8 +17,7 @@ class TestWebsocket(unittest.TestCase):
             socket.send("callbackurl", "message")
 
         m_aws.assert_called_once_with(
-            "apigatewaymanagementapi",
-            endpoint_url="https://endpointurl/dev"
+            "apigatewaymanagementapi", endpoint_url="https://endpointurl/dev"
         )
         mock_api_client.post_to_connection.assert_called_once_with(
             Data="message",
diff --git a/awstin/dynamodb/testing.py b/awstin/dynamodb/testing.py
index 98a4504..cad9faf 100644
--- a/awstin/dynamodb/testing.py
+++ b/awstin/dynamodb/testing.py
@@ -6,20 +6,21 @@ from awstin.dynamodb import DynamoDB
 
 @contextlib.contextmanager
 def temporary_dynamodb_table(
-        table_name,
-        hashkey_name,
-        hashkey_type="S",
-        sortkey_name=None,
-        sortkey_type="S",
-        delay=5.0,
-        max_attempts=10,):
+    data_model,
+    hashkey_name,
+    hashkey_type="S",
+    sortkey_name=None,
+    sortkey_type="S",
+    delay=5.0,
+    max_attempts=10,
+):
     """
     Context manager creating a temporary DynamoDB table for testing.
 
     Parameters
     ----------
-    table_name : str
-        Name of the table to create
+    data_model : DynamoModel
+        Model to interface with this table
     hashkey_name : str
         Name of the hash key of the table
     hashkey_type : str, optional
@@ -37,31 +38,33 @@ def temporary_dynamodb_table(
     # TODO: make filter more specific
     warnings.simplefilter("ignore", ResourceWarning)
 
+    table_name = data_model._table_name_
+
     dynamodb = DynamoDB()
 
     attribute_definitions = [
         {
-            'AttributeName': hashkey_name,
-            'AttributeType': hashkey_type,
+            "AttributeName": hashkey_name,
+            "AttributeType": hashkey_type,
         },
     ]
     key_schema = [
         {
-            'AttributeName': hashkey_name,
-            'KeyType': 'HASH',
+            "AttributeName": hashkey_name,
+            "KeyType": "HASH",
         },
     ]
     if sortkey_name is not None and sortkey_type is not None:
         attribute_definitions.append(
             {
-                'AttributeName': sortkey_name,
-                'AttributeType': sortkey_type,
+                "AttributeName": sortkey_name,
+                "AttributeType": sortkey_type,
             }
         )
         key_schema.append(
             {
-                'AttributeName': sortkey_name,
-                'KeyType': 'RANGE',
+                "AttributeName": sortkey_name,
+                "KeyType": "RANGE",
             },
         )
 
@@ -69,10 +72,7 @@ def temporary_dynamodb_table(
         TableName=table_name,
         AttributeDefinitions=attribute_definitions,
         KeySchema=key_schema,
-        ProvisionedThroughput={
-            'ReadCapacityUnits': 123,
-            'WriteCapacityUnits': 123
-        },
+        ProvisionedThroughput={"ReadCapacityUnits": 123, "WriteCapacityUnits": 123},
     )
 
     exists_waiter = dynamodb.client.get_waiter("table_exists")
@@ -81,24 +81,24 @@ def temporary_dynamodb_table(
     result = exists_waiter.wait(
         TableName=table_name,
         WaiterConfig={
-            'Delay': delay,
-            'MaxAttempts': max_attempts,
-        }
+            "Delay": delay,
+            "MaxAttempts": max_attempts,
+        },
     )
     if result is not None:
         raise RuntimeError("Could not create table {!r}".format(table_name))
 
     try:
-        yield dynamodb[table_name]
+        yield dynamodb[data_model]
     finally:
         dynamodb.client.delete_table(TableName=table_name)
 
         result = not_exists_waiter.wait(
             TableName=table_name,
             WaiterConfig={
-                'Delay': delay,
-                'MaxAttempts': max_attempts,
-            }
+                "Delay": delay,
+                "MaxAttempts": max_attempts,
+            },
         )
         if result is not None:
             msg = "Could not delete table {!r}"
diff --git a/awstin/dynamodb/tests/test_dynamodb.py b/awstin/dynamodb/tests/test_dynamodb.py
index 607fad0..a7699ed 100644
--- a/awstin/dynamodb/tests/test_dynamodb.py
+++ b/awstin/dynamodb/tests/test_dynamodb.py
@@ -1,67 +1,148 @@
 import unittest
 from contextlib import ExitStack
 
-import awstin.dynamodb.api as ddb_api
-from awstin.dynamodb import DynamoDB
+import awstin.dynamodb.table as ddb_table
+from awstin.dynamodb import NOT_SET, Attr, DynamoDB, DynamoModel, Key
 from awstin.dynamodb.testing import temporary_dynamodb_table
 
 
+class ModelWithoutSortkey(DynamoModel):
+    _table_name_ = "test_without_sort"
+
+    hashkey = Key()
+
+    another_attr = Attr()
+
+    def __eq__(self, other):
+        if isinstance(other, ModelWithoutSortkey):
+            return (
+                self.hashkey == other.hashkey
+                and self.another_attr == other.another_attr
+            )
+        return NotImplemented
+
+
+class ModelWithSortkey(DynamoModel):
+    _table_name_ = "test_with_sort"
+
+    hashkey = Key()
+
+    sortkey = Key()
+
+    another_attr = Attr()
+
+    def __eq__(self, other):
+        if isinstance(other, ModelWithSortkey):
+            return (
+                self.hashkey == other.hashkey
+                and self.sortkey == other.sortkey
+                and self.another_attr == other.another_attr
+            )
+        return NotImplemented
+
+
 class TestDynamoDB(unittest.TestCase):
+    def setUp(self):
+        self.table_without_sortkey = temporary_dynamodb_table(
+            ModelWithoutSortkey,
+            "hashkey",
+        )
+
+        self.table_with_sortkey = temporary_dynamodb_table(
+            ModelWithSortkey, "hashkey", sortkey_name="sortkey", sortkey_type="N"
+        )
+
+        self.table_with_str_sortkey = temporary_dynamodb_table(
+            ModelWithSortkey, "hashkey", sortkey_name="sortkey", sortkey_type="S"
+        )
+
     def test_dynamodb_table(self):
-        with temporary_dynamodb_table("table_name", "hashkey_name") as table:
-            test_item = {
-                "hashkey_name": "test_value",
-                "another_key": 5,
-            }
+        with self.table_without_sortkey as table:
+            test_item = ModelWithoutSortkey(
+                hashkey="test_value",
+                another_attr=5.5,
+            )
 
             # Table can be used
             table.put_item(test_item)
             result_item = table["test_value"]
+            self.assertIsInstance(result_item, ModelWithoutSortkey)
             self.assertEqual(result_item, test_item)
 
             # DynamoDB can access table
             dynamodb = DynamoDB()
-            self.assertEqual(dynamodb["table_name"]["test_value"], test_item)
+            ddb_result = dynamodb[ModelWithoutSortkey]["test_value"]
+            self.assertIsInstance(ddb_result, ModelWithoutSortkey)
+            self.assertEqual(ddb_result, test_item)
 
     def test_dynamodb_table_access_by_shorthand_composite_key(self):
-        temp_table = temporary_dynamodb_table(
-            "tabname",
-            "hashname",
-            sortkey_name="sortname",
-            sortkey_type="N",
-        )
-        with temp_table as table:
-            test_item = {"hashname": "aaa", "sortname": 555, "aa": "bb"}
+        with self.table_with_sortkey as table:
+            test_item = ModelWithSortkey(
+                hashkey="aaa",
+                sortkey=555,
+                another_attr="bb",
+            )
             table.put_item(test_item)
 
             self.assertEqual(table[("aaa", 555)], test_item)
 
     def test_dynamodb_table_access_by_full_hash_key(self):
-        with temporary_dynamodb_table("tabname", "hashname") as table:
-            test_item = {"hashname": "123", "nothashname": "456"}
+        with self.table_without_sortkey as table:
+            test_item = ModelWithoutSortkey(
+                hashkey="abcd",
+                another_attr=777,
+            )
             table.put_item(test_item)
 
-            self.assertEqual(table[{"hashname": "123"}], test_item)
+            self.assertEqual(table[{"hashkey": "abcd"}], test_item)
 
     def test_dynamodb_table_access_by_full_composite_key(self):
-        temp_table = temporary_dynamodb_table(
-            "tabname",
-            "hashname",
-            sortkey_name="sortname",
-            sortkey_type="N",
-        )
-        with temp_table as table:
-            test_item = {"hashname": "aaa", "sortname": 555, "aa": "bb"}
+        with self.table_with_sortkey as table:
+            test_item = ModelWithSortkey(
+                hashkey="aaa",
+                sortkey=555,
+                another_attr="bb",
+            )
             table.put_item(test_item)
 
-            self.assertEqual(
-                table[{"hashname": "aaa", "sortname": 555}],
-                test_item
+            self.assertEqual(table[{"hashkey": "aaa", "sortkey": 555}], test_item)
+
+    def test_dynamodb_get_item_with_unset_attribute(self):
+        with self.table_without_sortkey as table:
+            test_item = ModelWithoutSortkey(
+                hashkey="abcd",
+            )
+            self.assertTrue(test_item.another_attr is NOT_SET)
+            table.put_item(test_item)
+
+            result_item = table["abcd"]
+            self.assertEqual(result_item, test_item)
+            self.assertTrue(result_item.another_attr is NOT_SET)
+
+    def test_dynamodb_get_item_attribute_not_in_model(self):
+        with self.table_without_sortkey as table:
+            table._boto3_table.put_item(
+                Item={
+                    "hashkey": "abcde",
+                    "another_attr": "vvv",
+                    "not_in_model": 555,
+                    "also_not_in_model": "aaaa",
+                }
+            )
+
+            expected = ModelWithoutSortkey(
+                hashkey="abcde",
+                another_attr="vvv",
             )
 
+            self.assertEqual(table["abcde"], expected)
+
     def test_dynamodb_table_delete_item(self):
-        with temporary_dynamodb_table("tabname", "hashname") as table:
-            test_item = {"hashname": "123", "nothashname": "456"}
+        with self.table_without_sortkey as table:
+            test_item = ModelWithoutSortkey(
+                hashkey="123",
+                another_attr="bb",
+            )
             table.put_item(test_item)
 
             self.assertEqual(table["123"], test_item)
@@ -72,35 +153,45 @@ class TestDynamoDB(unittest.TestCase):
                 table["123"]
 
     def test_dynamodb_list_tables(self):
-        with temporary_dynamodb_table("test_tab1", "test_key1"):
-            with temporary_dynamodb_table("test_tab2", "test_key2"):
+        with self.table_with_sortkey:
+            with self.table_without_sortkey:
                 tables = DynamoDB().list_tables()
 
-        self.assertEqual(tables, ["test_tab1", "test_tab2"])
+        self.assertEqual(tables, ["test_with_sort", "test_without_sort"])
 
     def test_dynamodb_list_tables_long(self):
         # Reduce page size so test doesn't have to make over 100 tables
-        ddb_api._PAGE_SIZE = 5
+        ddb_table._PAGE_SIZE = 5
 
         dynamodb = DynamoDB()
 
-        table_managers = [
-            temporary_dynamodb_table("abc"+str(i), "def"+str(i))
-            for i in range(11)
-        ]
         with ExitStack() as stack:
-            for table in table_managers:
-                stack.enter_context(table)
+            for i in range(11):
+
+                class Cls(DynamoModel):
+                    _table_name_ = "abc" + str(i)
+
+                table_ctx = temporary_dynamodb_table(Cls, "def" + str(i))
+                stack.enter_context(table_ctx)
 
             self.assertEqual(len(dynamodb.list_tables()), 11)
 
     def test_dynamodb_table_items_generator(self):
+        class BigItem(DynamoModel):
+            _table_name_ = "test_tab"
+            pkey = Key()
+            bigstring = Attr()
+
+            def __eq__(self, other):
+                if isinstance(other, BigItem):
+                    return self.pkey == other.pkey and self.bigstring == other.bigstring
+                return NotImplemented
+
         # Page size of DynamoDB item returns is 1MB. Add ~2.2MB of items
         add_items = [
-            {"pkey": "item "+str(i), "bigstring": "a" * 100000}
-            for i in range(22)
+            BigItem(pkey="item " + str(i), bigstring="a" * 100000) for i in range(22)
         ]
-        with temporary_dynamodb_table("test_tab", "pkey") as table:
+        with temporary_dynamodb_table(BigItem, "pkey") as table:
             for item in add_items:
                 table.put_item(item)
 
@@ -108,3 +199,726 @@ class TestDynamoDB(unittest.TestCase):
 
         self.assertEqual(len(result_items), 22)
         self.assertCountEqual(result_items, add_items)
+
+    def test_dynamo_model_floats(self):
+        temp_table = temporary_dynamodb_table(
+            ModelWithoutSortkey,
+            "hashkey",
+            hashkey_type="N",
+        )
+        with temp_table as table:
+            new_item = ModelWithoutSortkey(
+                hashkey=9.99,
+                another_attr=55.1,
+            )
+            table.put_item(new_item)
+
+            retrieved_item = table[9.99]
+            self.assertEqual(retrieved_item, new_item)
+            self.assertIsInstance(retrieved_item.hashkey, float)
+            self.assertIsInstance(retrieved_item.another_attr, float)
+
+    def test_dynamo_model_list(self):
+        with self.table_without_sortkey as table:
+            new_item = ModelWithoutSortkey(
+                hashkey="aaa",
+                another_attr=[55.1, 9.22, 100.1, 50.5],
+            )
+            table.put_item(new_item)
+
+            retrieved_item = table["aaa"]
+            self.assertEqual(retrieved_item, new_item)
+            self.assertIsInstance(retrieved_item.another_attr, list)
+            for item in retrieved_item.another_attr:
+                self.assertIsInstance(item, float)
+
+    def test_dynamo_model_set(self):
+        with self.table_without_sortkey as table:
+            new_item = ModelWithoutSortkey(
+                hashkey="aaa",
+                another_attr=set([55.1, 9.22, 100.1, 50.6]),
+            )
+            table.put_item(new_item)
+
+            retrieved_item = table["aaa"]
+            self.assertEqual(retrieved_item, new_item)
+            self.assertIsInstance(retrieved_item.another_attr, set)
+            for item in retrieved_item.another_attr:
+                self.assertIsInstance(item, float)
+
+    def test_dynamo_model_tuple(self):
+        with self.table_without_sortkey as table:
+            new_item = ModelWithoutSortkey(
+                hashkey="aaa",
+                another_attr=(55.1, 9.22, 100.1, 50.5),
+            )
+            table.put_item(new_item)
+
+            retrieved_item = table["aaa"]
+
+            expected = ModelWithoutSortkey(
+                hashkey="aaa",
+                another_attr=[55.1, 9.22, 100.1, 50.5],
+            )
+            # Tuples converted to lists
+            self.assertNotEqual(retrieved_item, new_item)
+            self.assertEqual(retrieved_item, expected)
+            self.assertIsInstance(retrieved_item.another_attr, list)
+            for item in retrieved_item.another_attr:
+                self.assertIsInstance(item, float)
+
+    def test_dynamo_model_dict(self):
+        with self.table_without_sortkey as table:
+            new_item = ModelWithoutSortkey(
+                hashkey="aaa",
+                another_attr={"hello": 2.2, "world": 4.4},
+            )
+            table.put_item(new_item)
+
+            retrieved_item = table["aaa"]
+            self.assertEqual(retrieved_item, new_item)
+            self.assertIsInstance(retrieved_item.another_attr, dict)
+            for value in retrieved_item.another_attr.values():
+                self.assertIsInstance(value, float)
+
+    def test_dynamo_model_different_attr_name(self):
+        class Model(DynamoModel):
+            _table_name_ = "diff_attr_table"
+            hash_key = Key("actual_key_name")
+            an_attr = Attr("actual_attr_name")
+
+            def __eq__(self, other):
+                if isinstance(other, Model):
+                    return (
+                        self.hash_key == other.hash_key
+                        and self.an_attr == other.an_attr
+                    )
+                return NotImplemented
+
+        with temporary_dynamodb_table(Model, "actual_key_name") as table:
+            test_item = Model(
+                hash_key="a hash key",
+                an_attr=12345,
+            )
+            table.put_item(test_item)
+
+            # Item is serialized with the defined attribute names
+            boto_item = table._boto3_table.get_item(
+                Key={"actual_key_name": "a hash key"},
+            )["Item"]
+            self.assertEqual(
+                boto_item,
+                {
+                    "actual_key_name": "a hash key",
+                    "actual_attr_name": 12345,
+                },
+            )
+
+            self.assertEqual(table["a hash key"], test_item)
+
+    def test_filter_begins_with(self):
+        with self.table_without_sortkey as table:
+            hit = ModelWithoutSortkey(
+                hashkey="abchello",
+                another_attr="defworld",
+            )
+            miss_key = ModelWithoutSortkey(
+                hashkey="hello",
+                another_attr="defworld",
+            )
+            miss_attr = ModelWithoutSortkey(
+                hashkey="abchello2",
+                another_attr="world",
+            )
+
+            table.put_item(hit)
+            table.put_item(miss_key)
+            table.put_item(miss_attr)
+
+            scan_filter = ModelWithoutSortkey.hashkey.begins_with(
+                "abc"
+            ) & ModelWithoutSortkey.another_attr.begins_with("def")
+
+            items = list(table.scan(scan_filter=scan_filter))
+
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+    def test_filter_between(self):
+        with self.table_without_sortkey as table:
+            hit = ModelWithoutSortkey(
+                hashkey="carving",
+                another_attr=1.1,
+            )
+            miss_key = ModelWithoutSortkey(
+                hashkey="yellow",
+                another_attr=2.2,
+            )
+            miss_attr = ModelWithoutSortkey(
+                hashkey="dartboard",
+                another_attr=9.5,
+            )
+
+            table.put_item(hit)
+            table.put_item(miss_key)
+            table.put_item(miss_attr)
+
+            scan_filter = ModelWithoutSortkey.hashkey.between(
+                "a", "e"
+            ) & ModelWithoutSortkey.another_attr.between(0.5, 4.12)
+
+            items = list(table.scan(scan_filter=scan_filter))
+
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+    def test_filter_eq(self):
+        with self.table_without_sortkey as table:
+            hit = ModelWithoutSortkey(
+                hashkey="interesting",
+                another_attr=55,
+            )
+            miss = ModelWithoutSortkey(
+                hashkey="typewriter",
+                another_attr=100,
+            )
+
+            table.put_item(hit)
+            table.put_item(miss)
+
+            key_filter = ModelWithoutSortkey.hashkey == "interesting"
+
+            items = list(table.scan(scan_filter=key_filter))
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+            attr_filter = ModelWithoutSortkey.another_attr == 55
+
+            items = list(table.scan(scan_filter=attr_filter))
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+    def test_filter_gt(self):
+        with self.table_without_sortkey as table:
+            hit = ModelWithoutSortkey(
+                hashkey="hello",
+                another_attr=77.5,
+            )
+            miss_key = ModelWithoutSortkey(
+                hashkey="afternoon",
+                another_attr=20.1,
+            )
+            miss_attr = ModelWithoutSortkey(
+                hashkey="kangaroo",
+                another_attr=15,
+            )
+
+            table.put_item(hit)
+            table.put_item(miss_key)
+            table.put_item(miss_attr)
+
+            scan_filter = (ModelWithoutSortkey.hashkey > "d") & (
+                ModelWithoutSortkey.another_attr > 20
+            )
+
+            items = list(table.scan(scan_filter=scan_filter))
+
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+    def test_filter_ge(self):
+        with self.table_without_sortkey as table:
+            hit = ModelWithoutSortkey(
+                hashkey="hello",
+                another_attr=20.1,
+            )
+            miss_key = ModelWithoutSortkey(
+                hashkey="afternoon",
+                another_attr=30,
+            )
+            miss_attr = ModelWithoutSortkey(
+                hashkey="kangaroo",
+                another_attr=15,
+            )
+
+            table.put_item(hit)
+            table.put_item(miss_key)
+            table.put_item(miss_attr)
+
+            scan_filter = (ModelWithoutSortkey.hashkey >= "h") & (
+                ModelWithoutSortkey.another_attr >= 20.1
+            )
+
+            items = list(table.scan(scan_filter=scan_filter))
+
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+    def test_filter_lt(self):
+        with self.table_without_sortkey as table:
+            hit = ModelWithoutSortkey(
+                hashkey="aardvark",
+                another_attr=1,
+            )
+            miss_key = ModelWithoutSortkey(
+                hashkey="zebra",
+                another_attr=2,
+            )
+            miss_attr = ModelWithoutSortkey(
+                hashkey="anteater",
+                another_attr=3,
+            )
+
+            table.put_item(hit)
+            table.put_item(miss_key)
+            table.put_item(miss_attr)
+
+            scan_filter = (ModelWithoutSortkey.hashkey < "c") & (
+                ModelWithoutSortkey.another_attr < 2.5
+            )
+
+            items = list(table.scan(scan_filter=scan_filter))
+
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+    def test_filter_le(self):
+        with self.table_without_sortkey as table:
+            hit = ModelWithoutSortkey(
+                hashkey="apple",
+                another_attr=1,
+            )
+            miss_key = ModelWithoutSortkey(
+                hashkey="zebra",
+                another_attr=2,
+            )
+            miss_attr = ModelWithoutSortkey(
+                hashkey="anteater",
+                another_attr=3,
+            )
+
+            table.put_item(hit)
+            table.put_item(miss_key)
+            table.put_item(miss_attr)
+
+            scan_filter = (ModelWithoutSortkey.hashkey <= "apple") & (
+                ModelWithoutSortkey.another_attr <= 2
+            )
+
+            items = list(table.scan(scan_filter=scan_filter))
+
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+    def test_filter_attribute_type(self):
+        with self.table_without_sortkey as table:
+            hit = ModelWithoutSortkey(
+                hashkey="interesting",
+                another_attr=55,
+            )
+            miss = ModelWithoutSortkey(
+                hashkey="typewriter",
+                another_attr="hello",
+            )
+
+            table.put_item(hit)
+            table.put_item(miss)
+
+            attr_filter = ModelWithoutSortkey.another_attr.attribute_type("N")
+
+            items = list(table.scan(scan_filter=attr_filter))
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+    def test_filter_contains(self):
+        with self.table_without_sortkey as table:
+            hit1 = ModelWithoutSortkey(
+                hashkey="interesting",
+                another_attr=[1.1, 2.2, 3.3, 4.4, 5.5],
+            )
+            hit2 = ModelWithoutSortkey(
+                hashkey="feathered",
+                another_attr=[2.2],
+            )
+            miss1 = ModelWithoutSortkey(
+                hashkey="typewriter",
+                another_attr=2.2,
+            )
+            miss2 = ModelWithoutSortkey(
+                hashkey="ghost",
+                another_attr=[1.1, 2, 3, 99],
+            )
+
+            table.put_item(hit1)
+            table.put_item(hit2)
+            table.put_item(miss1)
+            table.put_item(miss2)
+
+            attr_filter = ModelWithoutSortkey.another_attr.contains(2.2)
+
+            items = list(table.scan(scan_filter=attr_filter))
+            self.assertEqual(len(items), 2)
+            self.assertCountEqual(items, [hit1, hit2])
+
+    def test_filter_exists(self):
+        with self.table_without_sortkey as table:
+            hit = ModelWithoutSortkey(
+                hashkey="interesting",
+                another_attr=55,
+            )
+            miss = ModelWithoutSortkey(
+                hashkey="typewriter",
+            )
+
+            table.put_item(hit)
+            table.put_item(miss)
+
+            attr_filter = ModelWithoutSortkey.another_attr.exists()
+
+            items = list(table.scan(scan_filter=attr_filter))
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+    def test_filter_in(self):
+        with self.table_without_sortkey as table:
+            hit = ModelWithoutSortkey(
+                hashkey="interesting",
+                another_attr=55,
+            )
+            miss_hash = ModelWithoutSortkey(
+                hashkey="typewriter",
+                another_attr=100,
+            )
+            miss_attr = ModelWithoutSortkey(
+                hashkey="vermouth",
+                another_attr=99,
+            )
+
+            table.put_item(hit)
+            table.put_item(miss_hash)
+            table.put_item(miss_attr)
+
+            attr_filter = ModelWithoutSortkey.another_attr.in_(
+                [55, 100]
+            ) & ModelWithoutSortkey.hashkey.in_(["interesting", "vermouth"])
+
+            items = list(table.scan(scan_filter=attr_filter))
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+    def test_filter_ne(self):
+        with self.table_without_sortkey as table:
+            hit = ModelWithoutSortkey(
+                hashkey="interesting",
+                another_attr=55,
+            )
+            miss = ModelWithoutSortkey(
+                hashkey="typewriter",
+                another_attr=99,
+            )
+
+            table.put_item(hit)
+            table.put_item(miss)
+
+            attr_filter = ModelWithoutSortkey.another_attr != 99
+
+            items = list(table.scan(scan_filter=attr_filter))
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+    def test_filter_not_exists(self):
+        with self.table_without_sortkey as table:
+            hit = ModelWithoutSortkey(
+                hashkey="interesting",
+            )
+            miss = ModelWithoutSortkey(
+                hashkey="typewriter",
+                another_attr=55,
+            )
+
+            table.put_item(hit)
+            table.put_item(miss)
+
+            attr_filter = ModelWithoutSortkey.another_attr.not_exists()
+
+            items = list(table.scan(scan_filter=attr_filter))
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+            self.assertTrue(item.another_attr is NOT_SET)
+
+    def test_query_begins_with(self):
+        with self.table_with_str_sortkey as table:
+            hit = ModelWithSortkey(
+                hashkey="a",
+                sortkey="abchello",
+                another_attr="defworld",
+            )
+            miss = ModelWithSortkey(
+                hashkey="a",
+                sortkey="hello",
+                another_attr="defworld",
+            )
+
+            table.put_item(hit)
+            table.put_item(miss)
+
+            query_expression = (ModelWithSortkey.sortkey.begins_with("abc")) & (
+                ModelWithSortkey.hashkey == "a"
+            )
+
+            items = list(table.query(query_expression))
+
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+    def test_query_between(self):
+        with self.table_with_str_sortkey as table:
+            hit = ModelWithSortkey(
+                hashkey="a",
+                sortkey="carving",
+                another_attr=1.1,
+            )
+            miss = ModelWithSortkey(
+                hashkey="a",
+                sortkey="yellow",
+                another_attr=2.2,
+            )
+
+            table.put_item(hit)
+            table.put_item(miss)
+
+            query_expression = (ModelWithSortkey.sortkey.between("a", "e")) & (
+                ModelWithSortkey.hashkey == "a"
+            )
+
+            items = list(table.query(query_expression=query_expression))
+
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+    def test_query_eq_hash(self):
+        with self.table_with_str_sortkey as table:
+            hit = ModelWithSortkey(
+                hashkey="a",
+                sortkey="interesting",
+                another_attr=55,
+            )
+            miss = ModelWithSortkey(
+                hashkey="b",
+                sortkey="typewriter",
+                another_attr=100,
+            )
+
+            table.put_item(hit)
+            table.put_item(miss)
+
+            key_filter = ModelWithSortkey.hashkey == "a"
+
+            items = list(table.query(query_expression=key_filter))
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+    def test_query_eq_hash_and_sort(self):
+        with self.table_with_str_sortkey as table:
+            hit = ModelWithSortkey(
+                hashkey="a",
+                sortkey="interesting",
+                another_attr=55,
+            )
+            miss_hash = ModelWithSortkey(
+                hashkey="b",
+                sortkey="interesting",
+                another_attr=100,
+            )
+            miss_sort = ModelWithSortkey(
+                hashkey="a",
+                sortkey="typewriter",
+                another_attr=100,
+            )
+
+            table.put_item(hit)
+            table.put_item(miss_hash)
+            table.put_item(miss_sort)
+
+            key_filter = (ModelWithSortkey.hashkey == "a") & (
+                ModelWithSortkey.sortkey == "interesting"
+            )
+
+            items = list(table.query(query_expression=key_filter))
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+    def test_query_gt(self):
+        with self.table_with_str_sortkey as table:
+            hit = ModelWithSortkey(
+                hashkey="a",
+                sortkey="hello",
+                another_attr=77.5,
+            )
+            miss = ModelWithSortkey(
+                hashkey="a",
+                sortkey="afternoon",
+                another_attr=20.1,
+            )
+
+            table.put_item(hit)
+            table.put_item(miss)
+
+            query_expression = (ModelWithSortkey.sortkey > "d") & (
+                ModelWithSortkey.hashkey == "a"
+            )
+
+            items = list(table.query(query_expression=query_expression))
+
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+    def test_query_ge(self):
+        with self.table_with_str_sortkey as table:
+            hit = ModelWithSortkey(
+                hashkey="a",
+                sortkey="hello",
+                another_attr=20.1,
+            )
+            miss = ModelWithSortkey(
+                hashkey="a",
+                sortkey="afternoon",
+                another_attr=30,
+            )
+
+            table.put_item(hit)
+            table.put_item(miss)
+
+            query_expression = (ModelWithSortkey.sortkey >= "h") & (
+                ModelWithSortkey.hashkey == "a"
+            )
+
+            items = list(table.query(query_expression=query_expression))
+
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+    def test_query_lt(self):
+        with self.table_with_str_sortkey as table:
+            hit = ModelWithSortkey(
+                hashkey="a",
+                sortkey="aardvark",
+                another_attr=1,
+            )
+            miss = ModelWithSortkey(
+                hashkey="a",
+                sortkey="zebra",
+                another_attr=2,
+            )
+
+            table.put_item(hit)
+            table.put_item(miss)
+
+            query_expression = (ModelWithSortkey.sortkey < "c") & (
+                ModelWithSortkey.hashkey == "a"
+            )
+
+            items = list(table.query(query_expression=query_expression))
+
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+    def test_query_le(self):
+        with self.table_with_str_sortkey as table:
+            hit = ModelWithSortkey(
+                hashkey="a",
+                sortkey="apple",
+                another_attr=1,
+            )
+            miss = ModelWithSortkey(
+                hashkey="a",
+                sortkey="zebra",
+                another_attr=2,
+            )
+
+            table.put_item(hit)
+            table.put_item(miss)
+
+            query_expression = (ModelWithSortkey.sortkey <= "apple") & (
+                ModelWithSortkey.hashkey == "a"
+            )
+
+            items = list(table.query(query_expression=query_expression))
+
+            self.assertEqual(len(items), 1)
+            (item,) = items
+            self.assertEqual(item, hit)
+
+    def test_query_and_filter(self):
+        class Student(DynamoModel):
+            _table_name_ = "Students"
+
+            # Hash key
+            name = Key()
+
+            # Sort key
+            year = Key()
+
+            homeroom = Attr()
+
+            def __eq__(self, other):
+                if isinstance(other, Student):
+                    return (
+                        (self.name == other.name)
+                        & (self.year == other.year)
+                        & (self.homeroom == other.homeroom)
+                    )
+                return NotImplemented
+
+        with temporary_dynamodb_table(
+            Student,
+            "name",
+            hashkey_type="S",
+            sortkey_name="year",
+            sortkey_type="N",
+        ) as students_table:
+
+            item1 = Student(name="Cecil", year=10, homeroom="Faba")
+            item2 = Student(name="Cecil", year=11, homeroom="Aaa")
+            item3 = Student(name="Cecil", year=12, homeroom="Faba")
+            item4 = Student(name="Cloud", year=12, homeroom="Bbb")
+
+            students_table.put_item(item1)
+            students_table.put_item(item2)
+            students_table.put_item(item3)
+            students_table.put_item(item4)
+
+            query_expression = (Student.name == "Cecil") & (Student.year > 10)
+            filter_expression = Student.homeroom == "Faba"
+
+            results = list(
+                students_table.query(
+                    query_expression=query_expression,
+                    filter_expression=filter_expression,
+                )
+            )
+
+            self.assertEqual(len(results), 1)
+            (result,) = results
+            self.assertEqual(result, item3)
diff --git a/awstin/dynamodb/tests/test_testing.py b/awstin/dynamodb/tests/test_testing.py
index dcb44c2..e310f70 100644
--- a/awstin/dynamodb/tests/test_testing.py
+++ b/awstin/dynamodb/tests/test_testing.py
@@ -1,44 +1,40 @@
 import unittest
 import unittest.mock as mock
 
-from awstin.dynamodb import DynamoDB
-from awstin.dynamodb import __name__ as DYNAMODB_NAME
+from awstin.dynamodb import DynamoDB, DynamoModel, Key
+from awstin.dynamodb.table import __name__ as DYNAMODB_NAME
 from awstin.dynamodb.testing import temporary_dynamodb_table
 
 
 class TestTemporaryDynamoDBTable(unittest.TestCase):
-
     def test_create_dynamodb_table(self):
         dynamodb = DynamoDB()
 
-        self.assertNotIn(
-            "test_table_name",
-            dynamodb.list_tables()
-        )
+        class Model(DynamoModel):
+            _table_name_ = "test_table_name"
+            test_hashkey_name = Key()
 
-        with temporary_dynamodb_table("test_table_name", "test_hashkey_name"):
-            self.assertIn(
-                "test_table_name",
-                dynamodb.list_tables()
-            )
+        self.assertNotIn("test_table_name", dynamodb.list_tables())
 
-        self.assertNotIn(
-            "test_table_name",
-            dynamodb.list_tables()
-        )
+        with temporary_dynamodb_table(Model, "test_hashkey_name"):
+            self.assertIn("test_table_name", dynamodb.list_tables())
+
+        self.assertNotIn("test_table_name", dynamodb.list_tables())
 
     def test_create_dynamodb_table_composite_key(self):
         dynamodb = DynamoDB()
 
+        class Model(DynamoModel):
+            _table_name_ = "test_table_name"
+            test_hashkey_name = Key()
+            sortkey_name = Key()
+
         with temporary_dynamodb_table(
-            "test_table_name",
+            Model,
             "test_hashkey_name",
             sortkey_name="sortkey_name",
         ):
-            self.assertIn(
-                "test_table_name",
-                dynamodb.list_tables()
-            )
+            self.assertIn("test_table_name", dynamodb.list_tables())
             table_desc = dynamodb.client.describe_table(
                 TableName="test_table_name",
             )
@@ -49,9 +45,15 @@ class TestTemporaryDynamoDBTable(unittest.TestCase):
     def test_create_dynamodb_table_fails(self):
         fake_client = mock.Mock()
 
+        class Model(DynamoModel):
+            _table_name_ = "test_table_name"
+            test_hashkey_name = Key()
+
         # Mock the dynamodb client's list_tables method to return an empty
         # table names list, which should cause the loop to time out
-        def fake_list_tables(): return {"TableNames": []}
+        def fake_list_tables():
+            return {"TableNames": []}
+
         fake_client.list_tables = fake_list_tables
 
         mock_dynamodb = mock.patch(
@@ -60,7 +62,7 @@ class TestTemporaryDynamoDBTable(unittest.TestCase):
         )
 
         temp_table_ctx = temporary_dynamodb_table(
-            "test_table_name",
+            Model,
             "test_hashkey_name",
             delay=0.1,
             max_attempts=1,
@@ -72,7 +74,4 @@ class TestTemporaryDynamoDBTable(unittest.TestCase):
                     # Cannot create a table so we'll have an exception
                     pass
 
-        self.assertEqual(
-            "Could not create table 'test_table_name'",
-            str(err.exception)
-        )
+        self.assertEqual("Could not create table 'test_table_name'", str(err.exception))
diff --git a/awstin/sns/tests/test_sns.py b/awstin/sns/tests/test_sns.py
index 8cb4c64..dca54ce 100644
--- a/awstin/sns/tests/test_sns.py
+++ b/awstin/sns/tests/test_sns.py
@@ -26,7 +26,7 @@ class TestSNSTopic(unittest.TestCase):
         SNSTopic("name_of_topic")
         topics = self.sns_client.list_topics()["Topics"]
         self.assertEqual(len(topics), 1)
-        topic, = topics
+        (topic,) = topics
         self.assertTrue(topic["TopicArn"].endswith("name_of_topic"))
 
     def test_post_to_sns_topic(self):
diff --git a/awstin/tests/test_config.py b/awstin/tests/test_config.py
index 12fec98..6a30da3 100644
--- a/awstin/tests/test_config.py
+++ b/awstin/tests/test_config.py
@@ -33,16 +33,15 @@ class TestConfig(unittest.TestCase):
         self.assertEqual(
             "Please set either the AWS_REGION environment variable or set a "
             "testing endpoint.",
-            str(err.exception)
+            str(err.exception),
         )
 
     def test_aws_config_kwargs(self):
         # Check that kwargs are passed to the resource properly
 
-        with mock.patch(CONFIG_NAME+".Config") as mock_config:
+        with mock.patch(CONFIG_NAME + ".Config") as mock_config:
             aws_config(timeout=11, max_retries=55, endpoint="123.456")
 
         mock_config.assert_called_once_with(
-            connect_timeout=11,
-            retries={"max_attempts": 55}
+            connect_timeout=11, retries={"max_attempts": 55}
         )
diff --git a/awstin/tests/test_environment.py b/awstin/tests/test_environment.py
index a994679..01939be 100644
--- a/awstin/tests/test_environment.py
+++ b/awstin/tests/test_environment.py
@@ -7,12 +7,12 @@ UNLIKELY_ENV_VAR = "THIS_ENV_VAR_IS_UNLIKELY_TO_BE_IN_USE_150y71i1"
 
 
 class TestSetEnv(unittest.TestCase):
-
     def setUp(self):
         # Always remove the used environment variable
         def reset_env_var():
             if UNLIKELY_ENV_VAR in os.environ:
                 del os.environ[UNLIKELY_ENV_VAR]
+
         self.addCleanup(reset_env_var)
 
     def test_temp_set_new_env_var(self):