From b6c9c66fac1b79fff65e6f3d19b5acaabb7a5715 Mon Sep 17 00:00:00 2001 From: igorcoding Date: Sun, 5 May 2024 15:40:47 +0300 Subject: [PATCH] Improvements on the sending update/upsert statements **New features:** * Implemented ability to send update/upsert requests with field names when schema is disabled (`fetch_schema=False`) and when fields are not found in the schema (good example of this case is using json path like `data.inner1.inner2.key1` as a key) **Bug fixes:** * Fixed issue with not being able to send Decimals in update statements. Now there are no extra checks - any payload is sent directly to Tarantool (fixes #34) **Other changes** * Fixed tests failing on modern Tarantool in the SQL queries. * Remove from ci/cd testing on mac os python 3.7 * Added Tarantool 3 to CI testing --- .github/workflows/actions.yaml | 46 ++++++++------- CHANGELOG.md | 12 ++++ Makefile | 2 +- asynctnt/__init__.py | 2 +- asynctnt/iproto/requests/update.pyx | 61 +++++++++---------- asynctnt/iproto/schema.pxd | 1 + asynctnt/iproto/schema.pyx | 9 +++ pyproject.toml | 4 +- tests/files/app.lua | 34 +++++++---- tests/test_common.py | 4 +- tests/test_op_call.py | 16 +++-- tests/test_op_eval.py | 4 +- tests/test_op_sql_execute.py | 90 ++++++++++++++++++++++++----- tests/test_op_update.py | 82 ++++++++++++++++++++++---- 14 files changed, 269 insertions(+), 98 deletions(-) diff --git a/.github/workflows/actions.yaml b/.github/workflows/actions.yaml index 61e68a2..21468d6 100644 --- a/.github/workflows/actions.yaml +++ b/.github/workflows/actions.yaml @@ -8,40 +8,44 @@ jobs: matrix: os: [ ubuntu-latest, macos-latest ] python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy3.10'] - tarantool: ['1.10', '2'] + tarantool: ['1.10', '2', '3'] exclude: - os: macos-latest tarantool: '1.10' + - os: macos-latest + tarantool: '2' + - os: macos-latest + python-version: '3.7' - python-version: 'pypy3.10' tarantool: '1.10' runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: recursive - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install Tarantool ${{ matrix.tarantool }} + + - name: Install Tarantool ${{ matrix.tarantool }} on Ubuntu + if: matrix.os == 'ubuntu-latest' run: | - if [ "$RUNNER_OS" == "Linux" ]; then - curl -L https://tarantool.io/nTmSHOX/release/${{ matrix.tarantool }}/installer.sh | bash - sudo apt-get -y install tarantool - elif [ "$RUNNER_OS" == "macOS" ]; then - brew install tarantool - else - echo "$RUNNER_OS not supported" - exit 1 - fi + curl -L https://tarantool.io/nTmSHOX/release/${{ matrix.tarantool }}/installer.sh | bash + sudo apt-get -y install tarantool + + - name: Install Tarantool ${{ matrix.tarantool }} on MacOS + if: matrix.os == 'macos-latest' + run: brew install tarantool + - name: Install dependencies run: | python -m pip install --upgrade pip setuptools wheel coveralls - name: Run tests run: | - if [[ "$RUNNER_OS" == "Linux" && ${{ matrix.python-version }} == "3.12" && ${{ matrix.tarantool }} == "2" ]]; then + if [[ "$RUNNER_OS" == "Linux" && ${{ matrix.python-version }} == "3.12" && ${{ matrix.tarantool }} == "3" ]]; then make build && make test make clean && make debug && make coverage # coveralls @@ -62,11 +66,11 @@ jobs: - test steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: recursive - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 - name: Install cibuildwheel run: python -m pip install --upgrade cibuildwheel @@ -92,11 +96,11 @@ jobs: id: get_tag run: echo ::set-output name=TAG::${GITHUB_REF/refs\/tags\//} - run: echo "Current tag is ${{ steps.get_tag.outputs.TAG }}" - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: recursive - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.12' @@ -104,7 +108,7 @@ jobs: run: | python -m pip install --upgrade pip setuptools wheel twine build - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: name: wheels path: wheels @@ -133,12 +137,12 @@ jobs: needs: - test steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: recursive - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.12' diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e1c3f..daef2f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## v2.2.0 +**New features:** +* Implemented ability to send update/upsert requests with field names when schema is disabled (`fetch_schema=False`) and when fields are not found in the schema (good example of this case is using json path like `data.inner1.inner2.key1` as a key) + +**Bug fixes:** +* Fixed issue with not being able to send Decimals in update statements. Now there are no extra checks - any payload is sent directly to Tarantool (fixes [#34](https://github.com/igorcoding/asynctnt/issues/34)) + +**Other changes** +* Fixed tests failing on modern Tarantool in the SQL queries. +* Removed from ci/cd testing on macOS python 3.7 +* Added Tarantool 3 to CI Testing + ## v2.1.0 **Breaking changes:** * Dropped support for Python 3.6 diff --git a/Makefile b/Makefile index b1acef5..90e8798 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ mypy: $(PYTHON) -m mypy --enable-error-code ignore-without-code . ruff: - $(PYTHON) -m ruff . + $(PYTHON) -m ruff check . style-check: $(PYTHON) -m black --check --diff . diff --git a/asynctnt/__init__.py b/asynctnt/__init__.py index 9804faa..70a4944 100644 --- a/asynctnt/__init__.py +++ b/asynctnt/__init__.py @@ -16,4 +16,4 @@ TarantoolTuple, ) -__version__ = "2.1.0" +__version__ = "2.2.0" diff --git a/asynctnt/iproto/requests/update.pyx b/asynctnt/iproto/requests/update.pyx index f955e0c..e93a363 100644 --- a/asynctnt/iproto/requests/update.pyx +++ b/asynctnt/iproto/requests/update.pyx @@ -23,11 +23,17 @@ cdef char *encode_update_ops(WriteBuffer buffer, uint32_t extra_length + bint field_encode_as_str uint64_t field_no + char *field_str_c + ssize_t field_str_len object field_no_obj uint32_t splice_position, splice_offset + field_encode_as_str = 0 + field_str_c = NULL + begin = NULL if operations is not None: @@ -60,22 +66,27 @@ cdef char *encode_update_ops(WriteBuffer buffer, raise TypeError( 'Operation type must of a str or bytes type') + cpython.bytes.PyBytes_AsStringAndSize(str_temp, &op_str_c, + &op_str_len) + field_no_obj = operation[1] if isinstance(field_no_obj, int): field_no = field_no_obj elif isinstance(field_no_obj, str): if space.metadata is not None: - field_no = space.metadata.id_by_name(field_no_obj) + field_no = space.metadata.id_by_name_safe(field_no_obj) + if field_no == -1: + field_encode_as_str = 1 else: - raise TypeError( - 'Operation field_no must be int as there is ' - 'no format declaration in space {}'.format(space.sid)) + field_encode_as_str = 1 + + if field_encode_as_str: + str_temp = encode_unicode_string(field_no_obj, buffer._encoding) + cpython.bytes.PyBytes_AsStringAndSize(str_temp, &field_str_c, &field_str_len) else: raise TypeError( 'Operation field_no must be of either int or str type') - cpython.bytes.PyBytes_AsStringAndSize(str_temp, &op_str_c, - &op_str_len) op = 0 if op_str_len == 1: op = op_str_c[0] @@ -85,28 +96,9 @@ cdef char *encode_update_ops(WriteBuffer buffer, or op == tarantool.IPROTO_OP_AND \ or op == tarantool.IPROTO_OP_XOR \ or op == tarantool.IPROTO_OP_OR \ - or op == tarantool.IPROTO_OP_DELETE: - op_argument = operation[2] - if not isinstance(op_argument, int): - raise TypeError( - 'int argument required for ' - 'Arithmetic and Delete operations' - ) - # mp_sizeof_array(3) - # + mp_sizeof_str(1) - # + mp_sizeof_uint(field_no) - extra_length = 1 + 2 + mp_sizeof_uint(field_no) - p = begin = buffer._ensure_allocated(p, extra_length) - - p = mp_encode_array(p, 3) - p = mp_encode_str(p, op_str_c, 1) - p = mp_encode_uint(p, field_no) - buffer._length += (p - begin) - p = buffer.mp_encode_obj(p, op_argument) - elif op == tarantool.IPROTO_OP_INSERT \ + or op == tarantool.IPROTO_OP_DELETE \ + or op == tarantool.IPROTO_OP_INSERT \ or op == tarantool.IPROTO_OP_ASSIGN: - op_argument = operation[2] - # mp_sizeof_array(3) # + mp_sizeof_str(1) # + mp_sizeof_uint(field_no) @@ -115,13 +107,19 @@ cdef char *encode_update_ops(WriteBuffer buffer, p = mp_encode_array(p, 3) p = mp_encode_str(p, op_str_c, 1) - p = mp_encode_uint(p, field_no) + if field_str_c == NULL: + p = mp_encode_uint(p, field_no) + else: + p = mp_encode_str(p, field_str_c, field_str_len) + buffer._length += (p - begin) + + op_argument = operation[2] p = buffer.mp_encode_obj(p, op_argument) elif op == tarantool.IPROTO_OP_SPLICE: if op_len < 5: - raise IndexError( + raise ValueError( 'Splice operation must have length of 5, ' 'but got: {}'.format(op_len) ) @@ -146,7 +144,10 @@ cdef char *encode_update_ops(WriteBuffer buffer, p = mp_encode_array(p, 5) p = mp_encode_str(p, op_str_c, 1) - p = mp_encode_uint(p, field_no) + if field_str_c == NULL: + p = mp_encode_uint(p, field_no) + else: + p = mp_encode_str(p, field_str_c, field_str_len) p = mp_encode_uint(p, splice_position) p = mp_encode_uint(p, splice_offset) buffer._length += (p - begin) diff --git a/asynctnt/iproto/schema.pxd b/asynctnt/iproto/schema.pxd index 60e09c2..a10885e 100644 --- a/asynctnt/iproto/schema.pxd +++ b/asynctnt/iproto/schema.pxd @@ -21,6 +21,7 @@ cdef public class Metadata [object C_Metadata, type C_Metadata_Type]: cdef inline void add(self, int id, Field field) cdef inline str name_by_id(self, int i) cdef inline int id_by_name(self, str name) except * + cdef inline int id_by_name_safe(self, str name) except* cdef class SchemaIndex: diff --git a/asynctnt/iproto/schema.pyx b/asynctnt/iproto/schema.pyx index b696194..a6e993e 100644 --- a/asynctnt/iproto/schema.pyx +++ b/asynctnt/iproto/schema.pyx @@ -51,6 +51,15 @@ cdef class Metadata: raise KeyError('Field \'{}\' not found'.format(name)) return fld + cdef inline int id_by_name_safe(self, str name) except *: + cdef: + PyObject *fld + + fld = cpython.dict.PyDict_GetItem(self.name_id_map, name) + if fld == NULL: + return -1 + return fld + cdef inline int len(self): return cpython.list.PyList_GET_SIZE(self.fields) diff --git a/pyproject.toml b/pyproject.toml index 74c5c58..2716b58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,7 +120,7 @@ skip_glob = [ [tool.ruff] -select = [ +lint.select = [ "E", # pycodestyle errors "W", # pycodestyle warnings "F", # pyflakes @@ -128,7 +128,7 @@ select = [ "C", # flake8-comprehensions "B", # flake8-bugbear ] -ignore = [ +lint.ignore = [ "E501", # line too long, handled by black "B008", # do not perform function calls in argument defaults "C901", # too complex diff --git a/tests/files/app.lua b/tests/files/app.lua index ff2fb83..e5e64f2 100644 --- a/tests/files/app.lua +++ b/tests/files/app.lua @@ -51,6 +51,14 @@ local function bootstrap() types = {} } + function b:sql_space_name(space_name) + if self:check_version({3, 0}) then + return space_name + else + return space_name:upper() + end + end + function b:check_version(expected) return check_version(expected, self.tarantool_ver) end @@ -197,16 +205,22 @@ function truncate() _truncate(box.space.tester) _truncate(box.space.no_schema_space) - if box.space.SQL_SPACE ~= nil then - box.execute('DELETE FROM sql_space') - end - - if box.space.SQL_SPACE_AUTOINCREMENT ~= nil then - box.execute('DELETE FROM sql_space_autoincrement') - end - - if box.space.SQL_SPACE_AUTOINCREMENT_MULTIPLE ~= nil then - box.execute('DELETE FROM sql_space_autoincrement_multiple') + local sql_spaces = { + 'sql_space', + 'sql_space_autoincrement', + 'sql_space_autoincrement_multiple', + } + for _, sql_space in ipairs(sql_spaces) do + local variants = { + sql_space, + sql_space:upper(), + } + + for _, variant in ipairs(variants) do + if box.space[variant] ~= nil then + box.execute('DELETE FROM ' .. variant) + end + end end _truncate(box.space.tester_ext_dec) diff --git a/tests/test_common.py b/tests/test_common.py index cf36666..e57deb5 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -123,7 +123,9 @@ async def test__parse_numeric_map_keys(self): async def test__read_buffer_reallocate_ok(self): await self.tnt_reconnect(initial_read_buffer_size=1) - p, cmp = get_complex_param(encoding=self.conn.encoding) + p, cmp = get_complex_param( + encoding=self.conn.encoding, replace_bin=self.conn.version < (3, 0) + ) try: res = await self.conn.call("func_param", [p]) except Exception as e: diff --git a/tests/test_op_call.py b/tests/test_op_call.py index df7ed16..41e57be 100644 --- a/tests/test_op_call.py +++ b/tests/test_op_call.py @@ -79,12 +79,16 @@ async def test__call_args_tuple(self): self.fail(e) async def test__call_complex_param(self): - p, cmp = get_complex_param(encoding=self.conn.encoding) + p, cmp = get_complex_param( + encoding=self.conn.encoding, replace_bin=self.conn.version < (3, 0) + ) res = await self.conn.call("func_param", [p]) self.assertDictEqual(res[0][0], cmp, "Body ok") async def test__call_complex_param_bare(self): - p, cmp = get_complex_param(encoding=self.conn.encoding) + p, cmp = get_complex_param( + encoding=self.conn.encoding, replace_bin=self.conn.version < (3, 0) + ) cmp = [cmp] res = await self.conn.call("func_param_bare", [p]) if not self.has_new_call(): @@ -177,12 +181,16 @@ async def test__call16_args_tuple(self): self.fail(e) async def test__call16_complex_param(self): - p, cmp = get_complex_param(encoding=self.conn.encoding) + p, cmp = get_complex_param( + encoding=self.conn.encoding, replace_bin=self.conn.version < (3, 0) + ) res = await self.conn.call("func_param", [p]) self.assertDictEqual(res[0][0], cmp, "Body ok") async def test__call16_complex_param_bare(self): - p, cmp = get_complex_param(encoding=self.conn.encoding) + p, cmp = get_complex_param( + encoding=self.conn.encoding, replace_bin=self.conn.version < (3, 0) + ) res = await self.conn.call16("func_param_bare", [p]) self.assertDictEqual(res[0][0], cmp, "Body ok") diff --git a/tests/test_op_eval.py b/tests/test_op_eval.py index 868eff5..48fd72b 100644 --- a/tests/test_op_eval.py +++ b/tests/test_op_eval.py @@ -64,7 +64,9 @@ async def test__eval_args_tuple(self): self.fail(e) async def test__eval_complex_param(self): - p, cmp = get_complex_param(encoding=self.conn.encoding) + p, cmp = get_complex_param( + encoding=self.conn.encoding, replace_bin=self.conn.version < (3, 0) + ) res = await self.conn.eval("return {...}", [p]) self.assertDictEqual(res[0][0], cmp, "Body ok") diff --git a/tests/test_op_sql_execute.py b/tests/test_op_sql_execute.py index 5479eb8..574e7a6 100644 --- a/tests/test_op_sql_execute.py +++ b/tests/test_op_sql_execute.py @@ -1,9 +1,15 @@ +import asynctnt from asynctnt import Response from tests import BaseTarantoolTestCase from tests._testbase import ensure_version class SQLExecuteTestCase(BaseTarantoolTestCase): + def _compat_field_name(self, field_name: str) -> str: + if self.conn.version >= (3, 0): + return field_name + return field_name.upper() + @ensure_version(min=(2, 0)) async def test__sql_basic(self): res = await self.conn.execute("select 1, 2") @@ -23,7 +29,16 @@ async def test__sql_with_param(self): async def test__sql_with_param_cols(self): res = await self.conn.execute("select 1 as a, 2 as b where 1 = ?", [1]) - self.assertResponseEqualKV(res, [{"A": 1, "B": 2}], "Body ok") + self.assertResponseEqualKV( + res, + [ + { + self._compat_field_name("a"): 1, + self._compat_field_name("b"): 2, + } + ], + "Body ok", + ) @ensure_version(min=(2, 0)) async def test__sql_with_param_cols2(self): @@ -31,7 +46,16 @@ async def test__sql_with_param_cols2(self): "select 1 as a, 2 as b where 1 = ? and 2 = ?", [1, 2] ) - self.assertResponseEqualKV(res, [{"A": 1, "B": 2}], "Body ok") + self.assertResponseEqualKV( + res, + [ + { + self._compat_field_name("a"): 1, + self._compat_field_name("b"): 2, + } + ], + "Body ok", + ) @ensure_version(min=(2, 0)) async def test__sql_with_param_cols_maps(self): @@ -43,16 +67,34 @@ async def test__sql_with_param_cols_maps(self): ], ) - self.assertResponseEqualKV(res, [{"A": 1, "B": 2}], "Body ok") + self.assertResponseEqualKV( + res, + [ + { + self._compat_field_name("a"): 1, + self._compat_field_name("b"): 2, + } + ], + "Body ok", + ) @ensure_version(min=(2, 0)) async def test__sql_with_param_cols_maps_and_positional(self): res = await self.conn.execute( - "select 1 as a, 2 as b " "where 1 = :p1 and 2 = :p2 and 3 = ? and 4 = ?", + "select 1 as a, 2 as b where 1 = :p1 and 2 = :p2 and 3 = ? and 4 = ?", [{":p1": 1}, {":p2": 2}, 3, 4], ) - self.assertResponseEqualKV(res, [{"A": 1, "B": 2}], "Body ok") + self.assertResponseEqualKV( + res, + [ + { + self._compat_field_name("a"): 1, + self._compat_field_name("b"): 2, + } + ], + "Body ok", + ) @ensure_version(min=(2, 0)) async def test__sql_insert(self): @@ -111,24 +153,30 @@ async def test__sql_update_multiple(self): @ensure_version(min=(2, 0)) async def test__sql_delete(self): + await self._compat(self.conn) + await self.conn.execute("insert into sql_space values (1, 'one')") res = await self.conn.execute("delete from sql_space where name = 'one'") self.assertEqual(1, res.rowcount, "rowcount ok") @ensure_version(min=(2, 0)) async def test__sql_select(self): + await self._compat(self.conn) + await self.conn.execute("insert into sql_space values (1, 'one')") await self.conn.execute("insert into sql_space values (2, 'two')") res = await self.conn.execute("select * from sql_space") self.assertEqual(2, res.rowcount, "rowcount is surely ok") - self.assertEqual(1, res.body[0]["ID"]) - self.assertEqual("one", res.body[0]["NAME"]) - self.assertEqual(2, res.body[1]["ID"]) - self.assertEqual("two", res.body[1]["NAME"]) + self.assertEqual(1, res.body[0][self._compat_field_name("id")]) + self.assertEqual("one", res.body[0][self._compat_field_name("name")]) + self.assertEqual(2, res.body[1][self._compat_field_name("id")]) + self.assertEqual("two", res.body[1][self._compat_field_name("name")]) @ensure_version(min=(2, 0)) async def test__sql_delete_multiple(self): + await self._compat(self.conn) + await self.conn.execute("insert into sql_space values (1, 'one')") await self.conn.execute("insert into sql_space values (2, 'two')") @@ -152,31 +200,35 @@ async def test__metadata(self): @ensure_version(min=(2, 0)) async def test__metadata_names(self): + await self._compat(self.conn) + res = await self.conn.execute("select 1 as a, 2 as b") self.assertIsNotNone(res.metadata) self.assertIsNotNone(res.metadata.fields) self.assertEqual(2, len(res.metadata.fields)) - self.assertEqual("A", res.metadata.fields[0].name) + self.assertEqual(self._compat_field_name("a"), res.metadata.fields[0].name) self.assertEqual("integer", res.metadata.fields[0].type) - self.assertEqual("B", res.metadata.fields[1].name) + self.assertEqual(self._compat_field_name("b"), res.metadata.fields[1].name) self.assertEqual("integer", res.metadata.fields[1].type) @ensure_version(min=(2, 0)) async def test__metadata_actual_space(self): + await self._compat(self.conn) + await self.conn.execute("insert into sql_space values (1, 'one')") await self.conn.execute("insert into sql_space values (2, 'two')") res = await self.conn.execute("select * from sql_space") self.assertEqual(2, res.rowcount, "rowcount is ok") self.assertEqual(2, len(res.metadata.fields)) - self.assertEqual("ID", res.metadata.fields[0].name) + self.assertEqual(self._compat_field_name("id"), res.metadata.fields[0].name) self.assertEqual("integer", res.metadata.fields[0].type) self.assertIsNone(res.metadata.fields[0].is_nullable) self.assertIsNone(res.metadata.fields[0].is_autoincrement) self.assertIsNone(res.metadata.fields[0].collation) - self.assertEqual("NAME", res.metadata.fields[1].name) + self.assertEqual(self._compat_field_name("name"), res.metadata.fields[1].name) self.assertEqual("string", res.metadata.fields[1].type) self.assertIsNone(res.metadata.fields[1].is_nullable) self.assertIsNone(res.metadata.fields[1].is_autoincrement) @@ -184,6 +236,8 @@ async def test__metadata_actual_space(self): @ensure_version(min=(2, 0)) async def test__sql_select_full_metadata(self): + await self._compat(self.conn) + await self.conn.execute("insert into sql_space values (1, 'one')") await self.conn.execute("insert into sql_space values (2, 'two')") @@ -194,13 +248,15 @@ async def test__sql_select_full_metadata(self): try: res = await self.conn.execute("select * from sql_space") self.assertEqual(2, len(res.metadata.fields)) - self.assertEqual("ID", res.metadata.fields[0].name) + self.assertEqual(self._compat_field_name("id"), res.metadata.fields[0].name) self.assertEqual("integer", res.metadata.fields[0].type) self.assertEqual(False, res.metadata.fields[0].is_nullable) self.assertEqual(None, res.metadata.fields[1].is_autoincrement) self.assertIsNone(res.metadata.fields[0].collation) - self.assertEqual("NAME", res.metadata.fields[1].name) + self.assertEqual( + self._compat_field_name("name"), res.metadata.fields[1].name + ) self.assertEqual("string", res.metadata.fields[1].type) self.assertEqual(True, res.metadata.fields[1].is_nullable) self.assertEqual(None, res.metadata.fields[1].is_autoincrement) @@ -209,3 +265,7 @@ async def test__sql_select_full_metadata(self): await self.conn.update( "_session_settings", ["sql_full_metadata"], [("=", "value", False)] ) + + async def _compat(self, conn: asynctnt.Connection): + if conn.version >= (2, 11): + await conn.execute('SET SESSION "sql_seq_scan" = true;') diff --git a/tests/test_op_update.py b/tests/test_op_update.py index 6d4bf82..ad71a7b 100644 --- a/tests/test_op_update.py +++ b/tests/test_op_update.py @@ -1,6 +1,9 @@ +from decimal import Decimal + from asynctnt import Response from asynctnt.exceptions import ErrorCode, TarantoolDatabaseError, TarantoolSchemaError from tests import BaseTarantoolTestCase +from tests._testbase import ensure_version class UpdateTestCase(BaseTarantoolTestCase): @@ -9,6 +12,20 @@ async def _fill_data(self): [0, "a", 1, 5, "data1"], [1, "b", 8, 6, "data2"], [2, "c", 10, 12, "data3", "extra_field"], + [3, "d", 14, 16, "data4", Decimal("12.3"), 12.5], + [ + 4, + "e", + 18, + 20, + { + "tree1": { + "tree11": { + "key1": "value1", + } + } + }, + ], ] for t in data: await self.conn.insert(self.TESTER_SPACE_ID, t) @@ -26,6 +43,26 @@ async def test__update_one_assign(self): data[1][2] = 2 self.assertResponseEqual(res, [data[1]], "Body ok") + @ensure_version(min=(2, 3)) + async def test__update_one_assign_by_field_name_with_no_schema(self): + data = await self._fill_data() + + await self.tnt_reconnect(fetch_schema=False) + + res = await self.conn.update(self.TESTER_SPACE_ID, [4], [["=", "f4", 100]]) + data[4][3] = 100 + self.assertResponseEqual(res, [data[4]], "Body ok") + + @ensure_version(min=(2, 3)) + async def test__update_one_assign_by_json(self): + data = await self._fill_data() + + res = await self.conn.update( + self.TESTER_SPACE_ID, [4], [["=", "f5.tree1.tree11.key1", "value2"]] + ) + data[4][4]["tree1"]["tree11"]["key1"] = "value2" + self.assertResponseEqual(res, [data[4]], "Body ok") + async def test__update_one_insert(self): data = await self._fill_data() @@ -47,6 +84,24 @@ async def test__update_one_plus(self): data[1][2] += 3 self.assertResponseEqual(res, [data[1]], "Body ok") + @ensure_version(min=(2, 3)) + async def test__update_one_plus_decimal(self): + data = await self._fill_data() + + add = Decimal("1.1") + res = await self.conn.update(self.TESTER_SPACE_ID, [3], [["+", 5, add]]) + data[3][5] += add + self.assertResponseEqual(res, [data[3]], "Body ok") + + @ensure_version(min=(2, 3)) + async def test__update_one_plus_float(self): + data = await self._fill_data() + + add = 1.5 + res = await self.conn.update(self.TESTER_SPACE_ID, [3], [["+", 6, add]]) + data[3][6] += add + self.assertResponseEqual(res, [data[3]], "Body ok") + async def test__update_one_plus_str_field(self): data = await self._fill_data() @@ -54,10 +109,13 @@ async def test__update_one_plus_str_field(self): data[1][2] += 3 self.assertResponseEqual(res, [data[1]], "Body ok") + @ensure_version(min=(2, 3)) async def test__update_one_plus_str_field_unknown(self): await self._fill_data() - with self.assertRaisesRegex(KeyError, "Field 'f10' not found"): + with self.assertRaisesRegex( + TarantoolDatabaseError, "Field 'f10' was not found in the tuple" + ): await self.conn.update(self.TESTER_SPACE_ID, [1], [["+", "f10", 3]]) async def test__update_one_plus_negative(self): @@ -138,18 +196,16 @@ async def test__update_one_bxor(self): data[1][2] ^= 0 self.assertResponseEqual(res, [data[1]], "Body ok") + @ensure_version(min=(2, 3)) async def test__update_operations_not_int_without_schema(self): await self.tnt_reconnect(fetch_schema=False) data = [1, "hello2", 1, 4, "what is up"] await self.conn.insert(self.TESTER_SPACE_ID, data) - msg = ( - "Operation field_no must be int as there is " - "no format declaration in space {}".format(self.TESTER_SPACE_ID) - ) - with self.assertRaisesRegex(TypeError, msg): - await self.conn.update(self.TESTER_SPACE_ID, [1], [["+", "f3", 1]]) + res = await self.conn.update(self.TESTER_SPACE_ID, [1], [["+", "f3", 1]]) + data[2] += 1 + self.assertResponseEqual(res, [data], "Body ok") async def test__update_splice(self): data = [1, "hello2", 1, 4, "what is up"] @@ -179,12 +235,12 @@ async def test__update_splice_wrong_args(self): await self.conn.update(self.TESTER_SPACE_ID, [1], [[":", 2]]) with self.assertRaisesRegex( - IndexError, r"Splice operation must have length of 5" + ValueError, r"Splice operation must have length of 5" ): await self.conn.update(self.TESTER_SPACE_ID, [1], [[":", 2, 1]]) with self.assertRaisesRegex( - IndexError, r"Splice operation must have length of 5" + ValueError, r"Splice operation must have length of 5" ): await self.conn.update(self.TESTER_SPACE_ID, [1], [[":", 2, 1, 3]]) @@ -214,10 +270,12 @@ async def test__update_splice_wrong_args(self): ): await self.conn.update(self.TESTER_SPACE_ID, [1], [{}]) - with self.assertRaisesRegex( - TypeError, r"int argument required for " r"Arithmetic and Delete operations" - ): + with self.assertRaises(TarantoolDatabaseError) as exc: await self.conn.update(self.TESTER_SPACE_ID, [1], [("+", 2, {})]) + self.assertRegex( + exc.exception.message, + r"Argument type in operation '\+' on field \d does not match field type: expected a number", + ) async def test__update_multiple_operations(self): t = [1, "1", 1, 5, "hello", 3, 4, 8]