Repository: pyjanitor-devs/pyjanitor

Issue:
Conditional join
# Brief Description

<!-- Please provide a brief description of what you'd like to propose. -->

I would like to propose conditional join, also known as non-equi joins, which is obtainable in SQL and R's datatable.

# Example API

```python
df1 = pd.DataFrame({'col_a': [1,2,3], 'col_b': ["A", "B", "C"]})
	col_a	col_b
0	1	A
1	2	B
2	3	C

df2 = pd.DataFrame({'col_a': [0, 2, 3], 'col_c': ["Z", "X", "Y"]})
     col_a	col_c
0	0	Z
1	2	X
2	3	Y
```

Please modify the example API below to illustrate your proposed API, and then delete this sentence.

```python
# the join can be >, < or !=. I guess as we get a stable function, we can add others, and combine with equi-joins
df1.conditional_join(df2, left_column = "col_a", right_column = "col_a", join_operator = ">")
    col_a   col_b   col_c
0	1	A	Z
1	2	B	Z
2	3	C	Z
3	3	C	X
```



Discussion:
@samukweku, in your example, do you instead mean this?

```python
df.conditional_join(df1, left_column = "col_a", right_column = "col_a", join_operator = "<")
```

I'm wondering, how is this different from an outer join? Maybe I'm missing a clarifying example that distinguishes outer joins from conditional joins. Could you help me understand the difference?
An outer join checks for equality, if `` df1.col_a == df2.col_a``. 
```python
# result of outer join 
df1.merge(df2, how='outer', on ='col_a')
     col_a	col_b	col_c
0	1	A	NaN
1	2	B	X
2	3	C	Y
3	0	NaN	Z
```

The conditional join/non-equi join looks at each value and compares to see which is greater. For this case, we have ``1 > 0, 2 > 0, 3 > 0 and 3 > 2`` for ``df.col_a > df1.col_a``
@szuckerman @hectormz I would also love your thoughts on this as well.
Since there is no interest in this, it is safe to close this issue.
@samukweku I think this one is worth leaving open. Let me tag them accordingly.
just some examples on Stack Overflow, and on pandas github issues page, regarding conditional joins: 

Stack Overflow 

- https://stackoverflow.com/questions/44204726/preserving-left-right-join-logic-in-pandas-conditional-join
- https://stackoverflow.com/questions/61948103/conditional-merge-join-of-two-large-pandas-dataframes-with-duplicated-keys-bas
- https://stackoverflow.com/questions/23508351/how-to-do-workaround-a-conditional-join-in-python-pandas
- https://stackoverflow.com/questions/44080248/pandas-join-dataframe-with-condition
- https://stackoverflow.com/questions/40924217/how-to-combine-two-pandas-dataframes-with-a-conditional
- https://stackoverflow.com/questions/37289405/dplyr-left-join-by-less-than-greater-than-condition

Links to issues on Pandas github page 
- https://github.com/pandas-dev/pandas/issues/7480
- https://github.com/pandas-dev/pandas/issues/34543
- https://github.com/pandas-dev/pandas/issues/8962

Resolving patch:
diff --git a/janitor/functions.py b/janitor/functions.py
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -58,6 +58,9 @@
     check,
     check_column,
     deprecated_alias,
+    _conditional_join_preliminary_checks,
+    _conditional_join_compute,
+    _cond_join_suffixes,
 )
 
 
@@ -6476,3 +6479,321 @@ def pivot_wider(
     )
 
     return df
+
+
+@pf.register_dataframe_method
+def conditional_join(
+    df: pd.DataFrame,
+    right: Union[pd.DataFrame, pd.Series],
+    *conditions,
+    how: str = "inner",
+    sort_by_appearance: bool = False,
+    suffixes=("_x", "_y"),
+) -> pd.DataFrame:
+    """
+
+    This is a convenience function that operates similarly to ``pd.merge``,
+    but allows joins on inequality operators, or a combination of equi
+    and non-equi joins.
+
+    If the join is solely on equality, `pd.merge` function
+    is more efficient and should be used instead.
+    If you are interested in nearest joins, or rolling joins,
+    `pd.merge_asof` covers that. There is also the IntervalIndex,
+    which can be more efficient for range joins, if the intervals
+    do not overlap.
+
+    This function returns rows, if any, where values from `df` meet the
+    condition(s) for values from `right`. The conditions are passed in
+    as a variable argument of tuples, where the tuple is of
+    the form ``(left_on, right_on, op)``; `left_on` is the column
+    label from `df`, `right_on` is the column label from `right`,
+    while `op` is the operator.
+
+    The operator can be any of `==`, `!=`, `<=`, `<`, `>=`, `>`.
+
+    If the join operator is a non-equi operator, a binary search is used
+    to get the relevant rows; this avoids a cartesian join, and makes the
+    process less memory intensive. If it is an equality operator, it simply
+    uses pandas' `merge` or `get_indexer_for` method to retrieve the relevant
+    rows.
+
+    The join is done only on the columns.
+    MultiIndex columns are not supported.
+
+    Only numeric, date and string columns are supported.
+
+    If joining on strings, only the `==` operator is supported.
+
+    Only `inner`, `left`, and `right` joins are supported.
+
+
+    Example :
+
+    df1::
+
+            id  value_1
+        0   1        2
+        1   1        5
+        2   1        7
+        3   2        1
+        4   2        3
+        5   3        4
+
+
+    df2::
+
+            id  value_2A  value_2B
+        0   1         0         1
+        1   1         3         5
+        2   1         7         9
+        3   1        12        15
+        4   2         0         1
+        5   2         2         4
+        6   2         3         6
+        7   3         1         3
+
+    Join on equi and non-equi operators is possible::
+
+        df1.conditional_join(
+                right = df2,
+                ('id', 'id', '=='),
+                ('value_1', 'value_2A', '>='),
+                ('value_1', 'value_2B', '<='),
+                sort_by_appearance = True
+            )
+
+            id_x  value_1  id_y  value_2A  value_2B
+        0     1        5     1         3         5
+        1     1        7     1         7         9
+        2     2        1     2         0         1
+        3     2        3     2         2         4
+        4     2        3     2         3         6
+
+    The default join is `inner`. left and right joins are supported as well::
+
+        df1.conditional_join(
+                right = df2,
+                ('id', 'id', '=='),
+                ('value_1', 'value_2A', '>='),
+                ('value_1', 'value_2B', '<='),
+                how='left',
+                sort_by_appearance = True
+            )
+
+            id_x  value_1  id_y  value_2A  value_2B
+        0     1        2   NaN       NaN       NaN
+        1     1        5   1.0       3.0       5.0
+        2     1        7   1.0       7.0       9.0
+        3     2        1   2.0       0.0       1.0
+        4     2        3   2.0       2.0       4.0
+        5     2        3   2.0       3.0       6.0
+        6     3        4   NaN       NaN       NaN
+
+
+        df1.conditional_join(
+                right = df2,
+                ('id', 'id', '=='),
+                ('value_1', 'value_2A', '>='),
+                ('value_1', 'value_2B', '<='),
+                how='right',
+                sort_by_appearance = True
+            )
+
+            id_x  value_1  id_y  value_2A  value_2B
+        0   NaN      NaN     1         0         1
+        1   1.0      5.0     1         3         5
+        2   1.0      7.0     1         7         9
+        3   NaN      NaN     1        12        15
+        4   2.0      1.0     2         0         1
+        5   2.0      3.0     2         2         4
+        6   2.0      3.0     2         3         6
+        7   NaN      NaN     3         1         3
+
+
+    Join on just the non-equi joins is also possible::
+
+        df1.conditional_join(
+                right = df2,
+                ('value_1', 'value_2A', '>'),
+                ('value_1', 'value_2B', '<'),
+                how='inner',
+                sort_by_appearance = True
+            )
+
+            id_x  value_1  id_y  value_2A  value_2B
+        0     1        2     3         1         3
+        1     1        5     2         3         6
+        2     2        3     2         2         4
+        3     3        4     1         3         5
+        4     3        4     2         3         6
+
+    The default for the `suffixes` parameter is ``(_x, _y)``,
+    One of the suffixes can be set as ``None``;
+    this avoids a suffix on the columns from the
+    relevant dataframe::
+
+        df1.conditional_join(
+                right = df2,
+                ('value_1', 'value_2A', '>'),
+                ('value_1', 'value_2B', '<'),
+                how='inner',
+                sort_by_appearance = True,
+                suffixes = (None, '_y')
+            )
+
+            id  value_1  id_y  value_2A  value_2B
+        0   1        2     3         1         3
+        1   1        5     2         3         6
+        2   2        3     2         2         4
+        3   3        4     1         3         5
+        4   3        4     2         3         6
+
+    Join on just equality is also possible, but should be avoided -
+    Pandas merge/join is more efficient::
+
+        df1.conditional_join(
+                right = df2,
+                ('col_a', 'col_a', '=='),
+                sort_by_appearance = True
+            )
+
+             col_a_x col_b  col_a_y col_c
+        0        2     B        2     X
+        1        3     C        3     Y
+
+    Join on not equal -> ``!=`` ::
+
+        df1.conditional_join(
+                right = df2,
+                ('col_a', 'col_a', '!='),
+                sort_by_appearance = True
+            )
+
+             col_a_x col_b  col_a_y col_c
+        0        1     A        0     Z
+        1        1     A        2     X
+        2        1     A        3     Y
+        3        2     B        0     Z
+        4        2     B        3     Y
+        5        3     C        0     Z
+        6        3     C        2     X
+
+
+    If the order from `right` is not important,
+    `sort_by_appearance` can be set to  ``False``
+    (this is the default)::
+
+        df1.conditional_join(
+                right = df2,
+                ('col_a', 'col_a', '>'),
+                sort_by_appearance = False
+            )
+
+             col_a_x col_b  col_a_y col_c
+        0        1     A        0     Z
+        1        2     B        0     Z
+        2        3     C        0     Z
+        3        3     C        2     X
+
+
+    .. note:: If `df` or `right` has labeled indices,
+              it will be lost after the merge,
+              and replaced with an integer index.
+              If you wish to preserve the labeled indices,
+              you can convert them to columns
+              before running the conditional join.
+
+    .. note:: All the columns from `df` and `right`
+              are returned in the final output.
+
+    Functional usage syntax:
+
+    .. code-block:: python
+
+        import pandas as pd
+        import janitor as jn
+
+        df = pd.DataFrame(...)
+        right = pd.DataFrame(...)
+
+        df = jn.conditional_join(
+                df = df,
+                right = right,
+                *conditions,
+                sort_by_appearance = True/False,
+                suffixes = ("_x", "_y"),
+                )
+
+    Method chaining syntax:
+
+    .. code-block:: python
+
+        df = df.conditional_join(
+                right = right,
+                *conditions,
+                sort_by_appearance = True/False,
+                suffixes = ("_x", "_y"),
+                )
+
+
+    :param df: A Pandas dataframe.
+    :param right: Named Series or DataFrame to join to.
+    :param conditions: Variable argument of tuple(s) of the form
+        ``(left_on, right_on, op)``, where `left_on` is the column
+        label from `df`, `right_on` is the column label from `right`,
+        while `op` is the operator. The operator can be any of
+        `==`, `!=`, `<=`, `<`, `>=`, `>`.
+    :param how: Indicates the type of join to be performed.
+        It can be one of `inner`, `left`, `right`.
+        Full join is not supported. Defaults to `inner`.
+    :param sort_by_appearance: Default is `False`. If True,
+        values from `right` that meet the join condition will be returned
+        in the final dataframe in the same order that they were before the
+        join.
+    :param suffixes: tuple, default is ``(_x, _y)``.
+        A sequence of length 2, where each element is optionally a string,
+        indicating the suffix to add to the overlapping column names
+        in `df` and `right`. Pass a value of ``None``
+        instead of a string to indicate that the  column name
+        from `df` or `right` should be left as-is, with no suffix.
+        At least one of the values must not be ``None``.
+    :returns: A pandas DataFrame of the two merged Pandas objects.
+    :raises ValueError: if columns from `df` or `right` is a MultiIndex.
+    :raises ValueError: if `right` is an unnamed Series.
+    :raises ValueError: if condition in *conditions is not a tuple.
+    :raises ValueError: if condition is not length 3.
+    :raises ValueError: if `left_on` and `right_on` in condition are not
+        both numeric, or string, or datetime.
+
+
+    .. # noqa: DAR402
+    """
+
+    (
+        df,
+        right,
+        conditions,
+        how,
+        sort_by_appearance,
+        suffixes,
+    ) = _conditional_join_preliminary_checks(
+        df,
+        right,
+        conditions,
+        how,
+        sort_by_appearance,
+        suffixes,
+    )
+
+    df, right, conditions = _cond_join_suffixes(
+        df, right, conditions, suffixes
+    )
+
+    # the numeric indexes play a crucial part in position tracking
+    df.index = np.arange(len(df))
+    right.index = np.arange(len(right))
+
+    return _conditional_join_compute(
+        df, right, conditions, how, sort_by_appearance
+    )
diff --git a/janitor/utils.py b/janitor/utils.py
--- a/janitor/utils.py
+++ b/janitor/utils.py
@@ -28,8 +28,12 @@
     is_extension_array_dtype,
     is_list_like,
     is_scalar,
+    is_numeric_dtype,
+    is_string_dtype,
+    is_datetime64_dtype,
 )
 from pandas.core.common import apply_if_callable
+from enum import Enum
 
 from .errors import JanitorError
 
@@ -2373,3 +2377,755 @@ def _sub_process_text_result_MultiIndex(index: pd.MultiIndex, result, df):
     # (# extra_index_line)
     df = df.droplevel(-1).set_index("match", append=True)
     return df
+
+
+class JOINOPERATOR(Enum):
+    """
+    List of operators used in conditional_join.
+    """
+
+    GREATER_THAN = ">"
+    LESS_THAN = "<"
+    GREATER_THAN_OR_EQUAL = ">="
+    LESS_THAN_OR_EQUAL = "<="
+    STRICTLY_EQUAL = "=="
+    NOT_EQUAL = "!="
+
+
+class JOINTYPES(Enum):
+    """
+    List of join types for conditional_join.
+    """
+
+    INNER = "inner"
+    LEFT = "left"
+    RIGHT = "right"
+
+
+def _check_operator(op: str):
+    """
+    Check that operator is one of
+    `>`, `>=`, `==`, `!=`, `<`, `<=`.
+    Used in `conditional_join`.
+    """
+    sequence_of_operators = {op.value for op in JOINOPERATOR}
+    if op not in sequence_of_operators:
+        raise ValueError(
+            f"""
+             The conditional join operator
+             should be one of {", ".join(sequence_of_operators)}
+             """
+        )
+
+
+def _conditional_join_preliminary_checks(
+    df: pd.DataFrame,
+    right: Union[pd.DataFrame, pd.Series],
+    conditions: tuple,
+    how: str = "inner",
+    sort_by_appearance: bool = False,
+    suffixes=("_x", "_y"),
+) -> tuple:
+    """
+    Preliminary checks are conducted here.
+    This function checks for conditions such as
+    MultiIndexed dataframe columns,
+    improper `suffixes` configuration,
+    as well as unnamed Series.
+
+    A tuple of
+    (`df`, `right`,
+     `left_on`, `right_on`,
+     `operator`, `sort_by_appearance`,
+     `suffixes`)
+    is returned.
+    """
+
+    if df.empty:
+        raise ValueError(
+            """
+            The dataframe on the left should not be empty.
+            """
+        )
+
+    if isinstance(df.columns, pd.MultiIndex):
+        raise ValueError(
+            """
+            MultiIndex columns are not
+            supported for conditional_join.
+            """
+        )
+
+    check("`right`", right, [pd.DataFrame, pd.Series])
+
+    df = df.copy()
+    right = right.copy()
+
+    if isinstance(right, pd.Series):
+        if not right.name:
+            raise ValueError(
+                """
+                Unnamed Series are not supported
+                for conditional_join.
+                """
+            )
+        right = right.to_frame()
+
+    if right.empty:
+        raise ValueError(
+            """
+            The Pandas object on the right
+            should not be empty.
+            """
+        )
+
+    if isinstance(right.columns, pd.MultiIndex):
+        raise ValueError(
+            """
+            MultiIndex columns are not supported
+            for conditional joins.
+            """
+        )
+
+    # each condition should be a tuple of length 3:
+    for condition in conditions:
+        check("condition", condition, [tuple])
+        if len(condition) != 3:
+            raise ValueError(
+                f"""
+                condition should have only three elements.
+                Your condition however is of length {len(condition)}
+                """
+            )
+
+    for left_on, right_on, op in conditions:
+        check("left_on", left_on, [str])
+        check("right_on", right_on, [str])
+        check("operator", op, [str])
+        check_column(df, left_on)
+        check_column(right, right_on)
+        _check_operator(op)
+
+    check("how", how, [str])
+
+    join_types = {jointype.value for jointype in JOINTYPES}
+    if how not in join_types:
+        raise ValueError(f"`how` should be one of {', '.join(join_types)}.")
+
+    check("sort_by_appearance", sort_by_appearance, [bool])
+
+    check("suffixes", suffixes, [tuple])
+
+    if len(suffixes) != 2:
+        raise ValueError("`suffixes` argument should be a 2-length tuple")
+
+    if suffixes == (None, None):
+        raise ValueError("At least one of the suffixes should be non-null.")
+
+    for suffix in suffixes:
+        check("suffix", suffix, [str, type(None)])
+
+    return (df, right, conditions, how, sort_by_appearance, suffixes)
+
+
+def _cond_join_suffixes(
+    df: pd.DataFrame, right: pd.DataFrame, conditions: tuple, suffixes: tuple
+):
+    """
+    If there are overlapping columns in `df` and `right`,
+    modify the columns, using the suffix in suffixes.
+    A tuple of (df, right, conditions) is returned.
+    """
+
+    common_columns = df.columns.intersection(right.columns, sort=False)
+
+    left_on, right_on, operators = zip(*conditions)
+    if not common_columns.empty:
+        left_suffix, right_suffix = suffixes
+
+        if left_suffix:
+            mapping = {}
+            for common in common_columns:
+                new_label = f"{common}{left_suffix}"
+                if new_label in df.columns:
+                    raise ValueError(
+                        f"""
+                        {new_label} is present in `df` columns.
+                        Kindly provide unique suffixes to create
+                        columns that are not present in `df`.
+                        """
+                    )
+                mapping[common] = new_label
+            left_on = [
+                f"{label}{left_suffix}" if label in common_columns else label
+                for label in left_on
+            ]
+            df = df.rename(columns=mapping)
+        if right_suffix:
+            mapping = {}
+            for common in common_columns:
+                new_label = f"{common}{right_suffix}"
+                if new_label in right.columns:
+                    raise ValueError(
+                        f"""
+                        {new_label} is present in `right` columns.
+                        Kindly provide unique suffixes to create
+                        columns that are not present in `right`.
+                        """
+                    )
+
+                mapping[common] = new_label
+            right_on = [
+                f"{label}{right_suffix}" if label in common_columns else label
+                for label in right_on
+            ]
+            right = right.rename(columns=mapping)
+
+    conditions = [*zip(left_on, right_on, operators)]
+
+    return df, right, conditions
+
+
+def _conditional_join_type_check(
+    left_column: pd.Series, right_column: pd.Series, op: str
+) -> None:
+    """
+    Raise error if column type is not any of
+    numeric, datetime, or string.
+    Strings are not supported on non-equi operators.
+    """
+
+    numeric_type = all(map(is_numeric_dtype, (left_column, right_column)))
+    date_type = all(map(is_datetime64_dtype, (left_column, right_column)))
+    string_type = all(map(is_string_dtype, (left_column, right_column)))
+
+    non_equi = {op.value for op in JOINOPERATOR if op.name != "STRICTLY_EQUAL"}
+    if all((op in non_equi, string_type)):
+        raise ValueError(
+            """
+            Strings can only be compared
+            on the equal(`==`) operator.
+            """
+        )
+    numeric_date_string = numeric_type, date_type, string_type
+    if any(numeric_date_string):
+        return None
+
+    raise ValueError(
+        """
+        conditional_join only supports
+        numeric, date, or string dtypes.
+        """
+    )
+
+
+def _le_create_ranges(indices: np.array, len_right: int) -> np.array:
+    """
+    Create ordered indices for each value in
+    `right_keys` in `_less_than_indices`.
+    Faster than a list comprehension, as
+    the array size increases.
+
+    code copied from Stack Overflow
+    https://stackoverflow.com/a/47126435/7175713
+    """
+    cum_length = len_right - indices
+    cum_length = cum_length.cumsum()
+    # generate ones
+    # note that cum_length[-1] is the total
+    # number of inidices to be generated
+    ids = np.ones(cum_length[-1], dtype=int)
+    ids[0] = indices[0]
+    # at each specific point in id, replace the value
+    # so, we should have say 0, 1, 1, 1, 1, -5, 1, 1, 1, -3, ...
+    # when a cumsum is implemented in the next line,
+    # we get, 0, 1, 2, 3, 4, 0, 1,2, 3, 0, ...
+    # our ranges is obtained, with more efficiency
+    # for larger arrays
+    ids[cum_length[:-1]] = indices[1:] - len_right + 1
+    # the cumsum here gives us the same output as
+    # [np.range(start, len_right) for start in search_indices]
+    # but much faster
+    return ids.cumsum()
+
+
+def _ge_create_ranges(indices: np.array) -> np.array:
+    """
+    Create ordered indices for each value in
+    `right_keys` in `_greater_than_indices`.
+    Faster than a list comprehension, as
+    the array size increases.
+
+    code copied from Stack Overflow
+    https://stackoverflow.com/a/47126435/7175713
+    """
+    cum_length = indices.cumsum()
+    ids = np.ones(cum_length[-1], dtype=int)
+    ids[0] = 0
+    ids[cum_length[:-1]] = -1 * indices[:-1] + 1
+    return ids.cumsum()
+
+
+def _equal_indices(left_c: pd.Series, right_c: pd.Series, len_conditions: int):
+    """
+    Use pandas' join method to get the index labels.
+    If len_condition is > 1, then get_indexer is used
+    to get relevant index labels for left_c.
+
+    Returns a tuple of (left_c, right_c)
+    if len_conditions is == 1, else left_c.
+    """
+
+    if len_conditions > 1:
+        if not right_c.is_unique:
+            right_c = right_c.factorize()[-1]
+        result = pd.Index(left_c).get_indexer_for(right_c)
+        exclude_rows = result == -1
+        if exclude_rows.all():
+            return None
+        if exclude_rows.any():
+            result = result[~exclude_rows]
+        return left_c.index.take(result)
+
+    left_c.index.name = "l"
+    right_c.index.name = "r"
+    left_c.name = "merge"
+    right_c.name = "merge"
+    left_c = left_c.reset_index()
+    right_c = right_c.reset_index()
+    result = left_c.merge(right_c, how="inner", sort=False, on="merge")
+    if result.empty:
+        return None
+    return pd.Index(result["l"]), pd.Index(result["r"])
+
+
+def _not_equal_indices(
+    left_c: pd.Series,
+    right_c: pd.Series,
+    len_conditions: int,
+    strict: bool = True,
+):
+    """
+    Use binary search to get indices where
+    `left_c` is exactly  not equal to `right_c`.
+    It is a combination of strictly less than
+    and strictly greater than indices.
+
+    Returns a tuple of (left_c, right_c)
+    if len_conditions is == 1, else left_c.
+    """
+
+    # get nulls, since they are not equal to anything
+    # NaNs are not equal to NaNs
+    l_nulls = pd.Index([], dtype=int)
+    r_nulls = pd.Index([], dtype=int)
+    nulls_l = pd.Index([], dtype=int)
+    nulls_r = pd.Index([], dtype=int)
+    left_hasnans = left_c.hasnans
+    right_hasnans = right_c.hasnans
+    if len_conditions == 1:
+        if left_hasnans:
+            left_c_isna = left_c.isna()
+            nulls_count = left_c_isna.sum()
+            l_nulls = left_c.index[left_c_isna]
+            # each value in right_c MUST be matched to all the null groups
+            l_nulls = pd.Int64Index(np.tile(l_nulls, right_c.size))
+            if nulls_count > 1:
+                nulls_r = right_c.index.repeat(nulls_count)
+            else:
+                nulls_r = right_c.index
+
+        if right_hasnans:
+            if left_hasnans:  # avoids duplication of NaN matching NaN
+                left_c = left_c.dropna()
+            right_c_isna = right_c.isna()
+            nulls_count = right_c_isna.sum()
+            r_nulls = right_c.index[right_c_isna]
+            # each value in left_c must be matched to all the null groups
+            r_nulls = pd.Int64Index(np.tile(r_nulls, left_c.size))
+            if nulls_count > 1:
+                nulls_l = left_c.index.repeat(nulls_count)
+            else:
+                nulls_l = left_c.index
+
+        l_nulls = l_nulls.append(nulls_l)
+        r_nulls = nulls_r.append(r_nulls)
+    else:
+        if right_c.hasnans:
+            # every row in left_c
+            # will not be equal to NaN
+            return left_c.index
+        if left_c.hasnans:
+            l_nulls = left_c.index[left_c.isna()]
+
+    # sort and drop nulls here
+    # to avoid sorting twice, in less_than_indices
+    # and greater_than _indices functions
+    if not right_c.is_monotonic_increasing:
+        right_c = right_c.sort_values()
+    if right_hasnans:
+        right_c = right_c.dropna()
+
+    # get less than index labels
+    result = _less_than_indices(left_c, right_c, len_conditions, strict, True)
+    if len_conditions == 1:
+        if result is None:
+            lt_left = pd.Index([], dtype=int)
+            lt_right = pd.Index([], dtype=int)
+        else:
+            lt_left, lt_right = result
+    else:
+        if result is None:
+            lt_left = pd.Index([], dtype=int)
+        else:
+            lt_left = result
+
+    # greater than index labels
+    result = _greater_than_indices(
+        left_c, right_c, len_conditions, strict, True
+    )
+    if len_conditions == 1:
+        if result is None:
+            gt_left = pd.Index([], dtype=int)
+            gt_right = pd.Index([], dtype=int)
+        else:
+            gt_left, gt_right = result
+    else:
+        if result is None:
+            gt_left = pd.Index([], dtype=int)
+        else:
+            gt_left = result
+    if len_conditions > 1:
+        left_c = lt_left.append([gt_left, l_nulls]).unique()
+        return left_c
+    else:
+        left_c = lt_left.append([gt_left, l_nulls])
+        right_c = lt_right.append([gt_right, r_nulls])
+
+    return left_c, right_c
+
+
+def _less_than_indices(
+    left_c: pd.Series,
+    right_c: pd.Series,
+    len_conditions: int,
+    strict: bool,
+    not_equal: bool = False,
+):
+    """
+    Use binary search to get indices where left_c is less than
+    or equal to right_c. If strict is True,
+    then only indices where `left_c`
+    is less than (but not equal to) `right_c` are returned.
+
+    Returns a tuple of (left_c, right_c)
+    if len_conditions is == 1, else left_c.
+    """
+
+    # no point going through all the hassle
+    if left_c.min() > right_c.max():
+        return None
+
+    if not_equal is False:
+        if right_c.hasnans:
+            right_c = right_c.dropna()
+        if not right_c.is_monotonic_increasing:
+            right_c = right_c.sort_values()
+
+    search_indices = right_c.searchsorted(left_c, side="left")
+    # if any of the positions in `search_indices`
+    # is equal to the length of `right_keys`
+    # that means the respective position in `left_c`
+    # has no values from `right_c` that are less than
+    # or equal, and should therefore be discarded
+    len_right = right_c.size
+    rows_equal = search_indices == len_right
+    if rows_equal.any():
+        left_c = left_c[~rows_equal]
+        search_indices = search_indices[~rows_equal]
+    if search_indices.size == 0:
+        return None
+
+    if len_conditions > 1:
+        return left_c.index
+    # for each index in `search_indices`,
+    # generate all indices for `right_keys`,
+    # where the values in `right_keys` are greater than
+    # or equal to `left_c`
+    positions = _le_create_ranges(search_indices, len_right)
+    search_indices = len_right - search_indices
+    if strict:
+        right_c = right_c.take(positions)
+        left_c = left_c.repeat(search_indices)
+        rows_equal = left_c.array == right_c.array
+        if rows_equal.all():
+            return None
+        if rows_equal.any():
+            left_c = left_c.index[~rows_equal]
+            right_c = right_c.index[~rows_equal]
+            return left_c, right_c
+        return left_c.index, right_c.index
+
+    right_c = right_c.index.take(positions)
+    left_c = left_c.index.repeat(search_indices)
+
+    return left_c, right_c
+
+
+def _greater_than_indices(
+    left_c: pd.Series,
+    right_c: pd.Series,
+    len_conditions: int,
+    strict: bool,
+    not_equal: bool = False,
+):
+    """
+    Use binary search to get indices where left_c is greater than
+    or equal to right_c. If strict is True,
+    then only indices where `left_c`
+    is greater than (but not equal to) `right_c` are returned.
+
+    Returns a tuple of (left_c, right_c)
+    if len_conditions is == 1, else left_c.
+    """
+
+    # quick break, avoiding the hassle
+    if left_c.max() < right_c.min():
+        return None
+
+    if not_equal is False:
+        if right_c.hasnans:
+            right_c = right_c.dropna()
+        if not right_c.is_monotonic_increasing:
+            right_c = right_c.sort_values()
+    if left_c.hasnans:
+        left_c = left_c.dropna()
+
+    search_indices = right_c.searchsorted(left_c, side="right")
+    # if any of the positions in `search_indices`
+    # is equal to 0
+    # that means the respective position in `left_c`
+    # has no values from `right_c` that are greater than
+    # or equal, and should therefore be discarded
+    rows_equal = search_indices == 0
+    if rows_equal.any():
+        left_c = left_c[~rows_equal]
+        search_indices = search_indices[~rows_equal]
+    if search_indices.size == 0:
+        return None
+
+    if len_conditions > 1:
+        return left_c.index
+    # for each index in `search_indices`,
+    # generate all indices for `right_keys`,
+    # where the values in `right_keys` are less than
+    # or equal to `left_c`
+    positions = _ge_create_ranges(search_indices)
+    if strict:
+        right_c = right_c.take(positions)
+        left_c = left_c.repeat(search_indices)
+        rows_equal = left_c.array == right_c.array
+        if rows_equal.all():
+            return None
+        if rows_equal.any():
+            left_c = left_c.index[~rows_equal]
+            right_c = right_c.index[~rows_equal]
+            return left_c, right_c
+        return left_c.index, right_c.index
+
+    right_c = right_c.index.take(positions)
+    left_c = left_c.index.repeat(search_indices)
+
+    return left_c, right_c
+
+
+def _create_conditional_join_empty_frame(
+    df: pd.DataFrame, right: pd.DataFrame, how: str
+):
+    """
+    Create final dataframe for conditional join,
+    if there are no matches.
+    """
+
+    if how == JOINTYPES.INNER.value:
+        df = df.dtypes.to_dict()
+        right = right.dtypes.to_dict()
+        df = {**df, **right}
+        df = {key: pd.Series([], dtype=value) for key, value in df.items()}
+        return pd.DataFrame(df)
+
+    if how == JOINTYPES.LEFT.value:
+        right = right.dtypes.to_dict()
+        right = {
+            key: float if dtype.kind == "i" else dtype
+            for key, dtype in right.items()
+        }
+        right = {
+            key: pd.Series([], dtype=value) for key, value in right.items()
+        }
+        right = pd.DataFrame(right)
+        return df.join(right, how=how, sort=False)
+
+    if how == JOINTYPES.RIGHT.value:
+        df = df.dtypes.to_dict()
+        df = {
+            key: float if dtype.kind == "i" else dtype
+            for key, dtype in df.items()
+        }
+        df = {key: pd.Series([], dtype=value) for key, value in df.items()}
+        df = pd.DataFrame(df)
+        return df.join(right, how=how, sort=False)
+
+
+def _create_conditional_join_frame(
+    df: pd.DataFrame,
+    right: pd.DataFrame,
+    left_index: pd.Index,
+    right_index: pd.Index,
+    how: str,
+    sort_by_appearance: bool,
+):
+    """
+    Create final dataframe for conditional join,
+    if there are matches.
+    """
+    if sort_by_appearance:
+        sorter = np.lexsort((right_index, left_index))
+        right_index = right_index[sorter]
+        left_index = left_index.take(sorter)
+
+    if how == JOINTYPES.INNER.value:
+        df = df.reindex(left_index)
+        right = right.reindex(right_index)
+        df.index = np.arange(left_index.size)
+        right.index = df.index
+        return pd.concat([df, right], axis="columns", join=how, sort=False)
+
+    if how == JOINTYPES.LEFT.value:
+        right = right.reindex(right_index)
+        right.index = left_index
+        return df.join(right, how=how, sort=False).reset_index(drop=True)
+
+    if how == JOINTYPES.RIGHT.value:
+        df = df.reindex(left_index)
+        df.index = right_index
+        return df.join(right, how=how, sort=False).reset_index(drop=True)
+
+
+def _generic_func_cond_join(
+    left_c: pd.Series, right_c: pd.Series, op: str, len_conditions: int
+):
+    """
+    Generic function to call any of the individual functions
+    (_less_than_indices, _greater_than_indices, _equal_indices,
+    or _not_equal_indices).
+    """
+    strict = False
+
+    if op in {
+        JOINOPERATOR.GREATER_THAN.value,
+        JOINOPERATOR.LESS_THAN.value,
+        JOINOPERATOR.NOT_EQUAL.value,
+    }:
+        strict = True
+
+    if op in {
+        JOINOPERATOR.LESS_THAN.value,
+        JOINOPERATOR.LESS_THAN_OR_EQUAL.value,
+    }:
+        return _less_than_indices(left_c, right_c, len_conditions, strict)
+    elif op in {
+        JOINOPERATOR.GREATER_THAN.value,
+        JOINOPERATOR.GREATER_THAN_OR_EQUAL.value,
+    }:
+        return _greater_than_indices(left_c, right_c, len_conditions, strict)
+    elif op == JOINOPERATOR.STRICTLY_EQUAL.value:
+        return _equal_indices(left_c, right_c, len_conditions)
+    elif op == JOINOPERATOR.NOT_EQUAL.value:
+        return _not_equal_indices(left_c, right_c, len_conditions, strict)
+
+
+def _conditional_join_compute(
+    df: pd.DataFrame,
+    right: pd.DataFrame,
+    conditions: list,
+    how: str,
+    sort_by_appearance: bool,
+) -> pd.DataFrame:
+    """
+    This is where the actual computation for the conditional join takes place.
+    If there are no matches, None is returned; if however, there is a match,
+    then a pandas DataFrame is returned.
+    """
+
+    len_conditions = len(conditions)
+
+    if len_conditions == 1:
+        left_on, right_on, op = conditions[0]
+
+        left_c = df[left_on]
+        right_c = right[right_on]
+
+        _conditional_join_type_check(left_c, right_c, op)
+
+        result = _generic_func_cond_join(left_c, right_c, op, len_conditions)
+
+        if result is None:
+            return _create_conditional_join_empty_frame(df, right, how)
+
+        left_c, right_c = result
+
+        return _create_conditional_join_frame(
+            df, right, left_c, right_c, how, sort_by_appearance
+        )
+
+    # TODO: improve the efficiency for multiple conditions
+    df_index = df.index
+    # iteratively reduce the number of rows
+    # from df, until we have the certain index labels
+    # that will be in the final dataframe
+    # usually much smaller, which should help
+    # reduce overall processing time
+    for condition in conditions:
+        left_on, right_on, op = condition
+        left_c = df.loc[df_index, left_on]
+        right_c = right[right_on]
+
+        _conditional_join_type_check(left_c, right_c, op)
+
+        df_index = _generic_func_cond_join(left_c, right_c, op, len_conditions)
+
+        if df_index is None:
+            return _create_conditional_join_empty_frame(df, right, how)
+
+    df = df.loc[df_index]
+
+    conditions = [
+        (df[left_on], right[right_on], op)
+        for left_on, right_on, op in conditions
+    ]
+    first, *rest = conditions
+    left_c, right_c, op = first
+
+    result = _generic_func_cond_join(left_c, right_c, op, 1)
+
+    if result is None:
+        return _create_conditional_join_empty_frame(df, right, how)
+
+    df_index, right_index = result
+
+    # use booleans to get the index labels
+    # for df and right, to create the final dataframe
+    for left_c, right_c, op in rest:
+        left_c = left_c[df_index].array
+        right_c = right_c[right_index].array
+        # using pd.eval might be faster for larger arrays
+        # if the user has numexpr installed
+        keep_rows = pd.eval(f"left_c {op} right_c")
+        if keep_rows.sum() == 0:
+            return _create_conditional_join_empty_frame(df, right, how)
+        df_index = df_index[keep_rows]
+        right_index = right_index[keep_rows]
+    return _create_conditional_join_frame(
+        df, right, df_index, right_index, how, sort_by_appearance
+    )


Test patch:
diff --git a/janitor/testing_utils/strategies.py b/janitor/testing_utils/strategies.py
--- a/janitor/testing_utils/strategies.py
+++ b/janitor/testing_utils/strategies.py
@@ -4,7 +4,7 @@
 
 import numpy as np
 from hypothesis import strategies as st
-from hypothesis.extra.pandas import column, data_frames, range_indexes
+from hypothesis.extra.pandas import column, data_frames, range_indexes, series
 
 
 def nulldf_strategy():
@@ -69,3 +69,36 @@ def categoricaldf_strategy():
 
 def names_strategy():
     return st.lists(elements=st.sampled_from(names))
+
+
+def conditional_df():
+    """Dataframe used in tests_conditional_join."""
+    return data_frames(
+        [
+            column(name="A", dtype=int),
+            column(name="B", elements=st.floats(allow_nan=True)),
+            column(name="C", elements=st.text(max_size=20)),
+            column(name="D", dtype=bool),
+            column(name="E", dtype="datetime64[ns]"),
+        ]
+    )
+
+
+def conditional_series():
+    """Series used in tests_conditional_join"""
+    return series(dtype=int)
+
+
+def conditional_right():
+    """Dataframe used in tests_conditional_join."""
+    return data_frames(
+        [
+            column(name="Integers", dtype=int),
+            column(name="Numeric", elements=st.floats(allow_nan=True)),
+            column(name="Floats", elements=st.floats(max_value=20)),
+            column(name="Strings", dtype=str),
+            column(name="Booleans", dtype=np.bool_),
+            column(name="Dates", dtype="datetime64[ns]"),
+            column(name="Dates_Right", dtype="datetime64[ns]"),
+        ]
+    )
diff --git a/tests/functions/test_conditional_join.py b/tests/functions/test_conditional_join.py
new file mode 100644
--- /dev/null
+++ b/tests/functions/test_conditional_join.py
@@ -0,0 +1,799 @@
+import numpy as np
+import pandas as pd
+import pytest
+from hypothesis import assume, given
+from pandas.testing import assert_frame_equal
+from janitor.testing_utils.strategies import (
+    conditional_df,
+    conditional_right,
+    conditional_series,
+)
+
+
+@given(s=conditional_series())
+def test_df_empty(s):
+    """Raise ValueError if `df` is empty."""
+    df = pd.DataFrame([], dtype="object")
+    with pytest.raises(ValueError):
+        df.conditional_join(s, ("A", "non", "=="))
+
+
+@given(df=conditional_df())
+def test_right_df(df):
+    """Raise TypeError if `right` is not a Series/DataFrame."""
+    assume(not df.empty)
+    with pytest.raises(TypeError):
+        df.conditional_join({"non": [2, 3, 4]}, ("A", "non", "=="))
+
+
+@given(df=conditional_df(), s=conditional_series())
+def test_right_series(df, s):
+    """Raise ValueError if `right` is not a named Series."""
+
+    with pytest.raises(ValueError):
+        df.conditional_join(s, ("A", "non", "=="))
+
+
+@given(df=conditional_df())
+def test_df_MultiIndex(df):
+    """Raise ValueError if `df` columns is a MultiIndex."""
+
+    with pytest.raises(ValueError):
+        df.columns = [list("ABCDE"), list("FGHIJ")]
+        df.conditional_join(
+            pd.Series([2, 3, 4], name="A"), (("A", "F"), "non", "==")
+        )
+
+
+@given(df=conditional_df())
+def test_right_MultiIndex(df):
+    """Raise ValueError if `right` columns is a MultiIndex."""
+
+    with pytest.raises(ValueError):
+        right = df.copy()
+        right.columns = [list("ABCDE"), list("FGHIJ")]
+        df.conditional_join(right, (("A", "F"), "non", ">="))
+
+
+@given(df=conditional_df(), s=conditional_series())
+def test_check_condition_type(df, s):
+    """Raise TypeError if any condition in conditions is not a tuple."""
+
+    assume(not df.empty)
+    assume(not s.empty)
+    with pytest.raises(TypeError):
+        s.name = "B"
+        df.conditional_join(s, ("A", "B", ""), ["A", "B"])
+
+
+@given(df=conditional_df(), s=conditional_series())
+def test_check_condition_length(df, s):
+    """Raise ValueError if any condition is not length 3."""
+
+    with pytest.raises(ValueError):
+        s.name = "B"
+        df.conditional_join(s, ("A", "B", "C", "<"))
+        df.conditional_join(s, ("A", "B", ""), ("A", "B"))
+
+
+@given(df=conditional_df(), s=conditional_series())
+def test_check_left_on_type(df, s):
+    """Raise TypeError if left_on is not a string."""
+
+    assume(not df.empty)
+    assume(not s.empty)
+    with pytest.raises(TypeError):
+        s.name = "B"
+        df.conditional_join(s, (1, "B", "<"))
+
+
+@given(df=conditional_df(), s=conditional_series())
+def test_check_right_on_type(df, s):
+    """Raise TypeError if right_on is not a string."""
+
+    assume(not df.empty)
+    assume(not s.empty)
+    with pytest.raises(TypeError):
+        s.name = "B"
+        df.conditional_join(s, ("B", 1, "<"))
+
+
+@given(df=conditional_df(), s=conditional_series())
+def test_check_op_type(df, s):
+    """Raise TypeError if the operator is not a string."""
+
+    assume(not df.empty)
+    assume(not s.empty)
+    with pytest.raises(TypeError):
+        s.name = "B"
+        df.conditional_join(s, ("B", "B", 1))
+
+
+@given(df=conditional_df(), s=conditional_series())
+def test_check_column_exists_df(df, s):
+    """
+    Raise ValueError if `left_on`
+    can not be found in `df`.
+    """
+
+    with pytest.raises(ValueError):
+        s.name = "B"
+        df.conditional_join(s, ("C", "B", "<"))
+
+
+@given(df=conditional_df(), s=conditional_series())
+def test_check_column_exists_right(df, s):
+    """
+    Raise ValueError if `right_on`
+    can not be found in `right`.
+    """
+
+    with pytest.raises(ValueError):
+        s.name = "B"
+        df.conditional_join(s, ("B", "A", ">="))
+
+
+@given(df=conditional_df(), s=conditional_series())
+def test_check_op_correct(df, s):
+    """
+    Raise ValueError if `op` is not any of
+    `==`, `!=`, `<`, `>`, `>=`, `<=`.
+    """
+
+    with pytest.raises(ValueError):
+        s.name = "B"
+        df.conditional_join(s, ("B", "B", "=!"))
+
+
+@given(df=conditional_df(), s=conditional_series())
+def test_check_how_type(df, s):
+    """
+    Raise TypeError if `how` is not a string.
+    """
+
+    assume(not df.empty)
+    assume(not s.empty)
+    with pytest.raises(TypeError):
+        s.name = "B"
+        df.conditional_join(s, ("B", "B", "<"), how=1)
+
+
+@given(df=conditional_df(), s=conditional_series())
+def test_check_how_value(df, s):
+    """
+    Raise ValueError if `how` is not one of `inner`, `outer`,
+    `left`, or `right`.
+    """
+
+    with pytest.raises(ValueError):
+        s.name = "B"
+        df.conditional_join(s, ("B", "B", "<"), how="INNER")
+
+
+@given(df=conditional_df(), s=conditional_series())
+def test_check_sort_by_appearance_type(df, s):
+    """
+    Raise TypeError if `sort_by_appearance` is not a boolean.
+    """
+
+    assume(not df.empty)
+    assume(not s.empty)
+    with pytest.raises(TypeError):
+        s.name = "B"
+        df.conditional_join(s, ("B", "B", "<"), sort_by_appearance="True")
+
+
+@given(df=conditional_df(), s=conditional_series())
+def test_check_suffixes_type(df, s):
+    """
+    Raise TypeError if `suffixes` is not a tuple.
+    """
+
+    assume(not df.empty)
+    assume(not s.empty)
+    with pytest.raises(TypeError):
+        s.name = "B"
+        df.conditional_join(s, ("B", "B", "<"), suffixes=["_x", "_y"])
+
+
+@given(df=conditional_df(), s=conditional_series())
+def test_check_suffixes_length(df, s):
+    """
+    Raise ValueError if `suffixes` is not a tuple of length 2.
+    """
+    with pytest.raises(ValueError):
+        s.name = "B"
+        df.conditional_join(s, ("B", "B", "<"), suffixes=("_x",))
+        df.conditional_join(s, ("B", "B", "<"), suffixes=("_x", "_y", None))
+
+
+@given(df=conditional_df(), s=conditional_series())
+def test_check_suffixes_None(df, s):
+    """
+    Raise ValueError if `suffixes` is (None, None).
+    """
+    with pytest.raises(ValueError):
+        s.name = "B"
+        df.conditional_join(s, ("B", "B", "<"), suffixes=(None, None))
+
+
+@given(df=conditional_df(), s=conditional_series())
+def test_check_suffixes_subtype(df, s):
+    """
+    Raise TypeError if any entry `suffixes`
+    is not either None or a string type.
+    """
+
+    assume(not df.empty)
+    assume(not s.empty)
+    with pytest.raises(TypeError):
+        s.name = "B"
+        df.conditional_join(s, ("B", "B", "<"), suffixes=(1, None))
+
+
+@given(df=conditional_df())
+def test_check_suffixes_exists_df(df):
+    """
+    Raise ValueError if suffix already exists in `df`.
+    """
+    with pytest.raises(ValueError):
+        right = df.copy()
+        df.columns = ["A", "A_x", "B", "F", "H"]
+        df.conditional_join(right, ("A", "A", "<"), suffixes=("_x", "_y"))
+
+
+@given(df=conditional_df())
+def test_check_suffixes_exists_right(df):
+    """
+    Raise ValueError if suffix already exists in `right`.
+    """
+    with pytest.raises(ValueError):
+        right = df.copy()
+        right.columns = ["A", "B", "B_y", "C", "E"]
+        df.conditional_join(right, ("B", "B", "<"), suffixes=("_x", "_y"))
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_dtype_strings_non_equi(df, right):
+    """
+    Raise ValueError if the dtypes are both strings,
+    and the join operator is not the equal operator.
+    """
+    with pytest.raises(ValueError):
+        df.conditional_join(
+            right, ("C", "Strings", "<"), suffixes=("_x", "_y")
+        )
+
+
+@given(df=conditional_df(), s=conditional_series())
+def test_dtype_Series(df, s):
+    """
+    Raise ValueError if dtype of column in `df`
+    does not match the dtype of column from `right`.
+    """
+    with pytest.raises(ValueError):
+        s.name = "A"
+        df.conditional_join(s, ("C", "A", "<"), suffixes=("_x", "_y"))
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_single_condition_equality_numeric(df, right):
+    """Test output for a single condition. "=="."""
+    assume(not df.empty)
+    assume(not right.empty)
+    # simulate output as it would be in SQL
+    left_on, right_on = ["B", "Integers"]
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} == {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, right_on])
+    actual = df.conditional_join(
+        right, (left_on, right_on, "=="), how="inner", sort_by_appearance=True
+    )
+    actual = actual.filter([left_on, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_single_condition_equality_datetime(df, right):
+    """Test output for a single condition. "=="."""
+    assume(not df.empty)
+    assume(not right.empty)
+    # simulate output as it would be in SQL
+    left_on, right_on = ["E", "Dates"]
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} == {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, right_on])
+    actual = df.conditional_join(
+        right, (left_on, right_on, "=="), how="inner", sort_by_appearance=True
+    )
+    actual = actual.filter([left_on, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_single_condition_equality_strings(df, right):
+    """Test output for a single condition. "=="."""
+    assume(not df.empty)
+    assume(not right.empty)
+    # simulate output as it would be in SQL
+    left_on, right_on = ["C", "Strings"]
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} == {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, right_on])
+    actual = df.conditional_join(
+        right, (left_on, right_on, "=="), how="inner", sort_by_appearance=True
+    )
+    actual = actual.filter([left_on, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_single_condition_not_equal_numeric(df, right):
+    """Test output for a single condition. "!="."""
+    assume(not df.empty)
+    assume(not right.empty)
+    left_on, right_on = ["B", "Integers"]
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} != {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, right_on])
+    actual = df.conditional_join(
+        right, (left_on, right_on, "!="), how="inner", sort_by_appearance=True
+    )
+    actual = actual.filter([left_on, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_single_condition_not_equal_ints_only(df, right):
+    """Test output for a single condition. "!="."""
+    assume(not df.empty)
+    assume(not right.empty)
+    left_on, right_on = ["A", "Integers"]
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} != {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, right_on])
+    actual = df.conditional_join(
+        right, (left_on, right_on, "!="), how="inner", sort_by_appearance=True
+    )
+    actual = actual.filter([left_on, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_single_condition_not_equal_floats_only(df, right):
+    """Test output for a single condition. "!="."""
+    assume(not df.empty)
+    assume(not right.empty)
+    # simulate output as it would be in SQL
+    left_on, right_on = ["B", "Numeric"]
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} != {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, right_on])
+    actual = df.conditional_join(
+        right, (left_on, right_on, "!="), how="inner", sort_by_appearance=True
+    )
+    actual = actual.filter([left_on, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_single_condition_not_equal_datetime(df, right):
+    """Test output for a single condition. "!="."""
+    assume(not df.empty)
+    assume(not right.empty)
+    # simulate output as it would be in SQL
+    left_on, right_on = ["E", "Dates"]
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} != {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, right_on])
+    actual = df.conditional_join(
+        right, (left_on, right_on, "!="), how="inner", sort_by_appearance=True
+    )
+    actual = actual.filter([left_on, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_how_left(df, right):
+    """Test output when `how==left`. "<="."""
+    assume(not df.empty)
+    assume(not right.empty)
+    left_on, right_on = ["A", "Integers"]
+    expected = (
+        df.assign(t=1, index=np.arange(len(df)))
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} <= {right_on}")
+    )
+    expected = expected.set_index("index")
+    expected.index.name = None
+    expected = df.join(
+        expected.filter(right.columns), how="left", sort=False
+    ).reset_index(drop=True)
+    actual = df.conditional_join(
+        right, (left_on, right_on, "<="), how="left", sort_by_appearance=True
+    )
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_how_right(df, right):
+    """Test output when `how==right`. ">"."""
+    assume(not df.empty)
+    assume(not right.empty)
+    left_on, right_on = ["E", "Dates"]
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1, index=np.arange(len(right))), on="t")
+        .query(f"{left_on} > {right_on}")
+    )
+    expected = expected.set_index("index")
+    expected.index.name = None
+    expected = (
+        expected.filter(df.columns)
+        .join(right, how="right", sort=False)
+        .reset_index(drop=True)
+    )
+    actual = df.conditional_join(
+        right, (left_on, right_on, ">"), how="right", sort_by_appearance=True
+    )
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_single_condition_less_than_floats(df, right):
+    """Test output for a single condition. "<".Floats vs Floats."""
+    assume(not df.empty)
+    assume(not right.empty)
+    left_on, right_on = ["B", "Numeric"]
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} < {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, right_on])
+    actual = df.conditional_join(
+        right, (left_on, right_on, "<"), how="inner", sort_by_appearance=True
+    )
+    actual = actual.filter([left_on, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_single_condition_less_than_int_float(df, right):
+    """Test output for a single condition. "<".Ints vs Floats."""
+    assume(not df.empty)
+    assume(not right.empty)
+    left_on, right_on = ["A", "Numeric"]
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} < {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, right_on])
+    actual = df.conditional_join(
+        right, (left_on, right_on, "<"), how="inner", sort_by_appearance=True
+    )
+    actual = actual.filter([left_on, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_single_condition_less_than_equal(df, right):
+    """Test output for a single condition. "<=". DateTimes"""
+    assume(not df.empty)
+    assume(not right.empty)
+    left_on, right_on = ["E", "Dates"]
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} <= {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, right_on])
+    actual = df.conditional_join(
+        right, (left_on, right_on, "<="), how="inner", sort_by_appearance=True
+    )
+    actual = actual.filter([left_on, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_single_condition_less_than_date(df, right):
+    """Test output for a single condition. "<". Dates"""
+    assume(not df.empty)
+    assume(not right.empty)
+    left_on, right_on = ["E", "Dates"]
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} < {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, right_on])
+    actual = df.conditional_join(
+        right, (left_on, right_on, "<"), how="inner", sort_by_appearance=True
+    )
+    actual = actual.filter([left_on, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_single_condition_greater_than_datetime(df, right):
+    """Test output for a single condition. ">". Datetimes"""
+    assume(not df.empty)
+    assume(not right.empty)
+    left_on, right_on = ["E", "Dates"]
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} > {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, right_on])
+    actual = df.conditional_join(
+        right, (left_on, right_on, ">"), how="inner", sort_by_appearance=True
+    )
+    actual = actual.filter([left_on, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_single_condition_greater_than_ints(df, right):
+    """Test output for a single condition. ">=". Ints vs Ints"""
+    assume(not df.empty)
+    assume(not right.empty)
+    left_on, right_on = ["A", "Integers"]
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} >= {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, right_on])
+    actual = df.conditional_join(
+        right, (left_on, right_on, ">="), how="inner", sort_by_appearance=True
+    )
+    actual = actual.filter([left_on, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_single_condition_greater_than_ints_floats(df, right):
+    """Test output for a single condition. ">". Ints vs Floats"""
+    assume(not df.empty)
+    assume(not right.empty)
+    left_on, right_on = ["A", "Numeric"]
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} > {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, right_on])
+    actual = df.conditional_join(
+        right, (left_on, right_on, ">"), how="inner", sort_by_appearance=True
+    )
+    actual = actual.filter([left_on, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_dual_conditions_gt_and_lt_dates(df, right):
+    """Test output for interval conditions."""
+    assume(not df.empty)
+    assume(not right.empty)
+    middle, left_on, right_on = ("E", "Dates", "Dates_Right")
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} < {middle} < {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, middle, right_on])
+    actual = df.conditional_join(
+        right,
+        (middle, left_on, ">"),
+        (middle, right_on, "<"),
+        how="inner",
+        sort_by_appearance=True,
+    )
+    actual = actual.filter([left_on, middle, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_dual_conditions_ge_and_le_dates(df, right):
+    """Test output for interval conditions."""
+    assume(not df.empty)
+    assume(not right.empty)
+    middle, left_on, right_on = ("E", "Dates", "Dates_Right")
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} <= {middle} <= {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, middle, right_on])
+    actual = df.conditional_join(
+        right,
+        (middle, left_on, ">="),
+        (middle, right_on, "<="),
+        how="inner",
+        sort_by_appearance=True,
+    )
+    actual = actual.filter([left_on, middle, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_dual_conditions_le_and_ge_dates(df, right):
+    """Test output for interval conditions."""
+    assume(not df.empty)
+    assume(not right.empty)
+    middle, left_on, right_on = ("E", "Dates", "Dates_Right")
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} <= {middle} <= {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, middle, right_on])
+    actual = df.conditional_join(
+        right,
+        (middle, right_on, "<="),
+        (middle, left_on, ">="),
+        how="inner",
+        sort_by_appearance=True,
+    )
+    actual = actual.filter([left_on, middle, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_dual_conditions_ge_and_le_numbers(df, right):
+    """Test output for interval conditions."""
+    assume(not df.empty)
+    assume(not right.empty)
+    middle, left_on, right_on = ("B", "Numeric", "Floats")
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} <= {middle} <= {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, middle, right_on])
+    actual = df.conditional_join(
+        right,
+        (middle, left_on, ">="),
+        (middle, right_on, "<="),
+        how="inner",
+        sort_by_appearance=True,
+    )
+    actual = actual.filter([left_on, middle, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_dual_conditions_le_and_ge_numbers(df, right):
+    """Test output for interval conditions."""
+    assume(not df.empty)
+    assume(not right.empty)
+    middle, left_on, right_on = ("B", "Numeric", "Floats")
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} <= {middle} <= {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, middle, right_on])
+    actual = df.conditional_join(
+        right,
+        (middle, right_on, "<="),
+        (middle, left_on, ">="),
+        how="inner",
+        sort_by_appearance=True,
+    )
+    actual = actual.filter([left_on, middle, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_dual_conditions_gt_and_lt_numbers(df, right):
+    """Test output for interval conditions."""
+    assume(not df.empty)
+    assume(not right.empty)
+    middle, left_on, right_on = ("B", "Numeric", "Floats")
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} < {middle} < {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, middle, right_on])
+    actual = df.conditional_join(
+        right,
+        (middle, left_on, ">"),
+        (middle, right_on, "<"),
+        how="inner",
+        sort_by_appearance=True,
+    )
+    actual = actual.filter([left_on, middle, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_dual_conditions_eq_and_ne(df, right):
+    """Test output for equal and not equal conditions."""
+    assume(not df.empty)
+    assume(not right.empty)
+    eq_A, eq_B, ne_A, ne_B = ("B", "Numeric", "E", "Dates")
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{eq_A} == {eq_B} and {ne_A} != {ne_B}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([eq_A, eq_B, ne_A, ne_B])
+    actual = df.conditional_join(
+        right,
+        (eq_A, eq_B, "=="),
+        (ne_A, ne_B, "!="),
+        how="inner",
+        sort_by_appearance=True,
+    )
+    actual = actual.filter([eq_A, eq_B, ne_A, ne_B])
+    assert_frame_equal(actual, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_dual_conditions_ne_and_eq(df, right):
+    """Test output for equal and not equal conditions."""
+    assume(not df.empty)
+    assume(not right.empty)
+    eq_A, eq_B, ne_A, ne_B = ("B", "Numeric", "E", "Dates")
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{eq_A} != {eq_B} and {ne_A} == {ne_B}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([eq_A, eq_B, ne_A, ne_B])
+    actual = df.conditional_join(
+        right,
+        (eq_A, eq_B, "!="),
+        (ne_A, ne_B, "=="),
+        how="inner",
+        sort_by_appearance=True,
+    )
+    actual = actual.filter([eq_A, eq_B, ne_A, ne_B])
+    assert_frame_equal(expected, actual)
diff --git a/tests/test_df_registration.py b/tests/test_df_registration.py
--- a/tests/test_df_registration.py
+++ b/tests/test_df_registration.py
@@ -126,6 +126,36 @@ def test_filter_date_registration(dataframe):
     assert dataframe.__getattr__("filter_date")
 
 
+def test_conditional_join_registration(dataframe):
+    """Test if DataFrame has conditional_join method"""
+    assert dataframe.__getattr__("conditional_join")
+
+
+def test_pivot_longer_registration(dataframe):
+    """Test if DataFrame has pivot_longer method"""
+    assert dataframe.__getattr__("pivot_longer")
+
+
+def test_pivot_wider_registration(dataframe):
+    """Test if DataFrame has pivot_wider method"""
+    assert dataframe.__getattr__("pivot_wider")
+
+
+def test_expand_grid_registration(dataframe):
+    """Test if DataFrame has expand_grid method"""
+    assert dataframe.__getattr__("expand_grid")
+
+
+def test_process_text_registration(dataframe):
+    """Test if DataFrame has process_text method"""
+    assert dataframe.__getattr__("process_text")
+
+
+def test_fill_direction_registration(dataframe):
+    """Test if DataFrame has fill_direction method"""
+    assert dataframe.__getattr__("fill_direction")
+
+
 def test_drop_constant_columns_registration(dataframe):
     """Test if DataFrame has drop_constant_columns method"""
     assert dataframe.__getattr__("drop_constant_columns")