diff --git a/doc/build/changelog/unreleased_13/4798.rst b/doc/build/changelog/unreleased_13/4798.rst new file mode 100644 index 00000000000..6d638117bd1 --- /dev/null +++ b/doc/build/changelog/unreleased_13/4798.rst @@ -0,0 +1,12 @@ +.. change:: + :tags: bug, sqlite + :tickets: 4798 + + The dialects that support json are supposed to take arguments + ``json_serializer`` and ``json_deserializer`` at the create_engine() level, + however the SQLite dialect calls them ``_json_serilizer`` and + ``_json_deserilalizer``. The names have been corrected, the old names are + accepted with a change warning, and these parameters are now documented as + :paramref:`.create_engine.json_serializer` and + :paramref:`.create_engine.json_deserializer`. + diff --git a/lib/sqlalchemy/dialects/sqlite/base.py b/lib/sqlalchemy/dialects/sqlite/base.py index ef8507d0522..78ce18ac641 100644 --- a/lib/sqlalchemy/dialects/sqlite/base.py +++ b/lib/sqlalchemy/dialects/sqlite/base.py @@ -1426,18 +1426,39 @@ class SQLiteDialect(default.DefaultDialect): _broken_fk_pragma_quotes = False _broken_dotted_colnames = False + @util.deprecated_params( + _json_serializer=( + "1.3.7", + "The _json_serializer argument to the SQLite dialect has " + "been renamed to the correct name of json_serializer. The old " + "argument name will be removed in a future release.", + ), + _json_deserializer=( + "1.3.7", + "The _json_deserializer argument to the SQLite dialect has " + "been renamed to the correct name of json_deserializer. The old " + "argument name will be removed in a future release.", + ), + ) def __init__( self, isolation_level=None, native_datetime=False, + json_serializer=None, + json_deserializer=None, _json_serializer=None, _json_deserializer=None, **kwargs ): default.DefaultDialect.__init__(self, **kwargs) self.isolation_level = isolation_level - self._json_serializer = _json_serializer - self._json_deserializer = _json_deserializer + + if _json_serializer: + json_serializer = _json_serializer + if _json_deserializer: + json_deserializer = _json_deserializer + self._json_serializer = json_serializer + self._json_deserializer = json_deserializer # this flag used by pysqlite dialect, and perhaps others in the # future, to indicate the driver is handling date/timestamp diff --git a/lib/sqlalchemy/engine/create.py b/lib/sqlalchemy/engine/create.py index 035953e9908..cc830413195 100644 --- a/lib/sqlalchemy/engine/create.py +++ b/lib/sqlalchemy/engine/create.py @@ -234,6 +234,22 @@ def create_engine(url, **kwargs): :ref:`session_transaction_isolation` - for the ORM + :param json_deserializer: for dialects that support the :class:`.JSON` + datatype, this is a Python callable that will convert a JSON string + to a Python object. By default, the Python ``json.loads`` function is + used. + + .. versionchanged:: 1.3.7 The SQLite dialect renamed this from + ``_json_deserializer``. + + :param json_serializer: for dialects that support the :class:`.JSON` + datatype, this is a Python callable that will render a given object + as JSON. By default, the Python ``json.dumps`` function is used. + + .. versionchanged:: 1.3.7 The SQLite dialect renamed this from + ``_json_serializer``. + + :param label_length=None: optional integer value which limits the size of dynamically generated column labels to that many characters. If less than 6, labels are generated as diff --git a/lib/sqlalchemy/sql/sqltypes.py b/lib/sqlalchemy/sql/sqltypes.py index 38731fcbe54..631352ceb79 100644 --- a/lib/sqlalchemy/sql/sqltypes.py +++ b/lib/sqlalchemy/sql/sqltypes.py @@ -2042,6 +2042,26 @@ class JSON(Indexable, TypeEngine): values, but care must be taken as to the value of the :paramref:`.JSON.none_as_null` in these cases. + The JSON serializer and deserializer used by :class:`.JSON` defaults to + Python's ``json.dumps`` and ``json.loads`` functions; in the case of the + psycopg2 dialect, psycopg2 may be using its own custom loader function. + + In order to affect the serializer / deserializer, they are currently + configurable at the :func:`.create_engine` level via the + :paramref:`.create_engine.json_serializer` and + :paramref:`.create_engine.json_deserializer` parameters. For example, + to turn off ``ensure_ascii``:: + + engine = create_engine( + "sqlite://", + json_serializer=lambda obj: json.dumps(obj, ensure_ascii=False)) + + .. versionchanged:: 1.3.7 + + SQLite dialect's ``json_serializer`` and ``json_deserializer`` + parameters renamed from ``_json_serializer`` and + ``_json_deserializer``. + .. seealso:: :class:`.postgresql.JSON` diff --git a/lib/sqlalchemy/testing/suite/test_types.py b/lib/sqlalchemy/testing/suite/test_types.py index 1e02c0e7424..3320dd93c7f 100644 --- a/lib/sqlalchemy/testing/suite/test_types.py +++ b/lib/sqlalchemy/testing/suite/test_types.py @@ -2,8 +2,12 @@ import datetime import decimal +import json + +import mock from .. import config +from .. import engines from .. import fixtures from ..assertions import eq_ from ..config import requirements @@ -727,6 +731,28 @@ def _test_round_trip(self, data_element): eq_(row, (data_element,)) + def test_round_trip_custom_json(self): + data_table = self.tables.data_table + data_element = self.data1 + + js = mock.Mock(side_effect=json.dumps) + jd = mock.Mock(side_effect=json.loads) + engine = engines.testing_engine( + options=dict(json_serializer=js, json_deserializer=jd) + ) + + # support sqlite :memory: database... + data_table.create(engine, checkfirst=True) + engine.execute( + data_table.insert(), {"name": "row1", "data": data_element} + ) + + row = engine.execute(select([data_table.c.data])).first() + + eq_(row, (data_element,)) + eq_(js.mock_calls, [mock.call(data_element)]) + eq_(jd.mock_calls, [mock.call(json.dumps(data_element))]) + def test_round_trip_none_as_sql_null(self): col = self.tables.data_table.c["nulldata"] diff --git a/test/dialect/test_sqlite.py b/test/dialect/test_sqlite.py index e727005103d..1e894da5566 100644 --- a/test/dialect/test_sqlite.py +++ b/test/dialect/test_sqlite.py @@ -2,6 +2,7 @@ """SQLite-specific tests.""" import datetime +import json import os from sqlalchemy import and_ @@ -318,6 +319,35 @@ def test_extract_subobject(self): conn.scalar(select([sqlite_json.c.foo["json"]])), value["json"] ) + @testing.provide_metadata + def test_deprecated_serializer_args(self): + sqlite_json = Table( + "json_test", self.metadata, Column("foo", sqlite.JSON) + ) + data_element = {"foo": "bar"} + + js = mock.Mock(side_effect=json.dumps) + jd = mock.Mock(side_effect=json.loads) + + with testing.expect_deprecated( + "The _json_deserializer argument to the SQLite " + "dialect has been renamed", + "The _json_serializer argument to the SQLite " + "dialect has been renamed", + ): + engine = engines.testing_engine( + options=dict(_json_serializer=js, _json_deserializer=jd) + ) + self.metadata.create_all(engine) + + engine.execute(sqlite_json.insert(), {"foo": data_element}) + + row = engine.execute(select([sqlite_json.c.foo])).first() + + eq_(row, (data_element,)) + eq_(js.mock_calls, [mock.call(data_element)]) + eq_(jd.mock_calls, [mock.call(json.dumps(data_element))]) + class DateTimeTest(fixtures.TestBase, AssertsCompiledSQL): def test_time_microseconds(self):