]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Ensure Variant passes along impl right-hand type
authorMike Bayer <mike_mp@zzzcomputing.com>
Tue, 22 Nov 2016 20:36:32 +0000 (15:36 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Tue, 22 Nov 2016 21:39:16 +0000 (16:39 -0500)
Fixed issue in :class:`.Variant` where the "right hand coercion" logic,
inherited from :class:`.TypeDecorator`, would
coerce the right-hand side into the :class:`.Variant` itself, rather than
what the default type for the :class:`.Variant` would do.   In the
case of :class:`.Variant`, we want the type to act mostly like the base
type so the default logic of :class:`.TypeDecorator` is now overridden
to fall back to the underlying wrapped type's logic.   Is mostly relevant
for JSON at the moment.

This patch additionally adds documentation and basic tests to allow
for backend-agnostic comparison of JSON index elements to other objects.
A future version should attempt to improve upon this by providing
"astext", "asint" types of operators.

Change-Id: I7b7b45d604a4ae8d1dc236a5a1248695aab5232e
Fixes: #3859
doc/build/changelog/changelog_11.rst
lib/sqlalchemy/sql/sqltypes.py
lib/sqlalchemy/sql/type_api.py
lib/sqlalchemy/testing/suite/test_types.py
test/sql/test_types.py

index 53c80eebad0141552bcd9ddbfea13ebe2dc66d36..5ab661fdad947c0edeaa4adb8b0e65cfe97a87dd 100644 (file)
 .. changelog::
     :version: 1.1.5
 
+    .. change:: 3859
+        :tags: bug, sql
+        :tickets: 3859
+
+        Fixed issue in :class:`.Variant` where the "right hand coercion" logic,
+        inherited from :class:`.TypeDecorator`, would
+        coerce the right-hand side into the :class:`.Variant` itself, rather than
+        what the default type for the :class:`.Variant` would do.   In the
+        case of :class:`.Variant`, we want the type to act mostly like the base
+        type so the default logic of :class:`.TypeDecorator` is now overridden
+        to fall back to the underlying wrapped type's logic.   Is mostly relevant
+        for JSON at the moment.
+
     .. change:: 3856
         :tags: bug, orm
         :tickets: 3856
index ef1624fa009e1d972fc987c50174a664b64c8210..215c09e605ed45729a5b40aa943f110c2326f794 100644 (file)
@@ -1728,7 +1728,55 @@ class JSON(Indexable, TypeEngine):
 
     Index operations return an expression object whose type defaults to
     :class:`.JSON` by default, so that further JSON-oriented instructions
-    may be called upon the result type.
+    may be called upon the result type.   Note that there are backend-specific
+    idiosyncracies here, including that the Postgresql database does not generally
+    compare a "json" to a "json" structure without type casts.  These idiosyncracies
+    can be accommodated in a backend-neutral way by by making explicit use
+    of the :func:`.cast` and :func:`.type_coerce` constructs.
+    Comparison of specific index elements of a :class:`.JSON` object
+    to other objects work best if the **left hand side is CAST to a string**
+    and the **right hand side is rendered as a json string**; a future SQLAlchemy
+    feature such as a generic "astext" modifier may simplify this at some point:
+
+    * **Compare an element of a JSON structure to a string**::
+
+        from sqlalchemy import cast, type_coerce
+        from sqlalchemy import String, JSON
+
+        cast(
+            data_table.c.data['some_key'], String
+        ) == '"some_value"'
+
+        cast(
+            data_table.c.data['some_key'], String
+        ) == type_coerce("some_value", JSON)
+
+    * **Compare an element of a JSON structure to an integer**::
+
+        from sqlalchemy import cast, type_coerce
+        from sqlalchemy import String, JSON
+
+        cast(data_table.c.data['some_key'], String) == '55'
+
+        cast(
+            data_table.c.data['some_key'], String
+        ) == type_coerce(55, JSON)
+
+    * **Compare an element of a JSON structure to some other JSON structure** - note
+      that Python dictionaries are typically not ordered so care should be taken
+      here to assert that the JSON structures are identical::
+
+        from sqlalchemy import cast, type_coerce
+        from sqlalchemy import String, JSON
+        import json
+
+        cast(
+            data_table.c.data['some_key'], String
+        ) == json.dumps({"foo": "bar"})
+
+        cast(
+            data_table.c.data['some_key'], String
+        ) == type_coerce({"foo": "bar"}, JSON)
 
     The :class:`.JSON` type, when used with the SQLAlchemy ORM, does not
     detect in-place mutations to the structure.  In order to detect these, the
index 98ede4e6642e59333a6c7152855f678671352036..bb9de20fc45f9a886fde15134ab8ada0d3183994 100644 (file)
@@ -1213,6 +1213,9 @@ class Variant(TypeDecorator):
         self.impl = base
         self.mapping = mapping
 
+    def coerce_compared_value(self, operator, value):
+        return self.impl.coerce_compared_value(operator, value)
+
     def load_dialect_impl(self, dialect):
         if dialect.name in self.mapping:
             return self.mapping[dialect.name]
index d85531396954099ff6345cca9e3f17ffd875c077..dbbe031118d252042e22dd5d7f8a8b983c69a805 100644 (file)
@@ -5,7 +5,7 @@ from ..assertions import eq_
 from ..config import requirements
 from sqlalchemy import Integer, Unicode, UnicodeText, select
 from sqlalchemy import Date, DateTime, Time, MetaData, String, \
-    Text, Numeric, Float, literal, Boolean, cast, null, JSON, and_
+    Text, Numeric, Float, literal, Boolean, cast, null, JSON, and_, type_coerce
 from ..schema import Table, Column
 from ... import testing
 import decimal
@@ -623,6 +623,12 @@ class JSONTest(_LiteralRoundTripFixture, fixtures.TablesTest):
         }
     }
 
+    data6 = {
+        "a": 5,
+        "b": "some value",
+        "c": {"foo": "bar"}
+    }
+
     @classmethod
     def define_tables(cls, metadata):
         Table('data_table', metadata,
@@ -730,10 +736,11 @@ class JSONTest(_LiteralRoundTripFixture, fixtures.TablesTest):
              {"name": "r2", "data": self.data2},
              {"name": "r3", "data": self.data3},
              {"name": "r4", "data": self.data4},
-             {"name": "r5", "data": self.data5}]
+             {"name": "r5", "data": self.data5},
+             {"name": "r6", "data": self.data6}]
         )
 
-    def _test_index_criteria(self, crit, expected):
+    def _test_index_criteria(self, crit, expected, test_literal=True):
         self._criteria_fixture()
         with config.db.connect() as conn:
             stmt = select([self.tables.data_table.c.name]).where(crit)
@@ -743,10 +750,11 @@ class JSONTest(_LiteralRoundTripFixture, fixtures.TablesTest):
                 expected
             )
 
-            literal_sql = str(stmt.compile(
-                config.db, compile_kwargs={"literal_binds": True}))
+            if test_literal:
+                literal_sql = str(stmt.compile(
+                    config.db, compile_kwargs={"literal_binds": True}))
 
-            eq_(conn.scalar(literal_sql), expected)
+                eq_(conn.scalar(literal_sql), expected)
 
     def test_crit_spaces_in_key(self):
         name = self.tables.data_table.c.name
@@ -791,6 +799,45 @@ class JSONTest(_LiteralRoundTripFixture, fixtures.TablesTest):
             "r5"
         )
 
+    def test_crit_against_string_basic(self):
+        name = self.tables.data_table.c.name
+        col = self.tables.data_table.c['data']
+
+        self._test_index_criteria(
+            and_(name == 'r6', cast(col["b"], String) == '"some value"'),
+            "r6"
+        )
+
+    def test_crit_against_string_coerce_type(self):
+        name = self.tables.data_table.c.name
+        col = self.tables.data_table.c['data']
+
+        self._test_index_criteria(
+            and_(name == 'r6',
+                 cast(col["b"], String) == type_coerce("some value", JSON)),
+            "r6",
+            test_literal=False
+        )
+
+    def test_crit_against_int_basic(self):
+        name = self.tables.data_table.c.name
+        col = self.tables.data_table.c['data']
+
+        self._test_index_criteria(
+            and_(name == 'r6', cast(col["a"], String) == '5'),
+            "r6"
+        )
+
+    def test_crit_against_int_coerce_type(self):
+        name = self.tables.data_table.c.name
+        col = self.tables.data_table.c['data']
+
+        self._test_index_criteria(
+            and_(name == 'r6', cast(col["a"], String) == type_coerce(5, JSON)),
+            "r6",
+            test_literal=False
+        )
+
     def test_unicode_round_trip(self):
         s = select([
             cast(
index 7f49991e6f425e56f3601a16a936b20cb71b2831..641db5bc006f24bfb06b9870fdd0c2902e25db0c 100644 (file)
@@ -1987,18 +1987,38 @@ class ExpressionTest(
         tab = table('test', column('bvalue', MyTypeDec))
         expr = tab.c.bvalue + 6
 
+
         self.assert_compile(
             expr,
             "test.bvalue || :bvalue_1",
             use_default_dialect=True
         )
 
-        assert expr.type.__class__ is MyTypeDec
+        is_(expr.right.type.__class__, MyTypeDec)
+        is_(expr.type.__class__, MyTypeDec)
+
         eq_(
             testing.db.execute(select([expr.label('foo')])).scalar(),
             "BIND_INfooBIND_IN6BIND_OUT"
         )
 
+    def test_variant_righthand_coercion(self):
+        my_json_normal = JSON()
+        my_json_variant = JSON().with_variant(String(), "sqlite")
+
+        tab = table(
+            'test',
+            column('avalue', my_json_normal),
+            column('bvalue', my_json_variant)
+        )
+        expr = tab.c.avalue['foo'] == 'bar'
+
+        is_(expr.right.type._type_affinity, String)
+
+        expr = tab.c.bvalue['foo'] == 'bar'
+
+        is_(expr.right.type._type_affinity, String)
+
     def test_bind_typing(self):
         from sqlalchemy.sql import column