Repository: tobymao/sqlglot

Issue:
XOR transformed into an OR depending on the alphabetical order of operands
Hello,

I stumbled upon an apparent bug still present in the 23.12.1 release (I was using 20.9.0 when I first saw the bug). When using the `optimize` function on a MySQL constraint, result depends on the alphabetical order of the operands (with one of the results being wrong):

```python
from sqlglot import parse_one
from sqlglot.optimizer import optimize

# Returns '`bar` IS NULL XOR `foo` IS NULL'
optimize(parse_one('(bar IS NULL) XOR (foo IS NULL)', read="mysql")).sql("mysql")

# Returns '`bar` IS NULL OR `foo` IS NULL'
optimize(parse_one('(foo IS NULL) XOR (bar IS NULL)', read="mysql")).sql("mysql")
```

In the second example, the XOR is transformed into an OR which in this case does not seem to be equivalent. I would expect both results to be the same (or at least not converting the XOR into OR).

Is there something wrong in the way I used those functions or is this a bug ?

I can give more details if needed !
Thank you




Discussion:
This look like a bug, thanks for reporting. We'll take a look soon.

Resolving patch:
diff --git a/sqlglot/expressions.py b/sqlglot/expressions.py
--- a/sqlglot/expressions.py
+++ b/sqlglot/expressions.py
@@ -6519,7 +6519,7 @@ def condition(
 
 def and_(
     *expressions: t.Optional[ExpOrStr], dialect: DialectType = None, copy: bool = True, **opts
-) -> Condition:
+) -> And:
     """
     Combine multiple conditions with an AND logical operator.
 
@@ -6537,12 +6537,12 @@ def and_(
     Returns:
         And: the new condition
     """
-    return t.cast(Condition, _combine(expressions, And, dialect, copy=copy, **opts))
+    return t.cast(And, _combine(expressions, And, dialect, copy=copy, **opts))
 
 
 def or_(
     *expressions: t.Optional[ExpOrStr], dialect: DialectType = None, copy: bool = True, **opts
-) -> Condition:
+) -> Or:
     """
     Combine multiple conditions with an OR logical operator.
 
@@ -6560,7 +6560,30 @@ def or_(
     Returns:
         Or: the new condition
     """
-    return t.cast(Condition, _combine(expressions, Or, dialect, copy=copy, **opts))
+    return t.cast(Or, _combine(expressions, Or, dialect, copy=copy, **opts))
+
+
+def xor(
+    *expressions: t.Optional[ExpOrStr], dialect: DialectType = None, copy: bool = True, **opts
+) -> Xor:
+    """
+    Combine multiple conditions with an XOR logical operator.
+
+    Example:
+        >>> xor("x=1", xor("y=1", "z=1")).sql()
+        'x = 1 XOR (y = 1 XOR z = 1)'
+
+    Args:
+        *expressions: the SQL code strings to parse.
+            If an Expression instance is passed, this is used as-is.
+        dialect: the dialect used to parse the input expression.
+        copy: whether to copy `expressions` (only applies to Expressions).
+        **opts: other options to use to parse the input expressions.
+
+    Returns:
+        Xor: the new condition
+    """
+    return t.cast(Xor, _combine(expressions, Xor, dialect, copy=copy, **opts))
 
 
 def not_(expression: ExpOrStr, dialect: DialectType = None, copy: bool = True, **opts) -> Not:
diff --git a/sqlglot/optimizer/simplify.py b/sqlglot/optimizer/simplify.py
--- a/sqlglot/optimizer/simplify.py
+++ b/sqlglot/optimizer/simplify.py
@@ -224,6 +224,8 @@ def flatten(expression):
 def simplify_connectors(expression, root=True):
     def _simplify_connectors(expression, left, right):
         if left == right:
+            if isinstance(expression, exp.Xor):
+                return exp.false()
             return left
         if isinstance(expression, exp.And):
             if is_false(left) or is_false(right):
@@ -365,10 +367,17 @@ def uniq_sort(expression, root=True):
     C AND A AND B AND B -> A AND B AND C
     """
     if isinstance(expression, exp.Connector) and (root or not expression.same_parent):
-        result_func = exp.and_ if isinstance(expression, exp.And) else exp.or_
         flattened = tuple(expression.flatten())
-        deduped = {gen(e): e for e in flattened}
-        arr = tuple(deduped.items())
+
+        if isinstance(expression, exp.Xor):
+            result_func = exp.xor
+            # Do not deduplicate XOR as A XOR A != A if A == True
+            deduped = None
+            arr = tuple((gen(e), e) for e in flattened)
+        else:
+            result_func = exp.and_ if isinstance(expression, exp.And) else exp.or_
+            deduped = {gen(e): e for e in flattened}
+            arr = tuple(deduped.items())
 
         # check if the operands are already sorted, if not sort them
         # A AND C AND B -> A AND B AND C
@@ -378,7 +387,7 @@ def uniq_sort(expression, root=True):
                 break
         else:
             # we didn't have to sort but maybe we need to dedup
-            if len(deduped) < len(flattened):
+            if deduped and len(deduped) < len(flattened):
                 expression = result_func(*deduped.values(), copy=False)
 
     return expression


Test patch:
diff --git a/tests/fixtures/optimizer/simplify.sql b/tests/fixtures/optimizer/simplify.sql
--- a/tests/fixtures/optimizer/simplify.sql
+++ b/tests/fixtures/optimizer/simplify.sql
@@ -109,6 +109,10 @@ a AND b;
 (x is not null) != (y is null);
 (NOT x IS NULL) <> (y IS NULL);
 
+# dialect: mysql
+A XOR A;
+FALSE;
+
 --------------------------------------
 -- Absorption
 --------------------------------------
@@ -232,6 +236,13 @@ x - 1;
 A AND D AND B AND E AND F AND G AND E AND A;
 A AND B AND D AND E AND F AND G;
 
+A OR D OR B OR E OR F OR G OR E OR A;
+A OR B OR D OR E OR F OR G;
+
+# dialect: mysql
+A XOR D XOR B XOR E XOR F XOR G XOR C;
+A XOR B XOR C XOR D XOR E XOR F XOR G;
+
 A AND NOT B AND C AND B;
 FALSE;