From e99662d0fab5c49c2ed784e3b946332dd953bbe4 Mon Sep 17 00:00:00 2001 From: Artem Morozov Date: Sun, 23 Aug 2020 16:44:56 +0300 Subject: [PATCH 1/5] appveyor: Add python 3.8 to environment matrix --- appveyor.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/appveyor.yml b/appveyor.yml index c129cec0..2b3e1cf6 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -16,6 +16,8 @@ environment: - PYTHON: "C:\\Python36-x64" - PYTHON: "C:\\Python37" - PYTHON: "C:\\Python37-x64" + - PYTHON: "C:\\Python38" + - PYTHON: "C:\\Python38-x64" install: # install runtime dependencies From c529767f3a121520a3a20ef3ebcd5ed2027207f6 Mon Sep 17 00:00:00 2001 From: Artem Morozov Date: Sun, 30 Aug 2020 23:25:56 +0300 Subject: [PATCH 2/5] Add sql execute to Connection Closes #159 Co-authored-by: Denis Ignatenko --- tarantool/connection.py | 40 +++++++++++++++-- tarantool/const.py | 8 ++++ tarantool/request.py | 33 ++++++++++++++ tarantool/response.py | 45 ++++++++++++++++++- unit/suites/__init__.py | 3 +- unit/suites/test_execute.py | 88 +++++++++++++++++++++++++++++++++++++ 6 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 unit/suites/test_execute.py diff --git a/tarantool/connection.py b/tarantool/connection.py index 325a664f..8cabea52 100644 --- a/tarantool/connection.py +++ b/tarantool/connection.py @@ -34,7 +34,8 @@ RequestSubscribe, RequestUpdate, RequestUpsert, - RequestAuthenticate + RequestAuthenticate, + RequestExecute ) from tarantool.space import Space from tarantool.const import ( @@ -257,7 +258,7 @@ def _read_response(self): def _send_request_wo_reconnect(self, request): ''' - :rtype: `Response` instance + :rtype: `Response` instance or subclass :raise: NetworkError ''' @@ -267,7 +268,7 @@ def _send_request_wo_reconnect(self, request): while True: try: self._socket.sendall(bytes(request)) - response = Response(self, self._read_response()) + response = request.response_class(self, self._read_response()) break except SchemaReloadException as e: self.update_schema(e.schema_version) @@ -792,3 +793,36 @@ def generate_sync(self): Need override for async io connection ''' return 0 + + def execute(self, query, params=None): + ''' + Execute SQL request. + + Tarantool binary protocol for SQL requests + supports "qmark" and "named" param styles. + Sequence of values can be used for "qmark" style. + A mapping is used for "named" param style + without leading colon in the keys. + + Example for "qmark" arguments: + >>> args = ['email@example.com'] + >>> c.execute('select * from "users" where "email"=?', args) + + Example for "named" arguments: + >>> args = {'email': 'email@example.com'} + >>> c.execute('select * from "users" where "email"=:email', args) + + :param query: SQL syntax query + :type query: str + + :param params: Bind values to use in the query. + :type params: list, dict + + :return: query result data + :rtype: `Response` instance + ''' + if not params: + params = [] + request = RequestExecute(self, query, params) + response = self._send_request(request) + return response diff --git a/tarantool/const.py b/tarantool/const.py index 9d175974..0db35978 100644 --- a/tarantool/const.py +++ b/tarantool/const.py @@ -29,6 +29,13 @@ # IPROTO_DATA = 0x30 IPROTO_ERROR = 0x31 +# +IPROTO_METADATA = 0x32 +IPROTO_SQL_TEXT = 0x40 +IPROTO_SQL_BIND = 0x41 +IPROTO_SQL_INFO = 0x42 +IPROTO_SQL_INFO_ROW_COUNT = 0x00 +IPROTO_SQL_INFO_AUTOINCREMENT_IDS = 0x01 IPROTO_GREETING_SIZE = 128 IPROTO_BODY_MAX_LEN = 2147483648 @@ -44,6 +51,7 @@ REQUEST_TYPE_EVAL = 8 REQUEST_TYPE_UPSERT = 9 REQUEST_TYPE_CALL = 10 +REQUEST_TYPE_EXECUTE = 11 REQUEST_TYPE_PING = 64 REQUEST_TYPE_JOIN = 65 REQUEST_TYPE_SUBSCRIBE = 66 diff --git a/tarantool/request.py b/tarantool/request.py index e4c1acc7..d1a5a829 100644 --- a/tarantool/request.py +++ b/tarantool/request.py @@ -4,10 +4,17 @@ Request types definitions ''' +import collections import msgpack import hashlib +try: + collectionsAbc = collections.abc +except AttributeError: + collectionsAbc = collections + +from tarantool.error import DatabaseError from tarantool.const import ( IPROTO_CODE, IPROTO_SYNC, @@ -27,6 +34,8 @@ IPROTO_OPS, # IPROTO_INDEX_BASE, IPROTO_SCHEMA_ID, + IPROTO_SQL_TEXT, + IPROTO_SQL_BIND, REQUEST_TYPE_OK, REQUEST_TYPE_PING, REQUEST_TYPE_SELECT, @@ -37,11 +46,13 @@ REQUEST_TYPE_UPSERT, REQUEST_TYPE_CALL16, REQUEST_TYPE_CALL, + REQUEST_TYPE_EXECUTE, REQUEST_TYPE_EVAL, REQUEST_TYPE_AUTHENTICATE, REQUEST_TYPE_JOIN, REQUEST_TYPE_SUBSCRIBE ) +from tarantool.response import Response, ResponseExecute from tarantool.utils import ( strxor, binary_types @@ -64,6 +75,7 @@ def __init__(self, conn): self.conn = conn self._sync = None self._body = '' + self.response_class = Response packer_kwargs = dict() @@ -360,3 +372,24 @@ def __init__(self, conn, sync): request_body = self._dumps({IPROTO_CODE: self.request_type, IPROTO_SYNC: sync}) self._body = request_body + + +class RequestExecute(Request): + ''' + Represents EXECUTE SQL request + ''' + request_type = REQUEST_TYPE_EXECUTE + + def __init__(self, conn, sql, args): + super(RequestExecute, self).__init__(conn) + if isinstance(args, collectionsAbc.Mapping): + args = [{":%s" % name: value} for name, value in args.items()] + elif not isinstance(args, collectionsAbc.Sequence): + raise TypeError("Parameter type '%s' is not supported. " + "Must be a mapping or sequence" % type(args)) + + request_body = self._dumps({IPROTO_SQL_TEXT: sql, + IPROTO_SQL_BIND: args}) + + self._body = request_body + self.response_class = ResponseExecute diff --git a/tarantool/response.py b/tarantool/response.py index d8b479c1..4f596fb4 100644 --- a/tarantool/response.py +++ b/tarantool/response.py @@ -17,7 +17,10 @@ IPROTO_ERROR, IPROTO_SYNC, IPROTO_SCHEMA_ID, - REQUEST_TYPE_ERROR + REQUEST_TYPE_ERROR, + IPROTO_SQL_INFO, + IPROTO_SQL_INFO_ROW_COUNT, + IPROTO_SQL_INFO_AUTOINCREMENT_IDS ) from tarantool.error import ( DatabaseError, @@ -268,3 +271,43 @@ def __str__(self): return ''.join(output) __repr__ = __str__ + + +class ResponseExecute(Response): + @property + def autoincrement_ids(self): + """ + Returns a list with the new primary-key value + (or values) for an INSERT in a table defined with + PRIMARY KEY AUTOINCREMENT + (NOT result set size) + + :rtype: list or None + """ + if self._return_code != 0: + return None + info = self._body.get(IPROTO_SQL_INFO) + + if info is None: + return None + + autoincrement_ids = info.get(IPROTO_SQL_INFO_AUTOINCREMENT_IDS) + + return autoincrement_ids + + @property + def affected_row_count(self): + """ + Returns the number of changed rows for responses + to DML requests and None for DQL requests. + + :rtype: int + """ + if self._return_code != 0: + return None + info = self._body.get(IPROTO_SQL_INFO) + + if info is None: + return None + + return info.get(IPROTO_SQL_INFO_ROW_COUNT) diff --git a/unit/suites/__init__.py b/unit/suites/__init__.py index 7e9d12e3..25e3a7e4 100644 --- a/unit/suites/__init__.py +++ b/unit/suites/__init__.py @@ -10,11 +10,12 @@ from .test_protocol import TestSuite_Protocol from .test_reconnect import TestSuite_Reconnect from .test_mesh import TestSuite_Mesh +from .test_execute import TestSuite_Execute test_cases = (TestSuite_Schema_UnicodeConnection, TestSuite_Schema_BinaryConnection, TestSuite_Request, TestSuite_Protocol, TestSuite_Reconnect, - TestSuite_Mesh) + TestSuite_Mesh, TestSuite_Execute) def load_tests(loader, tests, pattern): suite = unittest.TestSuite() diff --git a/unit/suites/test_execute.py b/unit/suites/test_execute.py new file mode 100644 index 00000000..21b8cfac --- /dev/null +++ b/unit/suites/test_execute.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- + +from __future__ import print_function + +import sys +import unittest + +import tarantool +from .lib.tarantool_server import TarantoolServer + + +class TestSuite_Execute(unittest.TestCase): + ddl = 'create table %s (id INTEGER PRIMARY KEY AUTOINCREMENT, ' \ + 'name varchar(20))' + + dml_params = [ + {'id': None, 'name': 'Michael'}, + {'id': None, 'name': 'Mary'}, + {'id': None, 'name': 'John'}, + {'id': None, 'name': 'Ruth'}, + {'id': None, 'name': 'Rachel'} + ] + + @classmethod + def setUpClass(self): + print(' EXECUTE '.center(70, '='), file=sys.stderr) + print('-' * 70, file=sys.stderr) + self.srv = TarantoolServer() + self.srv.script = 'unit/suites/box.lua' + self.srv.start() + self.con = tarantool.Connection(self.srv.host, self.srv.args['primary']) + + def setUp(self): + # prevent a remote tarantool from clean our session + if self.srv.is_started(): + self.srv.touch_lock() + self.con.flush_schema() + + # grant full access to guest + self.srv.admin("box.schema.user.grant('guest', 'create,read,write," + "execute', 'universe')") + + @classmethod + def tearDownClass(self): + self.con.close() + self.srv.stop() + self.srv.clean() + + def _populate_data(self, table_name): + query = "insert into %s values (:id, :name)" % table_name + for param in self.dml_params: + self.con.execute(query, param) + + def _create_table(self, table_name): + return self.con.execute(self.ddl % table_name) + + def test_dml_response(self): + table_name = 'foo' + response = self._create_table(table_name) + self.assertEqual(response.autoincrement_ids, None) + self.assertEqual(response.affected_row_count, 1) + self.assertEqual(response.data, None) + + query = "insert into %s values (:id, :name)" % table_name + + for num, param in enumerate(self.dml_params, start=1): + response = self.con.execute(query, param) + self.assertEqual(response.autoincrement_ids[0], num) + self.assertEqual(response.affected_row_count, 1) + self.assertEqual(response.data, None) + + query = "delete from %s where id in (4, 5)" % table_name + response = self.con.execute(query) + self.assertEqual(response.autoincrement_ids, None) + self.assertEqual(response.affected_row_count, 2) + self.assertEqual(response.data, None) + + def test_dql_response(self): + table_name = 'bar' + self._create_table(table_name) + self._populate_data(table_name) + + select_query = "select name from %s where id in (1, 3, 5)" % table_name + response = self.con.execute(select_query) + self.assertEqual(response.autoincrement_ids, None) + self.assertEqual(response.affected_row_count, None) + expected_data = [['Michael'], ['John'], ['Rachel']] + self.assertListEqual(response.data, expected_data) From 00d315b31d4f2373ea199497fa9a64996eaed89a Mon Sep 17 00:00:00 2001 From: Artem Morozov Date: Sun, 18 Oct 2020 22:20:07 +0300 Subject: [PATCH 3/5] Make use_list param configurable It is set up to be converted to lists by default. Django expects row type to be tuple. Also tuples tend to perform better than lists and it is good if it can be configurable. Closes #166 --- tarantool/connection.py | 2 ++ tarantool/response.py | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tarantool/connection.py b/tarantool/connection.py index 8cabea52..39708761 100644 --- a/tarantool/connection.py +++ b/tarantool/connection.py @@ -93,6 +93,7 @@ def __init__(self, host, port, reconnect_delay=RECONNECT_DELAY, connect_now=True, encoding=ENCODING_DEFAULT, + use_list=True, call_16=False, connection_timeout=CONNECTION_TIMEOUT): ''' @@ -132,6 +133,7 @@ def __init__(self, host, port, self.connected = False self.error = True self.encoding = encoding + self.use_list = use_list self.call_16 = call_16 self.connection_timeout = connection_timeout if connect_now: diff --git a/tarantool/response.py b/tarantool/response.py index 4f596fb4..9eac1b9e 100644 --- a/tarantool/response.py +++ b/tarantool/response.py @@ -55,8 +55,9 @@ def __init__(self, conn, response): unpacker_kwargs = dict() - # Decode msgpack arrays into Python lists (not tuples). - unpacker_kwargs['use_list'] = True + # Decode msgpack arrays into Python lists by default (not tuples). + # Can be configured in the Connection init + unpacker_kwargs['use_list'] = conn.use_list # Use raw=False instead of encoding='utf-8'. if msgpack.version >= (0, 5, 2) and conn.encoding == 'utf-8': From e43e41e5e918aeac14dee3ec6642ade64f9dc35e Mon Sep 17 00:00:00 2001 From: Artem Morozov Date: Mon, 31 Aug 2020 00:03:34 +0300 Subject: [PATCH 4/5] Add pep-249 dbapi module See [1] for details. The main motivation for the module creation was the integration Django with Tarantool database through django-tarantool database backend [2] which requires dbapi connector for the database. The most of the optional extensions and methods were ignored because Django does not require them. Anyway, feel free to suggest its implementation as needed. Interactive transactions are not currently supported by Tarantool and theirs implementation will be added in the connector when the feature is stable in Tarantool itself. [1] https://www.python.org/dev/peps/pep-0249/ [2] https://github.com/artembo/django-tarantool Co-authored-by: Denis Ignatenko --- tarantool/__init__.py | 2 +- tarantool/connection.py | 25 +++- tarantool/dbapi.py | 248 ++++++++++++++++++++++++++++++++++++++++ tarantool/error.py | 66 ++++++++++- 4 files changed, 336 insertions(+), 5 deletions(-) create mode 100644 tarantool/dbapi.py diff --git a/tarantool/__init__.py b/tarantool/__init__.py index a9838321..b4ed81d4 100644 --- a/tarantool/__init__.py +++ b/tarantool/__init__.py @@ -75,4 +75,4 @@ def connectmesh(addrs=({'host': 'localhost', 'port': 3301},), user=None, __all__ = ['connect', 'Connection', 'connectmesh', 'MeshConnection', 'Schema', 'Error', 'DatabaseError', 'NetworkError', 'NetworkWarning', - 'SchemaError'] + 'SchemaError', 'dbapi'] diff --git a/tarantool/connection.py b/tarantool/connection.py index 39708761..0ff39b58 100644 --- a/tarantool/connection.py +++ b/tarantool/connection.py @@ -50,13 +50,21 @@ ITERATOR_ALL ) from tarantool.error import ( + Error, NetworkError, DatabaseError, InterfaceError, ConfigurationError, SchemaError, NetworkWarning, + OperationalError, + DataError, + IntegrityError, + InternalError, + ProgrammingError, + NotSupportedError, SchemaReloadException, + Warning, warn ) from tarantool.schema import Schema @@ -78,12 +86,20 @@ class Connection(object): Also this class provides low-level interface to data manipulation (insert/delete/update/select). ''' - Error = tarantool.error + # DBAPI Extension: supply exceptions as attributes on the connection + Error = Error DatabaseError = DatabaseError InterfaceError = InterfaceError ConfigurationError = ConfigurationError SchemaError = SchemaError NetworkError = NetworkError + Warning = Warning + DataError = DataError + OperationalError = OperationalError + IntegrityError = IntegrityError + InternalError = InternalError + ProgrammingError = ProgrammingError + NotSupportedError = NotSupportedError def __init__(self, host, port, user=None, @@ -146,6 +162,13 @@ def close(self): self._socket.close() self._socket = None + def is_closed(self): + ''' + Returns the state of the Connection instance + :rtype: Boolean + ''' + return self._socket is None + def connect_basic(self): if self.host == None: self.connect_unix() diff --git a/tarantool/dbapi.py b/tarantool/dbapi.py new file mode 100644 index 00000000..9f4e1e10 --- /dev/null +++ b/tarantool/dbapi.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +from tarantool.connection import Connection as BaseConnection +from tarantool.error import * + + +paramstyle = 'named' +apilevel = "2.0" +threadsafety = 1 + + +class Cursor: + + def __init__(self, conn): + self._c = conn + self._lastrowid = None + self._rowcount = None + self.arraysize = 1 + self._rows = None + + def callproc(self, procname, *params): + """ + Call a stored database procedure with the given name. The sequence of + parameters must contain one entry for each argument that the + procedure expects. The result of the call is returned as modified + copy of the input sequence. Input parameters are left untouched, + output and input/output parameters replaced with possibly new values. + """ + raise NotSupportedError("callproc() method is not supported") + + @property + def rows(self): + return self._rows + + @property + def description(self): + # FIXME Implement this method please + raise NotImplementedError("description() property is not implemented") + + def close(self): + """ + Close the cursor now (rather than whenever __del__ is called). + The cursor will be unusable from this point forward; DatabaseError + exception will be raised if any operation is attempted with + the cursor. + """ + self._c = None + self._rows = None + self._lastrowid = None + self._rowcount = None + + def _check_not_closed(self, error=None): + if self._c is None: + raise InterfaceError(error or "Can not operate on a closed cursor") + if self._c.is_closed(): + raise InterfaceError("The cursor can not be used " + "with a closed connection") + + def execute(self, query, params=None): + """ + Prepare and execute a database operation (query or command). + """ + self._check_not_closed("Can not execute on closed cursor.") + + response = self._c.execute(query, params) + + self._rows = response.data + self._rowcount = response.affected_row_count or -1 + if response.autoincrement_ids: + self._lastrowid = response.autoincrement_ids[-1] + else: + self._lastrowid = None + + def executemany(self, query, param_sets): + self._check_not_closed("Can not execute on closed cursor.") + rowcount = 0 + for params in param_sets: + self.execute(query, params) + if self.rowcount == -1: + rowcount = -1 + if rowcount != -1: + rowcount += self.rowcount + self._rowcount = rowcount + + @property + def lastrowid(self): + """ + This read-only attribute provides the rowid of the last modified row + (most databases return a rowid only when a single INSERT operation is + performed). + """ + return self._lastrowid + + @property + def rowcount(self): + """ + This read-only attribute specifies the number of rows that the last + .execute*() produced (for DQL statements like SELECT) or affected ( + for DML statements like UPDATE or INSERT). + """ + return self._rowcount + + def _check_result_set(self, error=None): + """ + Non-public method for raising an error when Cursor object does not have + any row to fetch. Useful for checking access after DQL requests. + """ + if self._rows is None: + raise InterfaceError(error or "No result set to fetch from") + + def fetchone(self): + """ + Fetch the next row of a query result set, returning a single + sequence, or None when no more data is available. + """ + self._check_result_set() + return self.fetchmany(1)[0] if self._rows else None + + def fetchmany(self, size=None): + """ + Fetch the next set of rows of a query result, returning a sequence of + sequences (e.g. a list of tuples). An empty sequence is returned when + no more rows are available. + """ + self._check_result_set() + + size = size or self.arraysize + + if len(self._rows) < size: + items = self._rows + self._rows = [] + else: + items, self._rows = self._rows[:size], self._rows[size:] + + return items + + def fetchall(self): + """Fetch all (remaining) rows of a query result, returning them as a + sequence of sequences (e.g. a list of tuples). Note that the cursor's + arraysize attribute can affect the performance of this operation. + """ + self._check_result_set() + + items = self._rows + self._rows = [] + return items + + def setinputsizes(self, sizes): + """PEP-249 allows to not implement this method and do nothing.""" + + def setoutputsize(self, size, column=None): + """PEP-249 allows to not implement this method and do nothing.""" + + +class Connection(BaseConnection): + + def __init__(self, *args, **kwargs): + super(Connection, self).__init__(*args, **kwargs) + self._set_autocommit(kwargs.get('autocommit', True)) + + def _set_autocommit(self, autocommit): + """Autocommit is True by default and the default will be changed + to False. Set the autocommit property explicitly to True or verify + it when lean on autocommit behaviour.""" + if not isinstance(autocommit, bool): + raise InterfaceError("autocommit parameter must be boolean, " + "not %s" % autocommit.__class__.__name__) + if autocommit is False: + raise NotSupportedError("The connector supports " + "only autocommit mode") + self._autocommit = autocommit + + @property + def autocommit(self): + """Autocommit state""" + return self._autocommit + + @autocommit.setter + def autocommit(self, autocommit): + """Set autocommit state""" + self._set_autocommit(autocommit) + + def _check_not_closed(self, error=None): + """ + Checks if the connection is not closed and rises an error if it is. + """ + if self.is_closed(): + raise InterfaceError(error or "The connector is closed") + + def close(self): + """ + Closes the connection + """ + self._check_not_closed("The closed connector can not be closed again.") + super(Connection, self).close() + + def commit(self): + """ + Commit any pending transaction to the database. + """ + self._check_not_closed("Can not commit on the closed connection") + + def rollback(self): + """ + Roll back pending transaction + """ + self._check_not_closed("Can not roll back on a closed connection") + raise NotSupportedError("Transactions are not supported in this" + "version of connector") + + def cursor(self): + """ + Return a new Cursor Object using the connection. + """ + self._check_not_closed("Cursor creation is not allowed on a closed " + "connection") + return Cursor(self) + + +def connect(dsn=None, host=None, port=None, + user=None, password=None, **kwargs): + """ + Constructor for creating a connection to the database. + + :param str dsn: Data source name (Tarantool URI) + ([[[username[:password]@]host:]port) + :param str host: Server hostname or IP-address + :param int port: Server port + :param str user: Tarantool user + :param str password: User password + :rtype: Connection + """ + + if dsn: + raise NotImplementedError("dsn param is not implemented in" + "this version of dbapi module") + params = {} + if host: + params["host"] = host + if port: + params["port"] = port + if user: + params["user"] = user + if password: + params["password"] = password + + kwargs.update(params) + + return Connection(**kwargs) diff --git a/tarantool/error.py b/tarantool/error.py index cc66e8c5..78519b68 100644 --- a/tarantool/error.py +++ b/tarantool/error.py @@ -25,6 +25,15 @@ import warnings +try: + class Warning(StandardError): + '''Exception raised for important warnings + like data truncations while inserting, etc. ''' +except NameError: + class Warning(Exception): + '''Exception raised for important warnings + like data truncations while inserting, etc. ''' + try: class Error(StandardError): '''Base class for error exceptions''' @@ -33,13 +42,60 @@ class Error(Exception): '''Base class for error exceptions''' +class InterfaceError(Error): + ''' + Exception raised for errors that are related to the database interface + rather than the database itself. + ''' + + class DatabaseError(Error): - '''Error related to the database engine''' + '''Exception raised for errors that are related to the database.''' -class InterfaceError(Error): +class DataError(DatabaseError): + ''' + Exception raised for errors that are due to problems with the processed + data like division by zero, numeric value out of range, etc. + ''' + + +class OperationalError(DatabaseError): + ''' + Exception raised for errors that are related to the database's operation + and not necessarily under the control of the programmer, e.g. an + unexpected disconnect occurs, the data source name is not found, + a transaction could not be processed, a memory allocation error occurred + during processing, etc. + ''' + + +class IntegrityError(DatabaseError): + ''' + Exception raised when the relational integrity of the database is affected, + e.g. a foreign key check fails. + ''' + + +class InternalError(DatabaseError): ''' - Error related to the database interface rather than the database itself + Exception raised when the database encounters an internal error, e.g. the + cursor is not valid anymore, the transaction is out of sync, etc. + ''' + + +class ProgrammingError(DatabaseError): + ''' + Exception raised when the database encounters an internal error, e.g. the + cursor is not valid anymore, the transaction is out of sync, etc. + ''' + + +class NotSupportedError(DatabaseError): + ''' + Exception raised in case a method or database API was used which is not + supported by the database, e.g. requesting a .rollback() on a connection + that does not support transaction or has transactions turned off. ''' @@ -49,6 +105,10 @@ class ConfigurationError(Error): ''' +__all__ = ("Warning", "Error", "InterfaceError", "DatabaseError", "DataError", + "OperationalError", "IntegrityError", "InternalError", + "ProgrammingError", "NotSupportedError") + # Monkey patch os.strerror for win32 if sys.platform == "win32": # Windows Sockets Error Codes (not all, but related on network errors) From 5160aafa296e57ccaf85c9761e5f735b67737796 Mon Sep 17 00:00:00 2001 From: Artem Morozov Date: Wed, 26 Aug 2020 00:43:10 +0300 Subject: [PATCH 5/5] Add unit tests for dbapi module Used dbapi-compliance [1] package to test module according to pep-249 specification. Not implemented features are skipped in the tests. Added dbapi-compliance package to test.sh requirements and appveyor.yml [1] https://github.com/baztian/dbapi-compliance/ --- appveyor.yml | 2 +- test.sh | 2 +- unit/suites/__init__.py | 3 +- unit/suites/test_dbapi.py | 131 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 unit/suites/test_dbapi.py diff --git a/appveyor.yml b/appveyor.yml index 2b3e1cf6..620d1e85 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -23,7 +23,7 @@ install: # install runtime dependencies - "%PYTHON%\\python.exe -m pip install -r requirements.txt" # install testing dependencies - - "%PYTHON%\\python.exe -m pip install pyyaml%PYYAML%" + - "%PYTHON%\\python.exe -m pip install pyyaml%PYYAML% dbapi-compliance==1.15.0" build: off diff --git a/test.sh b/test.sh index ce701592..16bb73cc 100755 --- a/test.sh +++ b/test.sh @@ -16,7 +16,7 @@ pip install "${PYTHON_MSGPACK:-msgpack==1.0.0}" python -c 'import msgpack; print(msgpack.version)' # Install testing dependencies. -pip install pyyaml +pip install pyyaml dbapi-compliance==1.15.0 # Run tests. python setup.py test diff --git a/unit/suites/__init__.py b/unit/suites/__init__.py index 25e3a7e4..ecf3a201 100644 --- a/unit/suites/__init__.py +++ b/unit/suites/__init__.py @@ -11,11 +11,12 @@ from .test_reconnect import TestSuite_Reconnect from .test_mesh import TestSuite_Mesh from .test_execute import TestSuite_Execute +from .test_dbapi import TestSuite_DBAPI test_cases = (TestSuite_Schema_UnicodeConnection, TestSuite_Schema_BinaryConnection, TestSuite_Request, TestSuite_Protocol, TestSuite_Reconnect, - TestSuite_Mesh, TestSuite_Execute) + TestSuite_Mesh, TestSuite_Execute, TestSuite_DBAPI) def load_tests(loader, tests, pattern): suite = unittest.TestSuite() diff --git a/unit/suites/test_dbapi.py b/unit/suites/test_dbapi.py new file mode 100644 index 00000000..39672b5d --- /dev/null +++ b/unit/suites/test_dbapi.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- + +from __future__ import print_function + +import sys +import unittest + +import dbapi20 + +import tarantool +from tarantool import dbapi +from .lib.tarantool_server import TarantoolServer + + +class TestSuite_DBAPI(dbapi20.DatabaseAPI20Test): + table_prefix = 'dbapi20test_' # If you need to specify a prefix for tables + + ddl0 = 'create table %s (id INTEGER PRIMARY KEY AUTOINCREMENT, ' \ + 'name varchar(20))' + ddl1 = 'create table %sbooze (name varchar(20) primary key)' % table_prefix + ddl2 = 'create table %sbarflys (name varchar(20) primary key, ' \ + 'drink varchar(30))' % table_prefix + + @classmethod + def setUpClass(self): + print(' DBAPI '.center(70, '='), file=sys.stderr) + print('-' * 70, file=sys.stderr) + self.srv = TarantoolServer() + self.srv.script = 'unit/suites/box.lua' + self.srv.start() + self.con = tarantool.Connection(self.srv.host, self.srv.args['primary']) + self.driver = dbapi + self.connect_kw_args = dict( + host=self.srv.host, + port=self.srv.args['primary']) + + def setUp(self): + # prevent a remote tarantool from clean our session + if self.srv.is_started(): + self.srv.touch_lock() + self.con.flush_schema() + + # grant full access to guest + self.srv.admin("box.schema.user.grant('guest', 'create,read,write," + "execute', 'universe')") + + @classmethod + def tearDownClass(self): + self.con.close() + self.srv.stop() + self.srv.clean() + + def test_rowcount(self): + con = self._connect() + try: + cur = con.cursor() + self.executeDDL1(cur) + dbapi20._failUnless(self,cur.rowcount in (-1, 1), + 'cursor.rowcount should be -1 or 1 after executing no-result ' + 'statements' + str(cur.rowcount) + ) + cur.execute("%s into %sbooze values ('Victoria Bitter')" % ( + self.insert, self.table_prefix + )) + dbapi20._failUnless(self,cur.rowcount == 1, + 'cursor.rowcount should == number or rows inserted, or ' + 'set to -1 after executing an insert statement' + ) + cur.execute("select name from %sbooze" % self.table_prefix) + dbapi20._failUnless(self,cur.rowcount == -1, + 'cursor.rowcount should == number of rows returned, or ' + 'set to -1 after executing a select statement' + ) + self.executeDDL2(cur) + dbapi20._failUnless(self,cur.rowcount in (-1, 1), + 'cursor.rowcount should be -1 or 1 after executing no-result ' + 'statements' + ) + finally: + con.close() + + @unittest.skip('Not implemented') + def test_Binary(self): + pass + + @unittest.skip('Not implemented') + def test_STRING(self): + pass + + @unittest.skip('Not implemented') + def test_BINARY(self): + pass + + @unittest.skip('Not implemented') + def test_NUMBER(self): + pass + + @unittest.skip('Not implemented') + def test_DATETIME(self): + pass + + @unittest.skip('Not implemented') + def test_ROWID(self): + pass + + @unittest.skip('Not implemented') + def test_Date(self): + pass + + @unittest.skip('Not implemented') + def test_Time(self): + pass + + @unittest.skip('Not implemented') + def test_Timestamp(self): + pass + + @unittest.skip('Not implemented as optional.') + def test_nextset(self): + pass + + @unittest.skip('Not implemented') + def test_callproc(self): + pass + + def test_setoutputsize(self): # Do nothing + pass + + @unittest.skip('Not implemented') + def test_description(self): + pass