Skip to content

Commit c022adc

Browse files
authored
Do not call DescribeTable for models (#1095)
Do not call DescribeTable for models (#1091) When using DynamoDB through a `Model` or `Index` (rather than `(Table)Connection` directly), we will derive the "meta-table" from the model itself rather than make an initial `DescribeTable` call. This has numerous advantages: - Faster bootstrap (important for lambdas, as pointed out in #422) - More consistent handling of attribute types: Before this change, if the PynamoDB model definition and the DynamoDB table definition disagreed on a key attribute's type, PynamoDB would use its own idea of the type in some code paths and the underlying type in others. Now it would consistently use its own idea of the type, allowing the erroneous model definition to be spotted sooner. - Easier testing, since there's no longer a one-off request that only happens once and affects global state. This approach attempts to change the library as little as possible, by synthesizing a MetaTable from the model. This is a backport of #1091.
1 parent efe50f9 commit c022adc

10 files changed

+176
-283
lines changed

docs/release_notes.rst

+13
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
Release Notes
22
=============
33

4+
v5.3.0
5+
----------
6+
* No longer call ``DescribeTable`` API before first operation
7+
8+
Before this change, we would call ``DescribeTable`` before the first operation
9+
on a given table in order to discover its schema. This slowed down bootstrap
10+
(particularly important for lambdas), complicated testing and could potentially
11+
cause inconsistent behavior since queries were serialized using the table's
12+
(key) schema but deserialized using the model's schema.
13+
14+
With this change, both queries and models now use the model's schema.
15+
16+
417
v5.2.3
518
----------
619
* Update for botocore 1.28 private API change (#1087) which caused the following exception::

pynamodb/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77
"""
88
__author__ = 'Jharrod LaFon'
99
__license__ = 'MIT'
10-
__version__ = '5.2.3'
10+
__version__ = '5.3.0'

pynamodb/connection/base.py

+44-27
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ def __repr__(self) -> str:
7979
return "MetaTable<{}>".format(self.data.get(TABLE_NAME))
8080
return ""
8181

82+
@property
83+
def table_name(self) -> str:
84+
"""
85+
Returns the table name
86+
"""
87+
return self.data[TABLE_NAME]
88+
8289
@property
8390
def range_keyname(self) -> Optional[str]:
8491
"""
@@ -559,25 +566,22 @@ def client(self) -> BotocoreBaseClientPrivate:
559566
self._convert_to_request_dict__endpoint_url = 'endpoint_url' in inspect.signature(self._client._convert_to_request_dict).parameters
560567
return self._client
561568

562-
def get_meta_table(self, table_name: str, refresh: bool = False):
569+
def add_meta_table(self, meta_table: MetaTable) -> None:
563570
"""
564-
Returns a MetaTable
571+
Adds information about the table's schema.
565572
"""
566-
if table_name not in self._tables or refresh:
567-
operation_kwargs = {
568-
TABLE_NAME: table_name
569-
}
570-
try:
571-
data = self.dispatch(DESCRIBE_TABLE, operation_kwargs)
572-
self._tables[table_name] = MetaTable(data.get(TABLE_KEY))
573-
except BotoCoreError as e:
574-
raise TableError("Unable to describe table: {}".format(e), e)
575-
except ClientError as e:
576-
if 'ResourceNotFound' in e.response['Error']['Code']:
577-
raise TableDoesNotExist(e.response['Error']['Message'])
578-
else:
579-
raise
580-
return self._tables[table_name]
573+
if meta_table.table_name in self._tables:
574+
raise ValueError(f"Meta-table for '{meta_table.table_name}' already added")
575+
self._tables[meta_table.table_name] = meta_table
576+
577+
def get_meta_table(self, table_name: str) -> MetaTable:
578+
"""
579+
Returns information about the table's schema.
580+
"""
581+
try:
582+
return self._tables[table_name]
583+
except KeyError:
584+
raise TableError(f"Meta-table for '{table_name}' not initialized") from None
581585

582586
def create_table(
583587
self,
@@ -608,8 +612,8 @@ def create_table(
608612
raise ValueError("attribute_definitions argument is required")
609613
for attr in attribute_definitions:
610614
attrs_list.append({
611-
ATTR_NAME: attr.get('attribute_name'),
612-
ATTR_TYPE: attr.get('attribute_type')
615+
ATTR_NAME: attr.get(ATTR_NAME) or attr['attribute_name'],
616+
ATTR_TYPE: attr.get(ATTR_TYPE) or attr['attribute_type']
613617
})
614618
operation_kwargs[ATTR_DEFINITIONS] = attrs_list
615619

@@ -639,8 +643,8 @@ def create_table(
639643
key_schema_list = []
640644
for item in key_schema:
641645
key_schema_list.append({
642-
ATTR_NAME: item.get('attribute_name'),
643-
KEY_TYPE: str(item.get('key_type')).upper()
646+
ATTR_NAME: item.get(ATTR_NAME) or item['attribute_name'],
647+
KEY_TYPE: str(item.get(KEY_TYPE) or item['key_type']).upper()
644648
})
645649
operation_kwargs[KEY_SCHEMA] = sorted(key_schema_list, key=lambda x: x.get(KEY_TYPE))
646650

@@ -767,13 +771,26 @@ def describe_table(self, table_name: str) -> Dict:
767771
"""
768772
Performs the DescribeTable operation
769773
"""
774+
operation_kwargs = {
775+
TABLE_NAME: table_name
776+
}
770777
try:
771-
tbl = self.get_meta_table(table_name, refresh=True)
772-
if tbl:
773-
return tbl.data
774-
except ValueError:
775-
pass
776-
raise TableDoesNotExist(table_name)
778+
data = self.dispatch(DESCRIBE_TABLE, operation_kwargs)
779+
table_data = data.get(TABLE_KEY)
780+
# For compatibility with existing code which uses Connection directly,
781+
# we can let DescribeTable set the meta table.
782+
if table_data:
783+
meta_table = MetaTable(table_data)
784+
if meta_table.table_name not in self._tables:
785+
self.add_meta_table(meta_table)
786+
return table_data
787+
except BotoCoreError as e:
788+
raise TableError("Unable to describe table: {}".format(e), e)
789+
except ClientError as e:
790+
if 'ResourceNotFound' in e.response['Error']['Code']:
791+
raise TableDoesNotExist(e.response['Error']['Message'])
792+
else:
793+
raise
777794

778795
def get_item_attribute_map(
779796
self,

pynamodb/connection/table.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ def __init__(
3030
aws_access_key_id: Optional[str] = None,
3131
aws_secret_access_key: Optional[str] = None,
3232
aws_session_token: Optional[str] = None,
33+
*,
34+
meta_table: Optional[MetaTable] = None,
3335
) -> None:
3436
self.table_name = table_name
3537
self.connection = Connection(region=region,
@@ -40,17 +42,19 @@ def __init__(
4042
base_backoff_ms=base_backoff_ms,
4143
max_pool_connections=max_pool_connections,
4244
extra_headers=extra_headers)
45+
if meta_table is not None:
46+
self.connection.add_meta_table(meta_table)
4347

4448
if aws_access_key_id and aws_secret_access_key:
4549
self.connection.session.set_credentials(aws_access_key_id,
4650
aws_secret_access_key,
4751
aws_session_token)
4852

49-
def get_meta_table(self, refresh: bool = False) -> MetaTable:
53+
def get_meta_table(self) -> MetaTable:
5054
"""
5155
Returns a MetaTable
5256
"""
53-
return self.connection.get_meta_table(self.table_name, refresh=refresh)
57+
return self.connection.get_meta_table(self.table_name)
5458

5559
def get_operation_kwargs(
5660
self,

pynamodb/models.py

+35-11
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
from typing import Union
2525
from typing import cast
2626

27+
from pynamodb.connection.base import MetaTable
28+
2729
if sys.version_info >= (3, 8):
2830
from typing import Protocol
2931
else:
@@ -38,9 +40,10 @@
3840
from pynamodb.connection.table import TableConnection
3941
from pynamodb.expressions.condition import Condition
4042
from pynamodb.types import HASH, RANGE
41-
from pynamodb.indexes import Index, GlobalSecondaryIndex
43+
from pynamodb.indexes import Index, GlobalSecondaryIndex, LocalSecondaryIndex
4244
from pynamodb.pagination import ResultIterator
4345
from pynamodb.settings import get_settings_value, OperationSettings
46+
from pynamodb import constants
4447
from pynamodb.constants import (
4548
ATTR_DEFINITIONS, ATTR_NAME, ATTR_TYPE, KEY_SCHEMA,
4649
KEY_TYPE, ITEM, READ_CAPACITY_UNITS, WRITE_CAPACITY_UNITS,
@@ -53,7 +56,7 @@
5356
BATCH_WRITE_PAGE_LIMIT,
5457
META_CLASS_NAME, REGION, HOST, NULL,
5558
COUNT, ITEM_COUNT, KEY, UNPROCESSED_ITEMS, STREAM_VIEW_TYPE,
56-
STREAM_SPECIFICATION, STREAM_ENABLED, BILLING_MODE, PAY_PER_REQUEST_BILLING_MODE, TAGS
59+
STREAM_SPECIFICATION, STREAM_ENABLED, BILLING_MODE, PAY_PER_REQUEST_BILLING_MODE, TAGS, TABLE_NAME
5760
)
5861
from pynamodb.util import attribute_value_to_json
5962
from pynamodb.util import json_to_attribute_value
@@ -863,18 +866,18 @@ def _get_schema(cls) -> Dict[str, Any]:
863866
for attr_name, attr_cls in cls.get_attributes().items():
864867
if attr_cls.is_hash_key or attr_cls.is_range_key:
865868
schema['attribute_definitions'].append({
866-
'attribute_name': attr_cls.attr_name,
867-
'attribute_type': attr_cls.attr_type
869+
ATTR_NAME: attr_cls.attr_name,
870+
ATTR_TYPE: attr_cls.attr_type
868871
})
869872
if attr_cls.is_hash_key:
870873
schema['key_schema'].append({
871-
'key_type': HASH,
872-
'attribute_name': attr_cls.attr_name
874+
KEY_TYPE: HASH,
875+
ATTR_NAME: attr_cls.attr_name
873876
})
874877
elif attr_cls.is_range_key:
875878
schema['key_schema'].append({
876-
'key_type': RANGE,
877-
'attribute_name': attr_cls.attr_name
879+
KEY_TYPE: RANGE,
880+
ATTR_NAME: attr_cls.attr_name
878881
})
879882
for index in cls._indexes.values():
880883
index_schema = index._get_schema()
@@ -887,13 +890,13 @@ def _get_schema(cls) -> Dict[str, Any]:
887890
attr_names = {key_schema[ATTR_NAME]
888891
for index_schema in (*schema['global_secondary_indexes'], *schema['local_secondary_indexes'])
889892
for key_schema in index_schema['key_schema']}
890-
attr_keys = {attr.get('attribute_name') for attr in schema['attribute_definitions']}
893+
attr_keys = {attr[ATTR_NAME] for attr in schema['attribute_definitions']}
891894
for attr_name in attr_names:
892895
if attr_name not in attr_keys:
893896
attr_cls = cls.get_attributes()[cls._dynamo_to_python_attr(attr_name)]
894897
schema['attribute_definitions'].append({
895-
'attribute_name': attr_cls.attr_name,
896-
'attribute_type': attr_cls.attr_type
898+
ATTR_NAME: attr_cls.attr_name,
899+
ATTR_TYPE: attr_cls.attr_type
897900
})
898901
return schema
899902

@@ -1057,7 +1060,28 @@ def _get_connection(cls) -> TableConnection:
10571060
# For now we just check that the connection exists and (in the case of model inheritance)
10581061
# points to the same table. In the future we should update the connection if any of the attributes differ.
10591062
if cls._connection is None or cls._connection.table_name != cls.Meta.table_name:
1063+
schema = cls._get_schema()
1064+
meta_table = MetaTable({
1065+
constants.TABLE_NAME: cls.Meta.table_name,
1066+
constants.KEY_SCHEMA: schema['key_schema'],
1067+
constants.ATTR_DEFINITIONS: schema['attribute_definitions'],
1068+
constants.GLOBAL_SECONDARY_INDEXES: [
1069+
{
1070+
constants.INDEX_NAME: index_schema['index_name'],
1071+
constants.KEY_SCHEMA: index_schema['key_schema'],
1072+
}
1073+
for index_schema in schema['global_secondary_indexes']
1074+
],
1075+
constants.LOCAL_SECONDARY_INDEXES: [
1076+
{
1077+
constants.INDEX_NAME: index_schema['index_name'],
1078+
constants.KEY_SCHEMA: index_schema['key_schema'],
1079+
}
1080+
for index_schema in schema['local_secondary_indexes']
1081+
],
1082+
})
10601083
cls._connection = TableConnection(cls.Meta.table_name,
1084+
meta_table=meta_table,
10611085
region=cls.Meta.region,
10621086
host=cls.Meta.host,
10631087
connect_timeout_seconds=cls.Meta.connect_timeout_seconds,

tests/data.py

-84
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@
3939
}
4040
}
4141

42-
4342
MODEL_TABLE_DATA = {
4443
"Table": {
4544
"AttributeDefinitions": [
@@ -345,89 +344,6 @@
345344
}
346345
}
347346

348-
DESCRIBE_TABLE_DATA_PAY_PER_REQUEST = {
349-
"Table": {
350-
"AttributeDefinitions": [
351-
{
352-
"AttributeName": "ForumName",
353-
"AttributeType": "S"
354-
},
355-
{
356-
"AttributeName": "LastPostDateTime",
357-
"AttributeType": "S"
358-
},
359-
{
360-
"AttributeName": "Subject",
361-
"AttributeType": "S"
362-
}
363-
],
364-
"CreationDateTime": 1.363729002358E9,
365-
"ItemCount": 0,
366-
"KeySchema": [
367-
{
368-
"AttributeName": "ForumName",
369-
"KeyType": "HASH"
370-
},
371-
{
372-
"AttributeName": "Subject",
373-
"KeyType": "RANGE"
374-
}
375-
],
376-
"GlobalSecondaryIndexes": [
377-
{
378-
"IndexName": "LastPostIndex",
379-
"IndexSizeBytes": 0,
380-
"ItemCount": 0,
381-
"KeySchema": [
382-
{
383-
"AttributeName": "ForumName",
384-
"KeyType": "HASH"
385-
},
386-
{
387-
"AttributeName": "LastPostDateTime",
388-
"KeyType": "RANGE"
389-
}
390-
],
391-
"Projection": {
392-
"ProjectionType": "KEYS_ONLY"
393-
}
394-
}
395-
],
396-
"LocalSecondaryIndexes": [
397-
{
398-
"IndexName": "LastPostIndex",
399-
"IndexSizeBytes": 0,
400-
"ItemCount": 0,
401-
"KeySchema": [
402-
{
403-
"AttributeName": "ForumName",
404-
"KeyType": "HASH"
405-
},
406-
{
407-
"AttributeName": "LastPostDateTime",
408-
"KeyType": "RANGE"
409-
}
410-
],
411-
"Projection": {
412-
"ProjectionType": "KEYS_ONLY"
413-
}
414-
}
415-
],
416-
"ProvisionedThroughput": {
417-
"NumberOfDecreasesToday": 0,
418-
"ReadCapacityUnits": 0,
419-
"WriteCapacityUnits": 0
420-
},
421-
"TableName": "Thread",
422-
"TableSizeBytes": 0,
423-
"TableStatus": "ACTIVE",
424-
"BillingModeSummary": {
425-
"BillingMode": "PAY_PER_REQUEST",
426-
"LastUpdateToPayPerRequestDateTime": 1548353644.074
427-
}
428-
}
429-
}
430-
431347
GET_MODEL_ITEM_DATA = {
432348
'Item': {
433349
'user_name': {

0 commit comments

Comments
 (0)