Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

Dima-Bulavenko
Copy link
Contributor

@Dima-Bulavenko Dima-Bulavenko commented Jun 16, 2025

Change Summary

This PR adds a regular expression pattern to the JSON schema of Decimal fields to validate their string representation according to the max_digits and decimal_places parameters of the Field class.

  • Updated existing tests for Decimal fields to align with the new validation pattern.
  • Added new tests to validate the pattern across different combinations of max_digits and decimal_places.

You can run the new tests with the following command:

uv run pytest tests/test_json_schema.py::TestDecimalPattern

Related issue number

fixes #11016
fixes #11420
fixes #10867

Checklist

  • The pull request title is a good summary of the changes - it will be used in the changelog
  • Unit tests for the changes exist
  • Tests pass on CI
  • Documentation reflects the changes where applicable
  • My PR is ready to review, please add a comment including the phrase "please review" to assign reviewers

Selected Reviewer: @Viicos

@github-actions github-actions bot added the relnotes-fix Used for bugfixes. label Jun 16, 2025
Copy link
Contributor

hyperlint-ai bot commented Jun 16, 2025

PR Change Summary

Enhanced JSON schema validation for Decimal fields by introducing a regex pattern based on max_digits and decimal_places parameters.

  • Added regex pattern to JSON schema for validating Decimal fields.
  • Updated existing tests to reflect the new validation pattern.
  • Introduced new tests for various combinations of max_digits and decimal_places.

Modified Files

  • docs/concepts/json_schema.md

How can I customize these reviews?

Check out the Hyperlint AI Reviewer docs for more information on how to customize the review.

If you just want to ignore it on this PR, you can add the hyperlint-ignore label to the PR. Future changes won't trigger a Hyperlint review.

Note specifically for link checks, we only check the first 30 links in a file and we cache the results for several hours (for instance, if you just added a page, you might experience this). Our recommendation is to add hyperlint-ignore to the PR to ignore the link check for this PR.

Copy link

codspeed-hq bot commented Jun 16, 2025

CodSpeed Performance Report

Merging #11987 will not alter performance

Comparing Dima-Bulavenko:decima_constraint (f9ec7a1) with main (9452e13)

Summary

✅ 46 untouched benchmarks

Copy link
Contributor

github-actions bot commented Jun 16, 2025

Coverage report

This PR does not seem to contain any modification to coverable code.

@Dima-Bulavenko
Copy link
Contributor Author

please review - thanks!

Copy link
Member

@Viicos Viicos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution. I've checked locally and the regex seems to correctly match.

Similar to #11420, we'd have to also add patterns when max_digits xor decimal_places is None. Would you be interested in pursuing?

@Viicos Viicos added the awaiting author revision awaiting changes from the PR author label Jul 2, 2025
refactor: Update tests to match the new regex
@Dima-Bulavenko
Copy link
Contributor Author

Thanks for the contribution. I've checked locally and the regex seems to correctly match.

Similar to #11420, we'd have to also add patterns when max_digits xor decimal_places is None. Would you be interested in pursuing?

Thanks for reviewing!

It seems the pattern already handles cases where max_digits or decimal_places is None.

I've added tests to cover these scenarios:

@pytest.fixture
def get_decimal_pattern():
    def pattern(max_digits=None, decimal_places=None) -> str:
        filed = TypeAdapter(Annotated[Decimal, Field(max_digits=max_digits, decimal_places=decimal_places)])
        return filed.json_schema()['anyOf'][1]['pattern']

    return pattern

@pytest.mark.parametrize("valid_decimal", ["0.1", "0000.1", "11.1", "0011.1", "11111111.1"])
def test_decimal_pattern_max_digits_set_none(valid_decimal, get_decimal_pattern):
    max_digits = None
    decimal_places = 1
    pattern = get_decimal_pattern(max_digits=max_digits, decimal_places=decimal_places)
    
    assert re.fullmatch(pattern, valid_decimal) is not None


@pytest.mark.parametrize("valid_decimal", ["0.1", "000.1", "0.001000", "0000.001000"])
def test_decimal_pattern_decimal_places_set_none(valid_decimal, get_decimal_pattern):
    max_digits = 3
    decimal_places = None
    pattern = get_decimal_pattern(max_digits=max_digits, decimal_places=decimal_places)
    
    assert re.fullmatch(pattern, valid_decimal) is not None

@pytest.mark.parametrize("unvalid_decimal", ["0.0001", "111.1", "11.01"])
def test_decimal_pattern_with_unvalid_decimals_decimal_places_set_none(unvalid_decimal, get_decimal_pattern):
    max_digits = 3
    decimal_places = None
    pattern = get_decimal_pattern(max_digits=max_digits, decimal_places=decimal_places)
    
    assert re.fullmatch(pattern, unvalid_decimal) is None

@pytest.mark.parametrize("valid_decimal", ["11111111", "1111.11111", "0.00000001"])
def test_decimal_pattern_decimal_places_max_digits_set_none(valid_decimal, get_decimal_pattern):
    max_digits = None
    decimal_places = None
    pattern = get_decimal_pattern(max_digits=max_digits, decimal_places=decimal_places)
    
    assert re.fullmatch(pattern, valid_decimal) is not None

@Dima-Bulavenko Dima-Bulavenko requested a review from Viicos July 4, 2025 04:57
@Viicos
Copy link
Member

Viicos commented Jul 4, 2025

It seems the pattern already handles cases where max_digits or decimal_places is None.

Sorry what I said was confusing, I wanted to say that it would be nice to have simpler patterns generated for cases where max_digits xor decimal_places is None.

refactor: Delete redundunt tests.
test: Create new tests
…s are both set

The pattern incorrectly counted trailing zeros in integer strings towards max_digits.
For example, "1200" with max_digits=4 and decimal_places=2 would incorrectly match even thought pattern must allow only two integer places.
@Dima-Bulavenko
Copy link
Contributor Author

It seems the pattern already handles cases where max_digits or decimal_places is None.

Sorry what I said was confusing, I wanted to say that it would be nice to have simpler patterns generated for cases where max_digits xor decimal_places is None.

Thank you for clarifying your request.

I've updated the implementation to generate simpler patterns for each combination of max_digits and decimal_places.

Copy link
Member

@Viicos Viicos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the complexity to implement this correctly (see my comments above), I'm starting to think if we should just define a simpler regex, describing the allowed characters (something like ^(?:\+|-)?[\d.]+$ in validation mode, and ^-?[\d.]+$ in serialization mode). This regex will also allow invalid inputs (but at least we are certain valid inputs wont be disallowed).


# Case 4: Both are None (no restrictions)
else:
pattern += r'\d*\.?\d*$' # look for arbitrary integer or decimal
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This allows '.' as an input, which can't be validated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for pointing this out.
However, r'\d*\.?\d*$' is not the full pattern used for validation.

The complete pattern includes an additional regex component that prevents '.' from being a valid input:

pattern = (
r'^(?!^[-+.]*$)[+-]?0*' # check it is not empty string and not one or sequence of ".+-" characters.
)

Additionally, the edge case for '.' is covered by the following test:

@pytest.mark.parametrize('invalid_decimal', ['.', '-.', '..', '1.1.1', '0.0.0', '1..1', '-', '--'])
def test_decimal_pattern_reject_invalid_with_decimal_places_max_digits_unset(invalid_decimal, get_decimal_pattern):
pattern = get_decimal_pattern()
assert re.fullmatch(pattern, invalid_decimal) is None

)

# Case 2: Only max_digits is set
elif max_digits is not None and decimal_places is None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With max_digits set to e.g. 5, it wrongfully matches '1000.1111111', etc.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your feedback.

From my testing, the pattern appears to work correctly when max_digits=5 and the invalid value '1000.1111111' is rejected as expected.

Here is a test demonstrating this behavior:

@pytest.fixture
def get_decimal_pattern():
    def pattern(max_digits=None, decimal_places=None) -> str:
        field = TypeAdapter(Annotated[Decimal, Field(max_digits=max_digits, decimal_places=decimal_places)])
        return field.json_schema()['anyOf'][1]['pattern']
    return pattern

@pytest.mark.parametrize('invalid_decimal', ['1000.1111111'])
def test_only_max_digits_set(invalid_decimal, get_decimal_pattern):
    pattern = get_decimal_pattern(max_digits=5, decimal_places=None)
    assert re.fullmatch(pattern, invalid_decimal) is None

Let me know if there's a specific case I may have missed!

@Dima-Bulavenko
Copy link
Contributor Author

I think in validation mode, it's better to use the pattern ^[-+]?(?:\d+|\d+\.\d+)$, which allows both integers and decimals of any size. However, if we use this simple pattern, we completely lose the main purpose of this issue (#10867), which is to check for max_digits and decimal_places.

Also, the more complex regex patterns are only created in two cases:

  1. When max_digits is set, or
  2. When both max_digits and decimal_places are not None.

I believe these cases don't come up very often, but if a user sets these options, they probably want the regex pattern to actually check max_digits and decimal_places.

For serialization mode, I agree that a simple regex like ^-?(?:\d+|\d+\.\d+)$ is useful because it shows that the data can be either an integer or a decimal.

Please let me know what you think about this. I should also mention that it's possible I don't fully understand the purpose of the validation and serialization schemas or all the cases where they might be used.

@Dima-Bulavenko Dima-Bulavenko requested a review from Viicos July 8, 2025 14:56
@Viicos Viicos changed the title Add regex pattern to JSON schema for validating Decimal fields based on max_digits and decimal_places Add regex patterns to JSON schema for Decimal type Jul 11, 2025
@Viicos Viicos enabled auto-merge (squash) July 11, 2025 11:48
Copy link
Member

@Viicos Viicos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @Dima-Bulavenko, @Jack70248 I'm adding you as a co-author for the initial work.

@Viicos Viicos added relnotes-feature and removed relnotes-fix Used for bugfixes. labels Jul 11, 2025
@Viicos Viicos merged commit 4057cd2 into pydantic:main Jul 11, 2025
60 checks passed
cjwatson added a commit to cjwatson/fastapi that referenced this pull request Sep 2, 2025
`tests/test_multi_body_errors.py::test_openapi_schema` failed with
pydantic 2.12.0a1 due to
pydantic/pydantic#11987.  Since we're testing
the exact contents of the JSON schema, the easiest fix seems to be to
add version-dependent handling for this.
cjwatson added a commit to cjwatson/fastapi that referenced this pull request Sep 2, 2025
`tests/test_multi_body_errors.py::test_openapi_schema` failed with
pydantic 2.12.0a1 due to
pydantic/pydantic#11987.  Since we're testing
the exact contents of the JSON schema, the easiest fix seems to be to
add version-dependent handling for this.
cjwatson added a commit to cjwatson/fastapi that referenced this pull request Sep 7, 2025
`tests/test_multi_body_errors.py::test_openapi_schema` failed with
pydantic 2.12.0a1 due to
pydantic/pydantic#11987.  Since we're testing
the exact contents of the JSON schema, the easiest fix seems to be to
add version-dependent handling for this.
cjwatson added a commit to cjwatson/fastapi that referenced this pull request Sep 7, 2025
`tests/test_multi_body_errors.py::test_openapi_schema` failed with
pydantic 2.12.0a1 due to
pydantic/pydantic#11987.  Since we're testing
the exact contents of the JSON schema, the easiest fix seems to be to
add version-dependent handling for this.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

max_digits and decimal_places do not serialize to json_schema for decimal
2 participants