diff options
author | Mike Bayer <mike_mp@zzzcomputing.com> | 2016-09-02 11:27:58 -0400 |
---|---|---|
committer | Mike Bayer <mike_mp@zzzcomputing.com> | 2016-09-02 11:54:16 -0400 |
commit | ce577d48449588d3e5395c08c7f4d04cb8bb325f (patch) | |
tree | 7803f9ea8126728b0754218d3aa386bae6e10ddf | |
parent | f6022839c29f7f96cb9d279aaf2e44e81cafb661 (diff) | |
download | sqlalchemy-ce577d48449588d3e5395c08c7f4d04cb8bb325f.tar.gz |
Repair clauselist comparison to account for clause ordering
Fixed bug where the "simple many-to-one" condition that allows lazy
loading to use get() from identity map would fail to be invoked if the
primaryjoin of the relationship had multiple clauses separated by AND
which were not in the same order as that of the primary key columns
being compared in each clause. This ordering
difference occurs for a composite foreign key where the table-bound
columns on the referencing side were not in the same order in the .c
collection as the primary key columns on the referenced side....which
in turn occurs a lot if one is using declarative mixins and/or
declared_attr to set up columns.
Change-Id: I66cce74f614c04ed693dc0d58ac8c952b2f8ae54
Fixes: #3788
-rw-r--r-- | doc/build/changelog/changelog_11.rst | 15 | ||||
-rw-r--r-- | lib/sqlalchemy/sql/elements.py | 21 | ||||
-rw-r--r-- | lib/sqlalchemy/testing/__init__.py | 2 | ||||
-rw-r--r-- | lib/sqlalchemy/testing/assertions.py | 8 | ||||
-rw-r--r-- | test/orm/test_lazy_relations.py | 69 | ||||
-rw-r--r-- | test/sql/test_utils.py | 78 |
6 files changed, 186 insertions, 7 deletions
diff --git a/doc/build/changelog/changelog_11.rst b/doc/build/changelog/changelog_11.rst index 88f1ebb24..669f08188 100644 --- a/doc/build/changelog/changelog_11.rst +++ b/doc/build/changelog/changelog_11.rst @@ -22,6 +22,21 @@ :version: 1.1.0 .. change:: + :tags: bug, orm + :tickets: 3788 + + Fixed bug where the "simple many-to-one" condition that allows lazy + loading to use get() from identity map would fail to be invoked if the + primaryjoin of the relationship had multiple clauses separated by AND + which were not in the same order as that of the primary key columns + being compared in each clause. This ordering + difference occurs for a composite foreign key where the table-bound + columns on the referencing side were not in the same order in the .c + collection as the primary key columns on the referenced side....which + in turn occurs a lot if one is using declarative mixins and/or + declared_attr to set up columns. + + .. change:: :tags: bug, sql :tickets: 3786 diff --git a/lib/sqlalchemy/sql/elements.py b/lib/sqlalchemy/sql/elements.py index 75d5368d5..cff57372c 100644 --- a/lib/sqlalchemy/sql/elements.py +++ b/lib/sqlalchemy/sql/elements.py @@ -1828,12 +1828,23 @@ class ClauseList(ClauseElement): if not isinstance(other, ClauseList) and len(self.clauses) == 1: return self.clauses[0].compare(other, **kw) elif isinstance(other, ClauseList) and \ - len(self.clauses) == len(other.clauses): - for i in range(0, len(self.clauses)): - if not self.clauses[i].compare(other.clauses[i], **kw): - return False + len(self.clauses) == len(other.clauses) and \ + self.operator is other.operator: + + if self.operator in (operators.and_, operators.or_): + completed = set() + for clause in self.clauses: + for other_clause in set(other.clauses).difference(completed): + if clause.compare(other_clause, **kw): + completed.add(other_clause) + break + return len(completed) == len(other.clauses) else: - return self.operator == other.operator + for i in range(0, len(self.clauses)): + if not self.clauses[i].compare(other.clauses[i], **kw): + return False + else: + return True else: return False diff --git a/lib/sqlalchemy/testing/__init__.py b/lib/sqlalchemy/testing/__init__.py index f4a23d238..8f3d063be 100644 --- a/lib/sqlalchemy/testing/__init__.py +++ b/lib/sqlalchemy/testing/__init__.py @@ -22,7 +22,7 @@ from .assertions import emits_warning, emits_warning_on, uses_deprecated, \ eq_, ne_, le_, is_, is_not_, startswith_, assert_raises, \ assert_raises_message, AssertsCompiledSQL, ComparesTables, \ AssertsExecutionResults, expect_deprecated, expect_warnings, \ - in_, not_in_, eq_ignore_whitespace, eq_regex + in_, not_in_, eq_ignore_whitespace, eq_regex, is_true, is_false from .util import run_as_contextmanager, rowset, fail, \ provide_metadata, adict, force_drop_names, \ diff --git a/lib/sqlalchemy/testing/assertions.py b/lib/sqlalchemy/testing/assertions.py index bd1ccaa51..84653da5c 100644 --- a/lib/sqlalchemy/testing/assertions.py +++ b/lib/sqlalchemy/testing/assertions.py @@ -224,6 +224,14 @@ def le_(a, b, msg=None): assert a <= b, msg or "%r != %r" % (a, b) +def is_true(a, msg=None): + is_(a, True, msg=msg) + + +def is_false(a, msg=None): + is_(a, False, msg=msg) + + def is_(a, b, msg=None): """Assert a is b, with repr messaging on failure.""" assert a is b, msg or "%r is not %r" % (a, b) diff --git a/test/orm/test_lazy_relations.py b/test/orm/test_lazy_relations.py index f2e1db2da..56d1b8323 100644 --- a/test/orm/test_lazy_relations.py +++ b/test/orm/test_lazy_relations.py @@ -6,12 +6,13 @@ from sqlalchemy.orm import attributes, exc as orm_exc, configure_mappers import sqlalchemy as sa from sqlalchemy import testing, and_ from sqlalchemy import Integer, String, ForeignKey, SmallInteger, Boolean +from sqlalchemy import ForeignKeyConstraint from sqlalchemy.types import TypeDecorator from sqlalchemy.testing.schema import Table from sqlalchemy.testing.schema import Column from sqlalchemy import orm from sqlalchemy.orm import mapper, relationship, create_session, Session -from sqlalchemy.testing import eq_ +from sqlalchemy.testing import eq_, is_true, is_false from sqlalchemy.testing import fixtures from test.orm import _fixtures from sqlalchemy.testing.assertsql import CompiledSQL @@ -1148,3 +1149,69 @@ class TypeCoerceTest(fixtures.MappedTest, testing.AssertsExecutionResults,): [{'param_1': 5}] ) ) + + +class CompositeSimpleM2OTest(fixtures.MappedTest): + """ORM-level test for [ticket:3788]""" + + @classmethod + def define_tables(cls, metadata): + Table( + 'a', metadata, + Column("id1", Integer, primary_key=True), + Column("id2", Integer, primary_key=True), + ) + + Table( + "b_sameorder", metadata, + Column("id", Integer, primary_key=True), + Column('a_id1', Integer), + Column('a_id2', Integer), + ForeignKeyConstraint(['a_id1', 'a_id2'], ['a.id1', 'a.id2']) + ) + + Table( + "b_differentorder", metadata, + Column("id", Integer, primary_key=True), + Column('a_id1', Integer), + Column('a_id2', Integer), + ForeignKeyConstraint(['a_id1', 'a_id2'], ['a.id1', 'a.id2']) + ) + + @classmethod + def setup_classes(cls): + class A(cls.Basic): + pass + + class B(cls.Basic): + pass + + def test_use_get_sameorder(self): + mapper(self.classes.A, self.tables.a) + m_b = mapper(self.classes.B, self.tables.b_sameorder, properties={ + 'a': relationship(self.classes.A) + }) + + configure_mappers() + is_true(m_b.relationships.a.strategy.use_get) + + def test_use_get_reverseorder(self): + mapper(self.classes.A, self.tables.a) + m_b = mapper(self.classes.B, self.tables.b_differentorder, properties={ + 'a': relationship(self.classes.A) + }) + + configure_mappers() + is_true(m_b.relationships.a.strategy.use_get) + + def test_dont_use_get_pj_is_different(self): + mapper(self.classes.A, self.tables.a) + m_b = mapper(self.classes.B, self.tables.b_sameorder, properties={ + 'a': relationship(self.classes.A, primaryjoin=and_( + self.tables.a.c.id1 == self.tables.b_sameorder.c.a_id1, + self.tables.a.c.id2 == 12 + )) + }) + + configure_mappers() + is_false(m_b.relationships.a.strategy.use_get) diff --git a/test/sql/test_utils.py b/test/sql/test_utils.py new file mode 100644 index 000000000..09d7e98af --- /dev/null +++ b/test/sql/test_utils.py @@ -0,0 +1,78 @@ +from sqlalchemy.testing import fixtures, is_true, is_false +from sqlalchemy import MetaData, Table, Column, Integer +from sqlalchemy import and_, or_ +from sqlalchemy.sql.elements import ClauseList +from sqlalchemy.sql import operators + + +class CompareClausesTest(fixtures.TestBase): + def setup(self): + m = MetaData() + self.a = Table( + 'a', m, + Column('x', Integer), + Column('y', Integer) + ) + + self.b = Table( + 'b', m, + Column('y', Integer), + Column('z', Integer) + ) + + def test_compare_clauselist_associative(self): + + l1 = and_( + self.a.c.x == self.b.c.y, + self.a.c.y == self.b.c.z + ) + + l2 = and_( + self.a.c.y == self.b.c.z, + self.a.c.x == self.b.c.y, + ) + + l3 = and_( + self.a.c.x == self.b.c.z, + self.a.c.y == self.b.c.y + ) + + is_true(l1.compare(l1)) + is_true(l1.compare(l2)) + is_false(l1.compare(l3)) + + def test_compare_clauselist_not_associative(self): + + l1 = ClauseList( + self.a.c.x, self.a.c.y, self.b.c.y, operator=operators.sub) + + l2 = ClauseList( + self.b.c.y, self.a.c.x, self.a.c.y, operator=operators.sub) + + is_true(l1.compare(l1)) + is_false(l1.compare(l2)) + + def test_compare_clauselist_assoc_different_operator(self): + + l1 = and_( + self.a.c.x == self.b.c.y, + self.a.c.y == self.b.c.z + ) + + l2 = or_( + self.a.c.y == self.b.c.z, + self.a.c.x == self.b.c.y, + ) + + is_false(l1.compare(l2)) + + def test_compare_clauselist_not_assoc_different_operator(self): + + l1 = ClauseList( + self.a.c.x, self.a.c.y, self.b.c.y, operator=operators.sub) + + l2 = ClauseList( + self.a.c.x, self.a.c.y, self.b.c.y, operator=operators.div) + + is_false(l1.compare(l2)) + |