summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--doc/build/changelog/unreleased_13/4902.rst16
-rw-r--r--lib/sqlalchemy/engine/base.py11
-rw-r--r--lib/sqlalchemy/exc.py16
-rw-r--r--lib/sqlalchemy/sql/util.py20
-rw-r--r--lib/sqlalchemy/testing/__init__.py1
-rw-r--r--lib/sqlalchemy/testing/assertions.py14
-rw-r--r--test/base/test_except.py2
-rw-r--r--test/engine/test_logging.py134
8 files changed, 199 insertions, 15 deletions
diff --git a/doc/build/changelog/unreleased_13/4902.rst b/doc/build/changelog/unreleased_13/4902.rst
new file mode 100644
index 000000000..673077a32
--- /dev/null
+++ b/doc/build/changelog/unreleased_13/4902.rst
@@ -0,0 +1,16 @@
+.. change::
+ :tags: bug, engine
+ :tickets: 4902
+
+ Fixed bug where parameter repr as used in logging and error reporting needs
+ additional context in order to distinguish between a list of parameters for
+ a single statement and a list of parameter lists, as the "list of lists"
+ structure could also indicate a single parameter list where the first
+ parameter itself is a list, such as for an array parameter. The
+ engine/connection now passes in an additional boolean indicating how the
+ parameters should be considered. The only SQLAlchemy backend that expects
+ arrays as parameters is that of psycopg2 which uses pyformat parameters,
+ so this issue has not been too apparent, however as other drivers that use
+ positional gain more features it is important that this be supported. It
+ also eliminates the need for the parameter repr function to guess based on
+ the parameter structure passed.
diff --git a/lib/sqlalchemy/engine/base.py b/lib/sqlalchemy/engine/base.py
index 27da8c295..95e05be98 100644
--- a/lib/sqlalchemy/engine/base.py
+++ b/lib/sqlalchemy/engine/base.py
@@ -1223,7 +1223,10 @@ class Connection(Connectable):
self.engine.logger.info(statement)
if not self.engine.hide_parameters:
self.engine.logger.info(
- "%r", sql_util._repr_params(parameters, batches=10)
+ "%r",
+ sql_util._repr_params(
+ parameters, batches=10, ismulti=context.executemany
+ ),
)
else:
self.engine.logger.info(
@@ -1394,6 +1397,9 @@ class Connection(Connectable):
self.dialect.dbapi.Error,
hide_parameters=self.engine.hide_parameters,
dialect=self.dialect,
+ ismulti=context.executemany
+ if context is not None
+ else None,
),
exc_info,
)
@@ -1416,6 +1422,9 @@ class Connection(Connectable):
hide_parameters=self.engine.hide_parameters,
connection_invalidated=self._is_disconnect,
dialect=self.dialect,
+ ismulti=context.executemany
+ if context is not None
+ else None,
)
else:
sqlalchemy_exception = None
diff --git a/lib/sqlalchemy/exc.py b/lib/sqlalchemy/exc.py
index 1b3ac7ce2..79f786882 100644
--- a/lib/sqlalchemy/exc.py
+++ b/lib/sqlalchemy/exc.py
@@ -332,6 +332,8 @@ class StatementError(SQLAlchemyError):
orig = None
"""The DBAPI exception object."""
+ ismulti = None
+
def __init__(
self,
message,
@@ -340,11 +342,13 @@ class StatementError(SQLAlchemyError):
orig,
hide_parameters=False,
code=None,
+ ismulti=None,
):
SQLAlchemyError.__init__(self, message, code=code)
self.statement = statement
self.params = params
self.orig = orig
+ self.ismulti = ismulti
self.hide_parameters = hide_parameters
self.detail = []
@@ -360,6 +364,7 @@ class StatementError(SQLAlchemyError):
self.params,
self.orig,
self.hide_parameters,
+ self.ismulti,
),
)
@@ -381,7 +386,9 @@ class StatementError(SQLAlchemyError):
"[SQL parameters hidden due to hide_parameters=True]"
)
else:
- params_repr = util._repr_params(self.params, 10)
+ params_repr = util._repr_params(
+ self.params, 10, ismulti=self.ismulti
+ )
details.append("[parameters: %r]" % params_repr)
code_str = self._code_str()
if code_str:
@@ -424,6 +431,7 @@ class DBAPIError(StatementError):
hide_parameters=False,
connection_invalidated=False,
dialect=None,
+ ismulti=None,
):
# Don't ever wrap these, just return them directly as if
# DBAPIError didn't exist.
@@ -448,6 +456,7 @@ class DBAPIError(StatementError):
orig,
hide_parameters=hide_parameters,
code=orig.code,
+ ismulti=ismulti,
)
elif not isinstance(orig, dbapi_base_err) and statement:
return StatementError(
@@ -461,6 +470,7 @@ class DBAPIError(StatementError):
params,
orig,
hide_parameters=hide_parameters,
+ ismulti=ismulti,
)
glob = globals()
@@ -481,6 +491,7 @@ class DBAPIError(StatementError):
connection_invalidated=connection_invalidated,
hide_parameters=hide_parameters,
code=cls.code,
+ ismulti=ismulti,
)
def __reduce__(self):
@@ -492,6 +503,7 @@ class DBAPIError(StatementError):
self.orig,
self.hide_parameters,
self.connection_invalidated,
+ self.ismulti,
),
)
@@ -503,6 +515,7 @@ class DBAPIError(StatementError):
hide_parameters=False,
connection_invalidated=False,
code=None,
+ ismulti=None,
):
try:
text = str(orig)
@@ -517,6 +530,7 @@ class DBAPIError(StatementError):
orig,
hide_parameters,
code=code,
+ ismulti=ismulti,
)
self.connection_invalidated = connection_invalidated
diff --git a/lib/sqlalchemy/sql/util.py b/lib/sqlalchemy/sql/util.py
index 5aeed0c1c..e109852a2 100644
--- a/lib/sqlalchemy/sql/util.py
+++ b/lib/sqlalchemy/sql/util.py
@@ -464,31 +464,29 @@ class _repr_params(_repr_base):
"""
- __slots__ = "params", "batches"
+ __slots__ = "params", "batches", "ismulti"
- def __init__(self, params, batches, max_chars=300):
+ def __init__(self, params, batches, max_chars=300, ismulti=None):
self.params = params
+ self.ismulti = ismulti
self.batches = batches
self.max_chars = max_chars
def __repr__(self):
+ if self.ismulti is None:
+ return self.trunc(self.params)
+
if isinstance(self.params, list):
typ = self._LIST
- ismulti = self.params and isinstance(
- self.params[0], (list, dict, tuple)
- )
+
elif isinstance(self.params, tuple):
typ = self._TUPLE
- ismulti = self.params and isinstance(
- self.params[0], (list, dict, tuple)
- )
elif isinstance(self.params, dict):
typ = self._DICT
- ismulti = False
else:
return self.trunc(self.params)
- if ismulti and len(self.params) > self.batches:
+ if self.ismulti and len(self.params) > self.batches:
msg = " ... displaying %i of %i total bound parameter sets ... "
return " ".join(
(
@@ -499,7 +497,7 @@ class _repr_params(_repr_base):
self._repr_multi(self.params[-2:], typ)[1:],
)
)
- elif ismulti:
+ elif self.ismulti:
return self._repr_multi(self.params, typ)
else:
return self._repr_params(self.params, typ)
diff --git a/lib/sqlalchemy/testing/__init__.py b/lib/sqlalchemy/testing/__init__.py
index 090c8488a..2b8158fbb 100644
--- a/lib/sqlalchemy/testing/__init__.py
+++ b/lib/sqlalchemy/testing/__init__.py
@@ -10,6 +10,7 @@ from . import config # noqa
from . import mock # noqa
from .assertions import assert_raises # noqa
from .assertions import assert_raises_message # noqa
+from .assertions import assert_raises_return # noqa
from .assertions import AssertsCompiledSQL # noqa
from .assertions import AssertsExecutionResults # noqa
from .assertions import ComparesTables # noqa
diff --git a/lib/sqlalchemy/testing/assertions.py b/lib/sqlalchemy/testing/assertions.py
index 819fedcc7..f057ae37b 100644
--- a/lib/sqlalchemy/testing/assertions.py
+++ b/lib/sqlalchemy/testing/assertions.py
@@ -307,6 +307,20 @@ def assert_raises(except_cls, callable_, *args, **kw):
assert success, "Callable did not raise an exception"
+def assert_raises_return(except_cls, callable_, *args, **kw):
+ ret_err = None
+ try:
+ callable_(*args, **kw)
+ success = False
+ except except_cls as err:
+ success = True
+ ret_err = err
+
+ # assert outside the block so it works for AssertionError too !
+ assert success, "Callable did not raise an exception"
+ return ret_err
+
+
def assert_raises_message(except_cls, msg, callable_, *args, **kwargs):
try:
callable_(*args, **kwargs)
diff --git a/test/base/test_except.py b/test/base/test_except.py
index 7dcfbb1f0..be8c85a64 100644
--- a/test/base/test_except.py
+++ b/test/base/test_except.py
@@ -241,6 +241,7 @@ class WrapTest(fixtures.TestBase):
],
OperationalError(),
DatabaseError,
+ ismulti=True,
)
except sa_exceptions.DBAPIError as exc:
eq_(
@@ -288,6 +289,7 @@ class WrapTest(fixtures.TestBase):
],
OperationalError(),
DatabaseError,
+ ismulti=True,
)
except sa_exceptions.DBAPIError as exc:
eq_(
diff --git a/test/engine/test_logging.py b/test/engine/test_logging.py
index d55f4249a..14b49e5e8 100644
--- a/test/engine/test_logging.py
+++ b/test/engine/test_logging.py
@@ -9,7 +9,9 @@ from sqlalchemy import select
from sqlalchemy import String
from sqlalchemy import Table
from sqlalchemy import util
+from sqlalchemy.sql import util as sql_util
from sqlalchemy.testing import assert_raises_message
+from sqlalchemy.testing import assert_raises_return
from sqlalchemy.testing import engines
from sqlalchemy.testing import eq_
from sqlalchemy.testing import eq_regex
@@ -38,7 +40,7 @@ class LogParamsTest(fixtures.TestBase):
for log in [logging.getLogger("sqlalchemy.engine")]:
log.removeHandler(self.buf)
- def test_log_large_dict(self):
+ def test_log_large_list_of_dict(self):
self.eng.execute(
"INSERT INTO foo (data) values (:data)",
[{"data": str(i)} for i in range(100)],
@@ -51,6 +53,21 @@ class LogParamsTest(fixtures.TestBase):
"parameter sets ... {'data': '98'}, {'data': '99'}]",
)
+ def test_repr_params_large_list_of_dict(self):
+ eq_(
+ repr(
+ sql_util._repr_params(
+ [{"data": str(i)} for i in range(100)],
+ batches=10,
+ ismulti=True,
+ )
+ ),
+ "[{'data': '0'}, {'data': '1'}, {'data': '2'}, {'data': '3'}, "
+ "{'data': '4'}, {'data': '5'}, {'data': '6'}, {'data': '7'}"
+ " ... displaying 10 of 100 total bound "
+ "parameter sets ... {'data': '98'}, {'data': '99'}]",
+ )
+
def test_log_no_parameters(self):
self.no_param_engine.execute(
"INSERT INTO foo (data) values (:data)",
@@ -61,7 +78,7 @@ class LogParamsTest(fixtures.TestBase):
"[SQL parameters hidden due to hide_parameters=True]",
)
- def test_log_large_list(self):
+ def test_log_large_list_of_tuple(self):
self.eng.execute(
"INSERT INTO foo (data) values (?)",
[(str(i),) for i in range(100)],
@@ -73,6 +90,119 @@ class LogParamsTest(fixtures.TestBase):
"bound parameter sets ... ('98',), ('99',)]",
)
+ def test_log_positional_array(self):
+ with self.eng.connect() as conn:
+ exc_info = assert_raises_return(
+ tsa.exc.DBAPIError,
+ conn.execute,
+ tsa.text("SELECT * FROM foo WHERE id IN :foo AND bar=:bar"),
+ {"foo": [1, 2, 3], "bar": "hi"},
+ )
+
+ assert (
+ "[SQL: SELECT * FROM foo WHERE id IN ? AND bar=?]\n"
+ "[parameters: ([1, 2, 3], 'hi')]\n" in str(exc_info)
+ )
+
+ eq_(self.buf.buffer[1].message, "([1, 2, 3], 'hi')")
+
+ def test_repr_params_positional_array(self):
+ eq_(
+ repr(
+ sql_util._repr_params(
+ [[1, 2, 3], 5], batches=10, ismulti=False
+ )
+ ),
+ "[[1, 2, 3], 5]",
+ )
+
+ def test_repr_params_unknown_list(self):
+ # not known if given multiparams or not. repr params with
+ # straight truncation
+ eq_(
+ repr(
+ sql_util._repr_params(
+ [[i for i in range(300)], 5], batches=10, max_chars=80
+ )
+ ),
+ "[[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, ... "
+ "(1315 characters truncated) ... , 293, 294, 295, 296, "
+ "297, 298, 299], 5]",
+ )
+
+ def test_repr_params_positional_list(self):
+ # given non-multi-params in a list. repr params with
+ # per-element truncation, mostly does the exact same thing
+ eq_(
+ repr(
+ sql_util._repr_params(
+ [[i for i in range(300)], 5],
+ batches=10,
+ max_chars=80,
+ ismulti=False,
+ )
+ ),
+ "[[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 1 ... "
+ "(1310 characters truncated) ... "
+ "292, 293, 294, 295, 296, 297, 298, 299], 5]",
+ )
+
+ def test_repr_params_named_dict(self):
+ # given non-multi-params in a list. repr params with
+ # per-element truncation, mostly does the exact same thing
+ params = {"key_%s" % i: i for i in range(10)}
+ eq_(
+ repr(
+ sql_util._repr_params(
+ params, batches=10, max_chars=80, ismulti=False
+ )
+ ),
+ repr(params),
+ )
+
+ def test_repr_params_ismulti_named_dict(self):
+ # given non-multi-params in a list. repr params with
+ # per-element truncation, mostly does the exact same thing
+ param = {"key_%s" % i: i for i in range(10)}
+ eq_(
+ repr(
+ sql_util._repr_params(
+ [param for j in range(50)],
+ batches=5,
+ max_chars=80,
+ ismulti=True,
+ )
+ ),
+ "[%(param)r, %(param)r, %(param)r ... "
+ "displaying 5 of 50 total bound parameter sets ... "
+ "%(param)r, %(param)r]" % {"param": param},
+ )
+
+ def test_repr_params_ismulti_list(self):
+ # given multi-params in a list. repr params with
+ # per-element truncation, mostly does the exact same thing
+ eq_(
+ repr(
+ sql_util._repr_params(
+ [
+ [[i for i in range(300)], 5],
+ [[i for i in range(300)], 5],
+ [[i for i in range(300)], 5],
+ ],
+ batches=10,
+ max_chars=80,
+ ismulti=True,
+ )
+ ),
+ "[[[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 1 ... "
+ "(1310 characters truncated) ... 292, 293, 294, 295, 296, 297, "
+ "298, 299], 5], [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 1 ... "
+ "(1310 characters truncated) ... 292, 293, 294, 295, 296, 297, "
+ "298, 299], 5], [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 1 ... "
+ "(1310 characters truncated) ... 292, 293, 294, 295, 296, 297, "
+ "298, 299], 5]]",
+ )
+
def test_log_large_parameter_single(self):
import random