Skip to content

Commit 1e4cd1a

Browse files
committed
cqlengine: Remove two-phase query when deleting values
1 parent 464bf6c commit 1e4cd1a

6 files changed

Lines changed: 76 additions & 60 deletions

File tree

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Other
2020
* cqlengine: disallow Counter create, save operations (PYTHON-497)
2121
* cqlengine: remove the negative indices slicing support in ModelQuerySet (PYTHON-875)
2222
* cqlengine: Remove Model.__default_ttl__ (PYTHON-889)
23+
* cqlengine: Remove two-phase query when deleting values (PYTHON-506)
2324

2425
3.12.0
2526
======

cassandra/cqlengine/query.py

Lines changed: 18 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,7 +1280,6 @@ def update_async(self, **values):
12801280
if not values:
12811281
return
12821282

1283-
nulled_columns = set()
12841283
updated_columns = set()
12851284
us = UpdateStatement(self.column_family_name, where=self._where, ttl=self._ttl,
12861285
timestamp=self._timestamp, conditionals=self._conditional, if_exists=self._if_exists)
@@ -1303,25 +1302,13 @@ def update_async(self, **values):
13031302
# we should not provide default values in this use case.
13041303
val = col.validate(val)
13051304

1306-
if val is None:
1307-
nulled_columns.add(col_name)
1308-
continue
1309-
13101305
us.add_update(col, val, operation=col_op)
13111306
updated_columns.add(col_name)
13121307

1313-
futures = []
1314-
if us.assignments:
1315-
futures.append(self._execute_async(us))
1316-
1317-
if nulled_columns:
1318-
delete_conditional = [condition for condition in self._conditional
1319-
if condition.field not in updated_columns] if self._conditional else None
1320-
ds = DeleteStatement(self.column_family_name, fields=nulled_columns,
1321-
where=self._where, conditionals=delete_conditional, if_exists=self._if_exists)
1322-
futures.append(self._execute_async(ds))
1308+
if not us.assignments:
1309+
return CQLEngineFuture()
13231310

1324-
return CQLEngineFutureWaiter(futures)
1311+
return self._execute_async(us)
13251312

13261313
def update(self, **values):
13271314
"""
@@ -1470,20 +1457,20 @@ def batch(self, batch_obj):
14701457
self._batch = batch_obj
14711458
return self
14721459

1473-
def _delete_null_columns_async(self, conditionals=None):
1460+
def _delete_map_keys_async(self, conditionals=None):
14741461
"""
1475-
executes a delete query to remove columns that have changed to null
1462+
executes a delete query to remove map keys that have changed to null.
1463+
1464+
Doing map keys deletion in two phases allow us to update a map collection
1465+
without creating a tombstone.
14761466
"""
14771467
ds = DeleteStatement(self.column_family_name, conditionals=conditionals, if_exists=self._if_exists)
14781468
deleted_fields = False
14791469
static_only = True
1470+
14801471
for _, v in self.instance._values.items():
14811472
col = v.column
1482-
if v.deleted:
1483-
ds.add_field(col.db_field_name)
1484-
deleted_fields = True
1485-
static_only &= col.static
1486-
elif isinstance(col, columns.Map):
1473+
if isinstance(col, columns.Map):
14871474
uc = MapDeleteClause(col.db_field_name, v.value, v.previous_value)
14881475
if uc.get_context_size() > 0:
14891476
ds.add_field(uc)
@@ -1498,11 +1485,11 @@ def _delete_null_columns_async(self, conditionals=None):
14981485
else:
14991486
return CQLEngineFuture()
15001487

1501-
def _delete_null_columns(self, conditionals=None):
1488+
def _delete_map_keys(self, conditionals=None):
15021489
"""
15031490
executes a delete query to remove columns that have changed to null
15041491
"""
1505-
return self._delete_null_columns_async(conditionals=conditionals).result()
1492+
return self._delete_map_keys_async(conditionals=conditionals).result()
15061493

15071494
def update_async(self):
15081495
"""
@@ -1530,18 +1517,14 @@ def update_async(self):
15301517
val = getattr(self.instance, name, None)
15311518
val_mgr = self.instance._values[name]
15321519

1533-
if val is None:
1534-
continue
1535-
1536-
if not val_mgr.changed and not isinstance(col, columns.Counter):
1520+
if not (val_mgr.changed or val_mgr.deleted) and not isinstance(col, columns.Counter):
15371521
continue
15381522

15391523
static_changed_only = static_changed_only and col.static
15401524
statement.add_update(col, val, previous=val_mgr.previous_value)
15411525
updated_columns.add(col.db_field_name)
15421526

15431527
futures = []
1544-
15451528
if statement.assignments:
15461529
for name, col in self.model._primary_keys.items():
15471530
# only include clustering key if clustering key is not null, and non static columns are changed to avoid cql error
@@ -1554,7 +1537,7 @@ def update_async(self):
15541537
# remove conditions on fields that have been updated
15551538
delete_conditionals = [condition for condition in self._conditional
15561539
if condition.field not in updated_columns] if self._conditional else None
1557-
futures.append(self._delete_null_columns_async(delete_conditionals))
1540+
futures.append(self._delete_map_keys_async(delete_conditionals))
15581541

15591542
return CQLEngineFutureWaiter(futures)
15601543

@@ -1580,7 +1563,6 @@ def save_async(self):
15801563
if self.instance._has_counter:
15811564
raise CQLEngineException("Counters can only be updated. Use the 'update' mechanism instead.")
15821565

1583-
nulled_fields = set()
15841566
if self.instance._can_update():
15851567
return self.update_async()
15861568
else:
@@ -1592,9 +1574,8 @@ def save_async(self):
15921574
if static_save_only and not col.static and not col.partition_key:
15931575
continue
15941576
val = getattr(self.instance, name, None)
1595-
if col._val_is_null(val):
1596-
if self.instance._values[name].changed:
1597-
nulled_fields.add(col.db_field_name)
1577+
if (col._val_is_null(val) and
1578+
not (self.instance._values[name].changed or self.instance._values[name].deleted)):
15981579
continue
15991580
if col.has_default and not self.instance._values[name].changed:
16001581
# Ensure default columns included in a save() are marked as explicit, to get them *persisted* properly
@@ -1603,17 +1584,10 @@ def save_async(self):
16031584

16041585
# skip query execution if it's empty
16051586
# caused by pointless update queries
1606-
if insert.is_empty and static_save_only:
1587+
if insert.is_empty:
16071588
return CQLEngineFuture()
16081589

1609-
futures = []
1610-
if not insert.is_empty:
1611-
futures.append(self._execute_async(insert))
1612-
# delete any nulled columns
1613-
if not static_save_only:
1614-
futures.append(self._delete_null_columns_async())
1615-
1616-
return CQLEngineFutureWaiter(futures)
1590+
return self._execute_async(insert)
16171591

16181592
def save(self):
16191593
"""

cassandra/cqlengine/statements.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,11 @@ class SetUpdateClause(ContainerUpdateClause):
196196
_additions = None
197197
_removals = None
198198

199+
@property
200+
def _is_assignment(self):
201+
return not (self._additions or self._removals or
202+
self._operation or self.value == self.previous)
203+
199204
def __unicode__(self):
200205
qs = []
201206
ctx_id = self.context_id
@@ -204,7 +209,7 @@ def __unicode__(self):
204209
self._additions is None and
205210
self._removals is None):
206211
qs += ['"{0}" = %({1})s'.format(self.field, ctx_id)]
207-
if self._assignments is not None:
212+
if self._is_assignment:
208213
qs += ['"{0}" = %({1})s'.format(self.field, ctx_id)]
209214
ctx_id += 1
210215
if self._additions is not None:
@@ -235,11 +240,11 @@ def get_context_size(self):
235240
if not self._analyzed:
236241
self._analyze()
237242
if (self.previous is None and
238-
not self._assignments and
243+
not self._is_assignment and
239244
self._additions is None and
240245
self._removals is None):
241246
return 1
242-
return int(bool(self._assignments)) + int(bool(self._additions)) + int(bool(self._removals))
247+
return int(bool(self._is_assignment)) + int(bool(self._additions)) + int(bool(self._removals))
243248

244249
def update_context(self, ctx):
245250
if not self._analyzed:
@@ -250,7 +255,7 @@ def update_context(self, ctx):
250255
self._additions is None and
251256
self._removals is None):
252257
ctx[str(ctx_id)] = set()
253-
if self._assignments is not None:
258+
if self._is_assignment:
254259
ctx[str(ctx_id)] = self._assignments
255260
ctx_id += 1
256261
if self._additions is not None:
@@ -268,12 +273,17 @@ class ListUpdateClause(ContainerUpdateClause):
268273
_append = None
269274
_prepend = None
270275

276+
@property
277+
def _is_assignment(self):
278+
return not (self._append or self._prepend or
279+
self._operation or self.value == self.previous)
280+
271281
def __unicode__(self):
272282
if not self._analyzed:
273283
self._analyze()
274284
qs = []
275285
ctx_id = self.context_id
276-
if self._assignments is not None:
286+
if self._is_assignment:
277287
qs += ['"{0}" = %({1})s'.format(self.field, ctx_id)]
278288
ctx_id += 1
279289

@@ -289,13 +299,13 @@ def __unicode__(self):
289299
def get_context_size(self):
290300
if not self._analyzed:
291301
self._analyze()
292-
return int(self._assignments is not None) + int(bool(self._append)) + int(bool(self._prepend))
302+
return int(self._is_assignment) + int(bool(self._append)) + int(bool(self._prepend))
293303

294304
def update_context(self, ctx):
295305
if not self._analyzed:
296306
self._analyze()
297307
ctx_id = self.context_id
298-
if self._assignments is not None:
308+
if self._is_assignment:
299309
ctx[str(ctx_id)] = self._assignments
300310
ctx_id += 1
301311
if self._prepend is not None:
@@ -370,7 +380,9 @@ def _analyze(self):
370380
if self.previous is None:
371381
self._updates = sorted([k for k, v in self.value.items()])
372382
else:
373-
self._updates = sorted([k for k, v in self.value.items() if v != self.previous.get(k)]) or None
383+
if self.value:
384+
self._updates = sorted([k for k, v in self.value.items() if v != self.previous.get(k)]) or None
385+
374386
self._analyzed = True
375387

376388
def get_context_size(self):
@@ -381,7 +393,7 @@ def get_context_size(self):
381393
def update_context(self, ctx):
382394
ctx_id = self.context_id
383395
if self.is_assignment:
384-
ctx[str(ctx_id)] = {}
396+
ctx[str(ctx_id)] = {} if self.value is None else self.value
385397
elif self._removals is not None:
386398
ctx[str(ctx_id)] = self._removals
387399
else:
@@ -395,7 +407,7 @@ def update_context(self, ctx):
395407
def is_assignment(self):
396408
if not self._analyzed:
397409
self._analyze()
398-
return self.previous is None and not self._updates and not self._removals
410+
return not self._updates and not self._removals
399411

400412
def __unicode__(self):
401413
qs = []
@@ -813,7 +825,8 @@ def add_update(self, column, value, operation=None, previous=None):
813825
clause = CounterUpdateClause(column.db_field_name, value, previous)
814826
else:
815827
clause = AssignmentClause(column.db_field_name, value)
816-
if clause.get_context_size(): # this is to exclude map removals from updates. Can go away if we drop support for C* < 1.2.4 and remove two-phase updates
828+
829+
if clause.get_context_size():
817830
self._add_assignment_clause(clause)
818831

819832

tests/integration/cqlengine/query/test_updates.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def test_null_update_deletes_column(self):
106106
self.assertEqual(row.count, i)
107107
self.assertEqual(row.text, None if i == 3 else str(i))
108108

109-
@execute_count(9)
109+
@execute_count(8)
110110
def test_mixed_value_and_null_update(self):
111111
""" tests that updating a columns value, and removing another works properly """
112112
partition = uuid4()

tests/integration/cqlengine/statements/test_assignment_clauses.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,21 +49,22 @@ def test_update_from_none(self):
4949
self.assertEqual(ctx, {'0': set((1, 2))})
5050

5151
def test_null_update(self):
52-
""" tests setting a set to None creates an empty update statement """
52+
""" tests setting a set to None creates an update statement """
5353
c = SetUpdateClause('s', None, previous=set((1, 2)))
5454
c._analyze()
5555
c.set_context_id(0)
5656

57+
self.assertTrue(c._is_assignment)
5758
self.assertIsNone(c._assignments)
5859
self.assertIsNone(c._additions)
5960
self.assertIsNone(c._removals)
6061

61-
self.assertEqual(c.get_context_size(), 0)
62-
self.assertEqual(str(c), '')
62+
self.assertEqual(c.get_context_size(), 1)
63+
self.assertEqual(str(c), '"s" = %(0)s')
6364

6465
ctx = {}
6566
c.update_context(ctx)
66-
self.assertEqual(ctx, {})
67+
self.assertEqual(ctx, {'0': None})
6768

6869
def test_no_update(self):
6970
""" tests an unchanged value creates an empty update statement """
@@ -167,6 +168,23 @@ def test_update_from_none(self):
167168
c.update_context(ctx)
168169
self.assertEqual(ctx, {'0': [1, 2, 3]})
169170

171+
def test_update_to_none(self):
172+
c = ListUpdateClause('s', None, previous=[1, 2, 3])
173+
c._analyze()
174+
c.set_context_id(0)
175+
176+
self.assertTrue(c._is_assignment)
177+
self.assertIsNone(c._assignments)
178+
self.assertIsNone(c._append)
179+
self.assertIsNone(c._prepend)
180+
181+
self.assertEqual(c.get_context_size(), 1)
182+
self.assertEqual(str(c), '"s" = %(0)s')
183+
184+
ctx = {}
185+
c.update_context(ctx)
186+
self.assertEqual(ctx, {'0': None})
187+
170188
def test_update_from_empty(self):
171189
c = ListUpdateClause('s', [1, 2, 3], previous=[])
172190
c._analyze()

tests/integration/cqlengine/statements/test_update_statement.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ def test_update_set_add(self):
6969
us.add_update(Set(Text, db_field='a'), set((1,)), 'add')
7070
self.assertEqual(six.text_type(us), 'UPDATE table SET "a" = "a" + %(0)s')
7171

72+
def test_update_set_with_none(self):
73+
us = UpdateStatement('table')
74+
us.add_update(Set(Text, db_field='a'), None)
75+
self.assertTrue(us.assignments)
76+
7277
def test_update_empty_set_add_does_not_assign(self):
7378
us = UpdateStatement('table')
7479
us.add_update(Set(Text, db_field='a'), set(), 'add')
@@ -79,6 +84,11 @@ def test_update_empty_set_removal_does_not_assign(self):
7984
us.add_update(Set(Text, db_field='a'), set(), 'remove')
8085
self.assertFalse(us.assignments)
8186

87+
def test_update_list_with_none(self):
88+
us = UpdateStatement('table')
89+
us.add_update(List(Text, db_field='a'), None, previous=[1])
90+
self.assertTrue(us.assignments)
91+
8292
def test_update_list_prepend_with_empty_list(self):
8393
us = UpdateStatement('table')
8494
us.add_update(List(Text, db_field='a'), [], 'prepend')

0 commit comments

Comments
 (0)