Repository: pynamodb/PynamoDB

Issue:
Bug `model.batch_get()` reports duplicates when there are none.
model def:

```python

class StatsScheduleModel(Model):
    """Specify a schedule for StatsBot cron-triggered jobs"""

    class Meta(Model.Meta):
        table_name = os.environ["APP_DYN_STATSSCHEDULES_TABLENAME"]

    frequency = UnicodeAttribute(hash_key=True)
    job_name = UnicodeAttribute(range_key=True)
    target_type = UnicodeEnumAttribute(TargetType)
    athena_workgroup = UnicodeAttribute()
    athena_database = UnicodeAttribute()
    msteams_config = MSTeamsConfig(null=True)

```

calling code:

```python
def get_jobs_for_freq(frequencies):
    """
    For a given list of frequencies, query dynamoDB and return all jobs that
    should be run for the frequencies passed.
    """
    jobs = []
    LOGGER.info(f"Searching DB for jobs with frequencies: {frequencies}")

    for schedule in StatsScheduleModel.batch_get(frequencies):
        jobs.append(schedule)

    LOGGER.info(jobs)
    return (None, "replace me")

```


when I call the above function with `['once_a_month', 'once_a_day', 'twice_a_day']` as my parameter, I get the following when run against AWS:

```
An error occurred (ValidationException) on request (G976CCPIAM7N5LR5KDPA7ETI6BVV4KQNSO5AEMVJF66Q9ASUAAJG) on table (test-stats-schedules) when calling the BatchGetItem operation: Provided list of item keys contains duplicates"
```

But there are no duplicates anywhere...  Also, `model.query()` works fine.   I also tried replacing `frequencies` with `list(set(frequencies))` just in case -- no joy.

related to #1179  perhaps?

PS: I am running version 5.5.0 of pynamodb
Guard rails for batch_get with range key
# Problem
For tables/models with a range key, `batch_get` expects a list of 2-item iterables.

If the caller overlooks the fact there's a range key (or assumes the range key is optional), the following dumbfounding result is possible (see #1180):
```python
class MyModel:
  hk = UnicodeAttribute(hash_key=True)
  rk =  UnicodeAttribute(range_key=True)

for m in MyModel.batch_get(['abc', 'abd', 'abe']):  # <-- error
  ...
```
resulting in
```
An error occurred (ValidationException) on request (...) on table (...) when calling the BatchGetItem operation: Provided list of item keys contains duplicates
```

The surprising result is due to e.g. `'abc'` being treated as an iterable, where `'a'` is the hash key, `'b'` is the range key and `'c'` is ignored; ditto `'abd'` and `'abe'`, resulting in a query for `[('a', 'b'), ('a', 'b'), ('a', 'b')]`.

# Recommendation

For models with range keys:
- fail if iterable is a `str`
- fail if iterable results in number of items different than 2 items


Discussion:
ikonst: 👋 @jmphilli 
anandswaminathan: Thanks @ikonst (Sorry for the delay. Was OOO).
anandswaminathan: PTAL #core-datastores-team

Patch:
diff --git a/pynamodb/models.py b/pynamodb/models.py
index 16c690b..f05ed48 100644
--- a/pynamodb/models.py
+++ b/pynamodb/models.py
@@ -357,15 +357,23 @@ class Model(AttributeContainer, metaclass=MetaModel):
                         keys_to_get = []
             item = items.pop()
             if range_key_attribute:
-                hash_key, range_key = cls._serialize_keys(item[0], item[1])  # type: ignore
+                if isinstance(item, str):
+                    raise ValueError(f'Invalid key value {item!r}: '
+                                     'expected non-str iterable with exactly 2 elements (hash key, range key)')
+                try:
+                    hash_key, range_key = item
+                except (TypeError, ValueError):
+                    raise ValueError(f'Invalid key value {item!r}: '
+                                     'expected iterable with exactly 2 elements (hash key, range key)')
+                hash_key_ser, range_key_ser = cls._serialize_keys(hash_key, range_key)
                 keys_to_get.append({
-                    hash_key_attribute.attr_name: hash_key,
-                    range_key_attribute.attr_name: range_key
+                    hash_key_attribute.attr_name: hash_key_ser,
+                    range_key_attribute.attr_name: range_key_ser,
                 })
             else:
-                hash_key = cls._serialize_keys(item)[0]
+                hash_key_ser, _ = cls._serialize_keys(item)
                 keys_to_get.append({
-                    hash_key_attribute.attr_name: hash_key
+                    hash_key_attribute.attr_name: hash_key_ser
                 })
 
         while keys_to_get:


Test patch:
diff --git a/tests/test_model.py b/tests/test_model.py
index 8e965dc..cd236c9 100644
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -4,6 +4,7 @@ Test model API
 import base64
 import json
 import copy
+import re
 from datetime import datetime
 from datetime import timedelta
 from datetime import timezone
@@ -1845,7 +1846,6 @@ class ModelTestCase(TestCase):
             }
             self.assertEqual(params, req.call_args[0][1])
 
-
         with patch(PATCH_METHOD) as req:
             item_keys = [('hash-{}'.format(x), '{}'.format(x)) for x in range(10)]
             item_keys_copy = list(item_keys)
@@ -1906,6 +1906,56 @@ class ModelTestCase(TestCase):
             for item in UserModel.batch_get(item_keys):
                 self.assertIsNotNone(item)
 
+    def test_batch_get__range_key(self):
+        with patch(PATCH_METHOD) as req:
+            req.return_value = {
+                'UnprocessedKeys': {},
+                'Responses': {
+                    'UserModel': [],
+                }
+            }
+            items = [(f'hash-{x}', f'range-{x}') for x in range(10)]
+            _ = list(UserModel.batch_get(items))
+
+            actual_keys = req.call_args[0][1]['RequestItems']['UserModel']['Keys']
+            actual_keys.sort(key=json.dumps)
+            assert actual_keys == [
+                {'user_name': {'S': f'hash-{x}'}, 'user_id': {'S': f'range-{x}'}}
+                for x in range(10)
+            ]
+
+    def test_batch_get__range_key__invalid__string(self):
+        with patch(PATCH_METHOD) as req:
+            req.return_value = {
+                'UnprocessedKeys': {},
+                'Responses': {
+                    'UserModel': [],
+                }
+            }
+            with pytest.raises(
+                ValueError,
+                match=re.escape(
+                    "Invalid key value 'ab': expected non-str iterable with exactly 2 elements (hash key, range key)"
+                )
+            ):
+                _ = list(UserModel.batch_get(['ab']))
+
+    def test_batch_get__range_key__invalid__3_elements(self):
+        with patch(PATCH_METHOD) as req:
+            req.return_value = {
+                'UnprocessedKeys': {},
+                'Responses': {
+                    'UserModel': [],
+                }
+            }
+            with pytest.raises(
+                ValueError,
+                match=re.escape(
+                    "Invalid key value ('a', 'b', 'c'): expected iterable with exactly 2 elements (hash key, range key)"
+                )
+            ):
+                _ = list(UserModel.batch_get([('a', 'b', 'c')]))
+
     def test_batch_write(self):
         """
         Model.batch_write