Repository: mabel-dev/opteryx

Issue:
🪲 JOIN binding fails in complex test case
### Thank you for taking the time to report a problem with Opteryx.
_To help us to respond to your request we ask that you try to provide the below detail about the bug._

**Describe the bug** _A clear and specific description of what the bug is. What the error, incorrect or unexpected behaviour was._


**Expected behaviour** _A clear and concise description of what you expected to happen._


**Sample Code/Statement** _If you can, please submit the SQL statement or Python code snippet, or a representative example using the sample datasets._

~~~sql
SET @planet = 'Saturn';
SELECT name AS nom, bigsats.occurances, smallsats.occurances
FROM (
    SELECT DISTINCT id as planetId, name 
    FROM $planets 
    WHERE name = @planet
    ) as planets 
LEFT JOIN (
    SELECT planetId, COUNT(*) AS occurrences 
    FROM $satellites AS S1 
    FOR DATES BETWEEN '2022-01-01' AND TODAY 
    WHERE gm > 10 
    GROUP BY planetId
    ) AS bigsats 
ON bigsats.planetId = planets.planetId 
LEFT JOIN (
    SELECT planetId, COUNT(*) AS occurrences 
    FROM $satellites AS S2 
    FOR DATES IN LAST_MONTH 
    WHERE gm < 10
    GROUP BY planetId
    ) as smallsats 
ON smallsats.planetId = planets.planetId; 
~~~

**Additional context** _Add any other context about the problem here, for example what you have done to try to diagnose or workaround the problem._



Discussion:

Resolving patch:
diff --git a/opteryx/components/binder/binder.py b/opteryx/components/binder/binder.py
--- a/opteryx/components/binder/binder.py
+++ b/opteryx/components/binder/binder.py
@@ -29,44 +29,12 @@
 from opteryx.exceptions import UnexpectedDatasetReferenceError
 from opteryx.functions import FUNCTIONS
 from opteryx.managers.expression import NodeType
-from opteryx.managers.expression import format_expression
 from opteryx.models import Node
 from opteryx.operators.aggregate_node import AGGREGATORS
 
 COMBINED_FUNCTIONS = {**FUNCTIONS, **AGGREGATORS}
 
 
-def hash_tree(node):
-    from orso.cityhash import CityHash64
-
-    def inner(node):
-        _hash = 0
-
-        # First recurse and do this for all the sub parts of the evaluation plan
-        if node.left:
-            _hash ^= inner(node.left)
-        if node.right:
-            _hash ^= inner(node.right)
-        if node.centre:
-            _hash ^= inner(node.centre)
-        if node.parameters:
-            for parameter in node.parameters:
-                _hash ^= inner(parameter)
-
-        if _hash == 0 and node.identity is not None:
-            return CityHash64(node.identity)
-        if _hash == 0 and node.schema_column is not None:
-            return CityHash64(node.schema_column.identity)
-        if _hash == 0 and node.value is not None:
-            return CityHash64(str(node.value))
-        if _hash == 0 and node.node_type == NodeType.WILDCARD:
-            return CityHash64(str(node.source) + "*")
-        return _hash
-
-    _hash = CityHash64(format_expression(node, True)) ^ inner(node)
-    return hex(_hash)[2:]
-
-
 def merge_schemas(*schemas: Dict[str, RelationSchema]) -> Dict[str, RelationSchema]:
     """
     Handles the merging of relations, requiring a custom merge function.
@@ -333,11 +301,8 @@ def inner_binder(node: Node, context: Dict[str, Any]) -> Tuple[Node, Dict[str, A
                 raise FunctionNotFoundError(function=node.value, suggestion=suggest)
 
             # we need to add this new column to the schema
-            identity = hash_tree(node)
             aliases = [node.alias] if node.alias else []
-            schema_column = FunctionColumn(
-                name=column_name, type=0, binding=func, aliases=aliases, identity=identity
-            )
+            schema_column = FunctionColumn(name=column_name, type=0, binding=func, aliases=aliases)
             schemas["$derived"].columns.append(schema_column)
             node.function = func
             node.derived_from = []
@@ -345,13 +310,11 @@ def inner_binder(node: Node, context: Dict[str, Any]) -> Tuple[Node, Dict[str, A
             node.query_column = node.alias or column_name
 
         else:
-            identity = hash_tree(node)
             schema_column = ExpressionColumn(
                 name=column_name,
                 aliases=[node.alias] if node.alias else [],
                 type=0,
                 expression=node.value,
-                identity=identity,
             )
             schemas["$derived"].columns.append(schema_column)
             node.schema_column = schema_column
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
@@ -747,22 +747,27 @@ def visit_subquery(self, node: Node, context: BindingContext) -> Tuple[Node, Bin
         columns = []
         for name, schema in context.schemas.items():
             for schema_column in schema.columns:
-                projection_column = [
-                    column
-                    for column in node.columns
-                    if column.schema_column.identity == schema_column.identity
-                ]
-                if len(projection_column) > 1:
-                    from opteryx.exceptions import AmbiguousIdentifierError
-
-                    raise AmbiguousIdentifierError(
-                        message=f"Sub Query contains multiple instances of the same column."
-                    )
+                # Find the column in the projection if it exists
+                projection_column = next(
+                    (
+                        column
+                        for column in node.columns
+                        if column.schema_column.identity == schema_column.identity
+                    ),
+                    None,
+                )
 
+                projection_column.source = node.alias
                 schema_column.origin = [node.alias]
+
                 schema_column.name = (
-                    projection_column[0].current_name if projection_column else schema_column.name
+                    projection_column.current_name if projection_column else schema_column.name
                 )
+
+                if "." in schema_column.name:
+                    # If the column is not in the projection, it should retain its name without any prefix
+                    schema_column.name = schema_column.name.split(".")[-1]
+
                 schema_column.aliases = []
                 columns.append(schema_column)
             if name[0] != "$" and name in context.relations:
@@ -772,6 +777,7 @@ def visit_subquery(self, node: Node, context: BindingContext) -> Tuple[Node, Bin
         schema = RelationSchema(name=node.alias, columns=columns)
 
         context.schemas = {"$derived": derived.schema(), node.alias: schema}
+        context.relations = {node.alias}
         return node, context
 
     def traverse(
diff --git a/opteryx/components/logical_planner.py b/opteryx/components/logical_planner.py
--- a/opteryx/components/logical_planner.py
+++ b/opteryx/components/logical_planner.py
@@ -174,18 +174,26 @@ def get_subplan_schemas(sub_plan: Graph) -> List[str]:
         List[str]:
             A list of schemas corresponding to the exit and entry points in the sub-plan.
     """
-    schemas = []
-    for func_name in ["get_exit_points", "get_entry_points"]:
-        points = getattr(sub_plan, func_name)()
-
-        for point in points:
-            if sub_plan[point].alias is not None:
-                schemas.append(sub_plan[point].alias)
-
-        if len(schemas) > 0:
-            return schemas
-
-    return schemas
+    aliases = set()
+
+    def traverse(nid: dict):
+        # get the actual node
+        node = sub_plan[nid["name"]]
+        # If this node is a subquery, capture its alias and stop traversing deeper
+        if node.node_type == LogicalPlanStepType.Subquery:
+            aliases.add(node.alias)
+            return  # Stop traversing this branch as it's a separate scope
+        if node.alias:
+            aliases.add(node.alias)
+
+        # Otherwise, continue traversing the children
+        for child in nid.get("children", []):
+            traverse(child)
+
+    # Start the traversal from the provided node
+    traverse(sub_plan.depth_first_search())
+
+    return list(aliases)
 
 
 """


Test patch:
diff --git a/tests/sql_battery/tests/results/prepared_statments_01.results_tests b/tests/sql_battery/tests/results/prepared_statments_01.results_tests.disabled
similarity index 100%
rename from tests/sql_battery/tests/results/prepared_statments_01.results_tests
rename to tests/sql_battery/tests/results/prepared_statments_01.results_tests.disabled
diff --git a/tests/sql_battery/tests/results/selection_expressions_04.results_tests b/tests/sql_battery/tests/results/selection_expressions_04.results_tests
--- a/tests/sql_battery/tests/results/selection_expressions_04.results_tests
+++ b/tests/sql_battery/tests/results/selection_expressions_04.results_tests
@@ -1,5 +1,5 @@
 {
     "summary": "Test NOT",
     "statement": "SELECT NOT 1 = 2 AS V",
-    "result": {"NOT 1.0 = 2.0": [true]}
+    "result": {"NOT 1 = 2": [true]}
 }
\ No newline at end of file
diff --git a/tests/sql_battery/tests/sqlectron.run_tests b/tests/sql_battery/tests/sqlectron.run_tests.disabled
similarity index 100%
rename from tests/sql_battery/tests/sqlectron.run_tests
rename to tests/sql_battery/tests/sqlectron.run_tests.disabled