-
-
Notifications
You must be signed in to change notification settings - Fork 31.9k
bpo-27645: Supporting native backup facility of SQLite #377
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d93391d
7b1277a
1258d47
d8ea457
35fa0ad
c1af5a0
c07d982
cdc7eda
6d3eb69
6fab17f
6449f3d
f84d375
e748942
426dad4
895726b
77dfa39
71726ba
c4fbc20
ff1609b
d688f20
4ed6c31
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,135 @@ | ||
import os | ||
import sqlite3 as sqlite | ||
from tempfile import NamedTemporaryFile | ||
import unittest | ||
|
||
@unittest.skipIf(sqlite.sqlite_version_info < (3, 6, 11), "Backup API not supported") | ||
class BackupTests(unittest.TestCase): | ||
def setUp(self): | ||
cx = self.cx = sqlite.connect(":memory:") | ||
cx.execute('CREATE TABLE foo (key INTEGER)') | ||
cx.executemany('INSERT INTO foo (key) VALUES (?)', [(3,), (4,)]) | ||
cx.commit() | ||
|
||
def tearDown(self): | ||
self.cx.close() | ||
|
||
def testBackup(self, bckfn): | ||
cx = sqlite.connect(bckfn) | ||
result = cx.execute("SELECT key FROM foo ORDER BY key").fetchall() | ||
self.assertEqual(result[0][0], 3) | ||
self.assertEqual(result[1][0], 4) | ||
|
||
def CheckKeywordOnlyArgs(self): | ||
with self.assertRaises(TypeError): | ||
self.cx.backup('foo', 1) | ||
|
||
def CheckSimple(self): | ||
with NamedTemporaryFile(suffix='.sqlite') as bckfn: | ||
self.cx.backup(bckfn.name) | ||
self.testBackup(bckfn.name) | ||
|
||
def CheckProgress(self): | ||
journal = [] | ||
|
||
def progress(status, remaining, total): | ||
journal.append(status) | ||
|
||
with NamedTemporaryFile(suffix='.sqlite') as bckfn: | ||
self.cx.backup(bckfn.name, pages=1, progress=progress) | ||
self.testBackup(bckfn.name) | ||
|
||
self.assertEqual(len(journal), 2) | ||
self.assertEqual(journal[0], sqlite.SQLITE_OK) | ||
self.assertEqual(journal[1], sqlite.SQLITE_DONE) | ||
|
||
def CheckProgressAllPagesAtOnce_0(self): | ||
journal = [] | ||
|
||
def progress(status, remaining, total): | ||
journal.append(remaining) | ||
|
||
with NamedTemporaryFile(suffix='.sqlite') as bckfn: | ||
self.cx.backup(bckfn.name, progress=progress) | ||
self.testBackup(bckfn.name) | ||
|
||
self.assertEqual(len(journal), 1) | ||
self.assertEqual(journal[0], 0) | ||
|
||
def CheckProgressAllPagesAtOnce_1(self): | ||
journal = [] | ||
|
||
def progress(status, remaining, total): | ||
journal.append(remaining) | ||
|
||
with NamedTemporaryFile(suffix='.sqlite') as bckfn: | ||
self.cx.backup(bckfn.name, pages=-1, progress=progress) | ||
self.testBackup(bckfn.name) | ||
|
||
self.assertEqual(len(journal), 1) | ||
self.assertEqual(journal[0], 0) | ||
|
||
def CheckNonCallableProgress(self): | ||
with NamedTemporaryFile(suffix='.sqlite') as bckfn: | ||
with self.assertRaises(TypeError) as err: | ||
self.cx.backup(bckfn.name, pages=1, progress='bar') | ||
self.assertEqual(str(err.exception), 'progress argument must be a callable') | ||
|
||
def CheckModifyingProgress(self): | ||
journal = [] | ||
|
||
def progress(status, remaining, total): | ||
if not journal: | ||
self.cx.execute('INSERT INTO foo (key) VALUES (?)', (remaining+1000,)) | ||
self.cx.commit() | ||
journal.append(remaining) | ||
|
||
with NamedTemporaryFile(suffix='.sqlite') as bckfn: | ||
self.cx.backup(bckfn.name, pages=1, progress=progress) | ||
self.testBackup(bckfn.name) | ||
|
||
cx = sqlite.connect(bckfn.name) | ||
result = cx.execute("SELECT key FROM foo" | ||
" WHERE key >= 1000" | ||
" ORDER BY key").fetchall() | ||
self.assertEqual(result[0][0], 1001) | ||
|
||
self.assertEqual(len(journal), 3) | ||
self.assertEqual(journal[0], 1) | ||
self.assertEqual(journal[1], 1) | ||
self.assertEqual(journal[2], 0) | ||
|
||
def CheckFailingProgress(self): | ||
def progress(status, remaining, total): | ||
raise SystemError('nearly out of space') | ||
|
||
with NamedTemporaryFile(suffix='.sqlite', delete=False) as bckfn: | ||
with self.assertRaises(SystemError) as err: | ||
self.cx.backup(bckfn.name, progress=progress) | ||
self.assertEqual(str(err.exception), 'nearly out of space') | ||
self.assertFalse(os.path.exists(bckfn.name)) | ||
|
||
def CheckDatabaseSourceName(self): | ||
with NamedTemporaryFile(suffix='.sqlite', delete=False) as bckfn: | ||
self.cx.backup(bckfn.name, name='main') | ||
self.cx.backup(bckfn.name, name='temp') | ||
with self.assertRaises(sqlite.OperationalError): | ||
self.cx.backup(bckfn.name, name='non-existing') | ||
self.assertFalse(os.path.exists(bckfn.name)) | ||
self.cx.execute("ATTACH DATABASE ':memory:' AS attached_db") | ||
self.cx.execute('CREATE TABLE attached_db.foo (key INTEGER)') | ||
self.cx.executemany('INSERT INTO attached_db.foo (key) VALUES (?)', [(3,), (4,)]) | ||
self.cx.commit() | ||
with NamedTemporaryFile(suffix='.sqlite') as bckfn: | ||
self.cx.backup(bckfn.name, name='attached_db') | ||
self.testBackup(bckfn.name) | ||
|
||
def suite(): | ||
return unittest.TestSuite(unittest.makeSuite(BackupTests, "Check")) | ||
|
||
def test(): | ||
runner = unittest.TextTestRunner() | ||
runner.run(suite()) | ||
|
||
if __name__ == "__main__": | ||
test() |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -21,6 +21,12 @@ | |
* 3. This notice may not be removed or altered from any source distribution. | ||
*/ | ||
|
||
#ifdef HAVE_UNISTD_H | ||
#include <unistd.h> | ||
#else | ||
extern int unlink(const char *); | ||
#endif | ||
|
||
#include "cache.h" | ||
#include "module.h" | ||
#include "structmember.h" | ||
|
@@ -41,6 +47,10 @@ | |
#endif | ||
#endif | ||
|
||
#if SQLITE_VERSION_NUMBER >= 3006011 | ||
#define HAVE_BACKUP_API | ||
#endif | ||
|
||
_Py_IDENTIFIER(cursor); | ||
|
||
static const char * const begin_statements[] = { | ||
|
@@ -1477,6 +1487,112 @@ pysqlite_connection_iterdump(pysqlite_Connection* self, PyObject* args) | |
return retval; | ||
} | ||
|
||
#ifdef HAVE_BACKUP_API | ||
static PyObject * | ||
pysqlite_connection_backup(pysqlite_Connection* self, PyObject* args, PyObject* kwds) | ||
{ | ||
char* filename; | ||
int pages = -1; | ||
PyObject* progress = Py_None; | ||
char* name = "main"; | ||
PyObject* retval = NULL; | ||
int rc; | ||
int cberr = 0; | ||
sqlite3 *bckconn; | ||
sqlite3_backup *bckhandle; | ||
static char *keywords[] = {"filename", "pages", "progress", "name", NULL}; | ||
|
||
if (!PyArg_ParseTupleAndKeywords(args, kwds, "s|$iOs:backup", keywords, | ||
&filename, &pages, &progress, &name)) { | ||
goto finally; | ||
} | ||
|
||
if (progress != Py_None && !PyCallable_Check(progress)) { | ||
PyErr_SetString(PyExc_TypeError, "progress argument must be a callable"); | ||
goto finally; | ||
} | ||
|
||
if (pages == 0) { | ||
pages = -1; | ||
} | ||
|
||
Py_BEGIN_ALLOW_THREADS | ||
rc = sqlite3_open(filename, &bckconn); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #359 could be useful here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure what you are suggesting: should I take the same approach of conditionally using sqlite3_open_v2() instead? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was trying to say that this should be merged after #359. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This don't actually have any effect. |
||
Py_END_ALLOW_THREADS | ||
|
||
if (rc != SQLITE_OK) { | ||
goto finally; | ||
} | ||
|
||
Py_BEGIN_ALLOW_THREADS | ||
bckhandle = sqlite3_backup_init(bckconn, "main", self->db, name); | ||
Py_END_ALLOW_THREADS | ||
|
||
if (bckhandle) { | ||
do { | ||
Py_BEGIN_ALLOW_THREADS | ||
rc = sqlite3_backup_step(bckhandle, pages); | ||
Py_END_ALLOW_THREADS | ||
|
||
if (progress != Py_None) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should call There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure about that: maybe a better option would be pass the result status to the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now the progress callback receives three arguments. |
||
if (!PyObject_CallFunction(progress, "iii", rc, | ||
sqlite3_backup_remaining(bckhandle), | ||
sqlite3_backup_pagecount(bckhandle))) { | ||
/* User's callback raised an error: interrupt the loop and | ||
propagate it. */ | ||
cberr = 1; | ||
rc = -1; | ||
} | ||
} | ||
|
||
/* Sleep for 250ms if there are still further pages to copy and | ||
the engine could not make any progress */ | ||
if (rc == SQLITE_BUSY || rc == SQLITE_LOCKED) { | ||
Py_BEGIN_ALLOW_THREADS | ||
sqlite3_sleep(250); | ||
Py_END_ALLOW_THREADS | ||
} | ||
} while (rc == SQLITE_OK || rc == SQLITE_BUSY || rc == SQLITE_LOCKED); | ||
|
||
Py_BEGIN_ALLOW_THREADS | ||
rc = sqlite3_backup_finish(bckhandle); | ||
Py_END_ALLOW_THREADS | ||
} else { | ||
rc = _pysqlite_seterror(bckconn, NULL); | ||
} | ||
|
||
if (cberr == 0 && rc != SQLITE_OK) { | ||
/* We cannot use _pysqlite_seterror() here because the backup APIs do | ||
not set the error status on the connection object, but rather on | ||
the backup handle. */ | ||
if (rc == SQLITE_NOMEM) { | ||
(void)PyErr_NoMemory(); | ||
} else { | ||
PyErr_SetString(pysqlite_OperationalError, sqlite3_errstr(rc)); | ||
} | ||
} | ||
|
||
Py_BEGIN_ALLOW_THREADS | ||
sqlite3_close(bckconn); | ||
Py_END_ALLOW_THREADS | ||
|
||
if (cberr == 0 && rc == SQLITE_OK) { | ||
Py_INCREF(Py_None); | ||
retval = Py_None; | ||
} else { | ||
/* Remove the probably incomplete/invalid backup */ | ||
if (unlink(filename) < 0) { | ||
/* FIXME: this should probably be chained to the outstanding | ||
exception */ | ||
return PyErr_SetFromErrno(PyExc_OSError); | ||
} | ||
} | ||
|
||
finally: | ||
return retval; | ||
} | ||
#endif | ||
|
||
static PyObject * | ||
pysqlite_connection_create_collation(pysqlite_Connection* self, PyObject* args) | ||
{ | ||
|
@@ -1649,6 +1765,10 @@ static PyMethodDef connection_methods[] = { | |
PyDoc_STR("Abort any pending database operation. Non-standard.")}, | ||
{"iterdump", (PyCFunction)pysqlite_connection_iterdump, METH_NOARGS, | ||
PyDoc_STR("Returns iterator to the dump of the database in an SQL text format. Non-standard.")}, | ||
#ifdef HAVE_BACKUP_API | ||
{"backup", (PyCFunction)pysqlite_connection_backup, METH_VARARGS | METH_KEYWORDS, | ||
PyDoc_STR("Makes a backup of the database. Non-standard.")}, | ||
#endif | ||
{"__enter__", (PyCFunction)pysqlite_connection_enter, METH_NOARGS, | ||
PyDoc_STR("For context manager. Non-standard.")}, | ||
{"__exit__", (PyCFunction)pysqlite_connection_exit, METH_VARARGS, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe mention that backups work for on-disk and in-memory databases like the sqlite3 C api docs mention?