Search code examples
pythonvalidationpydanticsqlmodelpydantic-v2

Can't get validation to work for non-table SQLModel


I'm unable to get Pydantic validation working with and without SQLModel, and I'm unsure what I'm doing wrong. I'm unsure what I'm missing: the punchline is: validation doesn't seem to happen when I'd expect it to. So far the only q&a I've seen on this topic centers upon how this exact behavior is desirable for table=True models, which is not my problem here; I haven't seen anyone having quite the issue I'm having yet.

I have a simplistic (non-table) model which I've boiled down to one field for testing, and I have a simplistic regex for validation.

LOGIN_NAME_REGEX = r"^[a-z0-9_]{1,25}$"

class User(SQLModel, table=False):  # explicitly set table because it's been a long weekend
    login_name: str

Initially, I tried using constr as that used to do it (albeit with regex instead of pattern as the keyword) but no longer seems to work. Honestly seems like it's being passed over entirely. Other folks who've run into this seem to be doing this with the table=True model, but I'm not doing that; this behavior is happening with the base model that the table model inherits from.

LOGIN_NAME_REGEX = r"^[a-z0-9_]{1,25}$"

class User(SQLModel, table=False):
    login_name: constr(pattern=LOGIN_NAME_REGEX)

also:

class User(SQLModel, table=False):
    login_name: str = Field(..., regex=TWITCH_LOGIN_NAME_REGEX, index=True)

I've been up and down the docs page for this and tried implementing several alternative strategies with no success.

So far I've tried, with no success:

  • Pydantic's constr which doesn't seem to have any effect
  • a custom validator using @validator, which seems like it's never called.
  • a custom validator using @field_validator, which seems like it's never called.
  • an approximation of using WrapValidator from the docs

Here's an example of my efforts:

def matches_regex(value: Any, pattern: str) -> str:
    """Check if the given value matches the provided regex pattern."""
    print(">>> In the matcher", flush=True)
    if not isinstance(value, str) or not bool(re.match(pattern, value)):
        raise ValueError
    return value

class User(SQLModel, table=False):
    login_name: str

    @field_validator("login_name")
    @classmethod
    def validate_login_name(cls, v: str) -> str:
        print(f">>> In the field validator with {v=}", flush=True)
        return matches_regex(v, pattern=LOGIN_NAME_REGEX)

That print in matches_regex not printing was a smoking gun to me; I've made tiny unit tests that print before and after and those do print, so it's not a flushing issue; that said I added flush=True anyway because I'm running out of ideas.

I built up a tiny unit test so I can rapidly fire it off to see each change, maybe the error is there and my checking is incorrect?

def test_create_invalid_username():
    invalid_data = {
        "login_name": "invalid!username!",  # Invalid due to exclamation marks
    }

    with pytest.raises((ValueError, ValidationError)):
        print(f"\n > > > {invalid_data=} < < < \n")  # this print fires
        vs = User(**invalid_data)
        print(f"\n > > > {vs.login_name=} < < < \n")  # this print also fires

    assert vs.login_name != invalid_data["login_name"]  # sanity check

Here's a sample test result:

 > > > invalid_data={'login_name': 'invalid!username!'} < < <


 > > > vs.login_name='invalid!username!' < < <

FAILED tests/validation/test_user_validation.py::test_create_invalid_username - Failed: DID NOT RAISE (<class 'ValueError'>, <class 'pydantic_core._pydantic_core.ValidationError'>)

I also tried my hand at WrapValidator parroting the sample in the docs as best I could, but - again - the result was no result; it seems like validation just isn't happening, which is the entire reason I want to use Pydantic.

I'm still combing over docs and seeking examples on GitHub and, frustratingly, to my eye my code matches the examples I've seen. My versions of SQLModel and Pydantic are up-to-date. I've tried using just Pydantic's BaseModel and the results are precisely the same: those prints don't happen, the field_validator and matches_regex methods aren't being called. I'm at a loss for what I've done wrong, but clearly I'm doing something wrong.

I deeply appreciate additional eyes on the problem; thank you in advance for your time and effort.

EDIT: Added my attempt to use Field-based validation (Same non-result).


Solution

  • I found my mistake, it's 100% P.E.B.C.A.K. so I'm here to eat my crow.

    Validation was 100% working; the issue was that the failing test was decorated with pytest.mark.parametrize and I was 100% misreading the error reporting after in my fatigue. Today I sat down and hammered out a series of unit tests to try and figure out where the error was and...found none, validation was working the entire time. Here's the unit tests, for posterity.

    #/tests/validation/test_sanity.py
    from uuid import uuid4
    
    import pytest
    from pydantic import BaseModel, ValidationError, field_validator
    from sqlmodel import Field, SQLModel
    
    from app.models. import UserBase, UserCreate
    
    
    class TestSubject(BaseModel):
        name: str
    
        @field_validator("name")
        def validate_name(cls, v: str) -> str:
            if not isinstance(v, str):
                raise ValueError("nope")
            return v
    
    
    class TestModel(SQLModel):
        name: str
    
        @field_validator("name")
        def validate_name(cls, v: str) -> str:
            if not isinstance(v, str):
                raise ValueError("nope")
            return v
    
    
    class TestModelWithFieldAttrib(SQLModel):
        name: str = Field(...)
    
        @field_validator("name")
        def validate_name(cls, v: str) -> str:
            if not isinstance(v, str):
                raise ValueError("nope")
            return v
    
    
    def test_base_model():
        TestSubject(name="hello")
    
        with pytest.raises((ValidationError, ValueError)):
            TestSubject(name=123)
    
    
    def test_sqlmodel():
        TestModel(name="ohai")
    
        with pytest.raises((ValidationError, ValueError)):
            TestModel(name=321)
    
    
    def test_sqlmodel_with_attributes():
        TestModelWithFieldAttrib(name="ohai")
    
        with pytest.raises((ValidationError, ValueError)):
            TestModelWithFieldAttrib(name=321)
    
    
    def test_user_base():
        UserBase(login_name="howdy")
    
        with pytest.raises((ValidationError, ValueError)):
            UserBase(login_name=123)
    
        with pytest.raises((ValidationError, ValueError)):
            UserBase(viewer_login_name="hilo")
    
    
    def test_user_create():
        UserCreate(login_name="yessir")
    
        with pytest.raises((ValidationError, ValueError)):
            UserCreate(viewer_login_name=123)
    
        with pytest.raises((ValidationError, ValueError)):
            UserCreate(login_name="weeee")
    
        with pytest.raises((ValidationError, ValueError)):
            UserCreate(**{"login_name": "nope!!")
    
        # name of just numbers is technically legal, this wasn't raising an error
        UserCreate(**{"login_name": "123"})
    

    There was a parameterized testcase for username 123 which I had marked as expected to fail, and THAT was the error.

    Again, thanks if you read this. Sometimes we just need to sleep on it.