Repository: ibis-project/ibis

Issue:
feat: support `True`, `ibis.literal(True)` as predicates to join
### Is your feature request related to a problem?

I am trying to make a framework where users can specify a join condition, and I inject that into a `left.inner_join(right, predicates=condition)`.  If a user wants to do a cross join, in SQL they would be able to pass in `1=1`, so they should be able to do the equivalent of this by passing in a literal True.

### Describe the solution you'd like

support `True`, `ibis.literal(True)` as predicates. Maybe I'm not including some other values that make this more general. I guess `False` would also work, and should just give a table with the right schema, but 0 rows.

### What version of ibis are you running?

6.0.0

### What backend(s) are you using, if any?

NA

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:
Thanks for the issue! Slated for 6.1.

Resolving patch:
diff --git a/ibis/backends/conftest.py b/ibis/backends/conftest.py
--- a/ibis/backends/conftest.py
+++ b/ibis/backends/conftest.py
@@ -382,56 +382,6 @@ def pytest_runtest_call(item):
 
     backend = next(iter(backend))
 
-    for marker in item.iter_markers(name="min_server_version"):
-        kwargs = marker.kwargs
-        if backend not in kwargs:
-            continue
-
-        funcargs = item.funcargs
-        con = funcargs.get(
-            "con",
-            getattr(
-                funcargs.get("backend"),
-                "connection",
-                None,
-            ),
-        )
-
-        if con is None:
-            continue
-
-        min_server_version = kwargs.pop(backend)
-        server_version = con.version
-        condition = vparse(server_version) < vparse(min_server_version)
-        item.add_marker(
-            pytest.mark.xfail(
-                condition,
-                reason=(
-                    f"unsupported functionality for server version {server_version}"
-                ),
-                **kwargs,
-            )
-        )
-
-    for marker in item.iter_markers(name="min_version"):
-        kwargs = marker.kwargs
-        if backend not in kwargs:
-            continue
-
-        min_version = kwargs.pop(backend)
-        reason = kwargs.pop("reason", None)
-        version = getattr(importlib.import_module(backend), "__version__", None)
-        if condition := version is None:  # pragma: no cover
-            if reason is None:
-                reason = f"{backend} backend module has no __version__ attribute"
-        else:
-            condition = vparse(version) < vparse(min_version)
-            if reason is None:
-                reason = f"test requires {backend}>={version}; got version {version}"
-            else:
-                reason = f"{backend}@{version} (<{min_version}): {reason}"
-        item.add_marker(pytest.mark.xfail(condition, reason=reason, **kwargs))
-
     # Ibis hasn't exposed existing functionality
     # This xfails so that you know when it starts to pass
     for marker in item.iter_markers(name="notimpl"):
@@ -441,13 +391,9 @@ def pytest_runtest_call(item):
                 and "raises" not in marker.kwargs.keys()
             ):
                 raise ValueError("notimpl requires a raises")
-            reason = marker.kwargs.get("reason")
-            item.add_marker(
-                pytest.mark.xfail(
-                    reason=reason or f'Feature not yet exposed in {backend}',
-                    **{k: v for k, v in marker.kwargs.items() if k != "reason"},
-                )
-            )
+            kwargs = marker.kwargs.copy()
+            kwargs.setdefault("reason", f"Feature not yet exposed in {backend}")
+            item.add_marker(pytest.mark.xfail(**kwargs))
 
     # Functionality is unavailable upstream (but could be)
     # This xfails so that you know when it starts to pass
@@ -458,23 +404,16 @@ def pytest_runtest_call(item):
                 and "raises" not in marker.kwargs.keys()
             ):
                 raise ValueError("notyet requires a raises")
-            reason = marker.kwargs.get("reason")
-            item.add_marker(
-                pytest.mark.xfail(
-                    reason=reason or f'Feature not available upstream for {backend}',
-                    **{k: v for k, v in marker.kwargs.items() if k != "reason"},
-                )
-            )
+
+            kwargs = marker.kwargs.copy()
+            kwargs.setdefault("reason", f"Feature not available upstream for {backend}")
+            item.add_marker(pytest.mark.xfail(**kwargs))
 
     for marker in item.iter_markers(name="never"):
         if backend in marker.args[0]:
             if "reason" not in marker.kwargs.keys():
                 raise ValueError("never requires a reason")
-            item.add_marker(
-                pytest.mark.xfail(
-                    **marker.kwargs,
-                )
-            )
+            item.add_marker(pytest.mark.xfail(**marker.kwargs))
 
     # Something has been exposed as broken by a new test and it shouldn't be
     # imperative for a contributor to fix it just because they happened to
@@ -486,13 +425,10 @@ def pytest_runtest_call(item):
                 and "raises" not in marker.kwargs.keys()
             ):
                 raise ValueError("broken requires a raises")
-            reason = marker.kwargs.get("reason")
-            item.add_marker(
-                pytest.mark.xfail(
-                    reason=reason or f"Feature is failing on {backend}",
-                    **{k: v for k, v in marker.kwargs.items() if k != "reason"},
-                )
-            )
+
+            kwargs = marker.kwargs.copy()
+            kwargs.setdefault("reason", f"Feature is failing on {backend}")
+            item.add_marker(pytest.mark.xfail(**kwargs))
 
     for marker in item.iter_markers(name="xfail_version"):
         kwargs = marker.kwargs.copy()
diff --git a/ibis/backends/polars/compiler.py b/ibis/backends/polars/compiler.py
--- a/ibis/backends/polars/compiler.py
+++ b/ibis/backends/polars/compiler.py
@@ -1149,3 +1149,8 @@ def execute_view(op, *, ctx: pl.SQLContext, **kw):
     child = translate(op.child, ctx=ctx, **kw)
     ctx.register(op.name, child)
     return child
+
+
+@translate.register(ops.SelfReference)
+def execute_self_reference(op, **kw):
+    return translate(op.table, **kw)
diff --git a/ibis/expr/operations/relations.py b/ibis/expr/operations/relations.py
--- a/ibis/expr/operations/relations.py
+++ b/ibis/expr/operations/relations.py
@@ -169,6 +169,8 @@ def _clean_join_predicates(left, right, predicates):
             pred = lk == rk
         elif isinstance(pred, str):
             pred = left.to_expr()[pred] == right.to_expr()[pred]
+        elif pred is True or pred is False:
+            pred = ops.Literal(pred, dtype="bool").to_expr()
         elif isinstance(pred, Value):
             pred = pred.to_expr()
         elif isinstance(pred, Deferred):
@@ -177,8 +179,8 @@ def _clean_join_predicates(left, right, predicates):
         elif not isinstance(pred, ir.Expr):
             raise NotImplementedError
 
-        if not isinstance(pred, ir.BooleanColumn):
-            raise com.ExpressionError('Join predicate must be comparison')
+        if not isinstance(pred, ir.BooleanValue):
+            raise com.ExpressionError('Join predicate must be a boolean expression')
 
         preds = an.flatten_predicate(pred.op())
         result.extend(preds)
@@ -360,9 +362,11 @@ def schema(self):
 class SelfReference(TableNode):
     table = rlz.table
 
-    @property
+    @attribute.default
     def name(self) -> str:
-        return f"{self.table.name}_ref"
+        if (name := getattr(self.table, "name", None)) is not None:
+            return f"{name}_ref"
+        return util.gen_name("self_ref")
 
     @property
     def schema(self):


Test patch:
diff --git a/ibis/backends/tests/test_generic.py b/ibis/backends/tests/test_generic.py
--- a/ibis/backends/tests/test_generic.py
+++ b/ibis/backends/tests/test_generic.py
@@ -276,12 +276,7 @@ def test_notin(backend, alltypes, sorted_df, column, elements):
 @pytest.mark.parametrize(
     ('predicate_fn', 'expected_fn'),
     [
-        param(
-            lambda t: t['bool_col'],
-            lambda df: df['bool_col'],
-            id="no_op",
-            marks=pytest.mark.min_version(datafusion="0.5.0"),
-        ),
+        param(lambda t: t['bool_col'], lambda df: df['bool_col'], id="no_op"),
         param(lambda t: ~t['bool_col'], lambda df: ~df['bool_col'], id="negate"),
         param(
             lambda t: t.bool_col & t.bool_col,
@@ -372,7 +367,6 @@ def test_case_where(backend, alltypes, df):
 
 # TODO: some of these are notimpl (datafusion) others are probably never
 @pytest.mark.notimpl(["datafusion", "mysql", "sqlite", "mssql", "druid", "oracle"])
-@pytest.mark.min_version(duckdb="0.3.3", reason="isnan/isinf unsupported")
 def test_select_filter_mutate(backend, alltypes, df):
     """Test that select, filter and mutate are executed in right order.
 
@@ -688,10 +682,6 @@ def test_zeroifnull_literals(con, dtype, zero, expected):
 
 
 @pytest.mark.notimpl(["datafusion"])
-@pytest.mark.min_version(
-    dask="2022.01.1",
-    reason="unsupported operation with later versions of pandas",
-)
 def test_zeroifnull_column(backend, alltypes, df):
     expr = alltypes.int_col.nullif(1).zeroifnull().name('tmp')
     result = expr.execute().astype("int32")
diff --git a/ibis/backends/tests/test_join.py b/ibis/backends/tests/test_join.py
--- a/ibis/backends/tests/test_join.py
+++ b/ibis/backends/tests/test_join.py
@@ -5,9 +5,12 @@
 import numpy as np
 import pandas as pd
 import pytest
+import sqlalchemy as sa
 from packaging.version import parse as vparse
 from pytest import param
 
+import ibis
+import ibis.common.exceptions as com
 import ibis.expr.schema as sch
 
 try:
@@ -15,6 +18,11 @@
 except ImportError:
     ColumnNotFoundError = None
 
+try:
+    from impala.error import HiveServer2Error
+except ImportError:
+    HiveServer2Error = None
+
 
 def _pandas_semi_join(left, right, on, **_):
     assert len(on) == 1, str(on)
@@ -224,3 +232,97 @@ def test_join_with_pandas_non_null_typed_columns(batting, awards_players):
     expr = batting_filt.join(awards_players_filt, "yearID")
     df = expr.execute()
     assert df.yearID.nunique() == 7
+
+
+@pytest.mark.parametrize(
+    ("predicate", "pandas_value"),
+    [
+        # Trues
+        param(True, True, id="true"),
+        param(ibis.literal(True), True, id="true-literal"),
+        param([True], True, id="true-list"),
+        param([ibis.literal(True)], True, id="true-literal-list"),
+        # only trues
+        param([True, True], True, id="true-true-list"),
+        param(
+            [ibis.literal(True), ibis.literal(True)], True, id="true-true-literal-list"
+        ),
+        param([True, ibis.literal(True)], True, id="true-true-const-expr-list"),
+        param([ibis.literal(True), True], True, id="true-true-expr-const-list"),
+        # Falses
+        param(False, False, id="false"),
+        param(ibis.literal(False), False, id="false-literal"),
+        param([False], False, id="false-list"),
+        param([ibis.literal(False)], False, id="false-literal-list"),
+        # only falses
+        param([False, False], False, id="false-false-list"),
+        param(
+            [ibis.literal(False), ibis.literal(False)],
+            False,
+            id="false-false-literal-list",
+        ),
+        param([False, ibis.literal(False)], False, id="false-false-const-expr-list"),
+        param([ibis.literal(False), False], False, id="false-false-expr-const-list"),
+    ],
+)
+@pytest.mark.parametrize(
+    "how",
+    [
+        "inner",
+        "left",
+        "right",
+        param(
+            "outer",
+            marks=[
+                pytest.mark.notyet(
+                    ["mysql"],
+                    raises=sa.exc.ProgrammingError,
+                    reason="MySQL doesn't support full outer joins natively",
+                ),
+                pytest.mark.notyet(
+                    ["impala"],
+                    raises=HiveServer2Error,
+                    reason=(
+                        "impala doesn't support full outer joins with non-equi-join "
+                        "predicates"
+                    ),
+                ),
+                pytest.mark.notyet(
+                    ["sqlite"],
+                    condition=vparse(sqlite3.sqlite_version) < vparse("3.39"),
+                    reason="sqlite didn't support full outer join until 3.39",
+                ),
+            ],
+        ),
+    ],
+)
+@pytest.mark.notimpl(
+    ["datafusion"], raises=com.OperationNotDefinedError, reason="joins not implemented"
+)
+@pytest.mark.notimpl(
+    ["polars"],
+    raises=com.TranslationError,
+    reason="polars doesn't support join predicates",
+)
+@pytest.mark.notimpl(
+    ["dask", "pandas"],
+    raises=TypeError,
+    reason="dask and pandas don't support join predicates",
+)
+def test_join_with_trivial_predicate(awards_players, predicate, how, pandas_value):
+    n = 5
+
+    base = awards_players.limit(n)
+
+    left = base.select(left_key="playerID")
+    right = base.select(right_key="playerID")
+
+    left_df = pd.DataFrame({"key": [True] * n})
+    right_df = pd.DataFrame({"key": [pandas_value] * n})
+
+    expected = pd.merge(left_df, right_df, on="key", how=how)
+
+    expr = left.join(right, predicate, how=how)
+    result = expr.to_pandas()
+
+    assert len(result) == len(expected)
diff --git a/ibis/backends/tests/test_json.py b/ibis/backends/tests/test_json.py
--- a/ibis/backends/tests/test_json.py
+++ b/ibis/backends/tests/test_json.py
@@ -1,9 +1,12 @@
 """Tests for JSON operations."""
 from __future__ import annotations
 
+import sqlite3
+
 import pandas as pd
 import pandas.testing as tm
 import pytest
+from packaging.version import parse as vparse
 from pytest import param
 
 from ibis.common.exceptions import OperationNotDefinedError
@@ -20,26 +23,21 @@
     [
         param(
             lambda t: t.js["a"].name("res"),
-            pd.Series(
-                [[1, 2, 3, 4], None, "foo", None, None, None],
-                name="res",
-                dtype="object",
-            ),
-            marks=[pytest.mark.min_server_version(sqlite="3.38.0")],
-            id="getitem_object",
+            pd.Series([[1, 2, 3, 4], None, "foo"] + [None] * 3, name="res"),
+            id="object",
         ),
         param(
             lambda t: t.js[1].name("res"),
-            pd.Series(
-                [None, None, None, None, 47, None],
-                dtype="object",
-                name="res",
-            ),
-            marks=[pytest.mark.min_server_version(sqlite="3.38.0")],
-            id="getitem_array",
+            pd.Series([None] * 4 + [47, None], dtype="object", name="res"),
+            id="array",
         ),
     ],
 )
+@pytest.mark.notyet(
+    ["sqlite"],
+    condition=vparse(sqlite3.sqlite_version) < vparse("3.38.0"),
+    reason="JSON not supported in SQLite < 3.38.0",
+)
 def test_json_getitem(json_t, expr_fn, expected):
     expr = expr_fn(json_t)
     result = expr.execute()
diff --git a/ibis/backends/tests/test_timecontext.py b/ibis/backends/tests/test_timecontext.py
--- a/ibis/backends/tests/test_timecontext.py
+++ b/ibis/backends/tests/test_timecontext.py
@@ -58,7 +58,7 @@ def ctx_col():
 
 
 @pytest.mark.notimpl(["dask", "duckdb"])
-@pytest.mark.min_version(pyspark="3.1")
+@pytest.mark.xfail_version(pyspark=["pyspark<3.1"])
 @pytest.mark.parametrize(
     'window',
     [
diff --git a/ibis/backends/tests/test_vectorized_udf.py b/ibis/backends/tests/test_vectorized_udf.py
--- a/ibis/backends/tests/test_vectorized_udf.py
+++ b/ibis/backends/tests/test_vectorized_udf.py
@@ -491,11 +491,8 @@ def test_elementwise_udf_overwrite_destruct_and_assign(udf_backend, udf_alltypes
     udf_backend.assert_frame_equal(result, expected, check_like=True)
 
 
-@pytest.mark.min_version(pyspark="3.1")
-@pytest.mark.parametrize(
-    'method',
-    ['destructure', 'unpack'],
-)
+@pytest.mark.xfail_version(pyspark=["pyspark<3.1"])
+@pytest.mark.parametrize('method', ['destructure', 'unpack'])
 @pytest.mark.skip("dask")
 def test_elementwise_udf_destructure_exact_once(
     udf_backend, udf_alltypes, method, tmp_path
diff --git a/ibis/backends/tests/test_window.py b/ibis/backends/tests/test_window.py
--- a/ibis/backends/tests/test_window.py
+++ b/ibis/backends/tests/test_window.py
@@ -80,19 +80,13 @@ def calc_zscore(s):
             lambda t, win: t.id.rank().over(win),
             lambda t: t.id.rank(method='min').astype('int64') - 1,
             id='rank',
-            marks=[
-                pytest.mark.min_server_version(clickhouse="22.8"),
-                pytest.mark.notimpl(["dask"], raises=NotImplementedError),
-            ],
+            marks=[pytest.mark.notimpl(["dask"], raises=NotImplementedError)],
         ),
         param(
             lambda t, win: t.id.dense_rank().over(win),
             lambda t: t.id.rank(method='dense').astype('int64') - 1,
             id='dense_rank',
-            marks=[
-                pytest.mark.min_server_version(clickhouse="22.8"),
-                pytest.mark.notimpl(["dask"], raises=NotImplementedError),
-            ],
+            marks=[pytest.mark.notimpl(["dask"], raises=NotImplementedError)],
         ),
         param(
             lambda t, win: t.id.percent_rank().over(win),
@@ -163,9 +157,9 @@ def calc_zscore(s):
             lambda t: t.cumcount(),
             id='row_number',
             marks=[
-                pytest.mark.notimpl(["pandas"], raises=com.OperationNotDefinedError),
-                pytest.mark.notimpl(["dask"], raises=com.OperationNotDefinedError),
-                pytest.mark.min_server_version(clickhouse="22.8"),
+                pytest.mark.notimpl(
+                    ["dask", "pandas"], raises=com.OperationNotDefinedError
+                )
             ],
         ),
         param(