diff --git a/appveyor.yml b/appveyor.yml index c129cec0..620d1e85 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -16,12 +16,14 @@ environment: - PYTHON: "C:\\Python36-x64" - PYTHON: "C:\\Python37" - PYTHON: "C:\\Python37-x64" + - PYTHON: "C:\\Python38" + - PYTHON: "C:\\Python38-x64" 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/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 325a664f..0ff39b58 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 ( @@ -49,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 @@ -77,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, @@ -92,6 +109,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): ''' @@ -131,6 +149,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: @@ -143,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() @@ -257,7 +283,7 @@ def _read_response(self): def _send_request_wo_reconnect(self, request): ''' - :rtype: `Response` instance + :rtype: `Response` instance or subclass :raise: NetworkError ''' @@ -267,7 +293,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 +818,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/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) 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..9eac1b9e 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, @@ -52,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': @@ -268,3 +272,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/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 7e9d12e3..ecf3a201 100644 --- a/unit/suites/__init__.py +++ b/unit/suites/__init__.py @@ -10,11 +10,13 @@ from .test_protocol import TestSuite_Protocol 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_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 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)