]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Sqlite json
authorIlja Everilä <ilja.everila@siili.com>
Thu, 15 Mar 2018 14:18:13 +0000 (10:18 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Tue, 10 Jul 2018 22:55:31 +0000 (18:55 -0400)
Added support for SQLite's json functionality via the new
SQLite implementation for :class:`.sqltypes.JSON`, :class:`.sqlite.JSON`.
The name used for the type is ``JSON``, following an example found at
SQLite's own documentation. Pull request courtesy Ilja Everilä.

Fixes: #3850
Change-Id: I3d2714fb8655343a99d13dc751b16b93d05d7dda
Pull-request: https://github.com/zzzeek/sqlalchemy/pull/434

doc/build/changelog/migration_13.rst
doc/build/changelog/unreleased_13/3850.rst [new file with mode: 0644]
doc/build/dialects/sqlite.rst
lib/sqlalchemy/dialects/sqlite/__init__.py
lib/sqlalchemy/dialects/sqlite/base.py
lib/sqlalchemy/dialects/sqlite/json.py [new file with mode: 0644]
lib/sqlalchemy/sql/sqltypes.py
test/dialect/test_sqlite.py
test/requirements.py

index 980d12619afd9137d3bcb7e0a46f73612769aba7..86187a2f4e8460f9119f737b2730acd3d926234b 100644 (file)
@@ -174,6 +174,27 @@ This is a much more lightweight ping than the previous method of emitting
 Dialect Improvements and Changes - SQLite
 =============================================
 
+.. _change_3850:
+
+Support for SQLite JSON Added
+-----------------------------
+
+A new datatype :class:`.sqlite.JSON` is added which implements SQLite's json
+member access functions on behalf of the :class:`.types.JSON`
+base datatype.  The SQLite ``JSON_EXTRACT`` and ``JSON_QUOTE`` functions
+are used by the implementation to provide basic JSON support.
+
+Note that the name of the datatype itself as rendered in the database is
+the name "JSON".   This will create a SQLite datatype with "numeric" affinity,
+which normally should not be an issue except in the case of a JSON value that
+consists of single integer value.  Nevertheless, following an example
+in SQLite's own documentation at https://www.sqlite.org/json1.html the name
+JSON is being used for its familiarity.
+
+
+:ticket:`3850`
+
+
 Dialect Improvements and Changes - Oracle
 =============================================
 
diff --git a/doc/build/changelog/unreleased_13/3850.rst b/doc/build/changelog/unreleased_13/3850.rst
new file mode 100644 (file)
index 0000000..138414d
--- /dev/null
@@ -0,0 +1,12 @@
+.. change::
+    :tags: feature, sqlite
+    :tickets: 3850
+
+    Added support for SQLite's json functionality via the new
+    SQLite implementation for :class:`.types.JSON`, :class:`.sqlite.JSON`.
+    The name used for the type is ``JSON``, following an example found at
+    SQLite's own documentation. Pull request courtesy Ilja Everilä.
+
+    .. seealso::
+
+        :ref:`change_3850`
index 936ae253f17fee9b483d3cc461db88ea24d8b046..85a4bab4c9bdadda6be7f3b2cf1dd0df68339ede 100644 (file)
@@ -14,7 +14,7 @@ they originate from :mod:`sqlalchemy.types` or from the local dialect::
 
     from sqlalchemy.dialects.sqlite import \
                 BLOB, BOOLEAN, CHAR, DATE, DATETIME, DECIMAL, FLOAT, \
-                INTEGER, NUMERIC, SMALLINT, TEXT, TIME, TIMESTAMP, \
+                INTEGER, NUMERIC, JSON, SMALLINT, TEXT, TIME, TIMESTAMP, \
                 VARCHAR
 
 .. module:: sqlalchemy.dialects.sqlite
@@ -23,6 +23,8 @@ they originate from :mod:`sqlalchemy.types` or from the local dialect::
 
 .. autoclass:: DATE
 
+.. autoclass:: JSON
+
 .. autoclass:: TIME
 
 Pysqlite
index cb5337adb9fb6cedbefea13fdbf5d1b0df106fd8..a735815213b1d04cc58848fa51382067faf2a2ce 100644 (file)
@@ -8,7 +8,7 @@
 from . import base, pysqlite, pysqlcipher  # noqa
 
 from sqlalchemy.dialects.sqlite.base import (
-    BLOB, BOOLEAN, CHAR, DATE, DATETIME, DECIMAL, FLOAT, INTEGER, REAL,
+    BLOB, BOOLEAN, CHAR, DATE, DATETIME, DECIMAL, FLOAT, INTEGER, JSON, REAL,
     NUMERIC, SMALLINT, TEXT, TIME, TIMESTAMP, VARCHAR
 )
 
@@ -17,5 +17,5 @@ base.dialect = dialect = pysqlite.dialect
 
 
 __all__ = ('BLOB', 'BOOLEAN', 'CHAR', 'DATE', 'DATETIME', 'DECIMAL',
-           'FLOAT', 'INTEGER', 'NUMERIC', 'SMALLINT', 'TEXT', 'TIME',
+           'FLOAT', 'INTEGER', 'JSON', 'NUMERIC', 'SMALLINT', 'TEXT', 'TIME',
            'TIMESTAMP', 'VARCHAR', 'REAL', 'dialect')
index 5117025fb0bf2e78768794e9ea09aa11670b54ab..c6932be8f3fb6b731bea002b8dee5969e9a79e36 100644 (file)
@@ -478,6 +478,7 @@ from ...sql import compiler
 from ...types import (BLOB, BOOLEAN, CHAR, DECIMAL, FLOAT,
                       INTEGER, REAL, NUMERIC, SMALLINT, TEXT,
                       TIMESTAMP, VARCHAR)
+from .json import JSON, JSONIndexType, JSONPathType
 
 
 class _DateTimeMixin(object):
@@ -753,6 +754,9 @@ class TIME(_DateTimeMixin, sqltypes.Time):
 colspecs = {
     sqltypes.Date: DATE,
     sqltypes.DateTime: DATETIME,
+    sqltypes.JSON: JSON,
+    sqltypes.JSON.JSONIndexType: JSONIndexType,
+    sqltypes.JSON.JSONPathType: JSONPathType,
     sqltypes.Time: TIME,
 }
 
@@ -771,6 +775,7 @@ ischema_names = {
     'FLOAT': sqltypes.FLOAT,
     'INT': sqltypes.INTEGER,
     'INTEGER': sqltypes.INTEGER,
+    'JSON': JSON,
     'NUMERIC': sqltypes.NUMERIC,
     'REAL': sqltypes.REAL,
     'SMALLINT': sqltypes.SMALLINT,
@@ -855,6 +860,16 @@ class SQLiteCompiler(compiler.SQLCompiler):
         return "%s IS %s" % (self.process(binary.left),
                              self.process(binary.right))
 
+    def visit_json_getitem_op_binary(self, binary, operator, **kw):
+        return "JSON_QUOTE(JSON_EXTRACT(%s, %s))" % (
+            self.process(binary.left, **kw),
+            self.process(binary.right, **kw))
+
+    def visit_json_path_getitem_op_binary(self, binary, operator, **kw):
+        return "JSON_QUOTE(JSON_EXTRACT(%s, %s))" % (
+            self.process(binary.left, **kw),
+            self.process(binary.right, **kw))
+
 
 class SQLiteDDLCompiler(compiler.DDLCompiler):
 
@@ -973,6 +988,12 @@ class SQLiteTypeCompiler(compiler.GenericTypeCompiler):
         else:
             return "TIME_CHAR"
 
+    def visit_JSON(self, type_, **kw):
+        # note this name provides NUMERIC affinity, not TEXT.
+        # should not be an issue unless the JSON value consists of a single
+        # numeric value.   JSONTEXT can be used if this case is required.
+        return "JSON"
+
 
 class SQLiteIdentifierPreparer(compiler.IdentifierPreparer):
     reserved_words = set([
@@ -1065,9 +1086,12 @@ class SQLiteDialect(default.DefaultDialect):
     _broken_fk_pragma_quotes = False
     _broken_dotted_colnames = False
 
-    def __init__(self, isolation_level=None, native_datetime=False, **kwargs):
+    def __init__(self, isolation_level=None, native_datetime=False,
+                 _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
 
         # 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/dialects/sqlite/json.py b/lib/sqlalchemy/dialects/sqlite/json.py
new file mode 100644 (file)
index 0000000..90929fb
--- /dev/null
@@ -0,0 +1,77 @@
+from ... import types as sqltypes
+
+
+class JSON(sqltypes.JSON):
+    """SQLite JSON type.
+
+    SQLite supports JSON as of version 3.9 through its JSON1_ extension. Note
+    that JSON1_ is a
+    `loadable extension <https://www.sqlite.org/loadext.html>`_ and as such
+    may not be available, or may require run-time loading.
+
+    The :class:`.sqlite.JSON` type supports persistence of JSON values
+    as well as the core index operations provided by :class:`.types.JSON`
+    datatype, by adapting the operations to render the ``JSON_EXTRACT``
+    function wrapped in the ``JSON_QUOTE`` function at the database level.
+    Extracted values are quoted in order to ensure that the results are
+    always JSON string values.
+
+    .. versionadded:: 1.3
+
+    .. seealso::
+
+        JSON1_
+
+    .. _JSON1: https://www.sqlite.org/json1.html
+
+    """
+
+
+# Note: these objects currently match exactly those of MySQL, however since
+# these are not generalizable to all JSON implementations, remain separately
+# implemented for each dialect.
+class _FormatTypeMixin(object):
+    def _format_value(self, value):
+        raise NotImplementedError()
+
+    def bind_processor(self, dialect):
+        super_proc = self.string_bind_processor(dialect)
+
+        def process(value):
+            value = self._format_value(value)
+            if super_proc:
+                value = super_proc(value)
+            return value
+
+        return process
+
+    def literal_processor(self, dialect):
+        super_proc = self.string_literal_processor(dialect)
+
+        def process(value):
+            value = self._format_value(value)
+            if super_proc:
+                value = super_proc(value)
+            return value
+
+        return process
+
+
+class JSONIndexType(_FormatTypeMixin, sqltypes.JSON.JSONIndexType):
+
+    def _format_value(self, value):
+        if isinstance(value, int):
+            value = "$[%s]" % value
+        else:
+            value = '$."%s"' % value
+        return value
+
+
+class JSONPathType(_FormatTypeMixin, sqltypes.JSON.JSONPathType):
+    def _format_value(self, value):
+        return "$%s" % (
+            "".join([
+                "[%s]" % elem if isinstance(elem, int)
+                else '."%s"' % elem for elem in value
+            ])
+        )
index a2ae9de5023117868bd546054a96f4f68350ca2a..08af78606a5acaca6a6b8cf96a132e71c662a42e 100644 (file)
@@ -1834,8 +1834,13 @@ class JSON(Indexable, TypeEngine):
 
     .. note::  :class:`.types.JSON` is provided as a facade for vendor-specific
        JSON types.  Since it supports JSON SQL operations, it only
-       works on backends that have an actual JSON type, currently
-       PostgreSQL as well as certain versions of MySQL.
+       works on backends that have an actual JSON type, currently:
+
+       * PostgreSQL
+
+       * MySQL as of version 5.7 (MariaDB as of the 10.2 series does not)
+
+       * SQLite as of version 3.9
 
     :class:`.types.JSON` is part of the Core in support of the growing
     popularity of native JSON datatypes.
index d2d563208e77f9c4937c9f0f4b74eecaddfb681f..5e2535b30c4a2e6439f81fa04ec4f38fd0041b8e 100644 (file)
@@ -219,6 +219,68 @@ class TestTypes(fixtures.TestBase, AssertsExecutionResults):
                 isinstance(bindproc(util.u('some string')), util.text_type)
 
 
+class JSONTest(fixtures.TestBase):
+
+    __requires__ = ('json_type', )
+    __only_on__ = 'sqlite'
+
+    @testing.provide_metadata
+    @testing.requires.reflects_json_type
+    def test_reflection(self):
+        Table(
+            'json_test', self.metadata,
+            Column('foo', sqlite.JSON)
+        )
+        self.metadata.create_all()
+
+        reflected = Table('json_test', MetaData(), autoload_with=testing.db)
+        is_(reflected.c.foo.type._type_affinity, sqltypes.JSON)
+        assert isinstance(reflected.c.foo.type, sqlite.JSON)
+
+    @testing.provide_metadata
+    def test_rudimentary_roundtrip(self):
+        sqlite_json = Table(
+            'json_test', self.metadata,
+            Column('foo', sqlite.JSON)
+        )
+
+        self.metadata.create_all()
+
+        value = {
+            'json': {'foo': 'bar'},
+            'recs': ['one', 'two']
+        }
+
+        with testing.db.connect() as conn:
+            conn.execute(sqlite_json.insert(), foo=value)
+
+            eq_(
+                conn.scalar(select([sqlite_json.c.foo])),
+                value
+            )
+
+    @testing.provide_metadata
+    def test_extract_subobject(self):
+        sqlite_json = Table(
+            'json_test', self.metadata,
+            Column('foo', sqlite.JSON)
+        )
+
+        self.metadata.create_all()
+
+        value = {
+            'json': {'foo': 'bar'},
+        }
+
+        with testing.db.connect() as conn:
+            conn.execute(sqlite_json.insert(), foo=value)
+
+            eq_(
+                conn.scalar(select([sqlite_json.c.foo['json']])),
+                value['json']
+            )
+
+
 class DateTimeTest(fixtures.TestBase, AssertsCompiledSQL):
 
     def test_time_microseconds(self):
index 57324a3f40431c5c05d0dbd373e9d47efdd32d06..b11a6317fd45bcf142f1649b717e5a7c7f76a5c2 100644 (file)
@@ -729,7 +729,8 @@ class DefaultRequirements(SuiteRequirements):
                         (10, 2, 7)
                     )
                 ),
-            "postgresql >= 9.3"
+            "postgresql >= 9.3",
+            "sqlite >= 3.9"
         ])
 
     @property
@@ -737,7 +738,8 @@ class DefaultRequirements(SuiteRequirements):
         return only_on([
             lambda config: against(config, "mysql >= 5.7") and
             not config.db.dialect._is_mariadb,
-            "postgresql >= 9.3"
+            "postgresql >= 9.3",
+            "sqlite >= 3.9"
         ])
 
     @property