Repository: mabel-dev/opteryx

Issue:
🪲 multi way joins aren't binding all fields in join conditions 
~~~sql
SELECT a.id, b.id, c.id 
  FROM $planets AS a 
 INNER JOIN $planets AS b ON a.id = b.id 
 INNER JOIN $planets AS c ON c.id = b.id 
~~~




Discussion:
This is a naming clash, because the formatter formats `a.id = b.id` to `id = id`, and the same for `c.id = b.id` when it sees it a second time, it thinks it's already bound the leaf nodes.

Resolving patch:
diff --git a/opteryx/components/binder/binder_visitor.py b/opteryx/components/binder/binder_visitor.py
--- a/opteryx/components/binder/binder_visitor.py
+++ b/opteryx/components/binder/binder_visitor.py
@@ -329,7 +329,9 @@ def name_column(qualifier, column):
         def keep_column(column, identities):
             if len(node.columns) == 1 and node.columns[0].node_type == NodeType.WILDCARD:
                 if node.columns[0].value:
-                    return node.columns[0].value[0] == column.origin
+                    if isinstance(column.origin, str):
+                        column.origin = [column.origin]
+                    return node.columns[0].value[0] in column.origin
                 return True
             return column.identity in identities
 
@@ -430,7 +432,6 @@ def visit_join(self, node: Node, context: BindingContext) -> Tuple[Node, Binding
             Tuple[Node, Dict]
                 Updated node and context.
         """
-
         # Handle 'natural join' by converting to a 'using'
         if node.type == "natural join":
             left_columns = [
@@ -472,17 +473,15 @@ def visit_join(self, node: Node, context: BindingContext) -> Tuple[Node, Binding
             left_relation_name = ""
             right_relation_name = ""
             for column_name in (n.value for n in node.using):
-                # Try to pop the column from each left relation until found
+                # Pop the column from the left relation
                 for left_relation_name in node.left_relation_names:
-                    left_column = context.schemas[left_relation_name].pop_column(column_name)
-                    if left_column is not None:
-                        left_column.source_relation = None
-                        break
+                    context.schemas[left_relation_name].pop_column(column_name)
 
-                # Try to pop the column from each right relation until found
+                # Pop the column from the right relation, keep this one to add to a new relation
                 for right_relation_name in node.right_relation_names:
                     right_column = context.schemas[right_relation_name].pop_column(column_name)
                     if right_column is not None:
+                        right_column.origin = [left_relation_name, right_relation_name]
                         columns.append(right_column)
                         break
 
@@ -533,8 +532,8 @@ def visit_project(self, node: Node, context: BindingContext) -> Tuple[Node, Bind
                                 schema_column=schema_column,
                             )
                             columns.append(column_reference)
-                        if name.startswith("$shared") and f"^{column.value[0]}#" in schema.name:
-                            context.schemas.pop(name)
+                    if name.startswith("$shared") and f"^{column.value[0]}#" in schema.name:
+                        context.schemas.pop(name)
 
                     context.schemas[column.value[0]] = RelationSchema(
                         name=name, columns=[col.schema_column for col in columns]
diff --git a/opteryx/managers/expression/formatter.py b/opteryx/managers/expression/formatter.py
--- a/opteryx/managers/expression/formatter.py
+++ b/opteryx/managers/expression/formatter.py
@@ -10,7 +10,7 @@ class ExpressionColumn(FlatColumn):
     expression: Any = None
 
 
-def format_expression(root):
+def format_expression(root, qualify: bool = False):
     # circular imports
     from . import INTERNAL_TYPE
     from . import NodeType
@@ -18,8 +18,14 @@ def format_expression(root):
     if root is None:
         return "null"
 
+    if not qualify and root.left and root.right:
+        # if left and right would look the same without qualifying, force qualification
+        qualify = (root.left.current_name == root.right.current_name) and (
+            root.right.current_name is not None
+        )
+
     if isinstance(root, list):
-        return [format_expression(item) for item in root]
+        return [format_expression(item, qualify) for item in root]
 
     node_type = root.node_type
     _map: dict = {}
@@ -38,8 +44,8 @@ def format_expression(root):
     if node_type & INTERNAL_TYPE == INTERNAL_TYPE:
         if node_type in (NodeType.FUNCTION, NodeType.AGGREGATOR):
             if root.value == "CASE":
-                con = [format_expression(a) for a in root.parameters[0].value]
-                vals = [format_expression(a) for a in root.parameters[1].value]
+                con = [format_expression(a, qualify) for a in root.parameters[0].value]
+                vals = [format_expression(a, qualify) for a in root.parameters[1].value]
                 return "CASE " + "".join([f"WHEN {c} THEN {v} " for c, v in zip(con, vals)]) + "END"
             distinct = "DISTINCT " if root.distinct else ""
             order = ""
@@ -47,8 +53,8 @@ def format_expression(root):
                 order = f" ORDER BY {', '.join(item[0].value + (' DESC' if not item[1] else '') for item in (root.order or []))}"
             if root.value == "ARRAY_AGG":
                 limit = f" LIMIT {root.limit}" if root.limit else ""
-                return f"{root.value.upper()}({distinct}{format_expression(root.expression)}{order}{limit})"
-            return f"{root.value.upper()}({distinct}{','.join([format_expression(e) for e in root.parameters])}{order})"
+                return f"{root.value.upper()}({distinct}{format_expression(root.expression, qualify)}{order}{limit})"
+            return f"{root.value.upper()}({distinct}{','.join([format_expression(e, qualify) for e in root.parameters])}{order})"
         if node_type == NodeType.WILDCARD:
             if root.value:
                 return f"{root.value[0]}.*"
@@ -68,7 +74,7 @@ def format_expression(root):
                 "ShiftLeft": "<<",
                 "ShiftRight": ">>",
             }
-            return f"{format_expression(root.left)} {_map.get(root.value, root.value).upper()} {format_expression(root.right)}"
+            return f"{format_expression(root.left, qualify)} {_map.get(root.value, root.value).upper()} {format_expression(root.right, qualify)}"
     if node_type == NodeType.COMPARISON_OPERATOR:
         _map = {
             "Eq": "=",
@@ -79,23 +85,25 @@ def format_expression(root):
             "LtEq": "<=",
             "GtEq": ">=",
         }
-        return f"{format_expression(root.left)} {_map.get(root.value, root.value).upper()} {format_expression(root.right)}"
+        return f"{format_expression(root.left, qualify)} {_map.get(root.value, root.value).upper()} {format_expression(root.right, qualify)}"
     if node_type == NodeType.UNARY_OPERATOR:
         _map = {"IsNull": "%s IS NULL", "IsNotNull": "%s IS NOT NULL"}
         return _map.get(root.value, root.value + "(%s)").replace(
-            "%s", format_expression(root.centre)
+            "%s", format_expression(root.centre, qualify)
         )
     if node_type == NodeType.NOT:
-        return f"NOT {format_expression(root.centre)}"
+        return f"NOT {format_expression(root.centre, qualify)}"
     if node_type in (NodeType.AND, NodeType.OR, NodeType.XOR):
         _map = {
             NodeType.AND: "AND",
             NodeType.OR: "OR",
             NodeType.XOR: "XOR",
         }  # type:ignore
-        return f"{format_expression(root.left)} {_map[node_type]} {format_expression(root.right)}"
+        return f"{format_expression(root.left, qualify)} {_map[node_type]} {format_expression(root.right, qualify)}"
     if node_type == NodeType.NESTED:
-        return f"({format_expression(root.centre)})"
+        return f"({format_expression(root.centre, qualify)})"
     if node_type == NodeType.IDENTIFIER:
+        if qualify:
+            return root.qualified_name
         return root.current_name
     return str(root.value)


Test patch:
diff --git a/tests/sql_battery/test_shapes_and_errors_battery.py b/tests/sql_battery/test_shapes_and_errors_battery.py
--- a/tests/sql_battery/test_shapes_and_errors_battery.py
+++ b/tests/sql_battery/test_shapes_and_errors_battery.py
@@ -769,8 +769,7 @@
         ("SELECT DISTINCT planetId FROM $satellites FULL OUTER JOIN $planets ON $satellites.planetId = $planets.id", 8, 1, None),
         ("SELECT DISTINCT planetId FROM $satellites FULL JOIN $planets ON $satellites.planetId = $planets.id", 8, 1, None),
         ("SELECT planetId FROM $satellites FULL JOIN $planets ON $satellites.planetId = $planets.id", 179, 1, None),
-]
-A = [
+
         ("SELECT pid FROM ( SELECT id AS pid FROM $planets) AS SQ WHERE pid > 5", 4, 1, None),
         ("SELECT * FROM ( SELECT id AS pid FROM $planets) AS SQ WHERE pid > 5", 4, 1, None),
         ("SELECT * FROM ( SELECT COUNT(planetId) AS moons, planetId FROM $satellites GROUP BY planetId ) AS SQ WHERE moons > 10", 4, 2, None),
@@ -791,7 +790,8 @@
         ("SELECT s.* FROM $planets AS s INNER JOIN $planets AS p USING (id, name)", 9, 20, None),
         ("SELECT p.* FROM $planets AS s INNER JOIN $planets AS p USING (id, name)", 9, 20, None),
         ("SELECT id, name FROM $planets AS s INNER JOIN $planets AS p USING (id, name)", 9, 2, None),
-
+]
+A = [
         ("SELECT DATE_TRUNC('month', birth_date) FROM $astronauts", 357, 1, None),
         ("SELECT DISTINCT * FROM (SELECT DATE_TRUNC('year', birth_date) AS BIRTH_YEAR FROM $astronauts)", 54, 1, None),
         ("SELECT DISTINCT * FROM (SELECT DATE_TRUNC('month', birth_date) AS BIRTH_YEAR_MONTH FROM $astronauts)", 247, 1, None),