diff --git a/CHANGES.rst b/CHANGES.rst index 1264f56..e746ab7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -9,6 +9,7 @@ Here you can see the full list of changes between each SQLAlchemy-Utils release. - Added support for more SQLAlchemy based objects and classes in get_tables function - Added has_unique_index utility function +- Added make_order_by_deterministic utility function 0.27.0 (2014-10-14) diff --git a/docs/orm_helpers.rst b/docs/orm_helpers.rst index 443fb58..f2605b6 100644 --- a/docs/orm_helpers.rst +++ b/docs/orm_helpers.rst @@ -76,6 +76,12 @@ identity .. autofunction:: identity +make_order_by_deterministic +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autofunction:: make_order_by_deterministic + + naturally_equivalent ^^^^^^^^^^^^^^^^^^^^ diff --git a/sqlalchemy_utils/functions/__init__.py b/sqlalchemy_utils/functions/__init__.py index 7a6e71e..3d9fedd 100644 --- a/sqlalchemy_utils/functions/__init__.py +++ b/sqlalchemy_utils/functions/__init__.py @@ -1,7 +1,11 @@ from .defer_except import defer_except from .mock import create_mock_engine, mock_engine from .render import render_expression, render_statement -from .sort_query import sort_query, QuerySorterException +from .sort_query import ( + make_order_by_deterministic, + sort_query, + QuerySorterException +) from .database import ( analyze, create_database, @@ -63,6 +67,7 @@ __all__ = ( 'identity', 'is_auto_assigned_date_column', 'is_indexed_foreign_key', + 'make_order_by_deterministic', 'mock_engine', 'naturally_equivalent', 'non_indexed_foreign_keys', diff --git a/sqlalchemy_utils/functions/database.py b/sqlalchemy_utils/functions/database.py index af13d38..e5d0eb6 100644 --- a/sqlalchemy_utils/functions/database.py +++ b/sqlalchemy_utils/functions/database.py @@ -204,7 +204,7 @@ def has_unique_index(column): (column is pks.values()[0] and len(pks) == 1) or any( - constraint.columns.values()[0] is column and + match_columns(constraint.columns.values()[0], column) and len(constraint.columns) == 1 for constraint in column.table.constraints if isinstance(constraint, sa.sql.schema.UniqueConstraint) @@ -212,6 +212,10 @@ def has_unique_index(column): ) +def match_columns(column, column2): + return column.table is column2.table and column.name == column2.name + + def is_auto_assigned_date_column(column): """ Returns whether or not given SQLAlchemy Column object's is auto assigned diff --git a/sqlalchemy_utils/functions/orm.py b/sqlalchemy_utils/functions/orm.py index 4ef8b47..ed0bde2 100644 --- a/sqlalchemy_utils/functions/orm.py +++ b/sqlalchemy_utils/functions/orm.py @@ -83,6 +83,8 @@ def get_mapper(mixed): mixed = mixed.element if isinstance(mixed, AliasedInsp): return mixed.mapper + if isinstance(mixed, sa.orm.query._MapperEntity): + mixed = mixed.expr if isinstance(mixed, sa.orm.attributes.InstrumentedAttribute): mixed = mixed.class_ if isinstance(mixed, sa.Table): @@ -205,8 +207,8 @@ def get_tables(mixed): .. versionadded: 0.26.0 :param mixed: - SQLAlchemy Mapper / Declarative class or a SA Alias object wrapping - any of these objects. + SQLAlchemy Mapper, Declarative class, Column, InstrumentedAttribute or + a SA Alias object wrapping any of these objects. """ if isinstance(mixed, sa.Table): return [mixed] @@ -218,8 +220,12 @@ def get_tables(mixed): polymorphic_mappers = get_polymorphic_mappers(mapper) if polymorphic_mappers: - return sum((m.tables for m in polymorphic_mappers), []) - return mapper.tables + tables = sum((m.tables for m in polymorphic_mappers), []) + tables = mapper.tables + + if isinstance(mixed, sa.orm.attributes.InstrumentedAttribute): + mixed = mixed.class_ + return tables def get_columns(mixed): diff --git a/sqlalchemy_utils/functions/sort_query.py b/sqlalchemy_utils/functions/sort_query.py index 1fe25e6..ded72a1 100644 --- a/sqlalchemy_utils/functions/sort_query.py +++ b/sqlalchemy_utils/functions/sort_query.py @@ -1,5 +1,8 @@ +import sqlalchemy as sa from sqlalchemy.sql.expression import desc, asc -from .orm import get_query_descriptor + +from .database import has_unique_index +from .orm import get_query_descriptor, get_tables class QuerySorterException(Exception): @@ -130,3 +133,60 @@ def sort_query(query, *args, **kwargs): be raised for unknown columns. """ return QuerySorter(**kwargs)(query, *args) + + +def make_order_by_deterministic(query): + """ + Make query order by deterministic (if it isn't already). Order by is + considered deterministic if it contains column that is unique index ( + either it is a primary key or has a unique index). Many times it is design + flaw to order by queries in nondeterministic manner. + + Consider a User model with three fields: id (primary key), favorite color + and email (unique).:: + + + from sqlalchemy_utils import make_order_by_deterministic + + + query = session.query(User).order_by(User.favorite_color) + + query = make_order_by_deterministic(query) + print query # 'SELECT ... ORDER BY "user".favorite_color, "user".id' + + + query = session.query(User).order_by(User.email) + + query = make_order_by_deterministic(query) + print query # 'SELECT ... ORDER BY "user".email' + + + query = session.query(User).order_by(User.id) + + query = make_order_by_deterministic(query) + print query # 'SELECT ... ORDER BY "user".id' + + + .. versionadded: 0.27.1 + """ + order_by = query._order_by[0] + if isinstance(order_by, sa.Column): + order_by_func = sa.asc + column = order_by + elif isinstance(order_by, sa.sql.expression.UnaryExpression): + if order_by.modifier == sa.sql.operators.desc_op: + order_by_func = sa.desc + else: + order_by_func = sa.asc + column = order_by.get_children()[0] + else: + raise TypeError('Only simple columns in query order by are supported.') + + if has_unique_index(column): + return query + + base_table = get_tables(query._entities[0])[0] + query = query.order_by( + *(order_by_func(c) for c in base_table.c if c.primary_key) + ) + return query diff --git a/tests/functions/test_get_tables.py b/tests/functions/test_get_tables.py index e81ec15..4ffe769 100644 --- a/tests/functions/test_get_tables.py +++ b/tests/functions/test_get_tables.py @@ -42,6 +42,17 @@ class TestGetTables(TestCase): self.Article.__table__ ] + def test_instrumented_attribute(self): + assert get_tables(self.TextItem.name) == [ + self.TextItem.__table__, + ] + + def test_polymorphic_instrumented_attribute(self): + assert get_tables(self.Article.id) == [ + self.TextItem.__table__, + self.Article.__table__ + ] + def test_column(self): assert get_tables(self.Article.__table__.c.id) == [ self.Article.__table__ @@ -53,3 +64,8 @@ class TestGetTables(TestCase): self.TextItem.__table__, self.Article.__table__ ] + def test_mapper_entity(self): + query = self.session.query(self.Article) + assert get_tables(query._entities[0]) == [ + self.TextItem.__table__, self.Article.__table__ + ] diff --git a/tests/functions/test_make_order_by_deterministic.py b/tests/functions/test_make_order_by_deterministic.py new file mode 100644 index 0000000..f17e679 --- /dev/null +++ b/tests/functions/test_make_order_by_deterministic.py @@ -0,0 +1,51 @@ +from pytest import raises +import sqlalchemy as sa + +from sqlalchemy_utils.functions.sort_query import make_order_by_deterministic + +from tests import assert_contains, TestCase + + +class TestMakeOrderByDeterministic(TestCase): + def create_models(self): + class User(self.Base): + __tablename__ = 'user' + id = sa.Column(sa.Integer, primary_key=True) + name = sa.Column(sa.Unicode) + email = sa.Column(sa.Unicode, unique=True) + + email_lower = sa.orm.column_property( + sa.func.lower(name) + ) + + self.User = User + + def test_column_property(self): + query = self.session.query(self.User).order_by(self.User.email_lower) + with raises(TypeError): + make_order_by_deterministic(query) + + def test_unique_column(self): + query = self.session.query(self.User).order_by(self.User.email) + query = make_order_by_deterministic(query) + + assert str(query).endswith('ORDER BY "user".email') + + def test_non_unique_column(self): + query = self.session.query(self.User).order_by(self.User.name) + query = make_order_by_deterministic(query) + assert_contains('ORDER BY "user".name, "user".id ASC', query) + + def test_descending_order_by(self): + query = self.session.query(self.User).order_by( + sa.desc(self.User.name) + ) + query = make_order_by_deterministic(query) + assert_contains('ORDER BY "user".name DESC, "user".id DESC', query) + + def test_ascending_order_by(self): + query = self.session.query(self.User).order_by( + sa.asc(self.User.name) + ) + query = make_order_by_deterministic(query) + assert_contains('ORDER BY "user".name ASC, "user".id ASC', query) diff --git a/tests/test_sort_query.py b/tests/test_sort_query.py index d495d2e..31702eb 100644 --- a/tests/test_sort_query.py +++ b/tests/test_sort_query.py @@ -2,17 +2,10 @@ from pytest import raises import sqlalchemy as sa from sqlalchemy_utils import sort_query from sqlalchemy_utils.functions import QuerySorterException -from tests import TestCase - - -def assert_contains(clause, query): - # Test that query executes - query.all() - assert clause in str(query) +from tests import assert_contains, TestCase class TestSortQuery(TestCase): - def test_without_sort_param_returns_the_query_object_untouched(self): query = self.session.query(self.Article) query = sort_query(query, '')