]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
- The :meth:`.Operators.match` operator is now handled such that the
authorMike Bayer <mike_mp@zzzcomputing.com>
Thu, 4 Dec 2014 23:29:56 +0000 (18:29 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Thu, 4 Dec 2014 23:29:56 +0000 (18:29 -0500)
return type is not strictly assumed to be boolean; it now
returns a :class:`.Boolean` subclass called :class:`.MatchType`.
The type will still produce boolean behavior when used in Python
expressions, however the dialect can override its behavior at
result time.  In the case of MySQL, while the MATCH operator
is typically used in a boolean context within an expression,
if one actually queries for the value of a match expression, a
floating point value is returned; this value is not compatible
with SQLAlchemy's C-based boolean processor, so MySQL's result-set
behavior now follows that of the :class:`.Float` type.
A new operator object ``notmatch_op`` is also added to better allow
dialects to define the negation of a match operation.
fixes #3263

14 files changed:
doc/build/changelog/changelog_10.rst
doc/build/changelog/migration_10.rst
doc/build/core/types.rst
lib/sqlalchemy/dialects/mysql/base.py
lib/sqlalchemy/sql/compiler.py
lib/sqlalchemy/sql/default_comparator.py
lib/sqlalchemy/sql/elements.py
lib/sqlalchemy/sql/operators.py
lib/sqlalchemy/sql/sqltypes.py
lib/sqlalchemy/sql/type_api.py
lib/sqlalchemy/types.py
test/dialect/mysql/test_query.py
test/dialect/postgresql/test_query.py
test/sql/test_operators.py

index ad9eefa09b216706c33ca6173b970d68bb61c6b9..f90ae40f81eb7d436c20ddfb44ca1895be11cf29 100644 (file)
@@ -1,3 +1,4 @@
+
 ==============
 1.0 Changelog
 ==============
     series as well.  For changes that are specific to 1.0 with an emphasis
     on compatibility concerns, see :doc:`/changelog/migration_10`.
 
+    .. change::
+        :tags: bug, mysql
+        :tickets: 3263
+
+        The :meth:`.Operators.match` operator is now handled such that the
+        return type is not strictly assumed to be boolean; it now
+        returns a :class:`.Boolean` subclass called :class:`.MatchType`.
+        The type will still produce boolean behavior when used in Python
+        expressions, however the dialect can override its behavior at
+        result time.  In the case of MySQL, while the MATCH operator
+        is typically used in a boolean context within an expression,
+        if one actually queries for the value of a match expression, a
+        floating point value is returned; this value is not compatible
+        with SQLAlchemy's C-based boolean processor, so MySQL's result-set
+        behavior now follows that of the :class:`.Float` type.
+        A new operator object ``notmatch_op`` is also added to better allow
+        dialects to define the negation of a match operation.
+
+        .. seealso::
+
+            :ref:`change_3263`
+
     .. change::
         :tags: bug, postgresql
         :tickets: 3264
index e148e7d70cf4850833d19ae76d32cae104285a32..929a5fe3d012f7b8dcfbf4f5cf66f8523a024966 100644 (file)
@@ -1547,6 +1547,37 @@ again works on MySQL.
 
 :ticket:`3186`
 
+.. _change_3263:
+
+The match() operator now returns an agnostic MatchType compatible with MySQL's floating point return value
+----------------------------------------------------------------------------------------------------------
+
+The return type of a :meth:`.Operators.match` expression is now a new type
+called :class:`.MatchType`.  This is a subclass of :class:`.Boolean`,
+that can be intercepted by the dialect in order to produce a different
+result type at SQL execution time.
+
+Code like the following will now function correctly and return floating points
+on MySQL::
+
+    >>> connection.execute(
+    ...    select([
+    ...        matchtable.c.title.match('Agile Ruby Programming').label('ruby'),
+    ...        matchtable.c.title.match('Dive Python').label('python'),
+    ...        matchtable.c.title
+    ...    ]).order_by(matchtable.c.id)
+    ... )
+    [
+        (2.0, 0.0, 'Agile Web Development with Ruby On Rails'),
+        (0.0, 2.0, 'Dive Into Python'),
+        (2.0, 0.0, "Programming Matz's Ruby"),
+        (0.0, 0.0, 'The Definitive Guide to Django'),
+        (0.0, 1.0, 'Python in a Nutshell')
+    ]
+
+
+:ticket:`3263`
+
 .. _change_3182:
 
 PyODBC driver name is required with hostname-based SQL Server connections
index 14e30e46d1b824996493e70969edad90d3d27b9b..22b36a648d2b9977b13e63e9faf89177a4422dda 100644 (file)
@@ -67,6 +67,9 @@ Standard Types`_ and the other sections of this chapter.
 .. autoclass:: LargeBinary
   :members:
 
+.. autoclass:: MatchType
+  :members:
+
 .. autoclass:: Numeric
   :members:
 
index 58eb3afa03cfdf88e825d7a823e964e509182191..c868f58b2be1a7ba43889598278dd96ce18bcc8f 100644 (file)
@@ -602,6 +602,14 @@ class _StringType(sqltypes.String):
                                  to_inspect=[_StringType, sqltypes.String])
 
 
+class _MatchType(sqltypes.Float, sqltypes.MatchType):
+    def __init__(self, **kw):
+        # TODO: float arguments?
+        sqltypes.Float.__init__(self)
+        sqltypes.MatchType.__init__(self)
+
+
+
 class NUMERIC(_NumericType, sqltypes.NUMERIC):
     """MySQL NUMERIC type."""
 
@@ -1544,6 +1552,7 @@ colspecs = {
     sqltypes.Float: FLOAT,
     sqltypes.Time: TIME,
     sqltypes.Enum: ENUM,
+    sqltypes.MatchType: _MatchType
 }
 
 # Everything 3.23 through 5.1 excepting OpenGIS types.
index b102f024030971e576452c81812b43ff6d229830..29a7401a1669e24d6d55159cb87957c19c05b968 100644 (file)
@@ -82,6 +82,7 @@ OPERATORS = {
     operators.eq: ' = ',
     operators.concat_op: ' || ',
     operators.match_op: ' MATCH ',
+    operators.notmatch_op: ' NOT MATCH ',
     operators.in_op: ' IN ',
     operators.notin_op: ' NOT IN ',
     operators.comma_op: ', ',
@@ -862,14 +863,18 @@ class SQLCompiler(Compiled):
         else:
             return "%s = 0" % self.process(element.element, **kw)
 
-    def visit_binary(self, binary, **kw):
+    def visit_notmatch_op_binary(self, binary, operator, **kw):
+        return "NOT %s" % self.visit_binary(
+            binary, override_operator=operators.match_op)
+
+    def visit_binary(self, binary, override_operator=None, **kw):
         # don't allow "? = ?" to render
         if self.ansi_bind_rules and \
                 isinstance(binary.left, elements.BindParameter) and \
                 isinstance(binary.right, elements.BindParameter):
             kw['literal_binds'] = True
 
-        operator_ = binary.operator
+        operator_ = override_operator or binary.operator
         disp = getattr(self, "visit_%s_binary" % operator_.__name__, None)
         if disp:
             return disp(binary, operator_, **kw)
index 4f53e2979873195aeca6a48dcddf940f03355674..d26fdc4556a16f06c9ca304fd420a40b29af36e6 100644 (file)
@@ -68,8 +68,12 @@ class _DefaultColumnComparator(operators.ColumnOperators):
 
     def _boolean_compare(self, expr, op, obj, negate=None, reverse=False,
                          _python_is_types=(util.NoneType, bool),
+                         result_type = None,
                          **kwargs):
 
+        if result_type is None:
+            result_type = type_api.BOOLEANTYPE
+
         if isinstance(obj, _python_is_types + (Null, True_, False_)):
 
             # allow x ==/!= True/False to be treated as a literal.
@@ -80,7 +84,7 @@ class _DefaultColumnComparator(operators.ColumnOperators):
                 return BinaryExpression(expr,
                                         _literal_as_text(obj),
                                         op,
-                                        type_=type_api.BOOLEANTYPE,
+                                        type_=result_type,
                                         negate=negate, modifiers=kwargs)
             else:
                 # all other None/True/False uses IS, IS NOT
@@ -103,13 +107,13 @@ class _DefaultColumnComparator(operators.ColumnOperators):
             return BinaryExpression(obj,
                                     expr,
                                     op,
-                                    type_=type_api.BOOLEANTYPE,
+                                    type_=result_type,
                                     negate=negate, modifiers=kwargs)
         else:
             return BinaryExpression(expr,
                                     obj,
                                     op,
-                                    type_=type_api.BOOLEANTYPE,
+                                    type_=result_type,
                                     negate=negate, modifiers=kwargs)
 
     def _binary_operate(self, expr, op, obj, reverse=False, result_type=None,
@@ -125,7 +129,8 @@ class _DefaultColumnComparator(operators.ColumnOperators):
             op, result_type = left.comparator._adapt_expression(
                 op, right.comparator)
 
-        return BinaryExpression(left, right, op, type_=result_type)
+        return BinaryExpression(
+            left, right, op, type_=result_type, modifiers=kw)
 
     def _conjunction_operate(self, expr, op, other, **kw):
         if op is operators.and_:
@@ -216,11 +221,16 @@ class _DefaultColumnComparator(operators.ColumnOperators):
 
     def _match_impl(self, expr, op, other, **kw):
         """See :meth:`.ColumnOperators.match`."""
+
         return self._boolean_compare(
             expr, operators.match_op,
             self._check_literal(
                 expr, operators.match_op, other),
-            **kw)
+            result_type=type_api.MATCHTYPE,
+            negate=operators.notmatch_op
+            if op is operators.match_op else operators.match_op,
+            **kw
+        )
 
     def _distinct_impl(self, expr, op, **kw):
         """See :meth:`.ColumnOperators.distinct`."""
@@ -282,6 +292,7 @@ class _DefaultColumnComparator(operators.ColumnOperators):
         "isnot": (_boolean_compare, operators.isnot),
         "collate": (_collate_impl,),
         "match_op": (_match_impl,),
+        "notmatch_op": (_match_impl,),
         "distinct_op": (_distinct_impl,),
         "between_op": (_between_impl, ),
         "notbetween_op": (_between_impl, ),
index 734f78632b69ef036bda447ae7ff168a6f04020a..30965c80184f5b1f43885a46734f8bb5d4648816 100644 (file)
@@ -2763,7 +2763,7 @@ class BinaryExpression(ColumnElement):
                 self.right,
                 self.negate,
                 negate=self.operator,
-                type_=type_api.BOOLEANTYPE,
+                type_=self.type,
                 modifiers=self.modifiers)
         else:
             return super(BinaryExpression, self)._negate()
index 9453563280ca15b108c753240f572d5e963392ff..b08e44ab87d6542f27d3b2507b1f10473a3700fb 100644 (file)
@@ -767,6 +767,10 @@ def match_op(a, b, **kw):
     return a.match(b, **kw)
 
 
+def notmatch_op(a, b, **kw):
+    return a.notmatch(b, **kw)
+
+
 def comma_op(a, b):
     raise NotImplementedError()
 
@@ -834,6 +838,7 @@ _PRECEDENCE = {
 
     concat_op: 6,
     match_op: 6,
+    notmatch_op: 6,
 
     ilike_op: 6,
     notilike_op: 6,
index 7bf2f337ce432dbc57e0c866f8a3166a325da227..94db1d83701ce2d9650fa818dd1b0a91bc8dc08d 100644 (file)
@@ -1654,10 +1654,26 @@ class NullType(TypeEngine):
     comparator_factory = Comparator
 
 
+class MatchType(Boolean):
+    """Refers to the return type of the MATCH operator.
+
+    As the :meth:`.Operators.match` is probably the most open-ended
+    operator in generic SQLAlchemy Core, we can't assume the return type
+    at SQL evaluation time, as MySQL returns a floating point, not a boolean,
+    and other backends might do something different.    So this type
+    acts as a placeholder, currently subclassing :class:`.Boolean`.
+    The type allows dialects to inject result-processing functionality
+    if needed, and on MySQL will return floating-point values.
+
+    .. versionadded:: 1.0.0
+
+    """
+
 NULLTYPE = NullType()
 BOOLEANTYPE = Boolean()
 STRINGTYPE = String()
 INTEGERTYPE = Integer()
+MATCHTYPE = MatchType()
 
 _type_map = {
     int: Integer(),
@@ -1685,6 +1701,7 @@ type_api.BOOLEANTYPE = BOOLEANTYPE
 type_api.STRINGTYPE = STRINGTYPE
 type_api.INTEGERTYPE = INTEGERTYPE
 type_api.NULLTYPE = NULLTYPE
+type_api.MATCHTYPE = MATCHTYPE
 type_api._type_map = _type_map
 
 # this one, there's all kinds of ways to play it, but at the EOD
index 77c6e1b1eec79431ba52a22e1743bc261335d4e8..d3e0a008ecb40010087ffa7fb8e6e6fb0cff9b72 100644 (file)
@@ -19,7 +19,7 @@ BOOLEANTYPE = None
 INTEGERTYPE = None
 NULLTYPE = None
 STRINGTYPE = None
-
+MATCHTYPE = None
 
 class TypeEngine(Visitable):
     """The ultimate base class for all SQL datatypes.
index b49e389ac35769d59243087e4357ea1e131001e1..1215bd79023b4005bd5cce4bcf98cb51f209710a 100644 (file)
@@ -51,6 +51,7 @@ from .sql.sqltypes import (
     Integer,
     Interval,
     LargeBinary,
+    MatchType,
     NCHAR,
     NVARCHAR,
     NullType,
index e085d86c125af917fcbbe5d57310a4414a26d2d3..ccb501651a4b467cb39aed447e2585429a8bc751 100644 (file)
@@ -55,7 +55,7 @@ class MatchTest(fixtures.TestBase, AssertsCompiledSQL):
         ])
         matchtable.insert().execute([
             {'id': 1,
-             'title': 'Agile Web Development with Rails',
+             'title': 'Agile Web Development with Ruby On Rails',
              'category_id': 2},
             {'id': 2,
              'title': 'Dive Into Python',
@@ -76,7 +76,7 @@ class MatchTest(fixtures.TestBase, AssertsCompiledSQL):
         metadata.drop_all()
 
     @testing.fails_on('mysql+mysqlconnector', 'uses pyformat')
-    def test_expression(self):
+    def test_expression_format(self):
         format = testing.db.dialect.paramstyle == 'format' and '%s' or '?'
         self.assert_compile(
             matchtable.c.title.match('somstr'),
@@ -88,7 +88,7 @@ class MatchTest(fixtures.TestBase, AssertsCompiledSQL):
     @testing.fails_on('mysql+oursql', 'uses format')
     @testing.fails_on('mysql+pyodbc', 'uses format')
     @testing.fails_on('mysql+zxjdbc', 'uses format')
-    def test_expression(self):
+    def test_expression_pyformat(self):
         format = '%(title_1)s'
         self.assert_compile(
             matchtable.c.title.match('somstr'),
@@ -102,6 +102,14 @@ class MatchTest(fixtures.TestBase, AssertsCompiledSQL):
                    fetchall())
         eq_([2, 5], [r.id for r in results])
 
+    def test_not_match(self):
+        results = (matchtable.select().
+                   where(~matchtable.c.title.match('python')).
+                   order_by(matchtable.c.id).
+                   execute().
+                   fetchall())
+        eq_([1, 3, 4], [r.id for r in results])
+
     def test_simple_match_with_apostrophe(self):
         results = (matchtable.select().
                    where(matchtable.c.title.match("Matz's")).
@@ -109,6 +117,26 @@ class MatchTest(fixtures.TestBase, AssertsCompiledSQL):
                    fetchall())
         eq_([3], [r.id for r in results])
 
+    def test_return_value(self):
+        # test [ticket:3263]
+        result = testing.db.execute(
+            select([
+                matchtable.c.title.match('Agile Ruby Programming').label('ruby'),
+                matchtable.c.title.match('Dive Python').label('python'),
+                matchtable.c.title
+            ]).order_by(matchtable.c.id)
+        ).fetchall()
+        eq_(
+            result,
+            [
+                (2.0, 0.0, 'Agile Web Development with Ruby On Rails'),
+                (0.0, 2.0, 'Dive Into Python'),
+                (2.0, 0.0, "Programming Matz's Ruby"),
+                (0.0, 0.0, 'The Definitive Guide to Django'),
+                (0.0, 1.0, 'Python in a Nutshell')
+            ]
+        )
+
     def test_or_match(self):
         results1 = (matchtable.select().
                     where(or_(matchtable.c.title.match('nutshell'),
@@ -116,14 +144,13 @@ class MatchTest(fixtures.TestBase, AssertsCompiledSQL):
                     order_by(matchtable.c.id).
                     execute().
                     fetchall())
-        eq_([3, 5], [r.id for r in results1])
+        eq_([1, 3, 5], [r.id for r in results1])
         results2 = (matchtable.select().
                     where(matchtable.c.title.match('nutshell ruby')).
                     order_by(matchtable.c.id).
                     execute().
                     fetchall())
-        eq_([3, 5], [r.id for r in results2])
-
+        eq_([1, 3, 5], [r.id for r in results2])
 
     def test_and_match(self):
         results1 = (matchtable.select().
index a512b56fa01350f625887fc081e2e7afbb7e17d0..6841f397a9c835452deb9c04a1663ce539ae5d2c 100644 (file)
@@ -703,6 +703,12 @@ class MatchTest(fixtures.TestBase, AssertsCompiledSQL):
             matchtable.c.id).execute().fetchall()
         eq_([2, 5], [r.id for r in results])
 
+    def test_not_match(self):
+        results = matchtable.select().where(
+            ~matchtable.c.title.match('python')).order_by(
+            matchtable.c.id).execute().fetchall()
+        eq_([1, 3, 4], [r.id for r in results])
+
     def test_simple_match_with_apostrophe(self):
         results = matchtable.select().where(
             matchtable.c.title.match("Matz's")).execute().fetchall()
index e8ad88511482f9009137ee1ea40257fb924e0846..f8ac1528f01e8b6b976d4f3fe0f54b43d36b8f35 100644 (file)
@@ -12,7 +12,8 @@ from sqlalchemy import exc
 from sqlalchemy.engine import default
 from sqlalchemy.sql.elements import _literal_as_text
 from sqlalchemy.schema import Column, Table, MetaData
-from sqlalchemy.types import TypeEngine, TypeDecorator, UserDefinedType, Boolean
+from sqlalchemy.types import TypeEngine, TypeDecorator, UserDefinedType, \
+    Boolean, NullType, MatchType
 from sqlalchemy.dialects import mysql, firebird, postgresql, oracle, \
     sqlite, mssql
 from sqlalchemy import util
@@ -1619,6 +1620,31 @@ class MatchTest(fixtures.TestBase, testing.AssertsCompiledSQL):
                             "CONTAINS (mytable.myid, :myid_1)",
                             dialect=oracle.dialect())
 
+    def test_match_is_now_matchtype(self):
+        expr = self.table1.c.myid.match('somstr')
+        assert expr.type._type_affinity is MatchType()._type_affinity
+        assert isinstance(expr.type, MatchType)
+
+    def test_boolean_inversion_postgresql(self):
+        self.assert_compile(
+            ~self.table1.c.myid.match('somstr'),
+            "NOT mytable.myid @@ to_tsquery(%(myid_1)s)",
+            dialect=postgresql.dialect())
+
+    def test_boolean_inversion_mysql(self):
+        # because mysql doesnt have native boolean
+        self.assert_compile(
+            ~self.table1.c.myid.match('somstr'),
+            "NOT MATCH (mytable.myid) AGAINST (%s IN BOOLEAN MODE)",
+            dialect=mysql.dialect())
+
+    def test_boolean_inversion_mssql(self):
+        # because mssql doesnt have native boolean
+        self.assert_compile(
+            ~self.table1.c.myid.match('somstr'),
+            "NOT CONTAINS (mytable.myid, :myid_1)",
+            dialect=mssql.dialect())
+
 
 class ComposedLikeOperatorsTest(fixtures.TestBase, testing.AssertsCompiledSQL):
     __dialect__ = 'default'