Skip to content

Commit 11650c9

Browse files
authored
Replace AttributesToGet with ProjectionExpression / ExpressionAttributeNames (#319)
1 parent f5555e6 commit 11650c9

File tree

4 files changed

+99
-12
lines changed

4 files changed

+99
-12
lines changed

pynamodb/connection/base.py

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import logging
77
import math
88
import random
9+
import re
910
import time
1011
import uuid
1112
from base64 import b64decode
@@ -26,7 +27,7 @@
2627
COMPARISON_OPERATOR, EXCLUSIVE_START_KEY, SCAN_INDEX_FORWARD, SCAN_FILTER_VALUES, ATTR_DEFINITIONS,
2728
BATCH_WRITE_ITEM, CONSISTENT_READ, ATTR_VALUE_LIST, DESCRIBE_TABLE, KEY_CONDITIONS,
2829
BATCH_GET_ITEM, DELETE_REQUEST, SELECT_VALUES, RETURN_VALUES, REQUEST_ITEMS, ATTR_UPDATES,
29-
ATTRS_TO_GET, SERVICE_NAME, DELETE_ITEM, PUT_REQUEST, UPDATE_ITEM, SCAN_FILTER, TABLE_NAME,
30+
PROJECTION_EXPRESSION, SERVICE_NAME, DELETE_ITEM, PUT_REQUEST, UPDATE_ITEM, SCAN_FILTER, TABLE_NAME,
3031
INDEX_NAME, KEY_SCHEMA, ATTR_NAME, ATTR_TYPE, TABLE_KEY, EXPECTED, KEY_TYPE, GET_ITEM, UPDATE,
3132
PUT_ITEM, SELECT, ACTION, EXISTS, VALUE, LIMIT, QUERY, SCAN, ITEM, LOCAL_SECONDARY_INDEXES,
3233
KEYS, KEY, EQ, SEGMENT, TOTAL_SEGMENTS, CREATE_TABLE, PROVISIONED_THROUGHPUT, READ_CAPACITY_UNITS,
@@ -35,7 +36,7 @@
3536
CONSUMED_CAPACITY, CAPACITY_UNITS, QUERY_FILTER, QUERY_FILTER_VALUES, CONDITIONAL_OPERATOR,
3637
CONDITIONAL_OPERATORS, NULL, NOT_NULL, SHORT_ATTR_TYPES, DELETE,
3738
ITEMS, DEFAULT_ENCODING, BINARY_SHORT, BINARY_SET_SHORT, LAST_EVALUATED_KEY, RESPONSES, UNPROCESSED_KEYS,
38-
UNPROCESSED_ITEMS, STREAM_SPECIFICATION, STREAM_VIEW_TYPE, STREAM_ENABLED)
39+
UNPROCESSED_ITEMS, STREAM_SPECIFICATION, STREAM_VIEW_TYPE, STREAM_ENABLED, EXPRESSION_ATTRIBUTE_NAMES)
3940
from pynamodb.exceptions import (
4041
TableError, QueryError, PutError, DeleteError, UpdateError, GetError, ScanError, TableDoesNotExist,
4142
VerboseClientError
@@ -45,6 +46,7 @@
4546
from pynamodb.types import HASH, RANGE
4647

4748
BOTOCORE_EXCEPTIONS = (BotoCoreError, ClientError)
49+
PATH_SEGMENT_REGEX = re.compile(r'([^\[\]]+)((?:\[\d+\])*)$')
4850

4951
log = logging.getLogger(__name__)
5052
log.addHandler(NullHandler())
@@ -929,12 +931,16 @@ def batch_get_item(self,
929931
}
930932

931933
args_map = {}
934+
name_placeholders = {}
932935
if consistent_read:
933936
args_map[CONSISTENT_READ] = consistent_read
934937
if return_consumed_capacity:
935938
operation_kwargs.update(self.get_consumed_capacity_map(return_consumed_capacity))
936939
if attributes_to_get is not None:
937-
args_map[ATTRS_TO_GET] = attributes_to_get
940+
projection_expression = self._get_projection_expression(attributes_to_get, name_placeholders)
941+
args_map[PROJECTION_EXPRESSION] = projection_expression
942+
if name_placeholders:
943+
args_map[EXPRESSION_ATTRIBUTE_NAMES] = self._reverse_dict(name_placeholders)
938944
operation_kwargs[REQUEST_ITEMS][table_name].update(args_map)
939945

940946
keys_map = {KEYS: []}
@@ -958,8 +964,12 @@ def get_item(self,
958964
Performs the GetItem operation and returns the result
959965
"""
960966
operation_kwargs = {}
967+
name_placeholders = {}
961968
if attributes_to_get is not None:
962-
operation_kwargs[ATTRS_TO_GET] = attributes_to_get
969+
projection_expression = self._get_projection_expression(attributes_to_get, name_placeholders)
970+
operation_kwargs[PROJECTION_EXPRESSION] = projection_expression
971+
if name_placeholders:
972+
operation_kwargs[EXPRESSION_ATTRIBUTE_NAMES] = self._reverse_dict(name_placeholders)
963973
operation_kwargs[CONSISTENT_READ] = consistent_read
964974
operation_kwargs[TABLE_NAME] = table_name
965975
operation_kwargs.update(self.get_identifier_map(table_name, hash_key, range_key))
@@ -1129,8 +1139,12 @@ def scan(self,
11291139
Performs the scan operation
11301140
"""
11311141
operation_kwargs = {TABLE_NAME: table_name}
1142+
name_placeholders = {}
11321143
if attributes_to_get is not None:
1133-
operation_kwargs[ATTRS_TO_GET] = attributes_to_get
1144+
projection_expression = self._get_projection_expression(attributes_to_get, name_placeholders)
1145+
operation_kwargs[PROJECTION_EXPRESSION] = projection_expression
1146+
if name_placeholders:
1147+
operation_kwargs[EXPRESSION_ATTRIBUTE_NAMES] = self._reverse_dict(name_placeholders)
11341148
if limit is not None:
11351149
operation_kwargs[LIMIT] = limit
11361150
if return_consumed_capacity:
@@ -1183,8 +1197,12 @@ def query(self,
11831197
Performs the Query operation and returns the result
11841198
"""
11851199
operation_kwargs = {TABLE_NAME: table_name}
1200+
name_placeholders = {}
11861201
if attributes_to_get:
1187-
operation_kwargs[ATTRS_TO_GET] = attributes_to_get
1202+
projection_expression = self._get_projection_expression(attributes_to_get, name_placeholders)
1203+
operation_kwargs[PROJECTION_EXPRESSION] = projection_expression
1204+
if name_placeholders:
1205+
operation_kwargs[EXPRESSION_ATTRIBUTE_NAMES] = self._reverse_dict(name_placeholders)
11881206
if consistent_read:
11891207
operation_kwargs[CONSISTENT_READ] = True
11901208
if exclusive_start_key:
@@ -1244,6 +1262,15 @@ def query(self,
12441262
except BOTOCORE_EXCEPTIONS as e:
12451263
raise QueryError("Failed to query items: {0}".format(e), e)
12461264

1265+
@staticmethod
1266+
def _get_projection_expression(attributes_to_get, placeholders):
1267+
expressions = [_substitute_names(attribute, placeholders) for attribute in attributes_to_get]
1268+
return ', '.join(expressions)
1269+
1270+
@staticmethod
1271+
def _reverse_dict(d):
1272+
return dict((v, k) for k, v in six.iteritems(d))
1273+
12471274

12481275
def _convert_binary(attr):
12491276
if BINARY_SHORT in attr:
@@ -1252,3 +1279,23 @@ def _convert_binary(attr):
12521279
value = attr[BINARY_SET_SHORT]
12531280
if value and len(value):
12541281
attr[BINARY_SET_SHORT] = set(b64decode(v.encode(DEFAULT_ENCODING)) for v in value)
1282+
1283+
1284+
def _substitute_names(expression, placeholders):
1285+
"""
1286+
Replaces names in the given expression with placeholders.
1287+
Stores the placeholders in the given dictionary.
1288+
"""
1289+
path_segments = expression.split('.')
1290+
for idx, segment in enumerate(path_segments):
1291+
match = PATH_SEGMENT_REGEX.match(segment)
1292+
if not match:
1293+
raise ValueError('{0} is not a valid document path'.format(expression))
1294+
name, indexes = match.groups()
1295+
if name in placeholders:
1296+
placeholder = placeholders[name]
1297+
else:
1298+
placeholder = '#' + str(len(placeholders))
1299+
placeholders[name] = placeholder
1300+
path_segments[idx] = placeholder + indexes
1301+
return '.'.join(path_segments)

pynamodb/constants.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
DELETE_REQUEST = 'DeleteRequest'
3434
RETURN_VALUES = 'ReturnValues'
3535
REQUEST_ITEMS = 'RequestItems'
36-
ATTRS_TO_GET = 'AttributesToGet'
3736
ATTR_UPDATES = 'AttributeUpdates'
3837
TABLE_STATUS = 'TableStatus'
3938
SCAN_FILTER = 'ScanFilter'
@@ -62,6 +61,10 @@
6261
UTC = 'UTC'
6362
KEY = 'Key'
6463

64+
# Expression Parameters
65+
EXPRESSION_ATTRIBUTE_NAMES = 'ExpressionAttributeNames'
66+
PROJECTION_EXPRESSION = 'ProjectionExpression'
67+
6568
# Defaults
6669
DEFAULT_ENCODING = 'utf-8'
6770
DEFAULT_REGION = 'us-east-1'

pynamodb/tests/test_base_connection.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -606,7 +606,10 @@ def test_get_item(self):
606606
)
607607
params = {
608608
'ReturnConsumedCapacity': 'TOTAL',
609-
'AttributesToGet': ['ForumName'],
609+
'ProjectionExpression': '#0',
610+
'ExpressionAttributeNames': {
611+
'#0': 'ForumName'
612+
},
610613
'Key': {
611614
'ForumName': {
612615
'S': 'Amazon DynamoDB'
@@ -1231,7 +1234,10 @@ def test_batch_get_item(self):
12311234
'RequestItems': {
12321235
'Thread': {
12331236
'ConsistentRead': True,
1234-
'AttributesToGet': ['ForumName'],
1237+
'ProjectionExpression': '#0',
1238+
'ExpressionAttributeNames': {
1239+
'#0': 'ForumName'
1240+
},
12351241
'Keys': [
12361242
{'ForumName': {'S': 'FooForum'}, 'Subject': {'S': 'thread-0'}},
12371243
{'ForumName': {'S': 'FooForum'}, 'Subject': {'S': 'thread-1'}},
@@ -1394,7 +1400,10 @@ def test_query(self):
13941400
}
13951401
},
13961402
'IndexName': 'LastPostIndex',
1397-
'AttributesToGet': ['ForumName'],
1403+
'ProjectionExpression': '#0',
1404+
'ExpressionAttributeNames': {
1405+
'#0': 'ForumName'
1406+
},
13981407
'KeyConditions': {
13991408
'ForumName': {
14001409
'ComparisonOperator': 'EQ', 'AttributeValueList': [{
@@ -1650,7 +1659,10 @@ def test_scan(self):
16501659
attributes_to_get=['ForumName']
16511660
)
16521661
params = {
1653-
'AttributesToGet': ['ForumName'],
1662+
'ProjectionExpression': '#0',
1663+
'ExpressionAttributeNames': {
1664+
'#0': 'ForumName'
1665+
},
16541666
'ExclusiveStartKey': {
16551667
"ForumName": {
16561668
"S": "FooForum"
@@ -2186,3 +2198,25 @@ def test_handle_binary_attributes_for_unprocessed_items(self):
21862198
_assert=True
21872199
)
21882200

2201+
2202+
def test_get_projection_expression(self):
2203+
attributes_to_get = ['Description', 'RelatedItems[0]', 'ProductReviews.FiveStar']
2204+
placeholders = {}
2205+
projection_expression = Connection._get_projection_expression(attributes_to_get, placeholders)
2206+
assert projection_expression == "#0, #1[0], #2.#3"
2207+
assert placeholders == {'Description': '#0', 'RelatedItems': '#1', 'ProductReviews': '#2', 'FiveStar': '#3'}
2208+
2209+
2210+
def test_get_projection_expression_repeated_names(self):
2211+
attributes_to_get = ['ProductReviews.FiveStar', 'ProductReviews.ThreeStar', 'ProductReviews.OneStar']
2212+
placeholders = {}
2213+
projection_expression = Connection._get_projection_expression(attributes_to_get, placeholders)
2214+
assert projection_expression == "#0.#1, #0.#2, #0.#3"
2215+
assert placeholders == {'ProductReviews': '#0', 'FiveStar': '#1', 'ThreeStar': '#2', 'OneStar': '#3'}
2216+
2217+
2218+
def test_get_projection_expression_invalid_attribute_raises(self):
2219+
invalid_attributes = ['', '[0]', 'foo[bar]', 'MyList[-1]', 'MyList[0.4]']
2220+
for attribute in invalid_attributes:
2221+
with self.assertRaises(ValueError):
2222+
Connection._get_projection_expression([attribute], {})

pynamodb/tests/test_model.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2381,7 +2381,10 @@ def test_batch_get(self):
23812381
{'user_name': {'S': 'hash-1'}},
23822382
{'user_name': {'S': 'hash-0'}}
23832383
],
2384-
'AttributesToGet': ['numbers']
2384+
'ProjectionExpression': '#0',
2385+
'ExpressionAttributeNames': {
2386+
'#0': 'numbers'
2387+
}
23852388
}
23862389
}
23872390
}

0 commit comments

Comments
 (0)