Repository: bodo-ai/PyDough

Issue:
Implement LPAD/RPAD functions in PyDough
Goal: add functions that do left/right string padding. The behavior of these functions is as follows:
- `LPAD(s, c, n)` pads string `s` with the padding character `c` on its left until its length is `n`. If the length of `s` is greater than `n` already, truncates to only include the first `n` characters of `s`.
- `RPAD(s, c, n)` pads string `s` with the padding character `c` on its right until its length is `n`. If the length of `s` is greater than `n` already, truncates to only include the first `n` characters of `s`.
- For now, `c` must always be a single-character string literal
- For now, `n` must always be a positive integer literal

Examples of desired behavior:
- `LPAD('96', '0', 4)` returns `0096`.
- `RPAD('96', '0', 4)` returns `9600`.
- `LPAD('abcdefg', '*', 5)` returns `abcde`.
- `RPAD('abcdefg', '*', 5)` returns `abcde`.

The default implementation for ANSI (which sqlite also uses) does not have an explicit function for these features, but can instead be decomposed via concatenation & substring. For example:
- `RPAD(x, '0', 4)` should become `SUBSTRING(x || '0000', 1, 4)` in SQL
- `LPAD(x, '0', 4)` should become `CASE WHEN LENGTH(x) > 4 THEN SUBSTRING(x, 1, 4) ELSE SUBSTRING('0000' || x, -4) END` in SQL

Related to #260 since it handles some of these string slicing cases.

These functions should be thoroughly tested, and documented in the function list documentation.

Discussion:

Patch:
diff --git a/documentation/functions.md b/documentation/functions.md
index 49c1af1..8f954ea 100644
--- a/documentation/functions.md
+++ b/documentation/functions.md
@@ -21,6 +21,8 @@ Below is the list of every function/operator currently supported in PyDough as a
    * [CONTAINS](#contains)
    * [LIKE](#like)
    * [JOIN_STRINGS](#join_strings)
+   * [LPAD](#lpad)
+   * [RPAD](#rpad)
 - [Datetime Functions](#datetime-functions)
    * [DATETIME](#datetime)
    * [YEAR](#year)
@@ -270,6 +272,66 @@ Regions.CALCULATE(
 
 For instance, `JOIN_STRINGS("; ", "Alpha", "Beta", "Gamma)` returns `"Alpha; Beta; Gamma"`.
 
+<!-- TOC --><a name="lpad"></a>
+### LPAD
+
+The `LPAD` function pads an expression on the left side with a specified padding character until it is the desired length. It takes three arguments:
+
+1. The input string to pad.
+2. The desired final length of the expression. It should be a positive integer literal.
+3. The single character literal to use for padding.
+
+The function behaves as follows:
+
+- If the input expression is shorter than the desired length, it adds padding characters on the left until reaching the desired length. 
+- If the input expression is longer than the desired length, it truncates the expression by removing characters from the right side until it matches the desired length.
+- If the desired length is 0, it returns an empty string.
+- If the desired length is negative, it raises an error.
+- If the padding argument is not a single character, it raises an error.
+
+```py
+Customers.CALCULATE(left_padded_name = LPAD(name, 30, "*"))
+```
+
+Here are examples on how it pads on string literals:
+| Input | Output |
+|-------|--------|
+| `LPAD("123", 6, "0")` | `"000123"` |
+| `LPAD("123", 5, "#")` | `"##123"` |
+| `LPAD("123", 3, "0")` | `"123"` |
+| `LPAD("123", 2, "0")` | `"12"` |
+| `LPAD("123", 0, "0")` | `""` |
+
+<!-- TOC --><a name="rpad"></a>
+### RPAD
+
+The `RPAD` function pads an expression on the right side with a specified padding character until it is the desired length. It takes three arguments:
+
+1. The input string to pad.
+2. The desired final length of the expression. It should be a positive integer literal.
+3. The single character literal to use for padding.
+
+The function behaves as follows:
+
+- If the input expression is shorter than the desired length, it adds padding characters on the right until reaching the desired length.
+- If the input expression is longer than the desired length, it truncates the expression by removing characters from the right side until it matches the desired length.
+- If the desired length is 0, it returns an empty string
+- If the desired length is negative, it raises an error
+- If the padding argument is not a single character, it raises an error
+
+```py
+Customers.CALCULATE(right_padded_name = RPAD(name, 30, "*"))
+```
+
+Here are examples on how it pads on string literals:
+| Input | Output |
+|-------|--------|
+| `RPAD("123", 6, "0")` | `"123000"` |
+| `RPAD("123", 5, "#")` | `"123##"` |
+| `RPAD("123", 3, "0")` | `"123"` |
+| `RPAD("123", 2, "0")` | `"12"` |
+| `RPAD("123", 0, "0")` | `""` |
+
 <!-- TOC --><a name="datetime-functions"></a>
 
 ## Datetime Functions
diff --git a/pydough/pydough_operators/__init__.py b/pydough/pydough_operators/__init__.py
index c1c7394..9676b59 100644
--- a/pydough/pydough_operators/__init__.py
+++ b/pydough/pydough_operators/__init__.py
@@ -41,6 +41,7 @@ __all__ = [
     "LET",
     "LIKE",
     "LOWER",
+    "LPAD",
     "MAX",
     "MIN",
     "MINUTE",
@@ -59,6 +60,7 @@ __all__ = [
     "PyDoughOperator",
     "RANKING",
     "ROUND",
+    "RPAD",
     "RequireNumArgs",
     "SECOND",
     "SLICE",
@@ -106,6 +108,7 @@ from .expression_operators import (
     LET,
     LIKE,
     LOWER,
+    LPAD,
     MAX,
     MIN,
     MINUTE,
@@ -122,6 +125,7 @@ from .expression_operators import (
     PRESENT,
     RANKING,
     ROUND,
+    RPAD,
     SECOND,
     SLICE,
     SQRT,
diff --git a/pydough/pydough_operators/expression_operators/README.md b/pydough/pydough_operators/expression_operators/README.md
index fe99d92..a501fb0 100644
--- a/pydough/pydough_operators/expression_operators/README.md
+++ b/pydough/pydough_operators/expression_operators/README.md
@@ -75,6 +75,8 @@ These functions must be called on singular data as a function.
 - `CONTAINS`: returns whether the first argument string contains the second argument string.
 - `LIKE`: returns whether the first argument matches the SQL pattern text of the second argument, where `_` is a 1 character wildcard and `%` is an 0+ character wildcard.
 - `JOIN_STRINGS`: equivalent to the Python string join method, where the first argument is used as a delimiter to concatenate the remaining arguments.
+- `LPAD`: pads the first argument with the second argument to the left until the first argument is equal in length to the third argument.
+- `RPAD`: pads the first argument with the second argument to the right until the first argument is equal in length to the third argument.
 
 ##### Datetime Functions
 
diff --git a/pydough/pydough_operators/expression_operators/__init__.py b/pydough/pydough_operators/expression_operators/__init__.py
index 853f375..b333a59 100644
--- a/pydough/pydough_operators/expression_operators/__init__.py
+++ b/pydough/pydough_operators/expression_operators/__init__.py
@@ -38,6 +38,7 @@ __all__ = [
     "LET",
     "LIKE",
     "LOWER",
+    "LPAD",
     "MAX",
     "MIN",
     "MINUTE",
@@ -55,6 +56,7 @@ __all__ = [
     "PyDoughExpressionOperator",
     "RANKING",
     "ROUND",
+    "RPAD",
     "SECOND",
     "SLICE",
     "SQRT",
@@ -100,6 +102,7 @@ from .registered_expression_operators import (
     LET,
     LIKE,
     LOWER,
+    LPAD,
     MAX,
     MIN,
     MINUTE,
@@ -116,6 +119,7 @@ from .registered_expression_operators import (
     PRESENT,
     RANKING,
     ROUND,
+    RPAD,
     SECOND,
     SLICE,
     SQRT,
diff --git a/pydough/pydough_operators/expression_operators/registered_expression_operators.py b/pydough/pydough_operators/expression_operators/registered_expression_operators.py
index bdeaa30..aac1f97 100644
--- a/pydough/pydough_operators/expression_operators/registered_expression_operators.py
+++ b/pydough/pydough_operators/expression_operators/registered_expression_operators.py
@@ -33,6 +33,7 @@ __all__ = [
     "LET",
     "LIKE",
     "LOWER",
+    "LPAD",
     "MAX",
     "MIN",
     "MINUTE",
@@ -49,6 +50,7 @@ __all__ = [
     "PRESENT",
     "RANKING",
     "ROUND",
+    "RPAD",
     "SECOND",
     "SLICE",
     "SQRT",
@@ -160,6 +162,12 @@ DATEDIFF = ExpressionFunctionOperator(
 SLICE = ExpressionFunctionOperator(
     "SLICE", False, RequireNumArgs(4), SelectArgumentType(0)
 )
+LPAD = ExpressionFunctionOperator(
+    "LPAD", False, RequireNumArgs(3), SelectArgumentType(0)
+)
+RPAD = ExpressionFunctionOperator(
+    "RPAD", False, RequireNumArgs(3), SelectArgumentType(0)
+)
 NOT = ExpressionFunctionOperator(
     "NOT", False, RequireNumArgs(1), ConstantType(BooleanType())
 )
diff --git a/pydough/sqlglot/transform_bindings.py b/pydough/sqlglot/transform_bindings.py
index 64adf82..465004c 100644
--- a/pydough/sqlglot/transform_bindings.py
+++ b/pydough/sqlglot/transform_bindings.py
@@ -998,6 +998,155 @@ def convert_contains(
     return convert_like(None, [column, pattern])
 
 
+def pad_helper(
+    raw_args: Sequence[RelationalExpression] | None,
+    sql_glot_args: Sequence[SQLGlotExpression],
+    pad_func: str,
+) -> SQLGlotExpression:
+    """
+    Helper function for LPAD and RPAD.
+    Expects sqlglot_args[0] to be the column to pad.
+    Expects sqlglot_args[1] and sqlglot_args[2] to be literals.
+    Expects sqlglot_args[1] to be the returned length of the padded string.
+    Expects sqlglot_args[2] to be the string to pad with.
+
+    Args:
+        `raw_args`: The operands passed to the function before they were converted to
+        SQLGlot expressions. (Not actively used in this implementation.)
+        `sql_glot_args`: The operands passed to the function after they were converted
+        to SQLGlot expressions. The first operand is expected to be a string.
+        `pad_func`: The name of the padding function to use.
+
+    Returns:
+        A tuple of sqlglot expressions for the column to pad, the length of the column,
+        the required length, padding string and the integer literal of the required length.
+    """
+    assert pad_func in ["LPAD", "RPAD"]
+    assert len(sql_glot_args) == 3
+
+    if (
+        isinstance(sql_glot_args[1], sqlglot_expressions.Literal)
+        and not sql_glot_args[1].is_string
+    ):
+        try:
+            required_len = int(sql_glot_args[1].this)
+            if required_len < 0:
+                raise ValueError()
+        except ValueError:
+            raise ValueError(
+                f"{pad_func} function requires the length argument to be a non-negative integer literal."
+            )
+    else:
+        raise ValueError(
+            f"{pad_func} function requires the length argument to be a non-negative integer literal."
+        )
+
+    if (
+        not isinstance(sql_glot_args[2], sqlglot_expressions.Literal)
+        or not sql_glot_args[2].is_string
+    ):
+        raise ValueError(
+            f"{pad_func} function requires the padding argument to be a string literal of length 1."
+        )
+    if len(str(sql_glot_args[2].this)) != 1:
+        raise ValueError(
+            f"{pad_func} function requires the padding argument to be a string literal of length 1."
+        )
+
+    col_glot = sql_glot_args[0]
+    col_len_glot = sqlglot_expressions.Length(this=sql_glot_args[0])
+    required_len_glot = sqlglot_expressions.convert(required_len)
+    pad_string_glot = sqlglot_expressions.convert(
+        str(sql_glot_args[2].this) * required_len
+    )
+    return col_glot, col_len_glot, required_len_glot, pad_string_glot, required_len
+
+
+def convert_lpad(
+    raw_args: Sequence[RelationalExpression] | None,
+    sql_glot_args: Sequence[SQLGlotExpression],
+) -> SQLGlotExpression:
+    """
+    Converts and pads the string to the left till the string is the specified length.
+    If length is 0, return an empty string.
+    If length is negative, raise an error.
+    If length is positive, pad the string on the left to the specified length.
+
+    Args:
+        `raw_args`: The operands passed to the function before they were converted to
+        SQLGlot expressions. (Not actively used in this implementation.)
+        `sql_glot_args`: The operands passed to the function after they were converted
+        to SQLGlot expressions. The first operand is expected to be a string.
+
+    Returns:
+        The SQLGlot expression matching the functionality of
+        `LPAD(string, length, padding)`. With the caveat that if length is 0,
+        it will return an empty string.
+    """
+    col_glot, col_len_glot, required_len_glot, pad_string_glot, required_len = (
+        pad_helper(raw_args, sql_glot_args, "LPAD")
+    )
+    if required_len == 0:
+        return sqlglot_expressions.convert("")
+
+    answer = convert_iff_case(
+        None,
+        [
+            sqlglot_expressions.GTE(this=col_len_glot, expression=required_len_glot),
+            sqlglot_expressions.Substring(
+                this=col_glot,
+                start=sqlglot_expressions.convert(1),
+                length=required_len_glot,
+            ),
+            sqlglot_expressions.Substring(
+                this=convert_concat(None, [pad_string_glot, col_glot]),
+                start=apply_parens(
+                    sqlglot_expressions.Mul(
+                        this=required_len_glot,
+                        expression=sqlglot_expressions.convert(-1),
+                    )
+                ),
+            ),
+        ],
+    )
+    return answer
+
+
+def convert_rpad(
+    raw_args: Sequence[RelationalExpression] | None,
+    sql_glot_args: Sequence[SQLGlotExpression],
+) -> SQLGlotExpression:
+    """
+    Converts and pads the string to the right to the specified length.
+    If length is 0, return an empty string.
+    If length is negative, raise an error.
+    If length is positive, pad the string on the right to the specified length.
+
+    Args:
+        `raw_args`: The operands passed to the function before they were converted to
+        SQLGlot expressions. (Not actively used in this implementation.)
+        `sql_glot_args`: The operands passed to the function after they were converted
+        to SQLGlot expressions. The first operand is expected to be a string.
+
+    Returns:
+        The SQLGlot expression matching the functionality of
+        `RPAD(string, length, padding)`. With the caveat that if length is 0,
+        it will return an empty string.
+    """
+    col_glot, _, required_len_glot, pad_string_glot, required_len = pad_helper(
+        raw_args, sql_glot_args, "RPAD"
+    )
+    if required_len == 0:
+        return sqlglot_expressions.convert("")
+
+    answer = sqlglot_expressions.Substring(
+        this=convert_concat(None, [col_glot, pad_string_glot]),
+        start=sqlglot_expressions.convert(1),
+        length=required_len_glot,
+    )
+    return answer
+
+
 def convert_isin(
     raw_args: Sequence[RelationalExpression] | None,
     sql_glot_args: Sequence[SQLGlotExpression],
@@ -1486,6 +1635,8 @@ class SqlGlotTransformBindings:
         self.bindings[pydop.LIKE] = convert_like
         self.bindings[pydop.SLICE] = convert_slice
         self.bindings[pydop.JOIN_STRINGS] = convert_concat_ws
+        self.bindings[pydop.LPAD] = convert_lpad
+        self.bindings[pydop.RPAD] = convert_rpad
 
         # Numeric functions
         self.bind_simple_function(pydop.ABS, sqlglot_expressions.Abs)


Test patch:
diff --git a/tests/bad_pydough_functions.py b/tests/bad_pydough_functions.py
index d86b005..604119b 100644
--- a/tests/bad_pydough_functions.py
+++ b/tests/bad_pydough_functions.py
@@ -2,6 +2,7 @@
 # mypy: ignore-errors
 # ruff & mypy should not try to typecheck or verify any of this
 
+import datetime
 import math
 import datetime
 
@@ -58,6 +59,86 @@ def bad_window_7():
     return Orders.CALCULATE(PERCENTILE(by=order_key.ASC(), n_buckets=[1, 2, 3]))
 
 
+def bad_lpad_1():
+    # String length argument
+    return Customers.CALCULATE(padded_name=LPAD(name, "20", "*"))
+
+
+def bad_lpad_2():
+    # Empty padding string
+    return Customers.CALCULATE(padded_name=LPAD(name, 20, ""))
+
+
+def bad_lpad_3():
+    # Negative length
+    return Customers.CALCULATE(padded_name=LPAD(name, -5, "*"))
+
+
+def bad_lpad_4():
+    # Multi-character padding string
+    return Customers.CALCULATE(padded_name=LPAD(name, 20, "*#"))
+
+
+def bad_lpad_5():
+    # Non-integer length
+    return Customers.CALCULATE(padded_name=LPAD(name, 20.5, "*"))
+
+
+def bad_lpad_6():
+    # Non-integer length
+    return Customers.CALCULATE(padded_name=LPAD(name, datetime.datetime.now(), "*"))
+
+
+def bad_lpad_7():
+    # Non-literal length
+    return Customers.CALCULATE(padded_name=LPAD(name, LENGTH(phone), "*"))
+
+
+def bad_lpad_8():
+    # Non-literal padding string
+    return Customers.CALCULATE(padded_name=LPAD(name, 20, LENGTH(phone)))
+
+
+def bad_rpad_1():
+    # String length argument
+    return Customers.CALCULATE(padded_name=RPAD(name, "20", "*"))
+
+
+def bad_rpad_2():
+    # Empty padding string
+    return Customers.CALCULATE(padded_name=RPAD(name, 20, ""))
+
+
+def bad_rpad_3():
+    # Negative length
+    return Customers.CALCULATE(padded_name=RPAD(name, -5, "*"))
+
+
+def bad_rpad_4():
+    # Multi-character padding string
+    return Customers.CALCULATE(padded_name=RPAD(name, 20, "*#"))
+
+
+def bad_rpad_5():
+    # Non-integer length
+    return Customers.CALCULATE(padded_name=RPAD(name, 20.5, "*"))
+
+
+def bad_rpad_6():
+    # Non-integer length
+    return Customers.CALCULATE(padded_name=RPAD(name, datetime.datetime.now(), "*"))
+
+
+def bad_rpad_7():
+    # Non-literal length
+    return Customers.CALCULATE(padded_name=RPAD(name, LENGTH(phone), "*"))
+
+
+def bad_rpad_8():
+    # Non-literal padding string
+    return Customers.CALCULATE(padded_name=RPAD(name, 20, LENGTH(phone)))
+
+
 def bad_slice_1():
     # Unsupported slicing: skipping
     return Customers.CALCULATE(name[1:10:2])
diff --git a/tests/simple_pydough_functions.py b/tests/simple_pydough_functions.py
index 47b082a..b582aea 100644
--- a/tests/simple_pydough_functions.py
+++ b/tests/simple_pydough_functions.py
@@ -829,6 +829,22 @@ def datediff():
     )
 
 
+def padding_functions():
+    return Customers.CALCULATE(
+        original_name=name,
+        ref_rpad=RPAD("Cust0001", 30, "*"),
+        ref_lpad=LPAD("Cust0001", 30, "*"),
+        right_padded=RPAD(name, 30, "*"),
+        left_padded=LPAD(name, 30, "#"),
+        truncated_right=RPAD(name, 8, "-"),
+        truncated_left=LPAD(name, 8, "-"),
+        zero_pad_right=RPAD(name, 0, "."),
+        zero_pad_left=LPAD(name, 0, "."),
+        right_padded_space=RPAD(name, 30, " "),
+        left_padded_space=LPAD(name, 30, " "),
+    ).TOP_K(5, by=name.ASC())
+
+
 def step_slicing():
     return Customers.CALCULATE(
         name,
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
index 91ab4ae..e54729a 100644
--- a/tests/test_pipeline.py
+++ b/tests/test_pipeline.py
@@ -7,6 +7,22 @@ from collections.abc import Callable
 import pandas as pd
 import pytest
 from bad_pydough_functions import (
+    bad_lpad_1,
+    bad_lpad_2,
+    bad_lpad_3,
+    bad_lpad_4,
+    bad_lpad_5,
+    bad_lpad_6,
+    bad_lpad_7,
+    bad_lpad_8,
+    bad_rpad_1,
+    bad_rpad_2,
+    bad_rpad_3,
+    bad_rpad_4,
+    bad_rpad_5,
+    bad_rpad_6,
+    bad_rpad_7,
+    bad_rpad_8,
     bad_slice_1,
     bad_slice_2,
     bad_slice_3,
@@ -62,6 +78,7 @@ from simple_pydough_functions import (
     multi_partition_access_4,
     multi_partition_access_5,
     multi_partition_access_6,
+    padding_functions,
     percentile_customers_per_region,
     percentile_nations,
     rank_nations_by_region,
@@ -130,7 +147,7 @@ from tpch_test_functions import (
     impl_tpch_q22,
 )
 
-from pydough import init_pydough_context, to_df
+from pydough import init_pydough_context, to_df, to_sql
 from pydough.configs import PyDoughConfigs
 from pydough.conversion.relational_converter import convert_ast_to_relational
 from pydough.database_connectors import DatabaseContext
@@ -1926,6 +1943,58 @@ def test_pipeline_e2e_errors(
             ),
             id="minutes_seconds_datediff",
         ),
+        pytest.param(
+            (
+                padding_functions,
+                None,
+                "Broker",
+                "padding_functions",
+                lambda: pd.DataFrame(
+                    {
+                        "original_name": [
+                            "Alex Rodriguez",
+                            "Ava Wilson",
+                            "Bob Johnson",
+                            "David Kim",
+                            "Emily Davis",
+                        ]
+                    }
+                ).assign(
+                    ref_rpad=lambda x: "Cust0001**********************",
+                    ref_lpad=lambda x: "**********************Cust0001",
+                    right_padded=lambda x: x.original_name.apply(
+                        lambda s: (s + "*" * 30)[:30]
+                    ),
+                    # This lambda only works when each string is less than 30 characters
+                    left_padded=lambda x: x.original_name.apply(
+                        lambda s: ("#" * 30 + s)[-30:]
+                    ),
+                    truncated_right=[
+                        "Alex Rod",
+                        "Ava Wils",
+                        "Bob John",
+                        "David Ki",
+                        "Emily Da",
+                    ],
+                    truncated_left=[
+                        "Alex Rod",
+                        "Ava Wils",
+                        "Bob John",
+                        "David Ki",
+                        "Emily Da",
+                    ],
+                    zero_pad_right=[""] * 5,
+                    zero_pad_left=[""] * 5,
+                    right_padded_space=lambda x: x.original_name.apply(
+                        lambda s: (s + " " * 30)[:30]
+                    ),
+                    left_padded_space=lambda x: x.original_name.apply(
+                        lambda s: (" " * 30 + s)[-30:]
+                    ),
+                ),
+            ),
+            id="padding_functions",
+        ),
         pytest.param(
             (
                 step_slicing,
@@ -2094,3 +2163,121 @@ def test_defog_e2e_with_custom_data(
         root, columns=columns, metadata=graph, database=sqlite_defog_connection
     )
     pd.testing.assert_frame_equal(result, answer_impl())
+
+
+@pytest.mark.parametrize(
+    "impl, graph_name, error_msg",
+    [
+        pytest.param(
+            bad_lpad_1,
+            "Broker",
+            "LPAD function requires the length argument to be a non-negative integer literal.",
+            id="bad_lpad_1",
+        ),
+        pytest.param(
+            bad_lpad_2,
+            "Broker",
+            "LPAD function requires the padding argument to be a string literal of length 1.",
+            id="bad_lpad_2",
+        ),
+        pytest.param(
+            bad_lpad_3,
+            "Broker",
+            "LPAD function requires the length argument to be a non-negative integer literal.",
+            id="bad_lpad_3",
+        ),
+        pytest.param(
+            bad_lpad_4,
+            "Broker",
+            "LPAD function requires the padding argument to be a string literal of length 1.",
+            id="bad_lpad_4",
+        ),
+        pytest.param(
+            bad_lpad_5,
+            "Broker",
+            "LPAD function requires the length argument to be a non-negative integer literal.",
+            id="bad_lpad_5",
+        ),
+        pytest.param(
+            bad_lpad_6,
+            "Broker",
+            "LPAD function requires the length argument to be a non-negative integer literal.",
+            id="bad_lpad_6",
+        ),
+        pytest.param(
+            bad_lpad_7,
+            "Broker",
+            "LPAD function requires the length argument to be a non-negative integer literal.",
+            id="bad_lpad_7",
+        ),
+        pytest.param(
+            bad_lpad_8,
+            "Broker",
+            "LPAD function requires the padding argument to be a string literal of length 1.",
+            id="bad_lpad_8",
+        ),
+        pytest.param(
+            bad_rpad_1,
+            "Broker",
+            "RPAD function requires the length argument to be a non-negative integer literal.",
+            id="bad_rpad_1",
+        ),
+        pytest.param(
+            bad_rpad_2,
+            "Broker",
+            "RPAD function requires the padding argument to be a string literal of length 1.",
+            id="bad_rpad_2",
+        ),
+        pytest.param(
+            bad_rpad_3,
+            "Broker",
+            "RPAD function requires the length argument to be a non-negative integer literal.",
+            id="bad_rpad_3",
+        ),
+        pytest.param(
+            bad_rpad_4,
+            "Broker",
+            "RPAD function requires the padding argument to be a string literal of length 1.",
+            id="bad_rpad_4",
+        ),
+        pytest.param(
+            bad_rpad_5,
+            "Broker",
+            "RPAD function requires the length argument to be a non-negative integer literal.",
+            id="bad_rpad_5",
+        ),
+        pytest.param(
+            bad_rpad_6,
+            "Broker",
+            "RPAD function requires the length argument to be a non-negative integer literal.",
+            id="bad_rpad_6",
+        ),
+        pytest.param(
+            bad_rpad_7,
+            "Broker",
+            "RPAD function requires the length argument to be a non-negative integer literal.",
+            id="bad_rpad_7",
+        ),
+        pytest.param(
+            bad_rpad_8,
+            "Broker",
+            "RPAD function requires the padding argument to be a string literal of length 1.",
+            id="bad_rpad_8",
+        ),
+    ],
+)
+def test_defog_e2e_errors(
+    impl: Callable[[], UnqualifiedNode],
+    graph_name: str,
+    error_msg: str,
+    defog_graphs: graph_fetcher,
+    sqlite_defog_connection: DatabaseContext,
+):
+    """
+    Tests running bad PyDough code through the entire pipeline to verify that
+    a certain error is raised for defog database.
+    """
+    graph: GraphMetadata = defog_graphs(graph_name)
+    with pytest.raises(Exception, match=error_msg):
+        root: UnqualifiedNode = init_pydough_context(graph)(impl)()
+        to_sql(root, metadata=graph, database=sqlite_defog_connection)
diff --git a/tests/test_plan_refsols/padding_functions.txt b/tests/test_plan_refsols/padding_functions.txt
new file mode 100644
index 0000000..6a3047a
--- /dev/null
+++ b/tests/test_plan_refsols/padding_functions.txt
@@ -0,0 +1,5 @@
+ROOT(columns=[('original_name', original_name), ('ref_rpad', ref_rpad), ('ref_lpad', ref_lpad), ('right_padded', right_padded), ('left_padded', left_padded), ('truncated_right', truncated_right), ('truncated_left', truncated_left), ('zero_pad_right', zero_pad_right), ('zero_pad_left', zero_pad_left), ('right_padded_space', right_padded_space), ('left_padded_space', left_padded_space)], orderings=[(ordering_0):asc_first])
+ LIMIT(limit=Literal(value=5, type=Int64Type()), columns={'left_padded': left_padded, 'left_padded_space': left_padded_space, 'ordering_0': ordering_0, 'original_name': original_name, 'ref_lpad': ref_lpad, 'ref_rpad': ref_rpad, 'right_padded': right_padded, 'right_padded_space': right_padded_space, 'truncated_left': truncated_left, 'truncated_right': truncated_right, 'zero_pad_left': zero_pad_left, 'zero_pad_right': zero_pad_right}, orderings=[(ordering_0):asc_first])
+  PROJECT(columns={'left_padded': left_padded, 'left_padded_space': left_padded_space, 'ordering_0': name, 'original_name': original_name, 'ref_lpad': ref_lpad, 'ref_rpad': ref_rpad, 'right_padded': right_padded, 'right_padded_space': right_padded_space, 'truncated_left': truncated_left, 'truncated_right': truncated_right, 'zero_pad_left': zero_pad_left, 'zero_pad_right': zero_pad_right})
+   PROJECT(columns={'left_padded': LPAD(name, 30:int64, '#':string), 'left_padded_space': LPAD(name, 30:int64, ' ':string), 'name': name, 'original_name': name, 'ref_lpad': LPAD('Cust0001':string, 30:int64, '*':string), 'ref_rpad': RPAD('Cust0001':string, 30:int64, '*':string), 'right_padded': RPAD(name, 30:int64, '*':string), 'right_padded_space': RPAD(name, 30:int64, ' ':string), 'truncated_left': LPAD(name, 8:int64, '-':string), 'truncated_right': RPAD(name, 8:int64, '-':string), 'zero_pad_left': LPAD(name, 0:int64, '.':string), 'zero_pad_right': RPAD(name, 0:int64, '.':string)})
+    SCAN(table=main.sbCustomer, columns={'name': sbCustName})