Skip to content

Commit ad67f3b

Browse files
committed
WL11281: Transaction savepoints
This patch extends transaction API to allow setting savepoints operations. The SAVEPOINT statement sets a named transaction allowing to parts of a transaction to be rolled back before COMMIT. To allow these operations, this patch adds set_savepoint(), rollback_to() and release_savepoint() methods to Session class. Tests were added for regression.
1 parent ce5b352 commit ad67f3b

File tree

5 files changed

+236
-3
lines changed

5 files changed

+236
-3
lines changed

docs/mysqlx/tutorials.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@ Tutorials
55
:maxdepth: 4
66

77
tutorials/getting_started
8-
8+
tutorials/transactions
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
Transactions
2+
============
3+
4+
Savepoints
5+
----------
6+
7+
The ``SAVEPOINT`` statement sets a named transaction allowing parts of a transaction to be rolled back before ``COMMIT``.
8+
9+
Get the collection object
10+
^^^^^^^^^^^^^^^^^^^^^^^^^
11+
12+
Assuming the existence of ``test_schema.test_collection`` collection.
13+
14+
.. code-block:: python
15+
16+
[{
17+
"_id": 1,
18+
"name": "Fred",
19+
"age": 21
20+
}]
21+
22+
23+
Get the collection object.
24+
25+
.. code-block:: python
26+
27+
session = mysqlx.get_session("root:@localhost:33060")
28+
schema = session.get_schema("test_schema")
29+
collection = schema.get_collection("test_collection")
30+
31+
Set and rollback to a named transaction savepoint
32+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
33+
34+
A savepoint name can be provided to create a transaction savepoint, which can later be used to rollback.
35+
36+
A :class:`mysqlx.OperationalError` will be raised if the savepoint name is an invalid string or if a nonexistent savepoint is being used in :func:`mysqlx.Session.rollback_to`.
37+
38+
.. code-block:: python
39+
40+
# Start transaction
41+
session.start_transaction()
42+
43+
collection.add({"name": "Wilma", "age": 33}).execute()
44+
assert(2 == collection.count())
45+
46+
# Create a savepoint
47+
session.set_savepoint("sp")
48+
49+
collection.add({"name": "Barney", "age": 42}).execute()
50+
assert(3 == collection.count())
51+
52+
# Rollback to a savepoint
53+
session.rollback_to("sp")
54+
55+
assert(2 == collection.count())
56+
57+
# Commit all operations
58+
session.commit()
59+
60+
Set and rollback to an unnamed transaction savepoint
61+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
62+
63+
If a savepoint name is not provided, :func:`mysqlx.Session.release_savepoint` will return a generated savepoint name.
64+
65+
.. code-block:: python
66+
67+
# Start transaction
68+
session.start_transaction()
69+
70+
collection.add({"name": "Wilma", "age": 33}).execute()
71+
assert(2 == collection.count())
72+
73+
# Create a savepoint
74+
savepoint = session.set_savepoint()
75+
76+
collection.add({"name": "Barney", "age": 42}).execute()
77+
assert(3 == collection.count())
78+
79+
# Rollback to a savepoint
80+
session.rollback_to(savepoint)
81+
82+
assert(2 == collection.count())
83+
84+
# Commit all operations
85+
session.commit()
86+
87+
Releasing a transaction savepoint
88+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
89+
90+
A :class:`mysqlx.OperationalError` will be raised if a nonexistent savepoint is being used in :func:`mysqlx.Session.release_savepoint`.
91+
92+
.. code-block:: python
93+
94+
# Start transaction
95+
session.start_transaction()
96+
97+
collection.add({"name": "Wilma", "age": 33}).execute()
98+
assert(2 == collection.count())
99+
100+
# Create a savepoint
101+
session.set_savepoint("sp")
102+
103+
collection.add({"name": "Barney", "age": 42}).execute()
104+
assert(3 == collection.count())
105+
106+
# Release a savepoint
107+
session.release_savepoint("sp")
108+
109+
assert(3 == collection.count())
110+
111+
# Commit all operations
112+
session.commit()

lib/mysqlx/connection.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import sys
3333
import socket
3434
import logging
35+
import uuid
3536

3637
from functools import wraps
3738

@@ -44,7 +45,7 @@
4445
from .helpers import get_item_or_attr
4546
from .protocol import Protocol, MessageReaderWriter
4647
from .result import Result, RowResult, DocResult
47-
from .statement import SqlStatement, AddStatement
48+
from .statement import SqlStatement, AddStatement, quote_identifier
4849

4950

5051
_DROP_DATABASE_QUERY = "DROP DATABASE IF EXISTS `{0}`"
@@ -720,6 +721,51 @@ def rollback(self):
720721
"""
721722
self._connection.execute_nonquery("sql", "ROLLBACK", True)
722723

724+
def set_savepoint(self, name=None):
725+
"""Creates a transaction savepoint.
726+
727+
If a name is not provided, one will be generated using the uuid.uuid1()
728+
function.
729+
730+
Args:
731+
name (Optional[string]): The savepoint name.
732+
733+
Returns:
734+
string: The savepoint name.
735+
"""
736+
if name is None:
737+
name = "{0}".format(uuid.uuid1())
738+
elif not isinstance(name, STRING_TYPES) or len(name.strip()) == 0:
739+
raise ProgrammingError("Invalid SAVEPOINT name")
740+
self._connection.execute_nonquery("sql", "SAVEPOINT {0}"
741+
"".format(quote_identifier(name)),
742+
True)
743+
return name
744+
745+
def rollback_to(self, name):
746+
"""Rollback to a transaction savepoint with the given name.
747+
748+
Args:
749+
name (string): The savepoint name.
750+
"""
751+
if not isinstance(name, STRING_TYPES) or len(name.strip()) == 0:
752+
raise ProgrammingError("Invalid SAVEPOINT name")
753+
self._connection.execute_nonquery("sql", "ROLLBACK TO SAVEPOINT {0}"
754+
"".format(quote_identifier(name)),
755+
True)
756+
757+
def release_savepoint(self, name):
758+
"""Release a transaction savepoint with the given name.
759+
760+
Args:
761+
name (string): The savepoint name.
762+
"""
763+
if not isinstance(name, STRING_TYPES) or len(name.strip()) == 0:
764+
raise ProgrammingError("Invalid SAVEPOINT name")
765+
self._connection.execute_nonquery("sql", "RELEASE SAVEPOINT {0}"
766+
"".format(quote_identifier(name)),
767+
True)
768+
723769
def close(self):
724770
"""Closes the session."""
725771
self._connection.close_session()

lib/mysqlx/statement.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ def quote_identifier(identifier, sql_mode=""):
7474
Returns:
7575
A string with the identifier quoted with backticks.
7676
"""
77-
if is_quoted_identifier(identifier, sql_mode):
77+
if len(identifier) == 0:
78+
return "``"
79+
elif is_quoted_identifier(identifier, sql_mode):
7880
return identifier
7981
if "ANSI_QUOTES" in sql_mode:
8082
return '"{0}"'.format(identifier.replace('"', '""'))

tests/test_mysqlx_connection.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,79 @@ def test_commit(self):
446446

447447
drop_table(schema, table_name)
448448

449+
def test_savepoint(self):
450+
collection_name = "collection_test"
451+
schema = self.session.get_schema(self.schema_name)
452+
453+
# The savepoint name should be a valid string
454+
self.assertRaises(mysqlx.errors.ProgrammingError,
455+
self.session.set_savepoint, 123)
456+
457+
# The savepoint name should not be an empty string
458+
self.assertRaises(mysqlx.errors.ProgrammingError,
459+
self.session.set_savepoint, "")
460+
461+
# The savepoint name should not be a white space
462+
self.assertRaises(mysqlx.errors.ProgrammingError,
463+
self.session.set_savepoint, " ")
464+
465+
# Invalid rollback savepoint without a started transaction
466+
sp1 = self.session.set_savepoint("sp1")
467+
self.assertRaises(mysqlx.errors.OperationalError,
468+
self.session.rollback_to, sp1)
469+
470+
collection = schema.create_collection(collection_name)
471+
472+
self.session.start_transaction()
473+
474+
collection.add({"name": "Fred", "age": 21}).execute()
475+
self.assertEqual(1, collection.count())
476+
477+
# Create a savepoint named 'sp2'
478+
sp2 = self.session.set_savepoint("sp2")
479+
self.assertEqual(sp2, "sp2")
480+
481+
collection.add({"name": "Wilma", "age": 33}).execute()
482+
self.assertEqual(2, collection.count())
483+
484+
# Create a savepoint named 'sp3'
485+
sp3 = self.session.set_savepoint("sp3")
486+
487+
collection.add({"name": "Betty", "age": 67}).execute()
488+
self.assertEqual(3, collection.count())
489+
490+
# Rollback to 'sp3' savepoint
491+
self.session.rollback_to(sp3)
492+
self.assertEqual(2, collection.count())
493+
494+
# Rollback to 'sp2' savepoint
495+
self.session.rollback_to(sp2)
496+
self.assertEqual(1, collection.count())
497+
498+
# The 'sp3' savepoint should not exist at this point
499+
self.assertRaises(mysqlx.errors.OperationalError,
500+
self.session.rollback_to, sp3)
501+
502+
collection.add({"name": "Barney", "age": 42}).execute()
503+
self.assertEqual(2, collection.count())
504+
505+
# Create an unnamed savepoint
506+
sp4 = self.session.set_savepoint()
507+
508+
collection.add({"name": "Wilma", "age": 33}).execute()
509+
self.assertEqual(3, collection.count())
510+
511+
# Release unnamed savepoint
512+
self.session.release_savepoint(sp4)
513+
self.assertEqual(3, collection.count())
514+
515+
# The 'sp4' savepoint should not exist at this point
516+
self.assertRaises(mysqlx.errors.OperationalError,
517+
self.session.rollback_to, sp4)
518+
519+
self.session.commit()
520+
schema.drop_collection(collection_name)
521+
449522
def test_close(self):
450523
session = mysqlx.get_session(self.connect_kwargs)
451524
schema = session.get_schema(self.schema_name)

0 commit comments

Comments
 (0)