Repository: litestar-org/polyfactory (python)

Issue:
Bug: handle_constrained_decimal not enforcing SQL numeric type precision properly
### Description

When using the `SQLAlchemyFactory`, constraints on `Numeric` columns are not applied correctly. 

The [PostgreSQL documentation](https://www.postgresql.org/docs/current/datatype-numeric.html#DATATYPE-NUMERIC-DECIMAL) defines the following for the `Numeric` type:

> The precision of a numeric is the total count of significant digits in the whole number, that is, the number of digits to both sides of the decimal point. The scale of a numeric is the count of decimal digits in the fractional part, to the right of the decimal point. So the number 23.5141 has a precision of 6 and a scale of 4.

In our project we create a SQLAlchemy model with a bunch of columns, including a few `Numeric` columns. For one of these, we require the column to have at most 4 digits, and two decimals. Effectively, we're stating that the value should be between a float such that `(0, 100)` holds, translating into the following DDL:

```sql
add column tenor numeric(4,2)
```

Since the `Numeric` type doesn't allow specifying greater than or less than values, we expect this constraint would translate to the factory, accurately following the spec. While the numeric precision and digits translate to the correct `Constraint`, that `Constraint` isn't applied accurately for `SQLAlchemyFactory` factories: `max_digits` (which is mapped to `precision`) is applied only to the part before the decimal point. 

I believe the issue is that a `Decimal` is first created in `handle_constrained_decimal` before checking if the constraints hold. Therefore, we can end up with `Decimal`s that are outside of the `Numeric` constraints that we expect based on the SQL spec.

A workaround that I currently employed in our codebase is that for the explicit fields where this is causing flaky behavior, I manually set a minimum and maximum value so that the generated `Decimal` is correct. However, since `Numeric` doesn't allow specifying greater or lesser than values, this leads to increased overhead.

Please see below working example.

### URL to code causing the issue

_No response_

### MCVE

```python
from decimal import Decimal
from polyfactory.factories.sqlalchemy_factory import SQLAlchemyFactory
from sqlalchemy import Numeric
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    some_field: Mapped[Decimal] = mapped_column(Numeric(precision=4, scale=2))

class UserFactory(SQLAlchemyFactory[User]):
    ...

def test_some_field_constraint() -> None:
    # Run this test 1000 times to make it more likely the random number does not meet the constraints
    for _ in range(1000):
        user_instance = UserFactory.build()
        if user_instance.some_field >= float(100.00):
            print(user_instance.some_field)

test_some_field_constraint()
```

This will in most cases print out at least a few floats that are greater than the constraints specify:

```
100.4
100.4
101.3
100.1
101.4
100.5
100.8
101.3
100.5
100.9
100.4
101.5
100.1
```

### Steps to reproduce

```bash
The above example is self-contained and can be run as-is, e.g. in a Jupyter notebook.
```

### Screenshots

_No response_

### Logs

```bash

```

### Release Version

polyfactory==2.18.1
python==3.12.3

### Platform

- [x] Linux
- [ ] Mac
- [ ] Windows
- [ ] Other (Please specify in the description above)

Pull request description:
fix(#634): added a generate function to support SQL Numeric field
<!--
By submitting this pull request, you agree to:
- follow [Litestar's Code of Conduct](https://github.com/litestar-org/.github/blob/main/CODE_OF_CONDUCT.md)
- follow [Litestar's contribution guidelines](https://github.com/litestar-org/.github/blob/main/CONTRIBUTING.md)
- follow the [PSFs's Code of Conduct](https://www.python.org/psf/conduct/)
-->
## Description

- a function for generate random value for SQL NUMERIC type has been added;
- appropriate tests have been added.

I have decided to add a new separate function to work with only SQL NUMERIC type like [this](https://www.postgresql.org/docs/current/datatype-numeric.html#DATATYPE-NUMERIC-DECIMAL), because [Pydantic Decimal Constraints](https://docs.pydantic.dev/latest/concepts/fields/#decimal-constraints) works in a slightly different way.

Also this function does not allow to use **a negative scale** and **a scale larger than the declared precision**. This can be used only with the SQL standard requirements (the scale must be in the range 0 to precision). And generated values can be either positive or negative.

<!--
Please add in issue numbers this pull request will close, if applicable
Examples: Fixes #4321 or Closes #1234

Ensure you are using a supported keyword to properly link an issue:
https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword
-->
## Closes
- Closes #634 


Patch:
diff --git a/polyfactory/value_generators/constrained_numbers.py b/polyfactory/value_generators/constrained_numbers.py
index 3cf77de..eaa8f0a 100644
--- a/polyfactory/value_generators/constrained_numbers.py
+++ b/polyfactory/value_generators/constrained_numbers.py
@@ -1,10 +1,11 @@
 from __future__ import annotations
 
-from decimal import Decimal
+from decimal import ROUND_DOWN, Decimal, localcontext
 from sys import float_info
 from typing import TYPE_CHECKING, Any, Protocol, TypeVar, cast
 
 from polyfactory.exceptions import ParameterException
+from polyfactory.utils.deprecation import check_for_deprecated_parameters
 from polyfactory.value_generators.primitives import create_random_decimal, create_random_float, create_random_integer
 
 if TYPE_CHECKING:
@@ -135,6 +136,8 @@ def get_value_or_none(
     le: T | None = None,
     gt: T | None = None,
     ge: T | None = None,
+    max_digits: int | None = None,
+    decimal_places: int | None = None,
 ) -> tuple[T | None, T | None]:
     """Return an optional value.
 
@@ -157,6 +160,26 @@ def get_value_or_none(
         maximum_value = lt - get_increment(t_type)
     else:
         maximum_value = None
+
+    if max_digits is not None:
+        max_whole_digits = 10
+        whole_digits = max_digits - decimal_places if decimal_places is not None else max_digits
+        maximum = (
+            Decimal(10**whole_digits - 1) if whole_digits < max_whole_digits else Decimal(10**max_whole_digits - 1)
+        )
+        minimum = maximum * (-1)
+
+        if minimum_value is None or minimum_value < minimum:
+            minimum_value = t_type(minimum)
+        elif minimum_value > maximum:
+            msg = f"minimum value must be less than {maximum}"
+            raise ParameterException(msg)
+
+        if maximum_value is None or maximum_value > maximum:
+            maximum_value = t_type(maximum if maximum > 0 else Decimal(1))
+        elif maximum_value < minimum:
+            msg = f"maximum value must be greater than {minimum}"
+            raise ParameterException(msg)
     return minimum_value, maximum_value
 
 
@@ -168,6 +191,8 @@ def get_constrained_number_range(
     gt: T | None = None,
     ge: T | None = None,
     multiple_of: T | None = None,
+    max_digits: int | None = None,
+    decimal_places: int | None = None,
 ) -> tuple[T | None, T | None]:
     """Return the minimum and maximum values given a field_meta's constraints.
 
@@ -178,11 +203,15 @@ def get_constrained_number_range(
     :param gt: Greater than value.
     :param ge: Greater than or equal value.
     :param multiple_of: Multiple of value.
+    :param decimal_places: Number of decimal places.
+    :param max_digits: Maximal number of digits.
 
     :returns: a tuple of optional minimum and maximum values.
     """
     seed = t_type(random.random() * 10)
-    minimum, maximum = get_value_or_none(lt=lt, le=le, gt=gt, ge=ge, t_type=t_type)
+    minimum, maximum = get_value_or_none(
+        lt=lt, le=le, gt=gt, ge=ge, t_type=t_type, max_digits=max_digits, decimal_places=decimal_places
+    )
 
     if minimum is not None and maximum is not None and maximum < minimum:
         msg = "maximum value must be greater than minimum value"
@@ -329,19 +358,14 @@ def validate_max_digits(
     :returns: 'None'
 
     """
+    check_for_deprecated_parameters("2.19.1", parameters=(("minimum", minimum),))
+
     if max_digits <= 0:
         msg = "max_digits must be greater than 0"
         raise ParameterException(msg)
 
-    if minimum is not None:
-        min_str = str(minimum).split(".")[1] if "." in str(minimum) else str(minimum)
-
-        if max_digits <= len(min_str):
-            msg = "minimum is greater than max_digits"
-            raise ParameterException(msg)
-
-    if decimal_places is not None and max_digits <= decimal_places:
-        msg = "max_digits must be greater than decimal places"
+    if decimal_places is not None and max_digits < decimal_places:
+        msg = "max_digits must be greater or equal than decimal places"
         raise ParameterException(msg)
 
 
@@ -357,25 +381,18 @@ def handle_decimal_length(
     :param max_digits: Maximal number of digits.
 
     """
-    string_number = str(generated_decimal)
-    sign = "-" if "-" in string_number else "+"
-    string_number = string_number.replace("-", "")
-    whole_numbers, decimals = string_number.split(".")
-
-    if (max_digits is not None and decimal_places is not None and len(whole_numbers) + decimal_places > max_digits) or (
-        (max_digits is None or decimal_places is None) and max_digits is not None
-    ):
-        max_decimals = max_digits - len(whole_numbers)
-    elif max_digits is not None:
-        max_decimals = decimal_places  # type: ignore[assignment]
-    else:
-        max_decimals = cast("int", decimal_places)
-
-    if max_decimals < 0:  # pyright: ignore[reportOptionalOperand]
-        return Decimal(sign + whole_numbers[:max_decimals])
-
-    decimals = decimals[:max_decimals]
-    return Decimal(sign + whole_numbers + "." + decimals[:decimal_places])
+    with localcontext() as ctx:
+        ctx.rounding = ROUND_DOWN
+        list_decimal = str(generated_decimal).strip("-0").split(".")
+        decimal_parts = 2
+        if len(list_decimal) == decimal_parts:
+            whole, decimals = list_decimal
+            if decimal_places is not None and len(decimals) > decimal_places:
+                return round(generated_decimal, decimal_places)
+            if max_digits is not None and len(whole) + len(decimals) > max_digits:
+                max_decimals = max_digits - len(whole)
+                return round(generated_decimal, max_decimals)
+        return generated_decimal
 
 
 def handle_constrained_decimal(
@@ -402,13 +419,14 @@ def handle_constrained_decimal(
     :returns: A decimal.
 
     """
-
     minimum, maximum = get_constrained_number_range(
         gt=gt,
         ge=ge,
         lt=lt,
         le=le,
         multiple_of=multiple_of,
+        max_digits=max_digits,
+        decimal_places=decimal_places,
         t_type=Decimal,
         random=random,
     )
@@ -424,11 +442,8 @@ def handle_constrained_decimal(
         method=create_random_decimal,
     )
 
-    if max_digits is not None or decimal_places is not None:
-        return handle_decimal_length(
-            generated_decimal=generated_decimal,
-            max_digits=max_digits,
-            decimal_places=decimal_places,
-        )
-
-    return generated_decimal
+    return handle_decimal_length(
+        generated_decimal=generated_decimal,
+        max_digits=max_digits,
+        decimal_places=decimal_places,
+    )


Test patch:
diff --git a/tests/constraints/test_decimal_constraints.py b/tests/constraints/test_decimal_constraints.py
index b6a76a3..64dd62b 100644
--- a/tests/constraints/test_decimal_constraints.py
+++ b/tests/constraints/test_decimal_constraints.py
@@ -1,6 +1,6 @@
 from decimal import Decimal
 from random import Random
-from typing import Optional, cast
+from typing import cast
 
 import pytest
 from hypothesis import given
@@ -32,12 +32,23 @@ def test_handle_constrained_decimal_without_constraints() -> None:
     assert isinstance(result, Decimal)
 
 
-def test_handle_constrained_decimal_length_validation() -> None:
-    with pytest.raises(ParameterException):
+@pytest.mark.parametrize(
+    ("msg", "ge", "le"),
+    (
+        ("minimum value must be less than", Decimal("100.000"), Decimal()),
+        ("maximum value must be greater than", Decimal(), Decimal("-100.000")),
+    ),
+)
+def test_handle_constrained_decimal_length_validation(msg: str, ge: Decimal, le: Decimal) -> None:
+    with pytest.raises(
+        ParameterException,
+        match=msg,
+    ):
         handle_constrained_decimal(
             random=Random(),
             max_digits=2,
-            ge=Decimal("100.000"),
+            ge=ge,
+            le=le,
         )
 
 
@@ -48,9 +59,12 @@ def test_handle_constrained_decimal_handles_max_digits(max_digits: int) -> None:
             random=Random(),
             max_digits=max_digits,
         )
-        assert len(result.as_tuple().digits) - abs(cast("int", result.as_tuple().exponent)) <= max_digits
+        assert len(result.as_tuple().digits) <= max_digits
     else:
-        with pytest.raises(ParameterException):
+        with pytest.raises(
+            ParameterException,
+            match="max_digits must be greater than 0",
+        ):
             handle_constrained_decimal(
                 random=Random(),
                 max_digits=max_digits,
@@ -66,15 +80,23 @@ def test_handle_constrained_decimal_handles_decimal_places(decimal_places: int)
     assert abs(cast("int", result.as_tuple().exponent)) <= decimal_places
 
 
-@given(integers(min_value=0, max_value=100), integers(min_value=1, max_value=100))
+@given(integers(min_value=0, max_value=100), integers(min_value=0, max_value=100))
 def test_handle_constrained_decimal_handles_max_digits_and_decimal_places(max_digits: int, decimal_places: int) -> None:
-    if max_digits > 0 and max_digits > decimal_places:
+    if max_digits > 0 and max_digits >= decimal_places:
         result = handle_constrained_decimal(
             random=Random(),
             decimal_places=decimal_places,
             max_digits=max_digits,
         )
-        assert len(result.as_tuple().digits) - abs(cast("int", result.as_tuple().exponent)) <= max_digits
+        non_fractionals = max_digits - decimal_places
+        list_value = str(result).strip("-").split(".")
+        if decimal_places:
+            left_digits, right_digits = list_value
+            assert len(right_digits) <= decimal_places
+        else:
+            left_digits = list_value[0]
+        assert len(left_digits) <= non_fractionals or int(left_digits) == non_fractionals
+
     else:
         with pytest.raises(ParameterException):
             handle_constrained_decimal(
@@ -368,16 +390,6 @@ def test_max_digits_and_decimal_places() -> None:
 def test_handle_decimal_length() -> None:
     decimal = Decimal("999.9999999")
 
-    # here digits should determine decimal length
-    max_digits = 5
-    decimal_places: Optional[int] = 5
-
-    result = handle_decimal_length(decimal, decimal_places, max_digits)
-
-    assert isinstance(result, Decimal)
-    assert len(result.as_tuple().digits) == 5
-    assert abs(cast("int", result.as_tuple().exponent)) == 2
-
     # here decimal places should determine max length
     max_digits = 10
     decimal_places = 5
@@ -405,14 +417,6 @@ def test_handle_decimal_length() -> None:
     assert len(result.as_tuple().digits) == 8
     assert abs(cast("int", result.as_tuple().exponent)) == 5
 
-    # here max_decimals is below 0
-    decimal = Decimal("99.99")
-    max_digits = 1
-    result = handle_decimal_length(decimal, decimal_places, max_digits)
-    assert isinstance(result, Decimal)
-    assert len(result.as_tuple().digits) == 1
-    assert cast("int", result.as_tuple().exponent) == 0
-
 
 def test_zero_to_one_range() -> None:
     class FractionExample(BaseModel):
diff --git a/tests/sqlalchemy_factory/test_sqlalchemy_factory_common.py b/tests/sqlalchemy_factory/test_sqlalchemy_factory_common.py
index 5c8084c..780cc17 100644
--- a/tests/sqlalchemy_factory/test_sqlalchemy_factory_common.py
+++ b/tests/sqlalchemy_factory/test_sqlalchemy_factory_common.py
@@ -488,3 +488,37 @@ def test_constrained_types() -> None:
     constrained_number: Decimal = instance.constrainted_number
     assert isinstance(constrained_number, Decimal)
     assert abs(len(constrained_number.as_tuple().digits) - abs(int(constrained_number.as_tuple().exponent))) <= 2
+
+
+@pytest.mark.parametrize(
+    "numeric",
+    (
+        Numeric(),
+        Numeric(precision=4),
+        Numeric(precision=4, scale=0),
+        Numeric(precision=4, scale=2),
+    ),
+)
+def test_numeric_field(numeric: Numeric) -> None:
+    _registry = registry()
+
+    class Base(metaclass=DeclarativeMeta):
+        __abstract__ = True
+        __allow_unmapped__ = True
+
+        registry = _registry
+        metadata = _registry.metadata
+
+    class NumericModel(Base):
+        __tablename__ = "numerics"
+
+        id: Any = Column(Integer(), primary_key=True)
+        numeric_field: Any = Column(numeric, nullable=False)
+
+    class NumericModelFactory(SQLAlchemyFactory[NumericModel]): ...
+
+    result = NumericModelFactory.get_model_fields()[1]
+    assert result.annotation is Decimal
+    if constraints := result.constraints:
+        assert constraints.get("max_digits") == numeric.precision
+        assert constraints.get("decimal_places") == numeric.scale