]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
- Fixed more regressions caused by NEVER_SET; comparisons
authorMike Bayer <mike_mp@zzzcomputing.com>
Mon, 20 Apr 2015 21:38:03 +0000 (17:38 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Mon, 20 Apr 2015 21:38:03 +0000 (17:38 -0400)
to transient objects with attributes unset would leak NEVER_SET,
and negated_contains_or_equals would do so for any transient
object as the comparison used only the committed value.
Repaired the NEVER_SET cases, fixes #3371, and also made
negated_contains_or_equals() use state_attr_by_column() just
like a non-negated comparison, fixes #3374

doc/build/changelog/changelog_10.rst
doc/build/changelog/migration_10.rst
lib/sqlalchemy/orm/attributes.py
lib/sqlalchemy/orm/mapper.py
lib/sqlalchemy/orm/persistence.py
lib/sqlalchemy/orm/relationships.py
lib/sqlalchemy/orm/strategies.py
lib/sqlalchemy/orm/sync.py
test/ext/test_associationproxy.py
test/orm/test_attributes.py
test/orm/test_query.py

index cff3f1b5c949941effe9da3c2dc6e7a965a459d1..86bf7df6424c7a9740b9130f8eab690af7083873 100644 (file)
 .. changelog::
     :version: 1.0.1
 
+    .. change::
+        :tags: bug, orm
+        :tickets: 3374
+
+        A query of the form ``query(B).filter(B.a != A(id=7))`` would
+        render the ``NEVER_SET`` symbol, even in older versions as far back
+        as 0.8, for a transient object; for a persistent object, it would
+        always use the persisisted database value and not the currently set
+        value.   Assuming autoflush is turned on, this usually would not be
+        apparent for persistent values, as the pending changes would be
+        flushed first.  However, this is inconsistent vs. the logic used for
+        the  non-negated comparison, ``query(B).filter(B.a == A(id=7))``, which
+        does use the current value and additionally allows comparisons
+        to transient objects.   The comparison now uses the current value
+        and not the database-persisted value.
+
+        .. seealso::
+
+            :ref:`bug_3374`
+
+    .. change::
+        :tags: bug, orm
+        :tickets: 3371
+
+        Fixed a regression cause by :ticket:`3061` where the NEVER_SET
+        symbol could leak into relationship-oriented queries, including
+        ``filter()`` and ``with_parent()`` queries.  The ``None`` symbol
+        is returned in all cases, however many of these queries have never
+        been correctly supported in any case, and produce comparisons
+        to NULL without using the IS operator.  For this reason, a warning
+        is also added to that subset of relationship queries that don't
+        currently provide for ``IS NULL``.
+
+        .. seealso::
+
+            :ref:`bug_3371`
+
+
     .. change::
         :tags: bug, orm
         :tickets: 3368
index da00f1c6338568715987d4c2d247c21f34e65d93..a7a668fbe72e85a97fc120cb410feea22dc790c7 100644 (file)
@@ -955,6 +955,117 @@ to by string name as well::
 
 :ticket:`3228`
 
+.. _bug_3371:
+
+Warnings emitted when comparing objects with None values to relationships
+-------------------------------------------------------------------------
+
+This change is new as of 1.0.1.  Some users are performing
+queries that are essentially of this form::
+
+    session.query(Address).filter(Address.user == User(id=None))
+
+This pattern is not currently supported in SQLAlchemy.  For all versions,
+it emits SQL resembling::
+
+    SELECT address.id AS address_id, address.user_id AS address_user_id,
+    address.email_address AS address_email_address
+    FROM address WHERE ? = address.user_id
+    (None,)
+
+Note above, there is a comparison ``WHERE ? = address.user_id`` where the
+bound value ``?`` is receving ``None``, or ``NULL`` in SQL.  **This will
+always return False in SQL**.  The comparison here would in theory
+generate SQL as follows::
+
+    SELECT address.id AS address_id, address.user_id AS address_user_id,
+    address.email_address AS address_email_address
+    FROM address WHERE address.user_id IS NULL
+
+But right now, **it does not**.   Applications which are relying upon the
+fact that "NULL = NULL" produces False in all cases run the risk that
+someday, SQLAlchemy might fix this issue to generate "IS NULL", and the queries
+will then produce different results.  Therefore with this kind of operation,
+you will see a warning::
+
+    SAWarning: Got None for value of column user.id; this is unsupported
+    for a relationship comparison and will not currently produce an
+    IS comparison (but may in a future release)
+
+Note that this pattern was broken in most cases for release 1.0.0 including
+all of the betas; a value like ``SYMBOL('NEVER_SET')`` would be generated.
+This issue has been fixed, but as a result of identifying this pattern,
+the warning is now there so that we can more safely repair this broken
+behavior (now captured in :ticket:`3373`) in a future release.
+
+:ticket:`3371`
+
+.. _bug_3374:
+
+A "negated contains or equals" relationship comparison will use the current value of attributes, not the database value
+-------------------------------------------------------------------------------------------------------------------------
+
+This change is new as of 1.0.1; while we would have preferred for this to be in 1.0.0,
+it only became apparent as a result of :ticket`3371`.
+
+Given a mapping::
+
+    class A(Base):
+        __tablename__ = 'a'
+        id = Column(Integer, primary_key=True)
+
+    class B(Base):
+        __tablename__ = 'b'
+        id = Column(Integer, primary_key=True)
+        a_id = Column(ForeignKey('a.id'))
+        a = relationship("A")
+
+Given ``A``, with primary key of 7, but which we changed to be 10
+without committing::
+
+    s = Session(autoflush=False)
+    a1 = A(id=7)
+    s.add(a1)
+    s.commit()
+
+    a1.id = 10
+
+A query against a many-to-one relationship with this object as the target
+will use the value 10 in the bound parameters::
+
+    s.query(B).filter(B.a == a1)
+
+Produces::
+
+    SELECT b.id AS b_id, b.a_id AS b_a_id
+    FROM b
+    WHERE ? = b.a_id
+    (10,)
+
+However, before this change, the negation of this criteria would **not** use
+10, it would use 7, unless the object were flushed first::
+
+    s.query(B).filter(B.a != a1)
+
+Produces (in 0.9 and all versions prior to 1.0.1)::
+
+    SELECT b.id AS b_id, b.a_id AS b_a_id
+    FROM b
+    WHERE b.a_id != ? OR b.a_id IS NULL
+    (7,)
+
+For a transient object, it would produce a broken query::
+
+    SELECT b.id, b.a_id
+    FROM b
+    WHERE b.a_id != :a_id_1 OR b.a_id IS NULL
+    {u'a_id_1': symbol('NEVER_SET')}
+
+This inconsistency has been repaired, and in all queries the current attribute
+value, in this example ``10``, will now be used.
+
+:ticket:`3374`
+
 .. _migration_3061:
 
 Changes to attribute events and other operations regarding attributes that have no pre-existing value
index 41803c8bfbfa7eaaed6b15bc26585478b8b70f70..a45c22394a1d443e27520d30620a62e2c895a45b 100644 (file)
@@ -619,7 +619,7 @@ class AttributeImpl(object):
 
         if self.key in state.committed_state:
             value = state.committed_state[self.key]
-            if value is NO_VALUE:
+            if value in (NO_VALUE, NEVER_SET):
                 return None
             else:
                 return value
index 4554f78f9d1404b041df38aca75a2dc232b011d8..924130874f289f6afd70d6c8545494edd8fa7275 100644 (file)
@@ -2377,15 +2377,15 @@ class Mapper(InspectionAttr):
 
         """
         state = attributes.instance_state(instance)
-        return self._primary_key_from_state(state)
+        return self._primary_key_from_state(state, attributes.PASSIVE_OFF)
 
-    def _primary_key_from_state(self, state):
+    def _primary_key_from_state(
+            self, state, passive=attributes.PASSIVE_RETURN_NEVER_SET):
         dict_ = state.dict
         manager = state.manager
         return [
             manager[prop.key].
-            impl.get(state, dict_,
-                     attributes.PASSIVE_RETURN_NEVER_SET)
+            impl.get(state, dict_, passive)
             for prop in self._identity_key_props
         ]
 
@@ -2428,7 +2428,8 @@ class Mapper(InspectionAttr):
     def _get_committed_attr_by_column(self, obj, column):
         state = attributes.instance_state(obj)
         dict_ = attributes.instance_dict(obj)
-        return self._get_committed_state_attr_by_column(state, dict_, column)
+        return self._get_committed_state_attr_by_column(
+            state, dict_, column, passive=attributes.PASSIVE_OFF)
 
     def _get_committed_state_attr_by_column(
             self, state, dict_, column,
index c2527ee3d1450df71c25d9861b0d22ea8863bf51..34c37dab6499d724d453c9537c2f6247c48d7814 100644 (file)
@@ -531,7 +531,7 @@ def _collect_post_update_commands(base_mapper, uowtransaction, table,
                 params[col._label] = \
                     mapper._get_state_attr_by_column(
                         state,
-                        state_dict, col)
+                        state_dict, col, passive=attributes.PASSIVE_OFF)
 
             elif col in post_update_cols:
                 prop = mapper._columntoproperty[col]
index b649c9e217c7e4e7ad0b98ef9ec7d742854d0fd5..d1c5e5e516c0c4111c1bb5e5d6d76d7948cb6364 100644 (file)
@@ -1233,11 +1233,15 @@ class RelationshipProperty(StrategizedProperty):
                 state = attributes.instance_state(other)
 
                 def state_bindparam(x, state, col):
-                    o = state.obj()  # strong ref
+                    dict_ = state.dict
                     return sql.bindparam(
-                        x, unique=True, callable_=lambda:
-                        self.property.mapper.
-                        _get_committed_attr_by_column(o, col))
+                        x, unique=True,
+                        callable_=self.property._get_attr_w_warn_on_none(
+                            col,
+                            self.property.mapper._get_state_attr_by_column,
+                            state, dict_, col, passive=attributes.PASSIVE_OFF
+                        )
+                    )
 
                 def adapt(col):
                     if self.adapter:
@@ -1252,13 +1256,14 @@ class RelationshipProperty(StrategizedProperty):
                             adapt(x) == None)
                         for (x, y) in self.property.local_remote_pairs])
 
-            criterion = sql.and_(*[x == y for (x, y) in
-                                   zip(
-                self.property.mapper.primary_key,
-                self.property.
-                mapper.
-                primary_key_from_instance(other))
+            criterion = sql.and_(*[
+                x == y for (x, y) in
+                zip(
+                    self.property.mapper.primary_key,
+                    self.property.mapper.primary_key_from_instance(other)
+                )
             ])
+
             return ~self._criterion_exists(criterion)
 
         def __ne__(self, other):
@@ -1357,10 +1362,12 @@ class RelationshipProperty(StrategizedProperty):
 
         def visit_bindparam(bindparam):
             if bindparam._identifying_key in bind_to_col:
-                bindparam.callable = \
-                    lambda: mapper._get_state_attr_by_column(
-                        state, dict_,
-                        bind_to_col[bindparam._identifying_key])
+                bindparam.callable = self._get_attr_w_warn_on_none(
+                    bind_to_col[bindparam._identifying_key],
+                    mapper._get_state_attr_by_column,
+                    state, dict_,
+                    bind_to_col[bindparam._identifying_key],
+                    passive=attributes.PASSIVE_OFF)
 
         if self.secondary is not None and alias_secondary:
             criterion = ClauseAdapter(
@@ -1374,6 +1381,18 @@ class RelationshipProperty(StrategizedProperty):
             criterion = adapt_source(criterion)
         return criterion
 
+    def _get_attr_w_warn_on_none(self, column, fn, *arg, **kw):
+        def _go():
+            value = fn(*arg, **kw)
+            if value is None:
+                util.warn(
+                    "Got None for value of column %s; this is unsupported "
+                    "for a relationship comparison and will not "
+                    "currently produce an IS comparison "
+                    "(but may in a future release)" % column)
+            return value
+        return _go
+
     def _lazy_none_clause(self, reverse_direction=False, adapt_source=None):
         if not reverse_direction:
             criterion, bind_to_col = \
index 5c6618686abcf3e8bc43af3fbe0c9ac82f654a6c..85d233a05e1ec7c178b9243df3215e11b6e00d11 100644 (file)
@@ -456,15 +456,18 @@ class LazyLoader(AbstractRelationshipLoader, util.MemoizedSlots):
         o = state.obj()  # strong ref
         dict_ = attributes.instance_dict(o)
 
+        if passive & attributes.INIT_OK:
+            passive ^= attributes.INIT_OK
+
         params = {}
         for key, ident, value in param_keys:
             if ident is not None:
                 if passive and passive & attributes.LOAD_AGAINST_COMMITTED:
                     value = mapper._get_committed_state_attr_by_column(
-                        state, dict_, ident)
+                        state, dict_, ident, passive)
                 else:
                     value = mapper._get_state_attr_by_column(
-                        state, dict_, ident)
+                        state, dict_, ident, passive)
 
             params[key] = value
 
index e9a745cc085f00681ba7227983dfa55cd971c36d..e8e273a86234018281098556f71f663a99176c1b 100644 (file)
@@ -85,7 +85,7 @@ def update(source, source_mapper, dest, old_prefix, synchronize_pairs):
             oldvalue = source_mapper._get_committed_attr_by_column(
                 source.obj(), l)
             value = source_mapper._get_state_attr_by_column(
-                source, source.dict, l)
+                source, source.dict, l, passive=attributes.PASSIVE_OFF)
         except exc.UnmappedColumnError:
             _raise_col_to_prop(False, source_mapper, l, None, r)
         dest[r.key] = value
@@ -96,7 +96,7 @@ def populate_dict(source, source_mapper, dict_, synchronize_pairs):
     for l, r in synchronize_pairs:
         try:
             value = source_mapper._get_state_attr_by_column(
-                source, source.dict, l)
+                source, source.dict, l, passive=attributes.PASSIVE_OFF)
         except exc.UnmappedColumnError:
             _raise_col_to_prop(False, source_mapper, l, None, r)
 
index 9e328a35fd0c8e62891164af895b42436a5a953d..34c1a83220ce6e4cd235b2cee5c57c513cd77f95 100644 (file)
@@ -13,6 +13,7 @@ from sqlalchemy.testing import fixtures, AssertsCompiledSQL
 from sqlalchemy import testing
 from sqlalchemy.testing.schema import Table, Column
 from sqlalchemy.testing.mock import Mock, call
+from sqlalchemy.testing.assertions import expect_warnings
 
 class DictCollection(dict):
     @collection.appender
@@ -1300,16 +1301,18 @@ class ComparatorTest(fixtures.MappedTest, AssertsCompiledSQL):
     def test_filter_contains_nul_ul(self):
         User, Singular = self.classes.User, self.classes.Singular
 
-        self._equivalent(
-            self.session.query(User).filter(
-                            User.singular_keywords.contains(self.kw)
-            ),
-            self.session.query(User).filter(
-                            User.singular.has(
-                                Singular.keywords.contains(self.kw)
-                            )
-            ),
-        )
+        with expect_warnings(
+                "Got None for value of column keywords.singular_id;"):
+            self._equivalent(
+                self.session.query(User).filter(
+                                User.singular_keywords.contains(self.kw)
+                ),
+                self.session.query(User).filter(
+                                User.singular.has(
+                                    Singular.keywords.contains(self.kw)
+                                )
+                ),
+            )
 
     def test_filter_eq_nul_nul(self):
         Keyword = self.classes.Keyword
index b22fff1a9ce99f4c88bdec679ff71e4f8c80778a..96c6451d0ffe756573c1d8e18088205c0311396c 100644 (file)
@@ -1524,6 +1524,13 @@ class HistoryTest(fixtures.TestBase):
         f.someattr = 3
         eq_(self._someattr_committed_state(f), None)
 
+    def test_committed_value_set_active_hist(self):
+        Foo = self._fixture(uselist=False, useobject=False,
+                                active_history=True)
+        f = Foo()
+        f.someattr = 3
+        eq_(self._someattr_committed_state(f), None)
+
     def test_committed_value_set_commit(self):
         Foo = self._fixture(uselist=False, useobject=False,
                                 active_history=False)
index 4c909d6aaf5b05d24bc880a5731f24e14951844d..4021cdfbb6575174c73975ec91f9a9b08a68c34c 100644 (file)
@@ -742,7 +742,7 @@ class OperatorTest(QueryTest, AssertsCompiledSQL):
 
     __dialect__ = 'default'
 
-    def _test(self, clause, expected, entity=None):
+    def _test(self, clause, expected, entity=None, checkparams=None):
         dialect = default.DefaultDialect()
         if entity is not None:
             # specify a lead entity, so that when we are testing
@@ -754,9 +754,11 @@ class OperatorTest(QueryTest, AssertsCompiledSQL):
             lead = context.statement.compile(dialect=dialect)
             expected = (str(lead) + " WHERE " + expected).replace("\n", "")
             clause = sess.query(entity).filter(clause)
-        self.assert_compile(clause, expected)
+        self.assert_compile(clause, expected, checkparams=checkparams)
 
-    def _test_filter_aliases(self, clause, expected, from_, onclause):
+    def _test_filter_aliases(
+            self,
+            clause, expected, from_, onclause, checkparams=None):
         dialect = default.DefaultDialect()
         sess = Session()
         lead = sess.query(from_).join(onclause, aliased=True)
@@ -766,7 +768,7 @@ class OperatorTest(QueryTest, AssertsCompiledSQL):
         lead = context.statement.compile(dialect=dialect)
         expected = (str(lead) + " WHERE " + expected).replace("\n", "")
 
-        self.assert_compile(full, expected)
+        self.assert_compile(full, expected, checkparams=checkparams)
 
     def test_arithmetic(self):
         User = self.classes.User
@@ -933,65 +935,126 @@ class OperatorTest(QueryTest, AssertsCompiledSQL):
 
     def test_m2o_compare_instance(self):
         User, Address = self.classes.User, self.classes.Address
-        u7 = User(id=7)
+        u7 = User(id=5)
         attributes.instance_state(u7)._commit_all(attributes.instance_dict(u7))
+        u7.id = 7
 
         self._test(Address.user == u7, ":param_1 = addresses.user_id")
 
     def test_m2o_compare_instance_negated(self):
         User, Address = self.classes.User, self.classes.Address
-        u7 = User(id=7)
+        u7 = User(id=5)
         attributes.instance_state(u7)._commit_all(attributes.instance_dict(u7))
+        u7.id = 7
 
         self._test(
             Address.user != u7,
-            "addresses.user_id != :user_id_1 OR addresses.user_id IS NULL")
+            "addresses.user_id != :user_id_1 OR addresses.user_id IS NULL",
+            checkparams={'user_id_1': 7})
 
     def test_m2o_compare_instance_orm_adapt(self):
         User, Address = self.classes.User, self.classes.Address
-        u7 = User(id=7)
+        u7 = User(id=5)
         attributes.instance_state(u7)._commit_all(attributes.instance_dict(u7))
+        u7.id = 7
 
         self._test_filter_aliases(
             Address.user == u7,
-            ":param_1 = addresses_1.user_id", User, User.addresses
+            ":param_1 = addresses_1.user_id", User, User.addresses,
+            checkparams={'param_1': 7}
         )
 
+    def test_m2o_compare_instance_negated_warn_on_none(self):
+        User, Address = self.classes.User, self.classes.Address
+
+        u7_transient = User(id=None)
+
+        with expect_warnings("Got None for value of column users.id; "):
+            self._test_filter_aliases(
+                Address.user != u7_transient,
+                "addresses_1.user_id != :user_id_1 "
+                "OR addresses_1.user_id IS NULL",
+                User, User.addresses,
+                checkparams={'user_id_1': None}
+            )
+
     def test_m2o_compare_instance_negated_orm_adapt(self):
         User, Address = self.classes.User, self.classes.Address
-        u7 = User(id=7)
+        u7 = User(id=5)
         attributes.instance_state(u7)._commit_all(attributes.instance_dict(u7))
+        u7.id = 7
+
+        u7_transient = User(id=7)
 
         self._test_filter_aliases(
             Address.user != u7,
             "addresses_1.user_id != :user_id_1 OR addresses_1.user_id IS NULL",
-            User, User.addresses
+            User, User.addresses,
+            checkparams={'user_id_1': 7}
         )
 
         self._test_filter_aliases(
             ~(Address.user == u7), ":param_1 != addresses_1.user_id",
-            User, User.addresses
+            User, User.addresses,
+            checkparams={'param_1': 7}
         )
 
         self._test_filter_aliases(
             ~(Address.user != u7),
             "NOT (addresses_1.user_id != :user_id_1 "
-            "OR addresses_1.user_id IS NULL)", User, User.addresses
+            "OR addresses_1.user_id IS NULL)", User, User.addresses,
+            checkparams={'user_id_1': 7}
+        )
+
+        self._test_filter_aliases(
+            Address.user != u7_transient,
+            "addresses_1.user_id != :user_id_1 OR addresses_1.user_id IS NULL",
+            User, User.addresses,
+            checkparams={'user_id_1': 7}
+        )
+
+        self._test_filter_aliases(
+            ~(Address.user == u7_transient), ":param_1 != addresses_1.user_id",
+            User, User.addresses,
+            checkparams={'param_1': 7}
+        )
+
+        self._test_filter_aliases(
+            ~(Address.user != u7_transient),
+            "NOT (addresses_1.user_id != :user_id_1 "
+            "OR addresses_1.user_id IS NULL)", User, User.addresses,
+            checkparams={'user_id_1': 7}
         )
 
     def test_m2o_compare_instance_aliased(self):
         User, Address = self.classes.User, self.classes.Address
-        u7 = User(id=7)
+        u7 = User(id=5)
         attributes.instance_state(u7)._commit_all(attributes.instance_dict(u7))
+        u7.id = 7
+
+        u7_transient = User(id=7)
 
         a1 = aliased(Address)
         self._test(
             a1.user == u7,
-            ":param_1 = addresses_1.user_id")
+            ":param_1 = addresses_1.user_id",
+            checkparams={'param_1': 7})
 
         self._test(
             a1.user != u7,
-            "addresses_1.user_id != :user_id_1 OR addresses_1.user_id IS NULL")
+            "addresses_1.user_id != :user_id_1 OR addresses_1.user_id IS NULL",
+            checkparams={'user_id_1': 7})
+
+        a1 = aliased(Address)
+        self._test(
+            a1.user == u7_transient,
+            ":param_1 = addresses_1.user_id",
+            checkparams={'param_1': 7})
+
+        self._test(
+            a1.user != u7_transient,
+            "addresses_1.user_id != :user_id_1 OR addresses_1.user_id IS NULL",
+            checkparams={'user_id_1': 7})
 
     def test_selfref_relationship(self):
 
@@ -1004,7 +1067,8 @@ class OperatorTest(QueryTest, AssertsCompiledSQL):
             Node.children.any(Node.data == 'n1'),
             "EXISTS (SELECT 1 FROM nodes AS nodes_1 WHERE "
             "nodes.id = nodes_1.parent_id AND nodes_1.data = :data_1)",
-            entity=Node
+            entity=Node,
+            checkparams={'data_1': 'n1'}
         )
 
         # needs autoaliasing
@@ -1012,36 +1076,43 @@ class OperatorTest(QueryTest, AssertsCompiledSQL):
             Node.children == None,
             "NOT (EXISTS (SELECT 1 FROM nodes AS nodes_1 "
             "WHERE nodes.id = nodes_1.parent_id))",
-            entity=Node
+            entity=Node,
+            checkparams={}
         )
 
         self._test(
             Node.parent == None,
-            "nodes.parent_id IS NULL"
+            "nodes.parent_id IS NULL",
+            checkparams={}
         )
 
         self._test(
             nalias.parent == None,
-            "nodes_1.parent_id IS NULL"
+            "nodes_1.parent_id IS NULL",
+            checkparams={}
         )
 
         self._test(
             nalias.parent != None,
-            "nodes_1.parent_id IS NOT NULL"
+            "nodes_1.parent_id IS NOT NULL",
+            checkparams={}
         )
 
         self._test(
             nalias.children == None,
             "NOT (EXISTS ("
             "SELECT 1 FROM nodes WHERE nodes_1.id = nodes.parent_id))",
-            entity=nalias
+            entity=nalias,
+            checkparams={}
         )
 
         self._test(
             nalias.children.any(Node.data == 'some data'),
             "EXISTS (SELECT 1 FROM nodes WHERE "
             "nodes_1.id = nodes.parent_id AND nodes.data = :data_1)",
-            entity=nalias)
+            entity=nalias,
+            checkparams={'data_1': 'some data'}
+            )
 
         # this fails because self-referential any() is auto-aliasing;
         # the fact that we use "nalias" here means we get two aliases.
@@ -1056,33 +1127,48 @@ class OperatorTest(QueryTest, AssertsCompiledSQL):
             nalias.parent.has(Node.data == 'some data'),
             "EXISTS (SELECT 1 FROM nodes WHERE nodes.id = nodes_1.parent_id "
             "AND nodes.data = :data_1)",
-            entity=nalias
+            entity=nalias,
+            checkparams={'data_1': 'some data'}
         )
 
         self._test(
             Node.parent.has(Node.data == 'some data'),
             "EXISTS (SELECT 1 FROM nodes AS nodes_1 WHERE "
             "nodes_1.id = nodes.parent_id AND nodes_1.data = :data_1)",
-            entity=Node
+            entity=Node,
+            checkparams={'data_1': 'some data'}
         )
 
         self._test(
             Node.parent == Node(id=7),
-            ":param_1 = nodes.parent_id"
+            ":param_1 = nodes.parent_id",
+            checkparams={"param_1": 7}
         )
 
         self._test(
             nalias.parent == Node(id=7),
-            ":param_1 = nodes_1.parent_id"
+            ":param_1 = nodes_1.parent_id",
+            checkparams={"param_1": 7}
+        )
+
+        self._test(
+            nalias.parent != Node(id=7),
+            'nodes_1.parent_id != :parent_id_1 '
+            'OR nodes_1.parent_id IS NULL',
+            checkparams={"parent_id_1": 7}
         )
 
         self._test(
             nalias.parent != Node(id=7),
-            'nodes_1.parent_id != :parent_id_1 OR nodes_1.parent_id IS NULL'
+            'nodes_1.parent_id != :parent_id_1 '
+            'OR nodes_1.parent_id IS NULL',
+            checkparams={"parent_id_1": 7}
         )
 
         self._test(
-            nalias.children.contains(Node(id=7)), "nodes_1.id = :param_1"
+            nalias.children.contains(Node(id=7, parent_id=12)),
+            "nodes_1.id = :param_1",
+            checkparams={"param_1": 12}
         )
 
     def test_multilevel_any(self):
@@ -2944,6 +3030,7 @@ class ParentTest(QueryTest, AssertsCompiledSQL):
             o.all()
         )
 
+
     def test_with_pending_autoflush(self):
         Order, User = self.classes.Order, self.classes.User
 
@@ -3018,6 +3105,131 @@ class ParentTest(QueryTest, AssertsCompiledSQL):
         )
 
 
+class WithTransientOnNone(_fixtures.FixtureTest, AssertsCompiledSQL):
+    run_inserts = None
+    __dialect__ = 'default'
+
+    def _fixture1(self):
+        User, Address = self.classes.User, self.classes.Address
+        users, addresses = self.tables.users, self.tables.addresses
+
+        mapper(User, users)
+        mapper(Address, addresses, properties={
+            'user': relationship(User),
+            'special_user': relationship(
+                User, primaryjoin=and_(
+                    users.c.id == addresses.c.user_id,
+                    users.c.name == addresses.c.email_address))
+        })
+
+    def test_filter_with_transient_assume_pk(self):
+        self._fixture1()
+        User, Address = self.classes.User, self.classes.Address
+
+        sess = Session()
+
+        q = sess.query(Address).filter(Address.user == User())
+        with expect_warnings("Got None for value of column "):
+            self.assert_compile(
+                q,
+                "SELECT addresses.id AS addresses_id, "
+                "addresses.user_id AS addresses_user_id, "
+                "addresses.email_address AS addresses_email_address "
+                "FROM addresses WHERE :param_1 = addresses.user_id",
+                checkparams={'param_1': None}
+            )
+
+    def test_filter_with_transient_warn_for_none_against_non_pk(self):
+        self._fixture1()
+        User, Address = self.classes.User, self.classes.Address
+
+        s = Session()
+        q = s.query(Address).filter(Address.special_user == User())
+        with expect_warnings("Got None for value of column"):
+
+            self.assert_compile(
+                q,
+                "SELECT addresses.id AS addresses_id, "
+                "addresses.user_id AS addresses_user_id, "
+                "addresses.email_address AS addresses_email_address "
+                "FROM addresses WHERE :param_1 = addresses.user_id "
+                "AND :param_2 = addresses.email_address",
+                checkparams={"param_1": None, "param_2": None}
+            )
+
+    def test_with_parent_with_transient_assume_pk(self):
+        self._fixture1()
+        User, Address = self.classes.User, self.classes.Address
+
+        sess = Session()
+
+        q = sess.query(User).with_parent(Address(), "user")
+        with expect_warnings("Got None for value of column"):
+            self.assert_compile(
+                q,
+                "SELECT users.id AS users_id, users.name AS users_name "
+                "FROM users WHERE users.id = :param_1",
+                checkparams={'param_1': None}
+            )
+
+    def test_with_parent_with_transient_warn_for_none_against_non_pk(self):
+        self._fixture1()
+        User, Address = self.classes.User, self.classes.Address
+
+        s = Session()
+        q = s.query(User).with_parent(Address(), "special_user")
+        with expect_warnings("Got None for value of column"):
+
+            self.assert_compile(
+                q,
+                "SELECT users.id AS users_id, users.name AS users_name "
+                "FROM users WHERE users.id = :param_1 "
+                "AND users.name = :param_2",
+                checkparams={"param_1": None, "param_2": None}
+            )
+
+    def test_negated_contains_or_equals_plain_m2o(self):
+        self._fixture1()
+        User, Address = self.classes.User, self.classes.Address
+
+        s = Session()
+        q = s.query(Address).filter(Address.user != User())
+        with expect_warnings("Got None for value of column"):
+            self.assert_compile(
+                q,
+
+                "SELECT addresses.id AS addresses_id, "
+                "addresses.user_id AS addresses_user_id, "
+                "addresses.email_address AS addresses_email_address "
+                "FROM addresses "
+                "WHERE addresses.user_id != :user_id_1 "
+                "OR addresses.user_id IS NULL",
+                checkparams={'user_id_1': None}
+            )
+
+    def test_negated_contains_or_equals_complex_rel(self):
+        self._fixture1()
+        User, Address = self.classes.User, self.classes.Address
+
+        s = Session()
+
+        # this one does *not* warn because we do the criteria
+        # without deferral
+        q = s.query(Address).filter(Address.special_user != User())
+        self.assert_compile(
+            q,
+            "SELECT addresses.id AS addresses_id, "
+            "addresses.user_id AS addresses_user_id, "
+            "addresses.email_address AS addresses_email_address "
+            "FROM addresses "
+            "WHERE NOT (EXISTS (SELECT 1 "
+            "FROM users "
+            "WHERE users.id = addresses.user_id AND "
+            "users.name = addresses.email_address AND users.id IS NULL))",
+            checkparams={}
+        )
+
+
 class SynonymTest(QueryTest):
 
     @classmethod