Repository: pyjanitor-devs/pyjanitor

Issue:
[ENH] Add `fill_value` and `explicit` parameters to `complete`
# Brief Description

<!-- Please provide a brief description of what you'd like to propose. -->

fill null values generated during `complete` and decide if existing nulls should be filled, or only nulls generated from the `complete` function. idea inspired by the updates to the [complete](https://tidyr.tidyverse.org/reference/complete.html) function in tidyr.

# Example API

```python
# source dataframe
df =  dict(
  group = (1,2, 1, 2),
item_id = (1,2, 2, 3),
 item_name = ("a", "a", "b", "b"),
value1 = (1, pd.NA, 3, 4),
 value2 = range(4,8)
 )

df  = pd.DataFrame(df)

   group  item_id item_name value1  value2
0      1        1         a      1       4
1      2        2         a   <NA>       5
2      1        2         b      3       6
3      2        3         b      4       7

# fill value and explicit set to True
df.complete('group', ('item_id', 'item_name'), fill_value = 0, sort = True, explicit = True) 

   group  item_id item_name  value1  value2
0      1        1         a       1     4
1      1        2         a       0     0
2      1        2         b       3     6
3      1        3         b       0     0
4      2        1         a       0     0
5      2        2         a       0     5
6      2        2         b       0     0
7      2        3         b       4     7

df.complete('group', ('item_id', 'item_name'), fill_value = 0, sort = True, explicit = False) 

   group  item_id item_name  value1  value2
0      1        1         a       1     4
1      1        2         a       0     0
2      1        2         b       3     6
3      1        3         b       0     0
4      2        1         a       0     0
5      2        2         a    <NA>     5
6      2        2         b       0     0
7      2        3         b       4     7
``` 

It is easy to do a `fillna` after `complete`; however it looks cumbersome filling only the nulls generated from the `complete` function. I'll gladly take suggestions for filling only the null values without adding new parameters.



Discussion:

Resolving patch:
diff --git a/janitor/functions/complete.py b/janitor/functions/complete.py
--- a/janitor/functions/complete.py
+++ b/janitor/functions/complete.py
@@ -1,9 +1,9 @@
-from typing import Optional, Union, List, Tuple, Dict
+from typing import Optional, Union, List, Tuple, Dict, Any
 from pandas.core.common import apply_if_callable
 import pandas_flavor as pf
 import pandas as pd
 import functools
-from pandas.api.types import is_list_like
+from pandas.api.types import is_list_like, is_scalar
 
 from janitor.utils import check, check_column
 
@@ -16,22 +16,25 @@ def complete(
     *columns,
     sort: bool = False,
     by: Optional[Union[list, str]] = None,
+    fill_value: Optional[Union[Dict, Any]] = None,
+    explicit: bool = True,
 ) -> pd.DataFrame:
     """
     It is modeled after tidyr's `complete` function, and is a wrapper around
-    [`expand_grid`][janitor.functions.expand_grid.expand_grid] and `pd.merge`.
+    [`expand_grid`][janitor.functions.expand_grid.expand_grid], `pd.merge`
+    and `pd.fillna`. In a way, it is the inverse of `pd.dropna`, as it exposes
+    implicitly missing rows.
 
     Combinations of column names or a list/tuple of column names, or even a
     dictionary of column names and new values are possible.
 
-    It can also handle duplicated data.
-
     MultiIndex columns are not supported.
 
     Example:
 
         >>> import pandas as pd
         >>> import janitor
+        >>> import numpy as np
         >>> df = pd.DataFrame(
         ...     {
         ...         "Year": [1999, 2000, 2004, 1999, 2004],
@@ -55,7 +58,7 @@ def complete(
 
     Expose missing pairings of `Year` and `Taxon`:
 
-        >>> df.complete("Year", "Taxon", sort = True)
+        >>> df.complete("Year", "Taxon", sort=True)
            Year       Taxon  Abundance
         0  1999      Agarum        1.0
         1  1999  Saccharina        4.0
@@ -69,7 +72,7 @@ def complete(
         >>> df.complete(
         ...     {"Year": range(df.Year.min(), df.Year.max() + 1)},
         ...     "Taxon",
-        ...     sort=True,
+        ...     sort=True
         ... )
             Year       Taxon  Abundance
         0   1999      Agarum        1.0
@@ -85,6 +88,60 @@ def complete(
         10  2004      Agarum        8.0
         11  2004  Saccharina        2.0
 
+    Fill missing values:
+
+        >>> df = pd.DataFrame(
+        ...     dict(
+        ...         group=(1, 2, 1, 2),
+        ...         item_id=(1, 2, 2, 3),
+        ...         item_name=("a", "a", "b", "b"),
+        ...         value1=(1, np.nan, 3, 4),
+        ...         value2=range(4, 8),
+        ...     )
+        ... )
+        >>> df
+           group  item_id item_name  value1  value2
+        0      1        1         a     1.0       4
+        1      2        2         a     NaN       5
+        2      1        2         b     3.0       6
+        3      2        3         b     4.0       7
+        >>> df.complete(
+        ...     "group",
+        ...     ("item_id", "item_name"),
+        ...     fill_value={"value1": 0, "value2": 99},
+        ...     sort=True
+        ... )
+           group  item_id item_name  value1  value2
+        0      1        1         a       1       4
+        1      1        2         a       0      99
+        2      1        2         b       3       6
+        3      1        3         b       0      99
+        4      2        1         a       0      99
+        5      2        2         a       0       5
+        6      2        2         b       0      99
+        7      2        3         b       4       7
+
+    Limit the fill to only implicit missing values
+    by setting explicit to `False`:
+
+        >>> df.complete(
+        ...     "group",
+        ...     ("item_id", "item_name"),
+        ...     fill_value={"value1": 0, "value2": 99},
+        ...     explicit=False,
+        ...     sort=True
+        ... )
+           group  item_id item_name  value1  value2
+        0      1        1         a     1.0       4
+        1      1        2         a     0.0      99
+        2      1        2         b     3.0       6
+        3      1        3         b     0.0      99
+        4      2        1         a     0.0      99
+        5      2        2         a     NaN       5
+        6      2        2         b     0.0      99
+        7      2        3         b     4.0       7
+
+
     :param df: A pandas DataFrame.
     :param *columns: This refers to the columns to be
         completed. It could be column labels (string type),
@@ -93,6 +150,13 @@ def complete(
     :param sort: Sort DataFrame based on *columns. Default is `False`.
     :param by: label or list of labels to group by.
         The explicit missing rows are returned per group.
+    :param fill_value: Scalar value to use instead of NaN
+        for missing combinations. A dictionary, mapping columns names
+        to a scalar value is also accepted.
+    :param explicit: Determines if only implicitly missing values
+        should be filled (`False`), or all nulls existing in the dataframe
+        (`True`). Default is `True`. `explicit` is applicable only
+        if `fill_value` is not `None`.
     :returns: A pandas DataFrame with explicit missing rows, if any.
     """
 
@@ -101,14 +165,16 @@ def complete(
 
     df = df.copy()
 
-    return _computations_complete(df, columns, sort, by)
+    return _computations_complete(df, columns, sort, by, fill_value, explicit)
 
 
 def _computations_complete(
     df: pd.DataFrame,
     columns: List[Union[List, Tuple, Dict, str]],
-    sort: bool = False,
-    by: Optional[Union[list, str]] = None,
+    sort: bool,
+    by: Optional[Union[list, str]],
+    fill_value: Optional[Union[Dict, Any]],
+    explicit: bool,
 ) -> pd.DataFrame:
     """
     This function computes the final output for the `complete` function.
@@ -117,10 +183,14 @@ def _computations_complete(
 
     A DataFrame, with rows of missing values, if any, is returned.
     """
-
-    columns, column_checker, sort, by = _data_checks_complete(
-        df, columns, sort, by
-    )
+    (
+        columns,
+        column_checker,
+        sort,
+        by,
+        fill_value,
+        explicit,
+    ) = _data_checks_complete(df, columns, sort, by, fill_value, explicit)
 
     all_strings = True
     for column in columns:
@@ -143,17 +213,65 @@ def _computations_complete(
     # of course there could be a better way ...
     if by is None:
         uniques = _generic_complete(df, columns, all_strings)
-        return df.merge(uniques, how="outer", on=column_checker, sort=sort)
-
-    uniques = df.groupby(by)
-    uniques = uniques.apply(_generic_complete, columns, all_strings)
-    uniques = uniques.droplevel(-1)
-    return df.merge(uniques, how="outer", on=by + column_checker, sort=sort)
+    else:
+        uniques = df.groupby(by)
+        uniques = uniques.apply(_generic_complete, columns, all_strings)
+        uniques = uniques.droplevel(-1)
+        column_checker = by + column_checker
+    if fill_value is None:
+        return df.merge(
+            uniques, on=column_checker, how="outer", sort=sort, copy=False
+        )
+    # if fill_value is present
+    if is_scalar(fill_value):
+        # faster when fillna operates on a Series basis
+        fill_value = {col: fill_value for col in df}
+
+    if explicit:
+        return df.merge(
+            uniques, on=column_checker, how="outer", sort=sort, copy=False
+        ).fillna(fill_value, downcast="infer")
+    # keep only columns that are not part of column_checker
+    # IOW, we are excluding columns that were not used
+    # to generate the combinations
+    fill_value = {
+        col: value
+        for col, value in fill_value.items()
+        if col not in column_checker
+    }
+    if not fill_value:  # there is nothing to fill
+        return df.merge(
+            uniques, on=column_checker, how="outer", sort=sort, copy=False
+        )
 
+    # when explicit is False
+    # filter out rows from `unique` that already exist in the parent dataframe
+    # fill the null values in the trimmed `unique`
+    # and merge back to the main dataframe
 
-def _generic_complete(
-    df: pd.DataFrame, columns: list, all_strings: bool = True
-):
+    # to get a name that does not exist in the columns
+    indicator = "".join(df.columns)
+    trimmed = df.loc(axis=1)[column_checker]
+    uniques = uniques.merge(
+        trimmed, how="left", sort=False, copy=False, indicator=indicator
+    )
+    trimmed = uniques.iloc(axis=1)[-1] == "left_only"
+    uniques = uniques.loc[trimmed, column_checker]
+    trimmed = None
+    indicator = None
+    # iteration used here, instead of assign (which is also a for loop),
+    # to cater for scenarios where the column_name is not a string
+    # assign only works with keys that are strings
+    for column_name, value in fill_value.items():
+        uniques[column_name] = value
+    df = pd.concat([df, uniques], sort=False, copy=False, axis="index")
+    if sort:
+        df = df.sort_values(column_checker)
+    df.index = range(len(df))
+    return df
+
+
+def _generic_complete(df: pd.DataFrame, columns: list, all_strings: bool):
     """
     Generate cartesian product for `_computations_complete`.
 
@@ -182,7 +300,7 @@ def _generic_complete(
 
 
 @functools.singledispatch
-def _complete_column(column, df):
+def _complete_column(column: str, df):
     """
     Args:
         column : str/list/dict
@@ -191,22 +309,6 @@ def _complete_column(column, df):
     A Pandas Series/DataFrame with no duplicates,
     or a list of unique Pandas Series is returned.
     """
-    raise TypeError(
-        """This type is not supported in the `complete` function."""
-    )
-
-
-@_complete_column.register(str)  # noqa: F811
-def _sub_complete_column(column, df):  # noqa: F811
-    """
-    Args:
-        column : str
-        df: Pandas DataFrame
-
-    Returns:
-        Pandas Series
-    """
-
     column = df[column]
 
     if not column.is_unique:
@@ -248,20 +350,14 @@ def _sub_complete_column(column, df):  # noqa: F811
     for key, value in column.items():
         arr = apply_if_callable(value, df[key])
         if not is_list_like(arr):
-            raise ValueError(
-                f"""
-                value for {key} should be a 1-D array.
-                """
-            )
+            raise ValueError(f"value for {key} should be a 1-D array.")
         if not hasattr(arr, "shape"):
             arr = pd.Series([*arr], name=key)
 
         if not arr.size > 0:
             raise ValueError(
-                f"""
-                Kindly ensure the provided array for {key}
-                has at least one value.
-                """
+                f"Kindly ensure the provided array for {key} "
+                "has at least one value."
             )
 
         if isinstance(arr, pd.Index):
@@ -270,11 +366,7 @@ def _sub_complete_column(column, df):  # noqa: F811
             arr_ndim = arr.ndim
 
         if arr_ndim != 1:
-            raise ValueError(
-                f"""
-                Kindly provide a 1-D array for {key}.
-                """
-            )
+            raise ValueError(f"Kindly provide a 1-D array for {key}.")
 
         if not isinstance(arr, pd.Series):
             arr = pd.Series(arr)
@@ -292,8 +384,10 @@ def _sub_complete_column(column, df):  # noqa: F811
 def _data_checks_complete(
     df: pd.DataFrame,
     columns: List[Union[List, Tuple, Dict, str]],
-    sort: Optional[bool] = False,
-    by: Optional[Union[list, str]] = None,
+    sort: Optional[bool],
+    by: Optional[Union[list, str]],
+    fill_value: Optional[Union[Dict, Any]],
+    explicit: bool,
 ):
     """
     Function to check parameters in the `complete` function.
@@ -303,20 +397,16 @@ def _data_checks_complete(
     Check is conducted to ensure that column names are not repeated.
     Also checks that the names in `columns` actually exist in `df`.
 
-    Returns `df`, `columns`, `column_checker`, and `by` if
-    all checks pass.
+    Returns `df`, `columns`, `column_checker`, `by`, `fill_value`,
+    and `explicit` if all checks pass.
     """
     # TODO: get `complete` to work on MultiIndex columns,
     # if there is sufficient interest with use cases
     if isinstance(df.columns, pd.MultiIndex):
-        raise ValueError(
-            """
-            `complete` does not support MultiIndex columns.
-            """
-        )
+        raise ValueError("`complete` does not support MultiIndex columns.")
 
     columns = [
-        list(grouping) if isinstance(grouping, tuple) else grouping
+        [*grouping] if isinstance(grouping, tuple) else grouping
         for grouping in columns
     ]
     column_checker = []
@@ -333,9 +423,7 @@ def _data_checks_complete(
     column_checker_no_duplicates = set()
     for column in column_checker:
         if column in column_checker_no_duplicates:
-            raise ValueError(
-                f"""{column} column should be in only one group."""
-            )
+            raise ValueError(f"{column} column should be in only one group.")
         column_checker_no_duplicates.add(column)  # noqa: PD005
 
     check_column(df, column_checker)
@@ -349,4 +437,19 @@ def _data_checks_complete(
         check("by", by, [list])
         check_column(df, by)
 
-    return columns, column_checker, sort, by
+    check("explicit", explicit, [bool])
+
+    fill_value_check = is_scalar(fill_value), isinstance(fill_value, dict)
+    if not any(fill_value_check):
+        raise TypeError(
+            "`fill_value` should either be a dictionary or a scalar value."
+        )
+    if fill_value_check[-1]:
+        check_column(df, fill_value)
+        for column_name, value in fill_value.items():
+            if not is_scalar(value):
+                raise ValueError(
+                    f"The value for {column_name} should be a scalar."
+                )
+
+    return columns, column_checker, sort, by, fill_value, explicit


Test patch:
diff --git a/tests/functions/test_complete.py b/tests/functions/test_complete.py
--- a/tests/functions/test_complete.py
+++ b/tests/functions/test_complete.py
@@ -4,107 +4,155 @@
 from hypothesis import given
 from pandas.testing import assert_frame_equal
 from janitor.testing_utils.strategies import (
-    df_strategy,
     categoricaldf_strategy,
 )
 from string import ascii_lowercase
 
 
-@given(df=df_strategy())
-def test_column_None(df):
+@pytest.fixture
+def fill_df():
+    """pytest fixture"""
+    return pd.DataFrame(
+        dict(
+            group=(1, 2, 1, 2),
+            item_id=(1, 2, 2, 3),
+            item_name=("a", "a", "b", "b"),
+            value1=(1, np.nan, 3, 4),
+            value2=range(4, 8),
+        )
+    )
+
+
+@pytest.fixture
+def taxonomy_df():
+    """pytest fixture"""
+    return pd.DataFrame(
+        {
+            "Year": [1999, 2000, 2004, 1999, 2004],
+            "Taxon": [
+                "Saccharina",
+                "Saccharina",
+                "Saccharina",
+                "Agarum",
+                "Agarum",
+            ],
+            "Abundance": [4, 5, 2, 1, 8],
+        }
+    )
+
+
+def test_column_None(fill_df):
     """Test output if *columns is empty."""
-    assert_frame_equal(df.complete(), df)
+    assert_frame_equal(fill_df.complete(), fill_df)
 
 
-@given(df=df_strategy())
-def test_MultiIndex(df):
+def test_MultiIndex(fill_df):
     """Raise ValueError if `df` has MultiIndex columns."""
-    top = range(df.columns.size)
-    df.columns = pd.MultiIndex.from_arrays([top, df.columns])
+    top = range(fill_df.columns.size)
+    fill_df.columns = pd.MultiIndex.from_arrays([top, fill_df.columns])
     with pytest.raises(ValueError):
-        df.complete("a", "cities")
+        fill_df.complete("group", "item_id")
 
 
-@given(df=df_strategy())
-def test_empty_groups(df):
+def test_empty_groups(fill_df):
     """Raise ValueError if any of the groups is empty."""
     with pytest.raises(ValueError):
-        df.complete("a", {})
+        fill_df.complete("group", {})
 
 
-@given(df=df_strategy())
-def test_dict_not_list_like(df):
+def test_dict_not_list_like(fill_df):
     """
     Raise ValueError if `*columns`
     is a dictionary, and the value
     is not list-like.
     """
     with pytest.raises(ValueError):
-        df.complete("a", {"cities": "cities"})
+        fill_df.complete("group", {"item_id": "cities"})
 
 
-@given(df=df_strategy())
-def test_dict_not_1D(df):
+def test_dict_not_1D(fill_df):
     """
     Raise ValueError if `*columns`
     is a dictionary, and the value
     is not 1D array.
     """
     with pytest.raises(ValueError):
-        df.complete("a", {"cities": df})
+        fill_df.complete("group", {"item_id": fill_df})
 
 
-@given(df=df_strategy())
-def test_dict_empty(df):
+def test_dict_empty(fill_df):
     """
     Raise ValueError if `*columns`
     is a dictionary, and the value
     is an empty array.
     """
     with pytest.raises(ValueError):
-        df.complete("a", {"cities": pd.Series([], dtype=int)})
+        fill_df.complete("item_id", {"group": pd.Series([], dtype=int)})
 
 
-@given(df=df_strategy())
-def test_duplicate_groups(df):
+def test_duplicate_groups(fill_df):
     """Raise ValueError if there are duplicate groups."""
     with pytest.raises(ValueError):
-        df.complete("a", "cities", ("a", "cities"))
+        fill_df.complete("group", "item_id", ("item_id", "item_name"))
 
 
-@given(df=df_strategy())
-def test_type_groups(df):
+def test_type_groups(fill_df):
     """Raise TypeError if grouping is not a permitted type."""
     with pytest.raises(TypeError):
-        df.complete("a", "cities", {1, 2, 3})
+        fill_df.complete("group", "item_id", {1, 2, 3})
 
 
-@given(df=df_strategy())
-def test_type_by(df):
+def test_type_by(fill_df):
     """Raise TypeError if `by` is not a permitted type."""
     with pytest.raises(TypeError):
-        df.complete("a", "cities", by=1)
+        fill_df.complete("group", "item_id", by=1)
 
 
-@given(df=df_strategy())
-def test_type_sort(df):
+def test_type_sort(fill_df):
     """Raise TypeError if `sort` is not boolean."""
     with pytest.raises(TypeError):
-        df.complete("a", "cities", sort=11)
+        fill_df.complete("group", "item_id", sort=11)
 
 
-@given(df=df_strategy())
-def test_groups_not_found(df):
+def test_groups_not_found(fill_df):
     """Raise ValueError if group does not exist."""
     with pytest.raises(ValueError):
-        df.complete("a", "cities", ("a", "cties"))
+        fill_df.complete("group", ("item_id", "name"))
 
 
-@given(df=df_strategy())
-def test_by_not_found(df):
+def test_by_not_found(fill_df):
     """Raise ValueError if `by` does not exist."""
     with pytest.raises(ValueError):
-        df.complete("a", "cities", by=["Bell__Chart", "elephant"])
+        fill_df.complete("group", "item_id", by="name")
+
+
+def test_fill_value(fill_df):
+    """Raise ValueError if `fill_value` is not the right data type."""
+    with pytest.raises(TypeError):
+        fill_df.complete("group", "item_id", fill_value=pd.Series([2, 3, 4]))
+
+
+def test_fill_value_column(fill_df):
+    """Raise ValueError if `fill_value` has a non existent column."""
+    with pytest.raises(ValueError):
+        fill_df.complete("group", "item_id", fill_value={"cities": 0})
+
+
+def test_fill_value_dict_scalar(fill_df):
+    """
+    Raise ValueError if `fill_value` is a dictionary
+    and the value is not a scalar.
+    """
+    with pytest.raises(ValueError):
+        fill_df.complete(
+            "group", "item_id", fill_value={"item_name": pd.Series([2, 3, 4])}
+        )
+
+
+def test_type_explicit(fill_df):
+    """Raise TypeError if `explicit` is not boolean."""
+    with pytest.raises(TypeError):
+        fill_df.complete("group", "item_id", explicit=11)
 
 
 @given(df=categoricaldf_strategy())
@@ -314,38 +362,44 @@ def test_complete_multiple_groupings():
             "meta": ["A", "A", "A", "A", "B", "B", "B", "B", "C", "C"],
             "domain1": ["d", "d", "e", "e", "h", "h", "i", "i", "k", "k"],
             "question_count": [3, 2, 3, 2, 3, 2, 3, 2, 3, 2],
-            "tag_count": [2.0, 1.0, 1.0, 0.0, 3.0, 0.0, 2.0, 1.0, 0.0, 2.0],
+            "tag_count": [2, 1, 1, 0, 3, 0, 2, 1, 0, 2],
         }
     )
 
     result = df3.complete(
-        ("meta", "domain1"), ("project_id", "question_count"), sort=True
-    ).fillna({"tag_count": 0})
+        ("meta", "domain1"),
+        ("project_id", "question_count"),
+        fill_value={"tag_count": 0},
+        sort=True,
+    )
     assert_frame_equal(result, output3)
 
 
+def test_fill_value_scalar(taxonomy_df):
+    """Test output if the fill_value is a scalar."""
+    result = taxonomy_df.complete("Year", "Taxon", fill_value=0, sort=True)
+    expected = pd.DataFrame(
+        [
+            {"Year": 1999, "Taxon": "Agarum", "Abundance": 1},
+            {"Year": 1999, "Taxon": "Saccharina", "Abundance": 4},
+            {"Year": 2000, "Taxon": "Agarum", "Abundance": 0},
+            {"Year": 2000, "Taxon": "Saccharina", "Abundance": 5},
+            {"Year": 2004, "Taxon": "Agarum", "Abundance": 8},
+            {"Year": 2004, "Taxon": "Saccharina", "Abundance": 2},
+        ]
+    )
+
+    assert_frame_equal(result, expected)
+
+
 #  http://imachordata.com/2016/02/05/you-complete-me/
-def test_dict_tuple():
+def test_dict_tuple(taxonomy_df):
     """
-    Test if a dictionary and a tuple/list
+    Test output if a dictionary and a tuple/list
     are included in the `columns` parameter.
     """
 
-    df = pd.DataFrame(
-        {
-            "Year": [1999, 2000, 2004, 1999, 2004],
-            "Taxon": [
-                "Saccharina",
-                "Saccharina",
-                "Saccharina",
-                "Agarum",
-                "Agarum",
-            ],
-            "Abundance": [4, 5, 2, 1, 8],
-        }
-    )
-
-    result = df.complete(
+    result = taxonomy_df.complete(
         {"Year": lambda x: range(x.min(), x.max() + 1)},
         ("Taxon", "Abundance"),
         sort=True,
@@ -427,3 +481,179 @@ def test_complete_groupby():
     expected = expected.reindex(columns=df.columns)
 
     assert_frame_equal(result, expected)
+
+
+def test_explicit_scalar(fill_df):
+    """Test output if fill_value is a scalar, and explicit is False."""
+    result = fill_df.complete(
+        "group",
+        ("item_id", "item_name"),
+        fill_value=0,
+        explicit=False,
+        sort=True,
+    )
+    expected = pd.DataFrame(
+        [
+            {
+                "group": 1,
+                "item_id": 1,
+                "item_name": "a",
+                "value1": 1.0,
+                "value2": 4,
+            },
+            {
+                "group": 1,
+                "item_id": 2,
+                "item_name": "a",
+                "value1": 0.0,
+                "value2": 0,
+            },
+            {
+                "group": 1,
+                "item_id": 2,
+                "item_name": "b",
+                "value1": 3.0,
+                "value2": 6,
+            },
+            {
+                "group": 1,
+                "item_id": 3,
+                "item_name": "b",
+                "value1": 0.0,
+                "value2": 0,
+            },
+            {
+                "group": 2,
+                "item_id": 1,
+                "item_name": "a",
+                "value1": 0.0,
+                "value2": 0,
+            },
+            {
+                "group": 2,
+                "item_id": 2,
+                "item_name": "a",
+                "value1": np.nan,
+                "value2": 5,
+            },
+            {
+                "group": 2,
+                "item_id": 2,
+                "item_name": "b",
+                "value1": 0.0,
+                "value2": 0,
+            },
+            {
+                "group": 2,
+                "item_id": 3,
+                "item_name": "b",
+                "value1": 4.0,
+                "value2": 7,
+            },
+        ]
+    )
+
+    assert_frame_equal(result, expected)
+
+
+# https://tidyr.tidyverse.org/reference/complete.html
+def test_explicit_dict(fill_df):
+    """Test output if fill_value is a dictionary, and explicit is False."""
+    result = fill_df.complete(
+        "group",
+        ("item_id", "item_name"),
+        fill_value={"value1": 0, "value2": 99},
+        explicit=False,
+        sort=True,
+    )
+    expected = pd.DataFrame(
+        [
+            {
+                "group": 1,
+                "item_id": 1,
+                "item_name": "a",
+                "value1": 1.0,
+                "value2": 4,
+            },
+            {
+                "group": 1,
+                "item_id": 2,
+                "item_name": "a",
+                "value1": 0.0,
+                "value2": 99,
+            },
+            {
+                "group": 1,
+                "item_id": 2,
+                "item_name": "b",
+                "value1": 3.0,
+                "value2": 6,
+            },
+            {
+                "group": 1,
+                "item_id": 3,
+                "item_name": "b",
+                "value1": 0.0,
+                "value2": 99,
+            },
+            {
+                "group": 2,
+                "item_id": 1,
+                "item_name": "a",
+                "value1": 0.0,
+                "value2": 99,
+            },
+            {
+                "group": 2,
+                "item_id": 2,
+                "item_name": "a",
+                "value1": np.nan,
+                "value2": 5,
+            },
+            {
+                "group": 2,
+                "item_id": 2,
+                "item_name": "b",
+                "value1": 0.0,
+                "value2": 99,
+            },
+            {
+                "group": 2,
+                "item_id": 3,
+                "item_name": "b",
+                "value1": 4.0,
+                "value2": 7,
+            },
+        ]
+    )
+
+    assert_frame_equal(result, expected)
+
+
+def test_explicit_(fill_df):
+    """
+    Test output if explicit is False,
+    and the columns used for the combination
+    are reused in the fill_value.
+    """
+    trimmed = fill_df.select_columns("value*", invert=True)
+    result = trimmed.complete(
+        "group",
+        ("item_id", "item_name"),
+        fill_value=0,
+        explicit=False,
+        sort=True,
+    )
+    expected = pd.DataFrame(
+        [
+            {"group": 1, "item_id": 1, "item_name": "a"},
+            {"group": 1, "item_id": 2, "item_name": "a"},
+            {"group": 1, "item_id": 2, "item_name": "b"},
+            {"group": 1, "item_id": 3, "item_name": "b"},
+            {"group": 2, "item_id": 1, "item_name": "a"},
+            {"group": 2, "item_id": 2, "item_name": "a"},
+            {"group": 2, "item_id": 2, "item_name": "b"},
+            {"group": 2, "item_id": 3, "item_name": "b"},
+        ]
+    )
+    assert_frame_equal(result, expected)