Repository: mabel-dev/opteryx

Issue:
✨ Add NATURAL JOIN syntax
### Thanks for stopping by to let us know something could be better!

**Is your feature request related to a problem? Please describe.** _A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]_

**Describe the solution you'd like** _A clear and concise description of what you want to happen._

**Describe alternatives you've considered** _A clear and concise description of any alternative solutions or features you've considered._

**Additional context** _Add any other context or screenshots about the feature request here._



Discussion:

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
@@ -34,6 +34,37 @@
 CAMEL_TO_SNAKE = re.compile(r"(?<!^)(?=[A-Z])")
 
 
+def get_mismatched_condition_column_types(node: Node) -> dict:
+    """
+    Checks that the types of the fields involved a comparison are the same on both sides.
+
+    Parameters:
+        node: Node
+            The condition node representing the condition.
+
+    Returns:
+        a dictionary describing the columns
+    """
+    if node.node_type == NodeType.AND:
+        left_mismatches = get_mismatched_condition_column_types(node.left)
+        right_mismatches = get_mismatched_condition_column_types(node.right)
+        return left_mismatches or right_mismatches
+
+    elif node.node_type == NodeType.COMPARISON_OPERATOR:
+        left_type = node.left.schema_column.type
+        right_type = node.right.schema_column.type
+
+        if left_type != 0 and right_type != 0 and left_type != right_type:
+            return {
+                "left_column": f"{node.left.source}.{node.left.value}",
+                "left_type": left_type.name,
+                "right_column": f"{node.right.source}.{node.right.value}",
+                "right_type": right_type.name,
+            }
+
+    return None  # if we reach here, it means we didn't find any inconsistencies
+
+
 def extract_join_fields(
     condition_node: Node, left_relation_names: List[str], right_relation_names: List[str]
 ) -> Tuple[List[str], List[str]]:
@@ -364,13 +395,21 @@ def visit_join(self, node: Node, context: BindingContext) -> Tuple[Node, Binding
                 [n.value for n in node.using], node.left_relation_names, node.right_relation_names
             )
         if node.on:
-            # cross joins, natural joins and 'using' joins don't have an "on"
+            # All except CROSS JOINs have been mapped to have an ON condition
             node.on, context = inner_binder(node.on, context, node.identity)
             node.left_columns, node.right_columns = extract_join_fields(
                 node.on, node.left_relation_names, node.right_relation_names
             )
+            mismatches = get_mismatched_condition_column_types(node.on)
+            if mismatches:
+                from opteryx.exceptions import IncompatibleTypesError
+
+                raise IncompatibleTypesError(**mismatches)
 
         if node.using:
+            # Remove the columns used in the join condition from both relations, they're in
+            # the result set but not belonging to either table. We create a new schema to
+            # put them in, $joined-nnn.
             columns = []
 
             # Loop through all using fields in the node
@@ -389,7 +428,7 @@ def visit_join(self, node: Node, context: BindingContext) -> Tuple[Node, Binding
                         columns.append(right_column)
                         break
 
-            context.schemas[f"$joined-{random_string}"] = RelationSchema(
+            context.schemas[f"$joined-{random_string()}"] = RelationSchema(
                 name="$joined", columns=columns
             )
         if node.column:
diff --git a/opteryx/exceptions.py b/opteryx/exceptions.py
--- a/opteryx/exceptions.py
+++ b/opteryx/exceptions.py
@@ -45,6 +45,7 @@
                  ├── IncorrectTypeError
                  ├── InvalidFunctionParameterError
                  ├── InvalidTemporalRangeFilterError
+                 ├── IncompatibleTypesError
                  ├── UnexpectedDatasetReferenceError
                  ├── UnnamedColumnError
                  ├── UnnamedSubqueryError
@@ -238,6 +239,55 @@ class IncorrectTypeError(SqlError):
     """Exception raised for incorrect types."""
 
 
+class IncompatibleTypesError(Exception):
+    """
+    Raised when attempting to join fields of incompatible types.
+
+    Parameters:
+        left_type: str
+            The type of the left field.
+        right_type: str
+            The type of the right field.
+        column: Optional[str]
+            If the incompatibility occurs in a single column
+        left_column: Optional[str]
+            The column name where the error occurs.
+        right_columns: Optional[str]
+            The column name where the error occurs.
+
+    Attributes:
+        left_type (str): The type of the left field.
+        right_type (str): The type of the right field.
+        column (str): The column name where the error occurs.
+        left_column (str): The column name where the error occurs.
+        right_column (str): The column name where the error occurs.
+    """
+
+    def __init__(
+        self,
+        left_type: str,
+        right_type: str,
+        column: Optional[str] = None,
+        left_column: Optional[str] = None,
+        right_column: Optional[str] = None,
+    ):
+        self.left_type = left_type
+        self.right_type = right_type
+        self.column = column
+        self.left_column = left_column
+        self.right_column = right_column
+        if self.column:
+            super().__init__(
+                f"Incompatible types for column '{column}': {left_type} and {right_type}"
+            )
+        elif self.left_column or self.right_column:
+            super().__init__(
+                f"Incompatible types for columns '{left_column}' ({left_type}) and '{right_column}' ({right_type})."
+            )
+        else:
+            super().__init__("Incompatible column types.")
+
+
 class PermissionsError(SecurityError):
     """Exception raised for permissions errors."""
 


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
@@ -52,6 +52,7 @@
     ColumnNotFoundError,
     DatasetNotFoundError,
     EmptyDatasetError,
+    IncompatibleTypesError,
     IncorrectTypeError,
     InvalidFunctionParameterError,
     InvalidTemporalRangeFilterError,
@@ -672,17 +673,18 @@
         ("SELECT P_1.* FROM $planets AS P_1 INNER JOIN $satellites AS P_2 ON P_2.id = P_1.id AND P_2.name = P_1.name", 0, 20, None),
         ("SELECT P_1.* FROM $planets AS P_1 INNER JOIN $satellites AS P_2 ON P_1.id = P_2.id AND P_2.name = P_1.name", 0, 20, None),
         ("SELECT P_1.* FROM $planets AS P_1 INNER JOIN $satellites AS P_2 ON P_2.id = P_1.id AND P_1.name = P_2.name", 0, 20, None),
-]
-A = [
+
         ("SELECT * FROM $planets AS P_1 INNER JOIN $planets AS P_2 ON P_1.id = P_2.id AND P_2.name = P_1.name", 9, 40, None),
         ("SELECT * FROM $planets NATURAL JOIN generate_series(1, 5) as id", 5, 20, None),
-        ("SELECT * FROM $planets NATURAL JOIN $planets", 5, 20, None),
-        ("SELECT * FROM $planets AS P NATURAL JOIN $satellites AS S", 5, 20, None),
+        ("SELECT * FROM $planets AS P1 NATURAL JOIN $planets AS P2", 5, 20, None),
+        ("SELECT * FROM $satellites AS P1 NATURAL JOIN $satellites AS P2", 170, 8, None),
+        ("SELECT * FROM $planets AS P NATURAL JOIN $satellites AS S", None, None, IncompatibleTypesError),
         ("SELECT id FROM $planets AS P_1 INNER JOIN $planets AS P_2 USING (id)", 9, 1, None),
         ("SELECT id, name FROM $planets AS P_1 INNER JOIN $planets AS P_2 USING (id, name)", 9, 2, None),
         ("SELECT P_1.* FROM $planets AS P_1 INNER JOIN $planets AS P_2 USING (id, name)", 9, 18, None),
         ("SELECT * FROM $satellites AS P_1 INNER JOIN $satellites AS P_2 USING (id, name)", 177, 14, None),
-
+]
+A = [
         ("SELECT DISTINCT planetId FROM $satellites RIGHT OUTER JOIN $planets ON $satellites.planetId = $planets.id", 8, 1, None),
         ("SELECT DISTINCT planetId FROM $satellites RIGHT JOIN $planets ON $satellites.planetId = $planets.id", 8, 1, None),
         ("SELECT planetId FROM $satellites RIGHT JOIN $planets ON $satellites.planetId = $planets.id", 179, 1, None),