Conditions¶
Conditions are used for:
- Optimistic concurrency when saving or deleting objects
- To specify a Query's key condition
- To filter results from a Query or Scan
Built-In Conditions¶
There is no DynamoDB type that supports all of the conditions. For example, contains
does not work with
a numeric type "N"
such as Number or Integer. DynamoDB's ConditionExpression Reference has the full
specification.
class Model(BaseModel):
column = Column(SomeType)
# Comparisons
Model.column < value
Model.column <= value
Model.column == value
Model.column >= value
Model.column > value
Model.column != value
Model.column.begins_with(value)
Model.column.between(low, high)
Model.column.contains(value)
Model.column.in_([foo, bar, baz])
Model.column.is_(None)
Model.column.is_not(False)
# bitwise operators combine conditions
not_none = Model.column.is_not(None)
in_the_future = Model.column > now
in_the_past = ~in_the_future
either = not_none | in_the_future
both = not_none & in_the_future
Document Paths¶
You can construct conditions against individual elements of List and Map types with the usual indexing notation.
Item = Map(
name=String,
price=Number,
quantity=Integer)
Metrics = Map(**{
"payment-duration": Number,
"coupons.used"=Integer,
"coupons.available"=Integer
})
class Receipt(BaseModel):
transaction_id = Column(UUID, column=True)
total = Column(Integer)
items = Column(List(Item))
metrics = Column(Metrics)
Here are some basic conditions using paths:
Receipt.metrics["payment-duration"] > 30000
Receipt.items[0]["name"].begins_with("deli:salami:")
Atomic Conditions¶
When you specify atomic=True
during Engine.save
or
Engine.delete
, Bloop will insert a pre-constructed
condition on each object to be modified.
The condition depends on how the local version of your object was last synchronized with the corresponding row in DynamoDB. Here are the rules:
- If the object was created locally and hasn't been saved or deleted, expect all of the object's columns to be None in DynamoDB.
- If the object came from DynamoDB (load, query, stream), only include columns that should have been in the response.
- If a column is missing and was expected, include it in the atomic condition and expect the value to be None in DynamoDB.
- If a column is missing and wasn't expected (query on a projected Index), don't include it.
- Recompute the atomic condition whenever the local state is synchronized with the DynamoDB value.
The following examples use this model:
class Document(BaseModel):
id = Column(Integer, hash_key=True)
folder = Column(String)
name = Column(String)
size = Column(Integer)
data = Column(Binary)
by_name = GlobalSecondaryIndex(
projection=["size"], hash_key="name")
Example: New Object¶
This demonstrates Rule 1.
For a new instance created locally but not yet saved:
document = Document(id=10, folder="~", name=".bashrc")
The following atomic condition would be generated:
atomic = (
Document.id.is_(None) &
Document.folder.is_(None) &
Document.name.is_(None) &
Document.size.is_(None) &
Document.data.is_(None)
)
In this case, atomic means "only save if this object didn't exist before".
Example: Load a Partial Object¶
This demonstrates Rule 2.1.
Engine.load
will return all columns for an object; if a column's value is
missing, it hasn't been set. An atomic save or delete would expect those missing columns to still not have values.
First, save an object and then load it into a new instance:
original_document = Document(id=10, folder="~", name=".bashrc")
engine.save(original_document)
document = Document(id=10)
engine.load(document)
The document has the following attributes:
document.id = 10
document.folder = "~"
document.name = ".bashrc"
document.size = None
document.data = None
Now, modify the object locally:
document.data = b"# ... for non-login shells."
document.size = len(document.data)
If you try to save this with an atomic condition, it will expect all of the values to be the same as were last loaded from DynamoDB -- not the values you just set. The atomic condition is:
atomic = (
(Document.id == 10) &
(Document.folder == "~") &
(Document.name == ".bashrc") &
Document.size.is_(None) &
Document.data.is_(None)
)
If another call changed folder or name, or set a value for size or data, the atomic save will fail.
Example: Scan on a Table¶
This demonstrates Rule 2.2.
Here, the scan uses select
to only return a few columns (and the hash key column).
scan = engine.scan(Document, projection=[Document.name])
results = list(scan)
Each result will have values for id
and name
, but the scan did not try to load the other columns.
Those columns won't be set to None
- they won't even be loaded by the Column's typedef. Here's a document the
scan above found:
scan_doc = Document(id=343, name="john")
If you set the size on this file and then perform an atomic save:
scan_doc.size = 117
engine.save(scan_doc, atomic=True)
The following condition is used:
atomic = (
(Document.id == 10) &
(Document.name == ".bashrc")
)
There's no way to know if the previous value for eg. folder
had a value, since the scan told DynamoDB not to
include that column when it performed the scan. There's no save assumption for the state of that column in DynamoDB,
so it's not part of the generated atomic condition.
Example: Query on a Projection¶
This demonstrates Rule 2.1.
The scan above expected a subset of available columns, and finds a value for each. This query will also expect a subset of all columns (using the index's projection) but the value will be missing.
query = engine.query(
Document.by_name,
key=Document.name == ".profile")
result = query.first()
This index projects the size
column, which means it's expected to populate the
id
, name
, and size
columns. If the result looks like this:
result = Document(id=747, name="tps-reports.xls", size=None)
Then this document didn't have a value for size. Take a minute to compare this to the result from the previous example. Most importantly, this object has a value (None) for the size column, while the scan doesn't. This all comes down to whether the operation expects a value to be present or not.
The atomic condition used for this object will be:
atomic = (
(Document.id == 747) &
(Document.name == "tps-reports.xls") &
Document.size.is_(None)
)
If the value in DynamoDB has a value for size
, the operation will fail. If the document's data
column has
changed since the query executed, this atomic condition won't care.
Example: Save then Save¶
This demonstrates Rule 3.
Whenever you save or delete and the operation succeeds, the atomic condition is recomputed to match the current state of the object. Again, the condition will only expect values for any columns that have values.
To compare, here are two different Documents:
data_is_none = Document(id=5, data=None)
no_data = Document(id=6)
engine.save(data_is_none, no_data)
By setting a value for data
, the first object's atomic condition must expect the value to still be None.
The second object didn't indicate an expectation about the value of data
, so there's nothing to expect for the
next operation. Here are the two atomic conditions after the save:
# Atomic for data_is_none
atomic = (
(Document.id == 5) &
Document.data.is_(None)
)
# Atomic for no_data
atomic = (
(Document.id == 6)
)
You can also hit this case by querying an index with a small projection, and only making changes to the projected columns. When you save, the next atomic condition will still only be on the projected columns.