Repository: pynamodb/PynamoDB

Issue:
Inheriting from Model - should this work?
Hi,

Should it be possible to sub-class a pynamodb Model?  I'm getting the following error:

```
AttributeError: As of v1.0 PynamoDB Models require a `Meta` class with a table_name property.
                See https://pynamodb.readthedocs.io/en/latest/release_notes.html
```

MRE to reproduce (Python 2.7.12, pynamodb-1.5.3):

``` python
from pynamodb.models import Model
from pynamodb.attributes import UnicodeAttribute, NumberAttribute

class SomeBase(Model):

    class Meta:
        table_name='table-one'
        region     = 'us-west-2'

    email = UnicodeAttribute(hash_key=True)
    count = NumberAttribute(default=0)


class VariantA(SomeBase):

    def __init__(self, *args, **kwargs):
        super(VariantA, self).__init__(*args, **kwargs)


if __name__ == '__main__':
    some_a = VariantA()

    #print "Meta attribute present: {}".format(hasattr(some_a, 'Meta'))
    #print dir(some_a)

    some_a.email = 'user@example.com'
    some_a.count = 666
    some_a.save()
```


Discussion:
jpinner-lyft: reattempt of https://github.com/pynamodb/PynamoDB/pull/412
jpinner-lyft: Question from the previous attempt: say I have

```
ModelA(Model):
    class Meta:
        table_name = 'foo'

ModelB(ModelA):
    pass
```
with this change, ModelB.Meta.table_name == 'foo'

Now if i do ModelA.Meta.table_name = 'bar' what should ModelB.Meta.table_name be?
Or vice-versa, if i do ModelB.Meta.table_name = 'bar' should that change ModelA?

Do I need to make Meta some sort of "inherited, copy-on-write-per-property" class?

My current thoughts on this - subclassing will be initially used to support polymorphism where the parent model and its children classes all point to the same table so modifying the Meta class on any of the classes should modify it on all of them (they all reference the same configuration).
jpinner-lyft: Note: this PR does not fix #361 since connections are still cached on the Model object. A follow-up change to cache connections on the Meta class will address that issue.

Patch:
diff --git a/pynamodb/models.py b/pynamodb/models.py
index f2521f9..15944e3 100644
--- a/pynamodb/models.py
+++ b/pynamodb/models.py
@@ -12,7 +12,7 @@ from typing import Any, Dict, Generic, Iterable, Iterator, List, Optional, Seque
 from pynamodb.expressions.update import Action
 from pynamodb.exceptions import DoesNotExist, TableDoesNotExist, TableError, InvalidStateError, PutError
 from pynamodb.attributes import (
-    Attribute, AttributeContainer, AttributeContainerMeta, TTLAttribute, VersionAttribute
+    AttributeContainer, AttributeContainerMeta, TTLAttribute, VersionAttribute
 )
 from pynamodb.connection.table import TableConnection
 from pynamodb.expressions.condition import Condition
@@ -151,10 +151,6 @@ class BatchWrite(Generic[_T]):
             unprocessed_items = data.get(UNPROCESSED_ITEMS, {}).get(self.model.Meta.table_name)
 
 
-class DefaultMeta(object):
-    pass
-
-
 class MetaModel(AttributeContainerMeta):
     table_name: str
     read_capacity_units: Optional[int]
@@ -184,17 +180,26 @@ class MetaModel(AttributeContainerMeta):
         cls = cast(Type['Model'], self)
         for attr_name, attribute in cls.get_attributes().items():
             if attribute.is_hash_key:
+                if cls._hash_keyname and cls._hash_keyname != attr_name:
+                    raise ValueError(f"{cls.__name__} has more than one hash key: {cls._hash_keyname}, {attr_name}")
                 cls._hash_keyname = attr_name
             if attribute.is_range_key:
+                if cls._range_keyname and cls._range_keyname != attr_name:
+                    raise ValueError(f"{cls.__name__} has more than one range key: {cls._range_keyname}, {attr_name}")
                 cls._range_keyname = attr_name
             if isinstance(attribute, VersionAttribute):
-                if cls._version_attribute_name:
+                if cls._version_attribute_name and cls._version_attribute_name != attr_name:
                     raise ValueError(
                         "The model has more than one Version attribute: {}, {}"
                         .format(cls._version_attribute_name, attr_name)
                     )
                 cls._version_attribute_name = attr_name
 
+        ttl_attr_names = [name for name, attr in cls.get_attributes().items() if isinstance(attr, TTLAttribute)]
+        if len(ttl_attr_names) > 1:
+            raise ValueError("{} has more than one TTL attribute: {}".format(
+                cls.__name__, ", ".join(ttl_attr_names)))
+
         if isinstance(attrs, dict):
             for attr_name, attr_obj in attrs.items():
                 if attr_name == META_CLASS_NAME:
@@ -226,16 +231,6 @@ class MetaModel(AttributeContainerMeta):
                     attr_obj.Meta.model = cls
                     if not hasattr(attr_obj.Meta, "index_name"):
                         attr_obj.Meta.index_name = attr_name
-                elif isinstance(attr_obj, Attribute):
-                    if attr_obj.attr_name is None:
-                        attr_obj.attr_name = attr_name
-
-            ttl_attr_names = [name for name, attr_obj in attrs.items() if isinstance(attr_obj, TTLAttribute)]
-            if len(ttl_attr_names) > 1:
-                raise ValueError("The model has more than one TTL attribute: {}".format(", ".join(ttl_attr_names)))
-
-            if META_CLASS_NAME not in attrs:
-                setattr(cls, META_CLASS_NAME, DefaultMeta)
 
             # create a custom Model.DoesNotExist derived from pynamodb.exceptions.DoesNotExist,
             # so that "except Model.DoesNotExist:" would not catch other models' exceptions


Test patch:
diff --git a/tests/test_model.py b/tests/test_model.py
index d6236fe..71b8461 100644
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -2,7 +2,6 @@
 Test model API
 """
 import base64
-import random
 import json
 import copy
 from datetime import datetime
@@ -14,9 +13,7 @@ from botocore.client import ClientError
 import pytest
 
 from .deep_eq import deep_eq
-from pynamodb.util import snake_to_camel_case
 from pynamodb.exceptions import DoesNotExist, TableError, PutError, AttributeDeserializationError
-from pynamodb.types import RANGE
 from pynamodb.constants import (
     ITEM, STRING, ALL, KEYS_ONLY, INCLUDE, REQUEST_ITEMS, UNPROCESSED_KEYS, CAMEL_COUNT,
     RESPONSES, KEYS, ITEMS, LAST_EVALUATED_KEY, EXCLUSIVE_START_KEY, ATTRIBUTES, BINARY,
@@ -2424,6 +2421,17 @@ class ModelTestCase(TestCase):
         with self.assertRaises(AttributeError):
             OldStyleModel.exists()
 
+    def test_no_table_name_exception(self):
+        """
+        Display warning for Models without table names
+        """
+        class MissingTableNameModel(Model):
+            class Meta:
+                pass
+            user_name = UnicodeAttribute(hash_key=True)
+        with self.assertRaises(AttributeError):
+            MissingTableNameModel.exists()
+
     def _get_office_employee(self):
         justin = Person(
             fname='Justin',
@@ -3214,6 +3222,24 @@ class ModelInitTestCase(TestCase):
     def test_deserialized_with_invalid_type(self):
         self.assertRaises(AttributeDeserializationError, TTLModel.from_raw_data, {'my_ttl': {'S': '1546300800'}})
 
+    def test_multiple_hash_keys(self):
+        with self.assertRaises(ValueError):
+            class BadHashKeyModel(Model):
+                class Meta:
+                    table_name = 'BadHashKeyModel'
+
+                foo = UnicodeAttribute(hash_key=True)
+                bar = UnicodeAttribute(hash_key=True)
+
+    def test_multiple_range_keys(self):
+        with self.assertRaises(ValueError):
+            class BadRangeKeyModel(Model):
+                class Meta:
+                    table_name = 'BadRangeKeyModel'
+
+                foo = UnicodeAttribute(range_key=True)
+                bar = UnicodeAttribute(range_key=True)
+
     def test_multiple_version_attributes(self):
         with self.assertRaises(ValueError):
             class BadVersionedModel(Model):
@@ -3222,3 +3248,11 @@ class ModelInitTestCase(TestCase):
 
                 version = VersionAttribute()
                 another_version = VersionAttribute()
+
+    def test_inherit_metaclass(self):
+        class ParentModel(Model):
+            class Meta:
+                table_name = 'foo'
+        class ChildModel(ParentModel):
+            pass
+        self.assertEqual(ParentModel.Meta.table_name, ChildModel.Meta.table_name)