Repository: pyjanitor-devs/pyjanitor
Pull request #1367 (2024-05-24 12:48:42)

[ENH] `polars` `complete` function
# PR Description

Please describe the changes proposed in the pull request:

- Add `complete` method for polars

**This PR relates to #1352 .**

<!-- As you go down the PR template, please feel free to delete sections that are irrelevant. -->

# PR Checklist

<!-- This checklist exists for newcomers who are not yet familiar with our requirements. If you are experienced with
the project, please feel free to delete this section. -->

Please ensure that you have done the following:

1. [x] PR in from a fork off your branch. Do not PR from `<your_username>`:`dev`, but rather from `<your_username>`:`<feature-branch_name>`.
<!-- Doing this helps us keep the commit history much cleaner than it would otherwise be. -->
2. [x] If you're not on the contributors list, add yourself to `AUTHORS.md`.
<!-- We'd like to acknowledge your contributions! -->
3. [x] Add a line to `CHANGELOG.md` under the latest version header (i.e. the one that is "on deck") describing the contribution.
    - Do use some discretion here; if there are multiple PRs that are related, keep them in a single line.

# Automatic checks

There will be automatic checks run on the PR. These include:

- Building a preview of the docs on Netlify
- Automatically linting the code
- Making sure the code is documented
- Making sure that all tests are passed
- Making sure that code coverage doesn't go down.

# Relevant Reviewers

<!-- Finally, please tag relevant maintainers to review. -->

Please tag maintainers to review.

- @ericmjl


ericmjl: <!-- NETLIFY DEPLOY COMMENT GENERATED BY ACTIONS_NETLIFY -->
🚀 Deployed on https://deploy-preview-1367--pyjanitor.netlify.app
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pyjanitor-devs/pyjanitor/pull/1367?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyjanitor-devs) Report
Attention: Patch coverage is `77.43902%` with `37 lines` in your changes are missing coverage. Please review.
> Project coverage is 93.53%. Comparing base [(`62c57c6`)](https://app.codecov.io/gh/pyjanitor-devs/pyjanitor/commit/62c57c6ad27196a10e017f710ce7d523bc2541ba?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyjanitor-devs) to head [(`d6d875b`)](https://app.codecov.io/gh/pyjanitor-devs/pyjanitor/commit/d6d875b9803cc116e88cfc08f38265f59d69a764?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyjanitor-devs).
> Report is 9 commits behind head on dev.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##              dev    #1367      +/-   ##
==========================================
- Coverage   94.48%   93.53%   -0.95%     
==========================================
  Files          80       82       +2     
  Lines        4367     4502     +135     
==========================================
+ Hits         4126     4211      +85     
- Misses        241      291      +50     
```

</details>

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a7c5e68..f4ee02c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,10 @@
 # Changelog
 
 ## [Unreleased]
--  [ENH] Added a `row_to_names` method for polars. Issue #1352
--  [ENH] `read_commandline` function now supports polars - Issue #1352
+
+- [ENH] Added a `complete` method for polars. - Issue #1352 @samukweku
+- [ENH] Added a `row_to_names` method for polars. Issue #1352
+- [ENH] `read_commandline` function now supports polars - Issue #1352
 
 - [ENH] Improved performance for non-equi joins when using numba - @samukweku PR #1341
 - [ENH] Added a `clean_names` method for polars - it can be used to clean the column names, or clean column values . Issue #1343 @samukweku
diff --git a/janitor/polars/complete.py b/janitor/polars/complete.py
new file mode 100644
index 0000000..793c958
--- /dev/null
+++ b/janitor/polars/complete.py
@@ -0,0 +1,125 @@
+"""complete implementation for polars."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from janitor.utils import check, import_message
+
+try:
+    import polars as pl
+    import polars.selectors as cs
+    from polars.type_aliases import ColumnNameOrSelector
+except ImportError:
+    import_message(
+        submodule="polars",
+        package="polars",
+        conda_channel="conda-forge",
+        pip_install=True,
+    )
+
+
+def _complete(
+    df: pl.DataFrame | pl.LazyFrame,
+    columns: tuple[ColumnNameOrSelector],
+    fill_value: dict | Any | pl.Expr,
+    explicit: bool,
+    sort: bool,
+    by: ColumnNameOrSelector,
+) -> pl.DataFrame | pl.LazyFrame:
+    """
+    This function computes the final output for the `complete` function.
+
+    A DataFrame, with rows of missing values, if any, is returned.
+    """
+    if not columns:
+        return df
+
+    check("sort", sort, [bool])
+    check("explicit", explicit, [bool])
+    _columns = []
+    for column in columns:
+        if isinstance(column, str):
+            col = pl.col(column).unique()
+            if sort:
+                col = col.sort()
+            _columns.append(col)
+        elif cs.is_selector(column):
+            col = column.as_expr().unique()
+            if sort:
+                col = col.sort()
+            _columns.append(col)
+        elif isinstance(column, pl.Expr):
+            _columns.append(column)
+        else:
+            raise TypeError(
+                f"The argument passed to the columns parameter "
+                "should either be a string, a column selector, "
+                "or a polars expression, instead got - "
+                f"{type(column)}."
+            )
+    by_does_not_exist = by is None
+    if by_does_not_exist:
+        _columns = [column.implode() for column in _columns]
+        uniques = df.select(_columns)
+        _columns = uniques.columns
+    else:
+        uniques = df.group_by(by, maintain_order=sort).agg(_columns)
+        _by = uniques.select(by).columns
+        _columns = uniques.select(pl.exclude(_by)).columns
+    for column in _columns:
+        uniques = uniques.explode(column)
+
+    _columns = [
+        column
+        for column, dtype in zip(_columns, uniques.select(_columns).dtypes)
+        # this way we ensure there is no tampering with existing struct columns
+        if (dtype == pl.Struct) and (column not in df.columns)
+    ]
+
+    if _columns:
+        for column in _columns:
+            uniques = uniques.unnest(columns=column)
+
+    if fill_value is None:
+        return uniques.join(df, on=uniques.columns, how="full", coalesce=True)
+    idx = None
+    columns_to_select = df.columns
+    if not explicit:
+        idx = "".join(df.columns)
+        df = df.with_row_index(name=idx)
+    df = uniques.join(df, on=uniques.columns, how="full", coalesce=True)
+    # exclude columns that were not used
+    # to generate the combinations
+    exclude_columns = uniques.columns
+    if idx:
+        exclude_columns.append(idx)
+    expression = pl.exclude(exclude_columns).is_null().any()
+    booleans = df.select(expression)
+    if isinstance(booleans, pl.LazyFrame):
+        booleans = booleans.collect()
+    _columns = [
+        column
+        for column in booleans.columns
+        if booleans.get_column(column).item()
+    ]
+    if _columns and isinstance(fill_value, dict):
+        fill_value = [
+            pl.col(column_name).fill_null(value=value)
+            for column_name, value in fill_value.items()
+            if column_name in _columns
+        ]
+    elif _columns:
+        fill_value = [
+            pl.col(column).fill_null(value=fill_value) for column in _columns
+        ]
+    if _columns and not explicit:
+        condition = pl.col(idx).is_null()
+        fill_value = [
+            pl.when(condition).then(_fill_value).otherwise(pl.col(column_name))
+            for column_name, _fill_value in zip(_columns, fill_value)
+        ]
+    if _columns:
+        df = df.with_columns(fill_value)
+
+    return df.select(columns_to_select)
diff --git a/janitor/polars/dataframe.py b/janitor/polars/dataframe.py
index 4a4016c..31a55e4 100644
--- a/janitor/polars/dataframe.py
+++ b/janitor/polars/dataframe.py
@@ -1,10 +1,13 @@
 from __future__ import annotations
 
+from typing import Any
+
 from polars.type_aliases import ColumnNameOrSelector
 
 from janitor.utils import import_message
 
 from .clean_names import _clean_column_names
+from .complete import _complete
 from .pivot_longer import _pivot_longer
 from .row_to_names import _row_to_names
 
@@ -432,3 +435,298 @@ class PolarsDataFrame:
             remove_rows_above=remove_rows_above,
             separator=separator,
         )
+
+    def complete(
+        self,
+        *columns: ColumnNameOrSelector,
+        fill_value: dict | Any | pl.Expr = None,
+        explicit: bool = True,
+        sort: bool = False,
+        by: ColumnNameOrSelector = None,
+    ) -> pl.DataFrame:
+        """
+        Turns implicit missing values into explicit missing values
+
+        It is modeled after tidyr's `complete` function.
+        In a way, it is the inverse of `pl.drop_nulls`,
+        as it exposes implicitly missing rows.
+
+        If the combination involves multiple columns, pass it as a struct,
+        with an alias - the name of the struct should not exist in the DataFrame.
+
+        If new values need to be introduced, a polars Expression
+        with the new values can be passed, as long as the polars Expression
+        has a name that already exists in the DataFrame.
+
+        It is up to the user to ensure that the polars expression returns
+        unique values and/or sorted values.
+
+        Note that if the polars expression evaluates to a struct,
+        then the fields, not the name, should already exist in the DataFrame.
+
+        Examples:
+            >>> import polars as pl
+            >>> import janitor.polars
+            >>> df = pl.DataFrame(
+            ...     dict(
+            ...         group=(1, 2, 1, 2),
+            ...         item_id=(1, 2, 2, 3),
+            ...         item_name=("a", "a", "b", "b"),
+            ...         value1=(1, None, 3, 4),
+            ...         value2=range(4, 8),
+            ...     )
+            ... )
+            >>> df
+            shape: (4, 5)
+            ┌───────┬─────────┬───────────┬────────┬────────┐
+            │ group ┆ item_id ┆ item_name ┆ value1 ┆ value2 │
+            │ ---   ┆ ---     ┆ ---       ┆ ---    ┆ ---    │
+            │ i64   ┆ i64     ┆ str       ┆ i64    ┆ i64    │
+            ╞═══════╪═════════╪═══════════╪════════╪════════╡
+            │ 1     ┆ 1       ┆ a         ┆ 1      ┆ 4      │
+            │ 2     ┆ 2       ┆ a         ┆ null   ┆ 5      │
+            │ 1     ┆ 2       ┆ b         ┆ 3      ┆ 6      │
+            │ 2     ┆ 3       ┆ b         ┆ 4      ┆ 7      │
+            └───────┴─────────┴───────────┴────────┴────────┘
+
+            Generate all possible combinations of
+            `group`, `item_id`, and `item_name`
+            (whether or not they appear in the data)
+            >>> with pl.Config(tbl_rows=-1):
+            ...     df.janitor.complete("group", "item_id", "item_name", sort=True)
+            shape: (12, 5)
+            ┌───────┬─────────┬───────────┬────────┬────────┐
+            │ group ┆ item_id ┆ item_name ┆ value1 ┆ value2 │
+            │ ---   ┆ ---     ┆ ---       ┆ ---    ┆ ---    │
+            │ i64   ┆ i64     ┆ str       ┆ i64    ┆ i64    │
+            ╞═══════╪═════════╪═══════════╪════════╪════════╡
+            │ 1     ┆ 1       ┆ a         ┆ 1      ┆ 4      │
+            │ 1     ┆ 1       ┆ b         ┆ null   ┆ null   │
+            │ 1     ┆ 2       ┆ a         ┆ null   ┆ null   │
+            │ 1     ┆ 2       ┆ b         ┆ 3      ┆ 6      │
+            │ 1     ┆ 3       ┆ a         ┆ null   ┆ null   │
+            │ 1     ┆ 3       ┆ b         ┆ null   ┆ null   │
+            │ 2     ┆ 1       ┆ a         ┆ null   ┆ null   │
+            │ 2     ┆ 1       ┆ b         ┆ null   ┆ null   │
+            │ 2     ┆ 2       ┆ a         ┆ null   ┆ 5      │
+            │ 2     ┆ 2       ┆ b         ┆ null   ┆ null   │
+            │ 2     ┆ 3       ┆ a         ┆ null   ┆ null   │
+            │ 2     ┆ 3       ┆ b         ┆ 4      ┆ 7      │
+            └───────┴─────────┴───────────┴────────┴────────┘
+
+            Cross all possible `group` values with the unique pairs of
+            `(item_id, item_name)` that already exist in the data.
+            For such situations, where there is a group of columns,
+            pass it in as a struct:
+            >>> with pl.Config(tbl_rows=-1):
+            ...     df.janitor.complete(
+            ...         "group",
+            ...         pl.struct("item_id", "item_name").unique().sort().alias("rar"),
+            ...         sort=True
+            ...     )
+            shape: (8, 5)
+            ┌───────┬─────────┬───────────┬────────┬────────┐
+            │ group ┆ item_id ┆ item_name ┆ value1 ┆ value2 │
+            │ ---   ┆ ---     ┆ ---       ┆ ---    ┆ ---    │
+            │ i64   ┆ i64     ┆ str       ┆ i64    ┆ i64    │
+            ╞═══════╪═════════╪═══════════╪════════╪════════╡
+            │ 1     ┆ 1       ┆ a         ┆ 1      ┆ 4      │
+            │ 1     ┆ 2       ┆ a         ┆ null   ┆ null   │
+            │ 1     ┆ 2       ┆ b         ┆ 3      ┆ 6      │
+            │ 1     ┆ 3       ┆ b         ┆ null   ┆ null   │
+            │ 2     ┆ 1       ┆ a         ┆ null   ┆ null   │
+            │ 2     ┆ 2       ┆ a         ┆ null   ┆ 5      │
+            │ 2     ┆ 2       ┆ b         ┆ null   ┆ null   │
+            │ 2     ┆ 3       ┆ b         ┆ 4      ┆ 7      │
+            └───────┴─────────┴───────────┴────────┴────────┘
+
+            Fill in nulls:
+            >>> with pl.Config(tbl_rows=-1):
+            ...     df.janitor.complete(
+            ...         "group",
+            ...         pl.struct("item_id", "item_name").unique().sort().alias('rar'),
+            ...         fill_value={"value1": 0, "value2": 99},
+            ...         explicit=True,
+            ...         sort=True,
+            ...     )
+            shape: (8, 5)
+            ┌───────┬─────────┬───────────┬────────┬────────┐
+            │ group ┆ item_id ┆ item_name ┆ value1 ┆ value2 │
+            │ ---   ┆ ---     ┆ ---       ┆ ---    ┆ ---    │
+            │ i64   ┆ i64     ┆ str       ┆ i64    ┆ i64    │
+            ╞═══════╪═════════╪═══════════╪════════╪════════╡
+            │ 1     ┆ 1       ┆ a         ┆ 1      ┆ 4      │
+            │ 1     ┆ 2       ┆ a         ┆ 0      ┆ 99     │
+            │ 1     ┆ 2       ┆ b         ┆ 3      ┆ 6      │
+            │ 1     ┆ 3       ┆ b         ┆ 0      ┆ 99     │
+            │ 2     ┆ 1       ┆ a         ┆ 0      ┆ 99     │
+            │ 2     ┆ 2       ┆ a         ┆ 0      ┆ 5      │
+            │ 2     ┆ 2       ┆ b         ┆ 0      ┆ 99     │
+            │ 2     ┆ 3       ┆ b         ┆ 4      ┆ 7      │
+            └───────┴─────────┴───────────┴────────┴────────┘
+
+            Limit the fill to only the newly created
+            missing values with `explicit = FALSE`
+            >>> with pl.Config(tbl_rows=-1):
+            ...     df.janitor.complete(
+            ...         "group",
+            ...         pl.struct("item_id", "item_name").unique().sort().alias('rar'),
+            ...         fill_value={"value1": 0, "value2": 99},
+            ...         explicit=False,
+            ...         sort=True,
+            ...     )
+            shape: (8, 5)
+            ┌───────┬─────────┬───────────┬────────┬────────┐
+            │ group ┆ item_id ┆ item_name ┆ value1 ┆ value2 │
+            │ ---   ┆ ---     ┆ ---       ┆ ---    ┆ ---    │
+            │ i64   ┆ i64     ┆ str       ┆ i64    ┆ i64    │
+            ╞═══════╪═════════╪═══════════╪════════╪════════╡
+            │ 1     ┆ 1       ┆ a         ┆ 1      ┆ 4      │
+            │ 1     ┆ 2       ┆ a         ┆ 0      ┆ 99     │
+            │ 1     ┆ 2       ┆ b         ┆ 3      ┆ 6      │
+            │ 1     ┆ 3       ┆ b         ┆ 0      ┆ 99     │
+            │ 2     ┆ 1       ┆ a         ┆ 0      ┆ 99     │
+            │ 2     ┆ 2       ┆ a         ┆ null   ┆ 5      │
+            │ 2     ┆ 2       ┆ b         ┆ 0      ┆ 99     │
+            │ 2     ┆ 3       ┆ b         ┆ 4      ┆ 7      │
+            └───────┴─────────┴───────────┴────────┴────────┘
+
+            >>> df = pl.DataFrame(
+            ...     {
+            ...         "Year": [1999, 2000, 2004, 1999, 2004],
+            ...         "Taxon": [
+            ...             "Saccharina",
+            ...             "Saccharina",
+            ...             "Saccharina",
+            ...             "Agarum",
+            ...             "Agarum",
+            ...         ],
+            ...         "Abundance": [4, 5, 2, 1, 8],
+            ...     }
+            ... )
+            >>> df
+            shape: (5, 3)
+            ┌──────┬────────────┬───────────┐
+            │ Year ┆ Taxon      ┆ Abundance │
+            │ ---  ┆ ---        ┆ ---       │
+            │ i64  ┆ str        ┆ i64       │
+            ╞══════╪════════════╪═══════════╡
+            │ 1999 ┆ Saccharina ┆ 4         │
+            │ 2000 ┆ Saccharina ┆ 5         │
+            │ 2004 ┆ Saccharina ┆ 2         │
+            │ 1999 ┆ Agarum     ┆ 1         │
+            │ 2004 ┆ Agarum     ┆ 8         │
+            └──────┴────────────┴───────────┘
+
+            Expose missing years from 1999 to 2004 -
+            pass a polars expression with the new dates,
+            and ensure the expression's name already exists
+            in the DataFrame:
+            >>> expression = pl.int_range(1999,2005).alias('Year')
+            >>> with pl.Config(tbl_rows=-1):
+            ...     df.janitor.complete(expression,'Taxon',sort=True)
+            shape: (12, 3)
+            ┌──────┬────────────┬───────────┐
+            │ Year ┆ Taxon      ┆ Abundance │
+            │ ---  ┆ ---        ┆ ---       │
+            │ i64  ┆ str        ┆ i64       │
+            ╞══════╪════════════╪═══════════╡
+            │ 1999 ┆ Agarum     ┆ 1         │
+            │ 1999 ┆ Saccharina ┆ 4         │
+            │ 2000 ┆ Agarum     ┆ null      │
+            │ 2000 ┆ Saccharina ┆ 5         │
+            │ 2001 ┆ Agarum     ┆ null      │
+            │ 2001 ┆ Saccharina ┆ null      │
+            │ 2002 ┆ Agarum     ┆ null      │
+            │ 2002 ┆ Saccharina ┆ null      │
+            │ 2003 ┆ Agarum     ┆ null      │
+            │ 2003 ┆ Saccharina ┆ null      │
+            │ 2004 ┆ Agarum     ┆ 8         │
+            │ 2004 ┆ Saccharina ┆ 2         │
+            └──────┴────────────┴───────────┘
+
+            Expose missing rows per group:
+            >>> df = pl.DataFrame(
+            ...     {
+            ...         "state": ["CA", "CA", "HI", "HI", "HI", "NY", "NY"],
+            ...         "year": [2010, 2013, 2010, 2012, 2016, 2009, 2013],
+            ...         "value": [1, 3, 1, 2, 3, 2, 5],
+            ...     }
+            ... )
+            >>> df
+            shape: (7, 3)
+            ┌───────┬──────┬───────┐
+            │ state ┆ year ┆ value │
+            │ ---   ┆ ---  ┆ ---   │
+            │ str   ┆ i64  ┆ i64   │
+            ╞═══════╪══════╪═══════╡
+            │ CA    ┆ 2010 ┆ 1     │
+            │ CA    ┆ 2013 ┆ 3     │
+            │ HI    ┆ 2010 ┆ 1     │
+            │ HI    ┆ 2012 ┆ 2     │
+            │ HI    ┆ 2016 ┆ 3     │
+            │ NY    ┆ 2009 ┆ 2     │
+            │ NY    ┆ 2013 ┆ 5     │
+            └───────┴──────┴───────┘
+            >>> low = pl.col('year').min()
+            >>> high = pl.col('year').max().add(1)
+            >>> new_year_values=pl.int_range(low,high).alias('year')
+            >>> with pl.Config(tbl_rows=-1):
+            ...     df.janitor.complete(new_year_values,by='state',sort=True)
+            shape: (16, 3)
+            ┌───────┬──────┬───────┐
+            │ state ┆ year ┆ value │
+            │ ---   ┆ ---  ┆ ---   │
+            │ str   ┆ i64  ┆ i64   │
+            ╞═══════╪══════╪═══════╡
+            │ CA    ┆ 2010 ┆ 1     │
+            │ CA    ┆ 2011 ┆ null  │
+            │ CA    ┆ 2012 ┆ null  │
+            │ CA    ┆ 2013 ┆ 3     │
+            │ HI    ┆ 2010 ┆ 1     │
+            │ HI    ┆ 2011 ┆ null  │
+            │ HI    ┆ 2012 ┆ 2     │
+            │ HI    ┆ 2013 ┆ null  │
+            │ HI    ┆ 2014 ┆ null  │
+            │ HI    ┆ 2015 ┆ null  │
+            │ HI    ┆ 2016 ┆ 3     │
+            │ NY    ┆ 2009 ┆ 2     │
+            │ NY    ┆ 2010 ┆ null  │
+            │ NY    ┆ 2011 ┆ null  │
+            │ NY    ┆ 2012 ┆ null  │
+            │ NY    ┆ 2013 ┆ 5     │
+            └───────┴──────┴───────┘
+
+
+        !!! info "New in version 0.28.0"
+
+        Args:
+            *columns: This refers to the columns to be completed.
+                It can be a string or a column selector or a polars expression.
+                A polars expression can be used to introduced new values,
+                as long as the polars expression has a name that already exists
+                in the DataFrame.
+                It is up to the user to ensure that the polars expression returns
+                unique values.
+            fill_value: Scalar value or polars expression to use instead of nulls
+                for missing combinations. A dictionary, mapping columns names
+                to a scalar value is also accepted.
+            explicit: Determines if only implicitly missing values
+                should be filled (`False`), or all nulls existing in the LazyFrame
+                (`True`). `explicit` is applicable only
+                if `fill_value` is not `None`.
+            sort: Sort the DataFrame based on *columns.
+            by: Column(s) to group by.
+                The explicit missing rows are returned per group.
+
+        Returns:
+            A polars DataFrame.
+        """  # noqa: E501
+        return _complete(
+            df=self._df,
+            columns=columns,
+            fill_value=fill_value,
+            explicit=explicit,
+            sort=sort,
+            by=by,
+        )
diff --git a/janitor/polars/lazyframe.py b/janitor/polars/lazyframe.py
index 67f2f42..f059ab1 100644
--- a/janitor/polars/lazyframe.py
+++ b/janitor/polars/lazyframe.py
@@ -1,10 +1,13 @@
 from __future__ import annotations
 
+from typing import Any
+
 from polars.type_aliases import ColumnNameOrSelector
 
 from janitor.utils import import_message
 
 from .clean_names import _clean_column_names
+from .complete import _complete
 from .pivot_longer import _pivot_longer
 from .row_to_names import _row_to_names
 
@@ -312,3 +315,114 @@ class PolarsLazyFrame:
             remove_rows_above=remove_rows_above,
             separator=separator,
         )
+
+    def complete(
+        self,
+        *columns: ColumnNameOrSelector,
+        fill_value: dict | Any | pl.Expr = None,
+        explicit: bool = True,
+        sort: bool = False,
+        by: ColumnNameOrSelector = None,
+    ) -> pl.LazyFrame:
+        """
+        Turns implicit missing values into explicit missing values.
+
+        It is modeled after tidyr's `complete` function.
+        In a way, it is the inverse of `pl.drop_nulls`,
+        as it exposes implicitly missing rows.
+
+        If the combination involves multiple columns, pass it as a struct,
+        with an alias - the name of the struct should not exist in the LazyFrame.
+
+        If new values need to be introduced, a polars Expression
+        with the new values can be passed, as long as the polars Expression
+        has a name that already exists in the LazyFrame.
+
+        It is up to the user to ensure that the polars expression returns
+        unique values.
+
+        Note that if the polars expression evaluates to a struct,
+        then the fields, not the name, should already exist in the LazyFrame.
+
+        Examples:
+            >>> import polars as pl
+            >>> import janitor.polars
+            >>> df = pl.LazyFrame(
+            ...     dict(
+            ...         group=(1, 2, 1, 2),
+            ...         item_id=(1, 2, 2, 3),
+            ...         item_name=("a", "a", "b", "b"),
+            ...         value1=(1, None, 3, 4),
+            ...         value2=range(4, 8),
+            ...     )
+            ... )
+            >>> df.collect()
+            shape: (4, 5)
+            ┌───────┬─────────┬───────────┬────────┬────────┐
+            │ group ┆ item_id ┆ item_name ┆ value1 ┆ value2 │
+            │ ---   ┆ ---     ┆ ---       ┆ ---    ┆ ---    │
+            │ i64   ┆ i64     ┆ str       ┆ i64    ┆ i64    │
+            ╞═══════╪═════════╪═══════════╪════════╪════════╡
+            │ 1     ┆ 1       ┆ a         ┆ 1      ┆ 4      │
+            │ 2     ┆ 2       ┆ a         ┆ null   ┆ 5      │
+            │ 1     ┆ 2       ┆ b         ┆ 3      ┆ 6      │
+            │ 2     ┆ 3       ┆ b         ┆ 4      ┆ 7      │
+            └───────┴─────────┴───────────┴────────┴────────┘
+
+            Generate all possible combinations of
+            `group`, `item_id`, and `item_name`
+            (whether or not they appear in the data)
+            >>> with pl.Config(tbl_rows=-1):
+            ...     df.janitor.complete("group", "item_id", "item_name", sort=True).collect()
+            shape: (12, 5)
+            ┌───────┬─────────┬───────────┬────────┬────────┐
+            │ group ┆ item_id ┆ item_name ┆ value1 ┆ value2 │
+            │ ---   ┆ ---     ┆ ---       ┆ ---    ┆ ---    │
+            │ i64   ┆ i64     ┆ str       ┆ i64    ┆ i64    │
+            ╞═══════╪═════════╪═══════════╪════════╪════════╡
+            │ 1     ┆ 1       ┆ a         ┆ 1      ┆ 4      │
+            │ 1     ┆ 1       ┆ b         ┆ null   ┆ null   │
+            │ 1     ┆ 2       ┆ a         ┆ null   ┆ null   │
+            │ 1     ┆ 2       ┆ b         ┆ 3      ┆ 6      │
+            │ 1     ┆ 3       ┆ a         ┆ null   ┆ null   │
+            │ 1     ┆ 3       ┆ b         ┆ null   ┆ null   │
+            │ 2     ┆ 1       ┆ a         ┆ null   ┆ null   │
+            │ 2     ┆ 1       ┆ b         ┆ null   ┆ null   │
+            │ 2     ┆ 2       ┆ a         ┆ null   ┆ 5      │
+            │ 2     ┆ 2       ┆ b         ┆ null   ┆ null   │
+            │ 2     ┆ 3       ┆ a         ┆ null   ┆ null   │
+            │ 2     ┆ 3       ┆ b         ┆ 4      ┆ 7      │
+            └───────┴─────────┴───────────┴────────┴────────┘
+
+        !!! info "New in version 0.28.0"
+
+        Args:
+            *columns: This refers to the columns to be completed.
+                It can be a string or a column selector or a polars expression.
+                A polars expression can be used to introduced new values,
+                as long as the polars expression has a name that already exists
+                in the LazyFrame.
+                It is up to the user to ensure that the polars expression returns
+                unique values.
+            fill_value: Scalar value or polars expression to use instead of nulls
+                for missing combinations. A dictionary, mapping columns names
+                to a scalar value is also accepted.
+            explicit: Determines if only implicitly missing values
+                should be filled (`False`), or all nulls existing in the LazyFrame
+                (`True`). `explicit` is applicable only
+                if `fill_value` is not `None`.
+            sort: Sort the LazyFrame based on *columns.
+            by: Column(s) to group by.
+                The explicit missing rows are returned per group.
+
+        Returns:
+            A polars LazyFrame.
+        """  # noqa: E501
+        return _complete(
+            df=self._df,
+            columns=columns,
+            fill_value=fill_value,
+            explicit=explicit,
+            sort=sort,
+            by=by,
+        )


Test patch:
diff --git a/tests/polars/functions/test_complete_polars.py b/tests/polars/functions/test_complete_polars.py
new file mode 100644
index 0000000..4c9bd14
--- /dev/null
+++ b/tests/polars/functions/test_complete_polars.py
@@ -0,0 +1,260 @@
+import polars as pl
+import polars.selectors as cs
+import pytest
+from polars.testing import assert_frame_equal
+
+import janitor.polars  # noqa: F401
+
+
+@pytest.fixture
+def fill_df():
+    """pytest fixture"""
+    return pl.DataFrame(
+        dict(
+            group=(1, 2, 1, 2),
+            item_id=(1, 2, 2, 3),
+            item_name=("a", "a", "b", "b"),
+            value1=(1, None, 3, 4),
+            value2=range(4, 8),
+        )
+    )
+
+
+@pytest.fixture
+def taxonomy_df():
+    """pytest fixture"""
+    return pl.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(fill_df.janitor.complete(), fill_df)
+
+
+def test_empty_groups(fill_df):
+    """Raise TypeError if wrong column type is passed."""
+    msg = "The argument passed to the columns parameter "
+    msg += "should either be a string, a column selector, "
+    msg += "or a polars expression, instead got.+"
+    with pytest.raises(TypeError, match=msg):
+        fill_df.janitor.complete("group", {})
+
+
+def test_type_sort(fill_df):
+    """Raise TypeError if `sort` is not boolean."""
+    with pytest.raises(TypeError):
+        fill_df.janitor.complete("group", "item_id", sort=11)
+
+
+def test_type_explicit(fill_df):
+    """Raise TypeError if `explicit` is not boolean."""
+    with pytest.raises(TypeError):
+        fill_df.janitor.complete("group", "item_id", explicit=11)
+
+
+def test_complete_1(fill_df):
+    """
+    Test output for janitor.complete.
+    """
+    trimmed = fill_df.lazy().select(~cs.starts_with("value"))
+    result = trimmed.janitor.complete(
+        cs.by_name("group"),
+        pl.struct("item_id", "item_name").alias("rar").unique().sort(),
+        fill_value=0,
+        explicit=False,
+        sort=True,
+    )
+    expected = pl.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.collect(), expected)
+
+
+def test_groupby_complete():
+    """
+    Test output if by is present
+    """
+    # https://stackoverflow.com/q/77123843/7175713
+    data = [
+        {"Grid Cell": 1, "Site": "A", "Date": "1999-01-01", "Value": -2.45},
+        {"Grid Cell": 1, "Site": "A", "Date": "1999-02-01", "Value": -3.72},
+        {"Grid Cell": 1, "Site": "A", "Date": "1999-03-01", "Value": 1.34},
+        {"Grid Cell": 1, "Site": "A", "Date": "1999-04-01", "Value": 4.56},
+        {"Grid Cell": 1, "Site": "B", "Date": "1999-01-01", "Value": 0.23},
+        {"Grid Cell": 1, "Site": "B", "Date": "1999-02-01", "Value": 3.26},
+        {"Grid Cell": 1, "Site": "B", "Date": "1999-03-01", "Value": 6.76},
+        {"Grid Cell": 2, "Site": "C", "Date": "2000-01-01", "Value": -7.45},
+        {"Grid Cell": 2, "Site": "C", "Date": "2000-02-01", "Value": -6.43},
+        {"Grid Cell": 2, "Site": "C", "Date": "2000-03-01", "Value": -2.18},
+        {"Grid Cell": 2, "Site": "D", "Date": "2000-01-01", "Value": -10.72},
+        {"Grid Cell": 2, "Site": "D", "Date": "2000-02-01", "Value": -8.97},
+        {"Grid Cell": 2, "Site": "D", "Date": "2000-03-01", "Value": -5.32},
+        {"Grid Cell": 2, "Site": "D", "Date": "2000-04-01", "Value": -1.73},
+    ]
+
+    df = pl.LazyFrame(data)
+    expected = (
+        df.janitor.complete("Date", "Site", by="Grid Cell")
+        .select("Grid Cell", "Site", "Date", "Value")
+        .sort(by=pl.all())
+    )
+
+    actual = [
+        {"Grid Cell": 1, "Site": "A", "Date": "1999-01-01", "Value": -2.45},
+        {"Grid Cell": 1, "Site": "A", "Date": "1999-02-01", "Value": -3.72},
+        {"Grid Cell": 1, "Site": "A", "Date": "1999-03-01", "Value": 1.34},
+        {"Grid Cell": 1, "Site": "A", "Date": "1999-04-01", "Value": 4.56},
+        {"Grid Cell": 1, "Site": "B", "Date": "1999-01-01", "Value": 0.23},
+        {"Grid Cell": 1, "Site": "B", "Date": "1999-02-01", "Value": 3.26},
+        {"Grid Cell": 1, "Site": "B", "Date": "1999-03-01", "Value": 6.76},
+        {"Grid Cell": 1, "Site": "B", "Date": "1999-04-01", "Value": None},
+        {"Grid Cell": 2, "Site": "C", "Date": "2000-01-01", "Value": -7.45},
+        {"Grid Cell": 2, "Site": "C", "Date": "2000-02-01", "Value": -6.43},
+        {"Grid Cell": 2, "Site": "C", "Date": "2000-03-01", "Value": -2.18},
+        {"Grid Cell": 2, "Site": "C", "Date": "2000-04-01", "Value": None},
+        {"Grid Cell": 2, "Site": "D", "Date": "2000-01-01", "Value": -10.72},
+        {"Grid Cell": 2, "Site": "D", "Date": "2000-02-01", "Value": -8.97},
+        {"Grid Cell": 2, "Site": "D", "Date": "2000-03-01", "Value": -5.32},
+        {"Grid Cell": 2, "Site": "D", "Date": "2000-04-01", "Value": -1.73},
+    ]
+
+    actual = pl.DataFrame(actual).sort(by=pl.all())
+
+    assert_frame_equal(expected.collect(), actual)
+
+
+# https://tidyr.tidyverse.org/reference/complete.html
+def test_complete_2(fill_df):
+    """Test output for janitor.complete."""
+    result = fill_df.janitor.complete(
+        "group",
+        pl.struct("item_id", "item_name").alias("rar").unique().sort(),
+        fill_value={"value1": 0, "value2": 99},
+        explicit=False,
+        sort=True,
+    )
+    expected = pl.DataFrame(
+        [
+            {
+                "group": 1,
+                "item_id": 1,
+                "item_name": "a",
+                "value1": 1,
+                "value2": 4,
+            },
+            {
+                "group": 1,
+                "item_id": 2,
+                "item_name": "a",
+                "value1": 0,
+                "value2": 99,
+            },
+            {
+                "group": 1,
+                "item_id": 2,
+                "item_name": "b",
+                "value1": 3,
+                "value2": 6,
+            },
+            {
+                "group": 1,
+                "item_id": 3,
+                "item_name": "b",
+                "value1": 0,
+                "value2": 99,
+            },
+            {
+                "group": 2,
+                "item_id": 1,
+                "item_name": "a",
+                "value1": 0,
+                "value2": 99,
+            },
+            {
+                "group": 2,
+                "item_id": 2,
+                "item_name": "a",
+                "value1": None,
+                "value2": 5,
+            },
+            {
+                "group": 2,
+                "item_id": 2,
+                "item_name": "b",
+                "value1": 0,
+                "value2": 99,
+            },
+            {
+                "group": 2,
+                "item_id": 3,
+                "item_name": "b",
+                "value1": 4,
+                "value2": 7,
+            },
+        ]
+    )
+
+    assert_frame_equal(result, expected)
+
+
+# https://stackoverflow.com/questions/48914323/tidyr-complete-cases-nesting-misunderstanding
+def test_complete_multiple_groupings():
+    """Test that `janitor.complete` gets the correct output for multiple groupings."""
+    df3 = pl.DataFrame(
+        {
+            "project_id": [1, 1, 1, 1, 2, 2, 2],
+            "meta": ["A", "A", "B", "B", "A", "B", "C"],
+            "domain1": ["d", "e", "h", "i", "d", "i", "k"],
+            "question_count": [3, 3, 3, 3, 2, 2, 2],
+            "tag_count": [2, 1, 3, 2, 1, 1, 2],
+        }
+    )
+
+    output3 = pl.DataFrame(
+        {
+            "project_id": [1, 2, 1, 2, 1, 2, 1, 2, 1, 2],
+            "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, 1, 1, 0, 3, 0, 2, 1, 0, 2],
+        }
+    )
+
+    result = df3.janitor.complete(
+        pl.struct("meta", "domain1").alias("bar").unique().sort(),
+        pl.struct("project_id", "question_count").alias("foo").unique().sort(),
+        fill_value={"tag_count": 0},
+        sort=True,
+    ).select("project_id", "meta", "domain1", "question_count", "tag_count")
+    assert_frame_equal(result, output3)
+
+
+def test_complete_3(fill_df):
+    """
+    Test output for janitor.complete
+    """
+    assert_frame_equal(
+        fill_df.janitor.complete("group", sort=True).sort("group"),
+        fill_df.sort("group"),
+    )