Skip to content

Commit f6f67ae

Browse files
authored
Replace KeyConditions with KeyConditionExpression / ExpressionAttributeValues (#321)
1 parent 11650c9 commit f6f67ae

File tree

11 files changed

+444
-216
lines changed

11 files changed

+444
-216
lines changed

pynamodb/attributes.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
STRING, STRING_SHORT, NUMBER, BINARY, UTC, DATETIME_FORMAT, BINARY_SET, STRING_SET, NUMBER_SET,
1313
MAP, MAP_SHORT, LIST, LIST_SHORT, DEFAULT_ENCODING, BOOLEAN, ATTR_TYPE_MAP, NUMBER_SHORT, NULL
1414
)
15+
from pynamodb.expressions.condition import Path
1516
import collections
1617

1718

@@ -66,6 +67,38 @@ def get_value(self, value):
6667
serialized_dynamo_type = ATTR_TYPE_MAP[self.attr_type]
6768
return value.get(serialized_dynamo_type)
6869

70+
# Condition Expression Support
71+
def __eq__(self, other):
72+
if other is None or isinstance(other, Attribute): # handle object identity comparison
73+
return self is other
74+
return Path(self.attr_name).__eq__(self._get_attribute_value(other))
75+
76+
def __lt__(self, other):
77+
return Path(self.attr_name).__lt__(self._get_attribute_value(other))
78+
79+
def __le__(self, other):
80+
return Path(self.attr_name).__le__(self._get_attribute_value(other))
81+
82+
def __gt__(self, other):
83+
return Path(self.attr_name).__gt__(self._get_attribute_value(other))
84+
85+
def __ge__(self, other):
86+
return Path(self.attr_name).__ge__(self._get_attribute_value(other))
87+
88+
def __getitem__(self, idx): # support accessing list elements in condition expressions
89+
if not isinstance(idx, int):
90+
raise TypeError('list indices must be integers, not {}'.format(type(idx).__name__))
91+
return Path('{0}[{1}]'.format(self.attr_name, idx)) # TODO include attribute value formatting
92+
93+
def between(self, value1, value2):
94+
return Path(self.attr_name).between(self._get_attribute_value(value1), self._get_attribute_value(value2))
95+
96+
def startswith(self, prefix):
97+
return Path(self.attr_name).startswith(self._get_attribute_value(prefix))
98+
99+
def _get_attribute_value(self, value):
100+
return {ATTR_TYPE_MAP[self.attr_type]: self.serialize(value)}
101+
69102

70103
class AttributeContainer(object):
71104

pynamodb/connection/base.py

Lines changed: 47 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import logging
77
import math
88
import random
9-
import re
109
import time
1110
import uuid
1211
from base64 import b64decode
@@ -25,7 +24,7 @@
2524
RETURN_CONSUMED_CAPACITY_VALUES, RETURN_ITEM_COLL_METRICS_VALUES, COMPARISON_OPERATOR_VALUES,
2625
RETURN_ITEM_COLL_METRICS, RETURN_CONSUMED_CAPACITY, RETURN_VALUES_VALUES, ATTR_UPDATE_ACTIONS,
2726
COMPARISON_OPERATOR, EXCLUSIVE_START_KEY, SCAN_INDEX_FORWARD, SCAN_FILTER_VALUES, ATTR_DEFINITIONS,
28-
BATCH_WRITE_ITEM, CONSISTENT_READ, ATTR_VALUE_LIST, DESCRIBE_TABLE, KEY_CONDITIONS,
27+
BATCH_WRITE_ITEM, CONSISTENT_READ, ATTR_VALUE_LIST, DESCRIBE_TABLE, KEY_CONDITION_EXPRESSION,
2928
BATCH_GET_ITEM, DELETE_REQUEST, SELECT_VALUES, RETURN_VALUES, REQUEST_ITEMS, ATTR_UPDATES,
3029
PROJECTION_EXPRESSION, SERVICE_NAME, DELETE_ITEM, PUT_REQUEST, UPDATE_ITEM, SCAN_FILTER, TABLE_NAME,
3130
INDEX_NAME, KEY_SCHEMA, ATTR_NAME, ATTR_TYPE, TABLE_KEY, EXPECTED, KEY_TYPE, GET_ITEM, UPDATE,
@@ -36,17 +35,19 @@
3635
CONSUMED_CAPACITY, CAPACITY_UNITS, QUERY_FILTER, QUERY_FILTER_VALUES, CONDITIONAL_OPERATOR,
3736
CONDITIONAL_OPERATORS, NULL, NOT_NULL, SHORT_ATTR_TYPES, DELETE,
3837
ITEMS, DEFAULT_ENCODING, BINARY_SHORT, BINARY_SET_SHORT, LAST_EVALUATED_KEY, RESPONSES, UNPROCESSED_KEYS,
39-
UNPROCESSED_ITEMS, STREAM_SPECIFICATION, STREAM_VIEW_TYPE, STREAM_ENABLED, EXPRESSION_ATTRIBUTE_NAMES)
38+
UNPROCESSED_ITEMS, STREAM_SPECIFICATION, STREAM_VIEW_TYPE, STREAM_ENABLED,
39+
EXPRESSION_ATTRIBUTE_NAMES, EXPRESSION_ATTRIBUTE_VALUES, KEY_CONDITION_OPERATOR_MAP)
4040
from pynamodb.exceptions import (
4141
TableError, QueryError, PutError, DeleteError, UpdateError, GetError, ScanError, TableDoesNotExist,
4242
VerboseClientError
4343
)
44+
from pynamodb.expressions.condition import Path
45+
from pynamodb.expressions.projection import create_projection_expression
4446
from pynamodb.settings import get_settings_value
4547
from pynamodb.signals import pre_dynamodb_send, post_dynamodb_send
4648
from pynamodb.types import HASH, RANGE
4749

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

5152
log = logging.getLogger(__name__)
5253
log.addHandler(NullHandler())
@@ -937,7 +938,7 @@ def batch_get_item(self,
937938
if return_consumed_capacity:
938939
operation_kwargs.update(self.get_consumed_capacity_map(return_consumed_capacity))
939940
if attributes_to_get is not None:
940-
projection_expression = self._get_projection_expression(attributes_to_get, name_placeholders)
941+
projection_expression = create_projection_expression(attributes_to_get, name_placeholders)
941942
args_map[PROJECTION_EXPRESSION] = projection_expression
942943
if name_placeholders:
943944
args_map[EXPRESSION_ATTRIBUTE_NAMES] = self._reverse_dict(name_placeholders)
@@ -966,7 +967,7 @@ def get_item(self,
966967
operation_kwargs = {}
967968
name_placeholders = {}
968969
if attributes_to_get is not None:
969-
projection_expression = self._get_projection_expression(attributes_to_get, name_placeholders)
970+
projection_expression = create_projection_expression(attributes_to_get, name_placeholders)
970971
operation_kwargs[PROJECTION_EXPRESSION] = projection_expression
971972
if name_placeholders:
972973
operation_kwargs[EXPRESSION_ATTRIBUTE_NAMES] = self._reverse_dict(name_placeholders)
@@ -1141,7 +1142,7 @@ def scan(self,
11411142
operation_kwargs = {TABLE_NAME: table_name}
11421143
name_placeholders = {}
11431144
if attributes_to_get is not None:
1144-
projection_expression = self._get_projection_expression(attributes_to_get, name_placeholders)
1145+
projection_expression = create_projection_expression(attributes_to_get, name_placeholders)
11451146
operation_kwargs[PROJECTION_EXPRESSION] = projection_expression
11461147
if name_placeholders:
11471148
operation_kwargs[EXPRESSION_ATTRIBUTE_NAMES] = self._reverse_dict(name_placeholders)
@@ -1198,11 +1199,39 @@ def query(self,
11981199
"""
11991200
operation_kwargs = {TABLE_NAME: table_name}
12001201
name_placeholders = {}
1202+
expression_attribute_values = {}
1203+
1204+
tbl = self.get_meta_table(table_name)
1205+
if tbl is None:
1206+
raise TableError("No such table: {0}".format(table_name))
1207+
if index_name:
1208+
hash_keyname = tbl.get_index_hash_keyname(index_name)
1209+
if not hash_keyname:
1210+
raise ValueError("No hash key attribute for index: {0}".format(index_name))
1211+
else:
1212+
hash_keyname = tbl.hash_keyname
1213+
1214+
key_condition_expression = self._get_condition_expression(table_name, hash_keyname, '__eq__', hash_key)
1215+
if key_conditions is None or len(key_conditions) == 0:
1216+
pass # No comparisons on sort key
1217+
elif len(key_conditions) > 1:
1218+
raise ValueError("Multiple attributes are not supported in key_conditions: {0}".format(key_conditions))
1219+
else:
1220+
(key, condition), = key_conditions.items()
1221+
operator = condition.get(COMPARISON_OPERATOR)
1222+
if operator not in COMPARISON_OPERATOR_VALUES:
1223+
raise ValueError("{0} must be one of {1}".format(COMPARISON_OPERATOR, COMPARISON_OPERATOR_VALUES))
1224+
operator = KEY_CONDITION_OPERATOR_MAP[operator]
1225+
values = condition.get(ATTR_VALUE_LIST)
1226+
sort_key_expression = self._get_condition_expression(table_name, key, operator, *values)
1227+
key_condition_expression = key_condition_expression & sort_key_expression
1228+
1229+
operation_kwargs[KEY_CONDITION_EXPRESSION] = key_condition_expression.serialize(
1230+
name_placeholders, expression_attribute_values)
1231+
12011232
if attributes_to_get:
1202-
projection_expression = self._get_projection_expression(attributes_to_get, name_placeholders)
1233+
projection_expression = create_projection_expression(attributes_to_get, name_placeholders)
12031234
operation_kwargs[PROJECTION_EXPRESSION] = projection_expression
1204-
if name_placeholders:
1205-
operation_kwargs[EXPRESSION_ATTRIBUTE_NAMES] = self._reverse_dict(name_placeholders)
12061235
if consistent_read:
12071236
operation_kwargs[CONSISTENT_READ] = True
12081237
if exclusive_start_key:
@@ -1223,49 +1252,20 @@ def query(self,
12231252
operation_kwargs[SELECT] = str(select).upper()
12241253
if scan_index_forward is not None:
12251254
operation_kwargs[SCAN_INDEX_FORWARD] = scan_index_forward
1226-
tbl = self.get_meta_table(table_name)
1227-
if tbl is None:
1228-
raise TableError("No such table: {0}".format(table_name))
1229-
if index_name:
1230-
hash_keyname = tbl.get_index_hash_keyname(index_name)
1231-
if not hash_keyname:
1232-
raise ValueError("No hash key attribute for index: {0}".format(index_name))
1233-
else:
1234-
hash_keyname = tbl.hash_keyname
1235-
operation_kwargs[KEY_CONDITIONS] = {
1236-
hash_keyname: {
1237-
ATTR_VALUE_LIST: [
1238-
{
1239-
self.get_attribute_type(table_name, hash_keyname): hash_key,
1240-
}
1241-
],
1242-
COMPARISON_OPERATOR: EQ
1243-
},
1244-
}
1245-
if key_conditions is not None:
1246-
for key, condition in key_conditions.items():
1247-
attr_type = self.get_attribute_type(table_name, key)
1248-
operator = condition.get(COMPARISON_OPERATOR)
1249-
if operator not in COMPARISON_OPERATOR_VALUES:
1250-
raise ValueError("{0} must be one of {1}".format(COMPARISON_OPERATOR, COMPARISON_OPERATOR_VALUES))
1251-
operation_kwargs[KEY_CONDITIONS][key] = {
1252-
ATTR_VALUE_LIST: [
1253-
{
1254-
attr_type: self.parse_attribute(value)
1255-
} for value in condition.get(ATTR_VALUE_LIST)
1256-
],
1257-
COMPARISON_OPERATOR: operator
1258-
}
1255+
if name_placeholders:
1256+
operation_kwargs[EXPRESSION_ATTRIBUTE_NAMES] = self._reverse_dict(name_placeholders)
1257+
if expression_attribute_values:
1258+
operation_kwargs[EXPRESSION_ATTRIBUTE_VALUES] = expression_attribute_values
12591259

12601260
try:
12611261
return self.dispatch(QUERY, operation_kwargs)
12621262
except BOTOCORE_EXCEPTIONS as e:
12631263
raise QueryError("Failed to query items: {0}".format(e), e)
12641264

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)
1265+
def _get_condition_expression(self, table_name, attribute_name, operator, *values):
1266+
attr_type = self.get_attribute_type(table_name, attribute_name)
1267+
values = [{attr_type: self.parse_attribute(value)} for value in values]
1268+
return getattr(Path(attribute_name), operator)(*values)
12691269

12701270
@staticmethod
12711271
def _reverse_dict(d):
@@ -1279,23 +1279,3 @@ def _convert_binary(attr):
12791279
value = attr[BINARY_SET_SHORT]
12801280
if value and len(value):
12811281
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: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
DELETE_REQUEST = 'DeleteRequest'
3434
RETURN_VALUES = 'ReturnValues'
3535
REQUEST_ITEMS = 'RequestItems'
36+
ATTRS_TO_GET = 'AttributesToGet'
3637
ATTR_UPDATES = 'AttributeUpdates'
3738
TABLE_STATUS = 'TableStatus'
3839
SCAN_FILTER = 'ScanFilter'
@@ -63,6 +64,8 @@
6364

6465
# Expression Parameters
6566
EXPRESSION_ATTRIBUTE_NAMES = 'ExpressionAttributeNames'
67+
EXPRESSION_ATTRIBUTE_VALUES = 'ExpressionAttributeValues'
68+
KEY_CONDITION_EXPRESSION = 'KeyConditionExpression'
6669
PROJECTION_EXPRESSION = 'ProjectionExpression'
6770

6871
# Defaults
@@ -140,8 +143,8 @@
140143
STREAM_NEW_AND_OLD_IMAGE = 'NEW_AND_OLD_IMAGES'
141144
STREAM_KEYS_ONLY = 'KEYS_ONLY'
142145

143-
# These are constants used in the KeyConditions parameter
144-
# See: http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Query.html#DDB-Query-request-KeyConditions
146+
# These are constants used in the KeyConditionExpression parameter
147+
# http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Query.html#DDB-Query-request-KeyConditionExpression
145148
EXCLUSIVE_START_KEY = 'ExclusiveStartKey'
146149
LAST_EVALUATED_KEY = 'LastEvaluatedKey'
147150
QUERY_FILTER = 'QueryFilter'
@@ -165,6 +168,15 @@
165168
'begins_with': BEGINS_WITH,
166169
'between': BETWEEN
167170
}
171+
KEY_CONDITION_OPERATOR_MAP = {
172+
EQ: '__eq__',
173+
LE: '__le__',
174+
LT: '__lt__',
175+
GE: '__ge__',
176+
GT: '__gt__',
177+
BEGINS_WITH: 'startswith',
178+
BETWEEN: 'between'
179+
}
168180

169181
# These are the valid select values for the Scan operation
170182
# See: http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Scan.html#DDB-Scan-request-Select

pynamodb/expressions/__init__.py

Whitespace-only changes.

pynamodb/expressions/condition.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from pynamodb.expressions.util import get_value_placeholder, substitute_names
2+
3+
4+
class Path(object):
5+
6+
def __init__(self, path):
7+
self.path = path
8+
9+
def __eq__(self, other):
10+
return self._compare('=', other)
11+
12+
def __lt__(self, other):
13+
return self._compare('<', other)
14+
15+
def __le__(self, other):
16+
return self._compare('<=', other)
17+
18+
def __gt__(self, other):
19+
return self._compare('>', other)
20+
21+
def __ge__(self, other):
22+
return self._compare('>=', other)
23+
24+
def _compare(self, operator, value):
25+
return Condition(self.path, operator, value)
26+
27+
def between(self, value1, value2):
28+
# This seemed preferable to other options such as merging value1 <= attribute & attribute <= value2
29+
# into one condition expression. DynamoDB only allows a single sort key comparison and having this
30+
# work but similar expressions like value1 <= attribute & attribute < value2 fail seems too brittle.
31+
return Between(self.path, value1, value2)
32+
33+
def startswith(self, prefix):
34+
# A 'pythonic' replacement for begins_with to match string behavior (e.g. "foo".startswith("f"))
35+
return BeginsWith(self.path, prefix)
36+
37+
38+
class Condition(object):
39+
format_string = '{path} {operator} {0}'
40+
41+
def __init__(self, path, operator, *values):
42+
self.path = path
43+
self.operator = operator
44+
self.values = values
45+
self.logical_operator = None
46+
self.other_condition = None
47+
48+
def serialize(self, placeholder_names, expression_attribute_values):
49+
path = substitute_names(self.path, placeholder_names)
50+
values = [get_value_placeholder(value, expression_attribute_values) for value in self.values]
51+
condition = self.format_string.format(*values, path=path, operator=self.operator)
52+
if self.logical_operator:
53+
other_condition = self.other_condition.serialize(placeholder_names, expression_attribute_values)
54+
return '{0} {1} {2}'.format(condition, self.logical_operator, other_condition)
55+
return condition
56+
57+
def __and__(self, other):
58+
if not isinstance(other, Condition):
59+
raise TypeError("unsupported operand type(s) for &: '{0}' and '{1}'",
60+
self.__class__.__name__, other.__class__.__name__)
61+
self.logical_operator = 'AND'
62+
self.other_condition = other
63+
return self
64+
65+
def __nonzero__(self):
66+
# Prevent users from accidentally comparing the condition object instead of the attribute instance
67+
raise TypeError("unsupported operand type(s) for bool: '{0}'".format(self.__class__.__name__))
68+
69+
def __bool__(self):
70+
# Prevent users from accidentally comparing the condition object instead of the attribute instance
71+
raise TypeError("unsupported operand type(s) for bool: {0}".format(self.__class__.__name__))
72+
73+
74+
class Between(Condition):
75+
format_string = '{path} {operator} {0} AND {1}'
76+
77+
def __init__(self, attribute, value1, value2):
78+
super(Between, self).__init__(attribute, 'BETWEEN', value1, value2)
79+
80+
81+
class BeginsWith(Condition):
82+
format_string = '{operator} ({path}, {0})'
83+
84+
def __init__(self, attribute, prefix):
85+
super(BeginsWith, self).__init__(attribute, 'begins_with', prefix)

pynamodb/expressions/projection.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from pynamodb.expressions.util import substitute_names
2+
3+
4+
def create_projection_expression(attributes_to_get, placeholders):
5+
expressions = [substitute_names(attribute, placeholders) for attribute in attributes_to_get]
6+
return ', '.join(expressions)

0 commit comments

Comments
 (0)