--- /dev/null
+.. change::
+ :tags: bug, sql
+ :tickets: 5127
+
+ Improved the :func:`_sql.tuple_` construct such that it behaves predictably
+ when used in a columns-clause context. The SQL tuple is not supported as a
+ "SELECT" columns clause element on most backends; on those that do
+ (PostgreSQL, not surprisingly), the Python DBAPI does not have a "nested
+ type" concept so there are still challenges in fetching rows for such an
+ object. Use of :func:`_sql.tuple_` in a :func:`_sql.select` or
+ :class:`_orm.Query` will now raise a :class:`_exc.CompileError` at the
+ point at which the :func:`_sql.tuple_` object is seen as presenting itself
+ for fetching rows (i.e., if the tuple is in the columns clause of a
+ subquery, no error is raised). For ORM use,the :class:`_orm.Bundle` object
+ is an explicit directive that a series of columns should be returned as a
+ sub-tuple per row and is suggested by the error message. Additionally ,the
+ tuple will now render with parenthesis in all contexts. Previously, the
+ parenthesization would not render in a columns context leading to
+ non-defined behavior.
from ... import types as sqltypes
from ... import util
+from ...sql import coercions
from ...sql import expression
from ...sql import operators
+from ...sql import roles
def Any(other, arrexpr, operator=operators.eq):
return arrexpr.all(other, operator)
-class array(expression.Tuple):
+class array(expression.ClauseList, expression.ColumnElement):
"""A PostgreSQL ARRAY literal.
__visit_name__ = "array"
def __init__(self, clauses, **kw):
+ clauses = [
+ coercions.expect(roles.ExpressionElementRole, c) for c in clauses
+ ]
+
super(array, self).__init__(*clauses, **kw)
- if isinstance(self.type, ARRAY):
+
+ self._type_tuple = [arg.type for arg in clauses]
+ main_type = kw.pop(
+ "type_",
+ self._type_tuple[0] if self._type_tuple else sqltypes.NULLTYPE,
+ )
+
+ if isinstance(main_type, ARRAY):
self.type = ARRAY(
- self.type.item_type,
- dimensions=self.type.dimensions + 1
- if self.type.dimensions is not None
+ main_type.item_type,
+ dimensions=main_type.dimensions + 1
+ if main_type.dimensions is not None
else 2,
)
else:
- self.type = ARRAY(self.type)
+ self.type = ARRAY(main_type)
+
+ @property
+ def _select_iterable(self):
+ return (self,)
def _bind_param(self, operator, obj, _assume_scalar=False, type_=None):
if _assume_scalar or operator is operators.getitem:
continue
if key in self._expanded_parameters:
- if bindparam._expanding_in_types:
- num = len(bindparam._expanding_in_types)
+ if bindparam.type._is_tuple_type:
+ num = len(bindparam.type.types)
dbtypes = inputsizes[bindparam]
positional_inputsizes.extend(
[
continue
if key in self._expanded_parameters:
- if bindparam._expanding_in_types:
- num = len(bindparam._expanding_in_types)
+ if bindparam.type._is_tuple_type:
+ num = len(bindparam.type.types)
dbtypes = inputsizes[bindparam]
keyword_inputsizes.update(
[
get_corresponding_attr = operator.attrgetter(key)
return lambda obj: get_corresponding_attr(obj)
+ def visit_tuple(self, clause):
+ return self.visit_clauselist(clause)
+
def visit_clauselist(self, clause):
evaluators = list(map(self.process, clause.clauses))
if clause.operator is operators.or_:
if non_literal_expressions:
return elements.ClauseList(
- _tuple_values=isinstance(expr, elements.Tuple),
*[
non_literal_expressions[o]
if o in non_literal_expressions
# param to IN? check for ARRAY type?
element = element._clone(maintain_key=True)
element.expanding = True
- if isinstance(expr, elements.Tuple):
- element = element._with_expanding_in_types(
- [elem.type for elem in expr]
- )
return element
else:
(
self.bind_names[bindparam],
bindparam.type._cached_bind_processor(self.dialect)
- if not bindparam._expanding_in_types
+ if not bindparam.type._is_tuple_type
else tuple(
elem_type._cached_bind_processor(self.dialect)
- for elem_type in bindparam._expanding_in_types
+ for elem_type in bindparam.type.types
),
)
for bindparam in self.bind_names
if bindparam in literal_execute_params:
continue
- if bindparam._expanding_in_types:
+ if bindparam.type._is_tuple_type:
inputsizes[bindparam] = [
- _lookup_type(typ) for typ in bindparam._expanding_in_types
+ _lookup_type(typ) for typ in bindparam.type.types
]
else:
inputsizes[bindparam] = _lookup_type(bindparam.type)
if not parameter.literal_execute:
parameters.update(to_update)
- if parameter._expanding_in_types:
+ if parameter.type._is_tuple_type:
new_processors.update(
(
"%s_%s_%s" % (name, i, j),
if s
)
+ def visit_tuple(self, clauselist, **kw):
+ return "(%s)" % self.visit_clauselist(clauselist, **kw)
+
def visit_clauselist(self, clauselist, **kw):
sep = clauselist.operator
if sep is None:
else:
sep = OPERATORS[clauselist.operator]
- text = self._generate_delimited_list(clauselist.clauses, sep, **kw)
- if clauselist._tuple_values and self.dialect.tuple_in_values:
- text = "VALUES " + text
- return text
+ return self._generate_delimited_list(clauselist.clauses, sep, **kw)
def visit_case(self, clause, **kwargs):
x = "CASE "
def _literal_execute_expanding_parameter_literal_binds(
self, parameter, values
):
+
if not values:
+ assert not parameter.type._is_tuple_type
replacement_expression = self.visit_empty_set_expr(
- parameter._expanding_in_types
- if parameter._expanding_in_types
- else [parameter.type]
+ [parameter.type]
)
elif isinstance(values[0], (tuple, list)):
+ assert parameter.type._is_tuple_type
replacement_expression = (
"VALUES " if self.dialect.tuple_in_values else ""
) + ", ".join(
"(%s)"
% (
", ".join(
- self.render_literal_value(value, parameter.type)
- for value in tuple_element
+ self.render_literal_value(value, param_type)
+ for value, param_type in zip(
+ tuple_element, parameter.type.types
+ )
)
)
for i, tuple_element in enumerate(values)
)
else:
+ assert not parameter.type._is_tuple_type
replacement_expression = ", ".join(
self.render_literal_value(value, parameter.type)
for value in values
return (), replacement_expression
def _literal_execute_expanding_parameter(self, name, parameter, values):
+
if parameter.literal_execute:
return self._literal_execute_expanding_parameter_literal_binds(
parameter, values
if not values:
to_update = []
- replacement_expression = self.visit_empty_set_expr(
- parameter._expanding_in_types
- if parameter._expanding_in_types
- else [parameter.type]
- )
+ if parameter.type._is_tuple_type:
+
+ replacement_expression = self.visit_empty_set_expr(
+ parameter.type.types
+ )
+ else:
+ replacement_expression = self.visit_empty_set_expr(
+ [parameter.type]
+ )
elif isinstance(values[0], (tuple, list)):
to_update = [
if keyname is None:
self._ordered_columns = False
self._textual_ordered_columns = True
+ if type_._is_tuple_type:
+ raise exc.CompileError(
+ "Most backends don't support SELECTing "
+ "from a tuple() object. If this is an ORM query, "
+ "consider using the Bundle object."
+ )
self._result_columns.append((keyname, name, objects, type_))
def _label_select_column(
]
_is_crud = False
- _expanding_in_types = ()
_is_bind_parameter = True
_key_is_anon = False
else:
self.type = type_
- def _with_expanding_in_types(self, types):
- """Return a copy of this :class:`.BindParameter` in
- the context of an expanding IN against a tuple.
-
- """
- cloned = self._clone(maintain_key=True)
- cloned._expanding_in_types = types
- return cloned
-
def _with_value(self, value, maintain_key=False):
"""Return a copy of this :class:`.BindParameter` with the given value
set.
self.group_contents = kwargs.pop("group_contents", True)
if kwargs.pop("_flatten_sub_clauses", False):
clauses = util.flatten_iterator(clauses)
- self._tuple_values = kwargs.pop("_tuple_values", False)
self._text_converter_role = text_converter_role = kwargs.pop(
"_literal_as_text_role", roles.WhereHavingRole
)
self.group = True
self.operator = operator
self.group_contents = True
- self._tuple_values = False
self._is_implicitly_boolean = False
return self
__visit_name__ = "clauselist"
inherit_cache = True
- _tuple_values = False
-
def __init__(self, *arg, **kw):
raise NotImplementedError(
"BooleanClauseList has a private constructor"
class Tuple(ClauseList, ColumnElement):
"""Represent a SQL tuple."""
+ __visit_name__ = "tuple"
+
_traverse_internals = ClauseList._traverse_internals + []
+ @util.preload_module("sqlalchemy.sql.sqltypes")
def __init__(self, *clauses, **kw):
"""Return a :class:`.Tuple`.
invoked.
"""
+ sqltypes = util.preloaded.sql_sqltypes
clauses = [
coercions.expect(roles.ExpressionElementRole, c) for c in clauses
]
- self._type_tuple = [arg.type for arg in clauses]
- self.type = kw.pop(
- "type_",
- self._type_tuple[0] if self._type_tuple else type_api.NULLTYPE,
- )
+ self.type = sqltypes.TupleType(*[arg.type for arg in clauses])
super(Tuple, self).__init__(*clauses, **kw)
_compared_to_operator=operator,
unique=True,
expanding=True,
- )._with_expanding_in_types(self._type_tuple)
+ type_=self.type,
+ )
else:
return Tuple(
*[
unique=True,
type_=type_,
)
- for o, compared_to_type in zip(obj, self._type_tuple)
+ for o, compared_to_type in zip(obj, self.type.types)
]
- ).self_group()
+ )
+
+ def self_group(self, against=None):
+ # Tuple is parenthsized by definition.
+ return self
class Case(ColumnElement):
self.item_type._set_parent_with_dispatch(parent)
+class TupleType(TypeEngine):
+ """represent the composite type of a Tuple."""
+
+ _is_tuple_type = True
+
+ def __init__(self, *types):
+ self.types = types
+
+ def result_processor(self, dialect, coltype):
+ raise NotImplementedError(
+ "The tuple type does not support being fetched "
+ "as a column in a result row."
+ )
+
+
class REAL(Float):
"""The SQL REAL type."""
_sqla_type = True
_isnull = False
+ _is_tuple_type = False
class Comparator(operators.ColumnOperators):
"""Base class for custom comparison operations defined at the
)
)
+ @testing.requires.tuple_in
+ def test_execute_tuple_expanding_plus_literal_heterogeneous_execute(self):
+ table = self.tables.some_table
+
+ stmt = select([table.c.id]).where(
+ tuple_(table.c.x, table.c.z).in_(
+ bindparam("q", expanding=True, literal_execute=True)
+ )
+ )
+
+ with self.sql_execution_asserter() as asserter:
+ with config.db.connect() as conn:
+ conn.execute(stmt, q=[(5, "z1"), (12, "z3")])
+
+ asserter.assert_(
+ CursorSQL(
+ "SELECT some_table.id \nFROM some_table "
+ "\nWHERE (some_table.x, some_table.z) "
+ "IN (%s(5, 'z1'), (12, 'z3'))"
+ % ("VALUES " if config.db.dialect.tuple_in_values else ""),
+ () if config.db.dialect.positional else {},
+ )
+ )
+
class ExpandingBoundInTest(fixtures.TablesTest):
__backend__ = True
def test_extract(self, connection):
fivedaysago = testing.db.scalar(
- select(func.now())
+ select(func.now().op("at time zone")("UTC"))
) - datetime.timedelta(days=5)
for field, exp in (
("year", fivedaysago.year),
):
r = connection.execute(
select(
- extract(field, func.now() + datetime.timedelta(days=-5))
+ extract(
+ field,
+ func.now().op("at time zone")("UTC")
+ + datetime.timedelta(days=-5),
+ )
)
).scalar()
eq_(r, exp)
+from sqlalchemy import exc
from sqlalchemy import ForeignKey
from sqlalchemy import func
from sqlalchemy import Integer
from sqlalchemy import select
from sqlalchemy import String
from sqlalchemy import testing
+from sqlalchemy import tuple_
from sqlalchemy.orm import aliased
from sqlalchemy.orm import Bundle
from sqlalchemy.orm import mapper
from sqlalchemy.orm import relationship
from sqlalchemy.orm import Session
from sqlalchemy.sql.elements import ClauseList
+from sqlalchemy.testing import assert_raises_message
from sqlalchemy.testing import AssertsCompiledSQL
from sqlalchemy.testing import eq_
from sqlalchemy.testing import fixtures
)
sess.commit()
+ def test_tuple_suggests_bundle(self, connection):
+ Data, Other = self.classes("Data", "Other")
+
+ sess = Session(connection)
+ q = sess.query(tuple_(Data.id, Other.id)).join(Data.others)
+
+ assert_raises_message(
+ exc.CompileError,
+ r"Most backends don't support SELECTing from a tuple\(\) object. "
+ "If this is an ORM query, consider using the Bundle object.",
+ q.all,
+ )
+
+ def test_tuple_suggests_bundle_future(self, connection):
+ Data, Other = self.classes("Data", "Other")
+
+ stmt = select(tuple_(Data.id, Other.id)).join(Data.others)
+
+ sess = Session(connection, future=True)
+
+ assert_raises_message(
+ exc.CompileError,
+ r"Most backends don't support SELECTing from a tuple\(\) object. "
+ "If this is an ORM query, consider using the Bundle object.",
+ sess.execute,
+ stmt,
+ )
+
def test_same_named_col_clauselist(self):
Data, Other = self.classes("Data", "Other")
bundle = Bundle("pk", Data.id, Other.id)
)
t1 = tuple_(a, b, c)
expr = t1 == (3, "hi", "there")
- self._assert_types([bind.type for bind in expr.right.element.clauses])
+ self._assert_types([bind.type for bind in expr.right.clauses])
def test_type_coercion_on_in(self):
a, b, c = (
expr = t1.in_([(3, "hi", "there"), (4, "Q", "P")])
eq_(len(expr.right.value), 2)
- self._assert_types(expr.right._expanding_in_types)
+
+ self._assert_types(expr.right.type.types)
class InSelectableTest(fixtures.TestBase, testing.AssertsCompiledSQL):
assert row.x == True # noqa
assert row.y == False # noqa
+ def test_select_tuple(self, connection):
+ connection.execute(
+ users.insert(), {"user_id": 1, "user_name": "apples"},
+ )
+
+ assert_raises_message(
+ exc.CompileError,
+ r"Most backends don't support SELECTing from a tuple\(\) object.",
+ connection.execute,
+ select(tuple_(users.c.user_id, users.c.user_name)),
+ )
+
def test_like_ops(self, connection):
connection.execute(
users.insert(),
from sqlalchemy import select
from sqlalchemy import String
from sqlalchemy import Table
+from sqlalchemy import tuple_
from sqlalchemy.sql import column
from sqlalchemy.sql import table
from sqlalchemy.testing import assert_raises_message
select(table1).filter_by,
foo="bar",
)
+
+ def test_select_tuple_outer(self):
+ stmt = select(tuple_(table1.c.myid, table1.c.name))
+
+ assert_raises_message(
+ exc.CompileError,
+ r"Most backends don't support SELECTing from a tuple\(\) object. "
+ "If this is an ORM query, consider using the Bundle object.",
+ stmt.compile,
+ )
+
+ def test_select_tuple_subquery(self):
+ subq = select(
+ table1.c.name, tuple_(table1.c.myid, table1.c.name)
+ ).subquery()
+
+ stmt = select(subq.c.name)
+
+ # if we aren't fetching it, then render it
+ self.assert_compile(
+ stmt,
+ "SELECT anon_1.name FROM (SELECT mytable.name AS name, "
+ "(mytable.myid, mytable.name) AS anon_2 FROM mytable) AS anon_1",
+ )