Repository: tobymao/sqlglot

Issue:
Feat(mysql): transpile FULL OUTER JOIN to LEFT/UNION/RIGHT
MySQL does not support `FULL OUTER JOIN`. Whenever sqlglot is transpiling from another dialect, I'd like it to generate the equivalent operation of `LEFT JOIN` `UNION` `RIGHT JOIN`.

Discussion:

Patch:
diff --git a/sqlglot/dialects/mysql.py b/sqlglot/dialects/mysql.py
index 2185a85e..9d68deb3 100644
--- a/sqlglot/dialects/mysql.py
+++ b/sqlglot/dialects/mysql.py
@@ -629,6 +629,7 @@ class MySQL(Dialect):
                     transforms.eliminate_distinct_on,
                     transforms.eliminate_semi_and_anti_joins,
                     transforms.eliminate_qualify,
+                    transforms.eliminate_full_outer_join,
                 ]
             ),
             exp.StrPosition: strposition_to_locate_sql,
diff --git a/sqlglot/expressions.py b/sqlglot/expressions.py
index 5b012b15..f44af976 100644
--- a/sqlglot/expressions.py
+++ b/sqlglot/expressions.py
@@ -5459,7 +5459,12 @@ def _wrap(expression: E, kind: t.Type[Expression]) -> E | Paren:
 
 
 def union(
-    left: ExpOrStr, right: ExpOrStr, distinct: bool = True, dialect: DialectType = None, **opts
+    left: ExpOrStr,
+    right: ExpOrStr,
+    distinct: bool = True,
+    dialect: DialectType = None,
+    copy: bool = True,
+    **opts,
 ) -> Union:
     """
     Initializes a syntax tree from one UNION expression.
@@ -5475,19 +5480,25 @@ def union(
             If an `Expression` instance is passed, it will be used as-is.
         distinct: set the DISTINCT flag if and only if this is true.
         dialect: the dialect used to parse the input expression.
+        copy: whether or not to copy the expression.
         opts: other options to use to parse the input expressions.
 
     Returns:
         The new Union instance.
     """
-    left = maybe_parse(sql_or_expression=left, dialect=dialect, **opts)
-    right = maybe_parse(sql_or_expression=right, dialect=dialect, **opts)
+    left = maybe_parse(sql_or_expression=left, dialect=dialect, copy=copy, **opts)
+    right = maybe_parse(sql_or_expression=right, dialect=dialect, copy=copy, **opts)
 
     return Union(this=left, expression=right, distinct=distinct)
 
 
 def intersect(
-    left: ExpOrStr, right: ExpOrStr, distinct: bool = True, dialect: DialectType = None, **opts
+    left: ExpOrStr,
+    right: ExpOrStr,
+    distinct: bool = True,
+    dialect: DialectType = None,
+    copy: bool = True,
+    **opts,
 ) -> Intersect:
     """
     Initializes a syntax tree from one INTERSECT expression.
@@ -5503,19 +5514,25 @@ def intersect(
             If an `Expression` instance is passed, it will be used as-is.
         distinct: set the DISTINCT flag if and only if this is true.
         dialect: the dialect used to parse the input expression.
+        copy: whether or not to copy the expression.
         opts: other options to use to parse the input expressions.
 
     Returns:
         The new Intersect instance.
     """
-    left = maybe_parse(sql_or_expression=left, dialect=dialect, **opts)
-    right = maybe_parse(sql_or_expression=right, dialect=dialect, **opts)
+    left = maybe_parse(sql_or_expression=left, dialect=dialect, copy=copy, **opts)
+    right = maybe_parse(sql_or_expression=right, dialect=dialect, copy=copy, **opts)
 
     return Intersect(this=left, expression=right, distinct=distinct)
 
 
 def except_(
-    left: ExpOrStr, right: ExpOrStr, distinct: bool = True, dialect: DialectType = None, **opts
+    left: ExpOrStr,
+    right: ExpOrStr,
+    distinct: bool = True,
+    dialect: DialectType = None,
+    copy: bool = True,
+    **opts,
 ) -> Except:
     """
     Initializes a syntax tree from one EXCEPT expression.
@@ -5531,13 +5548,14 @@ def except_(
             If an `Expression` instance is passed, it will be used as-is.
         distinct: set the DISTINCT flag if and only if this is true.
         dialect: the dialect used to parse the input expression.
+        copy: whether or not to copy the expression.
         opts: other options to use to parse the input expressions.
 
     Returns:
         The new Except instance.
     """
-    left = maybe_parse(sql_or_expression=left, dialect=dialect, **opts)
-    right = maybe_parse(sql_or_expression=right, dialect=dialect, **opts)
+    left = maybe_parse(sql_or_expression=left, dialect=dialect, copy=copy, **opts)
+    right = maybe_parse(sql_or_expression=right, dialect=dialect, copy=copy, **opts)
 
     return Except(this=left, expression=right, distinct=distinct)
 
diff --git a/sqlglot/transforms.py b/sqlglot/transforms.py
index e0fd68f7..80b66233 100644
--- a/sqlglot/transforms.py
+++ b/sqlglot/transforms.py
@@ -374,6 +374,30 @@ def eliminate_semi_and_anti_joins(expression: exp.Expression) -> exp.Expression:
     return expression
 
 
+def eliminate_full_outer_join(expression: exp.Expression) -> exp.Expression:
+    """
+    Converts a query with a FULL OUTER join to a union of identical queries that
+    use LEFT/RIGHT OUTER joins instead. This transformation currently only works
+    for queries that have a single FULL OUTER join.
+    """
+    if isinstance(expression, exp.Select):
+        full_outer_joins = [
+            (index, join)
+            for index, join in enumerate(expression.args.get("joins") or [])
+            if join.side == "FULL" and join.kind == "OUTER"
+        ]
+
+        if len(full_outer_joins) == 1:
+            expression_copy = expression.copy()
+            index, full_outer_join = full_outer_joins[0]
+            full_outer_join.set("side", "left")
+            expression_copy.args["joins"][index].set("side", "right")
+
+            return exp.union(expression, expression_copy, copy=False)
+
+    return expression
+
+
 def preprocess(
     transforms: t.List[t.Callable[[exp.Expression], exp.Expression]],
 ) -> t.Callable[[Generator, exp.Expression], str]:


Test patch:
diff --git a/tests/dialects/test_mysql.py b/tests/dialects/test_mysql.py
index dce2b9d3..4d4c8430 100644
--- a/tests/dialects/test_mysql.py
+++ b/tests/dialects/test_mysql.py
@@ -566,6 +566,13 @@ class TestMySQL(Validator):
         )
 
     def test_mysql(self):
+        self.validate_all(
+            # MySQL doesn't support FULL OUTER joins
+            "SELECT * FROM t1 LEFT OUTER JOIN t2 ON t1.x = t2.x UNION SELECT * FROM t1 RIGHT OUTER JOIN t2 ON t1.x = t2.x",
+            read={
+                "postgres": "SELECT * FROM t1 FULL OUTER JOIN t2 ON t1.x = t2.x",
+            },
+        )
         self.validate_all(
             "a XOR b",
             read={