diff options
author | Mike Bayer <mike_mp@zzzcomputing.com> | 2014-05-16 15:33:39 -0400 |
---|---|---|
committer | Mike Bayer <mike_mp@zzzcomputing.com> | 2014-05-16 15:33:39 -0400 |
commit | 81959af6d37be503a13ce9c53317d443e14ae570 (patch) | |
tree | 98cf5d471114a6b14c91e7bd037009e04f604609 | |
parent | 8414c9f00b9ddf972d6b78c6883c315beaf29822 (diff) | |
download | sqlalchemy-81959af6d37be503a13ce9c53317d443e14ae570.tar.gz |
- more tests, including backend tests
- implement for SQL server, use window functions when simple limit/offset not available
-rw-r--r-- | lib/sqlalchemy/dialects/mssql/base.py | 51 | ||||
-rw-r--r-- | lib/sqlalchemy/sql/selectable.py | 84 | ||||
-rw-r--r-- | lib/sqlalchemy/testing/requirements.py | 6 | ||||
-rw-r--r-- | lib/sqlalchemy/testing/suite/test_select.py | 80 | ||||
-rw-r--r-- | test/dialect/mssql/test_compiler.py | 12 | ||||
-rw-r--r-- | test/orm/test_query.py | 26 | ||||
-rw-r--r-- | test/sql/test_compiler.py | 53 |
7 files changed, 251 insertions, 61 deletions
diff --git a/lib/sqlalchemy/dialects/mssql/base.py b/lib/sqlalchemy/dialects/mssql/base.py index 6a13d1dca..59cbb80bb 100644 --- a/lib/sqlalchemy/dialects/mssql/base.py +++ b/lib/sqlalchemy/dialects/mssql/base.py @@ -741,18 +741,21 @@ class MSSQLCompiler(compiler.SQLCompiler): def get_select_precolumns(self, select): """ MS-SQL puts TOP, it's version of LIMIT here """ - limit = select._limit - if select._distinct or limit is not None: - s = select._distinct and "DISTINCT " or "" + s = "" + if select._distinct: + s += "DISTINCT " + + if select._simple_int_limit and not select._offset: # ODBC drivers and possibly others # don't support bind params in the SELECT clause on SQL Server. # so have to use literal here. - if limit is not None: - if not select._offset: - s += "TOP %d " % limit + s += "TOP %d " % select._limit + + if s: return s - return compiler.SQLCompiler.get_select_precolumns(self, select) + else: + return compiler.SQLCompiler.get_select_precolumns(self, select) def get_from_hint_text(self, table, text): return text @@ -769,28 +772,42 @@ class MSSQLCompiler(compiler.SQLCompiler): so tries to wrap it in a subquery with ``row_number()`` criterion. """ - if select._offset and not getattr(select, '_mssql_visit', None): + if ( + ( + not select._simple_int_limit and + select._limit_clause is not None + ) or ( + select._offset_clause is not None and + not select._simple_int_offset or select._offset + ) + ) and not getattr(select, '_mssql_visit', None): + # to use ROW_NUMBER(), an ORDER BY is required. if not select._order_by_clause.clauses: raise exc.CompileError('MSSQL requires an order_by when ' - 'using an offset.') + 'using an OFFSET or a non-simple ' + 'LIMIT clause') - _offset = select._offset - _limit = select._limit _order_by_clauses = select._order_by_clause.clauses + limit_clause = select._limit_clause + offset_clause = select._offset_clause select = select._generate() select._mssql_visit = True select = select.column( - sql.func.ROW_NUMBER().over(order_by=_order_by_clauses) - .label("mssql_rn") - ).order_by(None).alias() + sql.func.ROW_NUMBER().over(order_by=_order_by_clauses) + .label("mssql_rn")).order_by(None).alias() mssql_rn = sql.column('mssql_rn') limitselect = sql.select([c for c in select.c if c.key != 'mssql_rn']) - limitselect.append_whereclause(mssql_rn > _offset) - if _limit is not None: - limitselect.append_whereclause(mssql_rn <= (_limit + _offset)) + if offset_clause is not None: + limitselect.append_whereclause(mssql_rn > offset_clause) + if limit_clause is not None: + limitselect.append_whereclause( + mssql_rn <= (limit_clause + offset_clause)) + else: + limitselect.append_whereclause( + mssql_rn <= (limit_clause)) return self.process(limitselect, iswrapper=True, **kwargs) else: return compiler.SQLCompiler.visit_select(self, select, **kwargs) diff --git a/lib/sqlalchemy/sql/selectable.py b/lib/sqlalchemy/sql/selectable.py index 767eb086c..72e4a930d 100644 --- a/lib/sqlalchemy/sql/selectable.py +++ b/lib/sqlalchemy/sql/selectable.py @@ -48,38 +48,46 @@ def _interpret_as_select(element): element = element.select() return element +class _OffsetLimitParam(BindParameter): + @property + def _limit_offset_value(self): + return self.effective_value + def _offset_or_limit_clause(element, name=None, type_=None): - """ - If the element is a custom clause of some sort, returns (None, element) - If the element is a BindParameter, return (element.effective_value, element) - Otherwise, assume element is an int and create a new bindparam and return (asint(element), BindParameter(...)) + """Convert the given value to an "offset or limit" clause. + + This handles incoming integers and converts to an expression; if + an expression is already given, it is passed through. + """ if element is None: return None - if hasattr(element, '__clause_element__'): + elif hasattr(element, '__clause_element__'): return element.__clause_element__() - if isinstance(element, Visitable): + elif isinstance(element, Visitable): return element + else: + value = util.asint(element) + return _OffsetLimitParam(name, value, type_=type_, unique=True) - value = util.asint(element) - return BindParameter(name, value, type_=type_, unique=True) +def _offset_or_limit_clause_asint(clause, attrname): + """Convert the "offset or limit" clause of a select construct to an + integer. + + This is only possible if the value is stored as a simple bound parameter. + Otherwise, a compilation error is raised. -def _offset_or_limit_clause_asint(clause): - """ - Get the integer value of an offset or limit clause, for database engines that - require it to be a plain integer instead of a BindParameter or other custom - clause. - - If the clause is None, returns None. - If the clause is not a BindParameter, throws an exception. - If the clause is a BindParameter but its value is not set yet or not an int, throws an exception. - Otherwise, returns the integer in the clause. """ if clause is None: return None - if not isinstance(clause, BindParameter): - raise Exception("Limit is not a simple integer") - return util.asint(clause.effective_value) + try: + value = clause._limit_offset_value + except AttributeError: + raise exc.CompileError( + "This SELECT structure does not use a simple " + "integer value for %s" % attrname) + else: + return util.asint(value) def subquery(alias, *args, **kwargs): """Return an :class:`.Alias` object derived @@ -1676,21 +1684,37 @@ class GenerativeSelect(SelectBase): @property def _limit(self): + """Get an integer value for the limit. This should only be used + by code that cannot support a limit as a BindParameter or + other custom clause as it will throw an exception if the limit + isn't currently set to an integer. + """ - Get an integer value for the limit. This should only be used by code that - cannot support a limit as a BindParameter or other custom clause as it will - throw an exception if the limit isn't currently set to an integer. + return _offset_or_limit_clause_asint(self._limit_clause, "limit") + + @property + def _simple_int_limit(self): + """True if the LIMIT clause is a simple integer, False + if it is not present or is a SQL expression. """ - return _offset_or_limit_clause_asint(self._limit_clause) + return isinstance(self._limit_clause, _OffsetLimitParam) @property - def _offset(self): + def _simple_int_offset(self): + """True if the OFFSET clause is a simple integer, False + if it is not present or is a SQL expression. """ - Get an integer value for the offset. This should only be used by code that - cannot support an offset as a BindParameter or other custom clause as it will - throw an exception if the offset isn't currently set to an integer. + return isinstance(self._offset_clause, _OffsetLimitParam) + + @property + def _offset(self): + """Get an integer value for the offset. This should only be used + by code that cannot support an offset as a BindParameter or + other custom clause as it will throw an exception if the + offset isn't currently set to an integer. + """ - return _offset_or_limit_clause_asint(self._offset_clause) + return _offset_or_limit_clause_asint(self._offset_clause, "offset") @_generative def limit(self, limit): diff --git a/lib/sqlalchemy/testing/requirements.py b/lib/sqlalchemy/testing/requirements.py index 04e8ad272..e1669e952 100644 --- a/lib/sqlalchemy/testing/requirements.py +++ b/lib/sqlalchemy/testing/requirements.py @@ -98,6 +98,12 @@ class SuiteRequirements(Requirements): return exclusions.open() @property + def bound_limit_offset(self): + """target database can render LIMIT and/or OFFSET using a bound parameter""" + + return exclusions.open() + + @property def boolean_col_expressions(self): """Target database must support boolean expressions as columns""" diff --git a/lib/sqlalchemy/testing/suite/test_select.py b/lib/sqlalchemy/testing/suite/test_select.py index 2ccff61ea..3461b1e94 100644 --- a/lib/sqlalchemy/testing/suite/test_select.py +++ b/lib/sqlalchemy/testing/suite/test_select.py @@ -2,7 +2,8 @@ from .. import fixtures, config from ..assertions import eq_ from sqlalchemy import util -from sqlalchemy import Integer, String, select, func +from sqlalchemy import Integer, String, select, func, bindparam +from sqlalchemy import testing from ..schema import Table, Column @@ -84,3 +85,80 @@ class OrderByLabelTest(fixtures.TablesTest): select([lx]).order_by(lx.desc()), [(7, ), (5, ), (3, )] ) + +class LimitOffsetTest(fixtures.TablesTest): + __backend__ = True + + @classmethod + def define_tables(cls, metadata): + Table("some_table", metadata, + Column('id', Integer, primary_key=True), + Column('x', Integer), + Column('y', Integer)) + + @classmethod + def insert_data(cls): + config.db.execute( + cls.tables.some_table.insert(), + [ + {"id": 1, "x": 1, "y": 2}, + {"id": 2, "x": 2, "y": 3}, + {"id": 3, "x": 3, "y": 4}, + {"id": 4, "x": 4, "y": 5}, + ] + ) + + def _assert_result(self, select, result, params=()): + eq_( + config.db.execute(select, params).fetchall(), + result + ) + + def test_simple_limit(self): + table = self.tables.some_table + self._assert_result( + select([table]).order_by(table.c.id).limit(2), + [(1, 1, 2), (2, 2, 3)] + ) + + def test_simple_offset(self): + table = self.tables.some_table + self._assert_result( + select([table]).order_by(table.c.id).offset(2), + [(3, 3, 4), (4, 4, 5)] + ) + + def test_simple_limit_offset(self): + table = self.tables.some_table + self._assert_result( + select([table]).order_by(table.c.id).limit(2).offset(1), + [(2, 2, 3), (3, 3, 4)] + ) + + @testing.requires.bound_limit_offset + def test_bound_limit(self): + table = self.tables.some_table + self._assert_result( + select([table]).order_by(table.c.id).limit(bindparam('l')), + [(1, 1, 2), (2, 2, 3)], + params={"l": 2} + ) + + @testing.requires.bound_limit_offset + def test_bound_offset(self): + table = self.tables.some_table + self._assert_result( + select([table]).order_by(table.c.id).offset(bindparam('o')), + [(3, 3, 4), (4, 4, 5)], + params={"o": 2} + ) + + @testing.requires.bound_limit_offset + def test_bound_limit_offset(self): + table = self.tables.some_table + self._assert_result( + select([table]).order_by(table.c.id).\ + limit(bindparam("l")).offset(bindparam("o")), + [(2, 2, 3), (3, 3, 4)], + params={"l": 2, "o": 1} + ) diff --git a/test/dialect/mssql/test_compiler.py b/test/dialect/mssql/test_compiler.py index f12ab0330..3de8ea5c9 100644 --- a/test/dialect/mssql/test_compiler.py +++ b/test/dialect/mssql/test_compiler.py @@ -430,8 +430,8 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL): "SELECT anon_1.x, anon_1.y FROM (SELECT t.x AS x, t.y " "AS y, ROW_NUMBER() OVER (ORDER BY t.y) AS " "mssql_rn FROM t WHERE t.x = :x_1) AS " - "anon_1 WHERE mssql_rn > :mssql_rn_1", - checkparams={'mssql_rn_1': 20, 'x_1': 5} + "anon_1 WHERE mssql_rn > :param_1", + checkparams={'param_1': 20, 'x_1': 5} ) def test_limit_offset_using_window(self): @@ -446,8 +446,8 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL): "ROW_NUMBER() OVER (ORDER BY t.y) AS mssql_rn " "FROM t " "WHERE t.x = :x_1) AS anon_1 " - "WHERE mssql_rn > :mssql_rn_1 AND mssql_rn <= :mssql_rn_2", - checkparams={'mssql_rn_1': 20, 'mssql_rn_2': 30, 'x_1': 5} + "WHERE mssql_rn > :param_1 AND mssql_rn <= :param_2 + :param_1", + checkparams={'param_1': 20, 'param_2': 10, 'x_1': 5} ) def test_limit_offset_with_correlated_order_by(self): @@ -467,8 +467,8 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL): ") AS mssql_rn " "FROM t1 " "WHERE t1.x = :x_1) AS anon_1 " - "WHERE mssql_rn > :mssql_rn_1 AND mssql_rn <= :mssql_rn_2", - checkparams={'mssql_rn_1': 20, 'mssql_rn_2': 30, 'x_1': 5} + "WHERE mssql_rn > :param_1 AND mssql_rn <= :param_2 + :param_1", + checkparams={'param_1': 20, 'param_2': 10, 'x_1': 5} ) def test_limit_zero_offset_using_window(self): diff --git a/test/orm/test_query.py b/test/orm/test_query.py index b4418042e..cc1f50023 100644 --- a/test/orm/test_query.py +++ b/test/orm/test_query.py @@ -1279,15 +1279,27 @@ class FilterTest(QueryTest, AssertsCompiledSQL): """Does a query allow bindparam for the limit?""" User = self.classes.User sess = create_session() - users = [] + q1 = sess.query(self.classes.User).\ + order_by(self.classes.User.id).limit(bindparam('n')) - q1 = sess.query(self.classes.User).order_by(self.classes.User.id).limit(bindparam('n')) - for n in xrange(1,4): - users[:] = q1.params(n=n).all() - assert len(users) == n + for n in range(1, 4): + result = q1.params(n=n).all() + eq_(len(result), n) - assert [User(id=8), User(id=9)] == sess.query(User).order_by(User.id).limit(bindparam('limit')).offset(bindparam('offset')).params(limit=2, offset=1).all() - assert [User(id=8), User(id=9)] == list(sess.query(User).params(a=1, b=3).order_by(User.id)[bindparam('a'):bindparam('b')]) + eq_( + sess.query(User).order_by(User.id). + limit(bindparam('limit')). + offset(bindparam('offset')). + params(limit=2, offset=1).all() + [User(id=8), User(id=9)] + ) + eq_( + list( + sess.query(User).params(a=1, b=3). + order_by(User.id)[bindparam('a'):bindparam('b')] + ) + [User(id=8), User(id=9)] + ) @testing.requires.boolean_col_expressions def test_exists(self): diff --git a/test/sql/test_compiler.py b/test/sql/test_compiler.py index 917c7d89d..301cf149c 100644 --- a/test/sql/test_compiler.py +++ b/test/sql/test_compiler.py @@ -167,6 +167,57 @@ class SelectTest(fixtures.TestBase, AssertsCompiledSQL): assert_raises(ValueError, select, offset="foo") assert_raises(ValueError, select, limit="foo") + def test_limit_offset_no_int_coercion_one(self): + exp1 = literal_column("Q") + exp2 = literal_column("Y") + self.assert_compile( + select([1]).limit(exp1).offset(exp2), + "SELECT 1 LIMIT Q OFFSET Y" + ) + + self.assert_compile( + select([1]).limit(bindparam('x')).offset(bindparam('y')), + "SELECT 1 LIMIT :x OFFSET :y" + ) + + def test_limit_offset_no_int_coercion_two(self): + exp1 = literal_column("Q") + exp2 = literal_column("Y") + sel = select([1]).limit(exp1).offset(exp2) + + assert_raises_message( + exc.CompileError, + "This SELECT structure does not use a simple integer " + "value for limit", + getattr, sel, "_limit" + ) + + assert_raises_message( + exc.CompileError, + "This SELECT structure does not use a simple integer " + "value for offset", + getattr, sel, "_offset" + ) + + def test_limit_offset_no_int_coercion_three(self): + exp1 = bindparam("Q") + exp2 = bindparam("Y") + sel = select([1]).limit(exp1).offset(exp2) + + assert_raises_message( + exc.CompileError, + "This SELECT structure does not use a simple integer " + "value for limit", + getattr, sel, "_limit" + ) + + assert_raises_message( + exc.CompileError, + "This SELECT structure does not use a simple integer " + "value for offset", + getattr, sel, "_offset" + ) + def test_limit_offset(self): for lim, offset, exp, params in [ (5, 10, "LIMIT :param_1 OFFSET :param_2", @@ -182,6 +233,8 @@ class SelectTest(fixtures.TestBase, AssertsCompiledSQL): checkparams=params ) + + def test_select_precol_compile_ordering(self): s1 = select([column('x')]).select_from('a').limit(5).as_scalar() s2 = select([s1]).limit(10) |