support optional annotated field with default value - #862

Closed
proever wants to merge 4 commits into
fastapi:mainfrom
proever:annotated-type-support
Closed

support optional annotated field with default value#862
proever wants to merge 4 commits into
fastapi:mainfrom
proever:annotated-type-support

Conversation

@proever

Copy link
Copy Markdown

Currently, it is not possible to specify a SQLModel with an Optional complex type, such as a Decimal, as follows:

fromdecimalimportDecimalfromtypingimportAnnotatedfromsqlmodelimportField, SQLModelclassPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
price: Annotated[Decimal, Field(decimal_places=2, max_digits=9)] |None=None

Doing so results in the following error (see #67, #312) in get_sqlalchemy_type:

TypeError: issubclass() arg 1 must be a class

This PR attempts to fix this issue by adding a check in get_sqlalchemy_type for whether the type of the Field is a typing_extensions.AnnotatedAlias. If it is, instead of using the class of the AnnotatedAlias for the following comparisons (which results in the above error), it uses AnnotatedAlias.__origin__. Similarly, it infers the metadata from AnnotatedAlias.__metadata__ instead of the AnnotatedAlias itself.

In my testing, this approach seems to work well. I also added a simple test here that checks whether restrictions on an optional annotated field are enforced on the database side. It could probably be improved or extended.

@proever

Copy link
Copy Markdown
Author

looks like some (many) tests are failing, I'll try to fix them!

Is this feature something that should work with Pydantic v1 too? I'm not too familiar with it.

@bootc

bootc commented Jun 4, 2024

Copy link
Copy Markdown

I've just hit exactly this problem; is there any chance you could update your MR please @proever?

@alejsdevalejsdev added the feature New feature or request label Jul 12, 2024
@msftcangoblowm

msftcangoblowm commented Jul 7, 2025

Copy link
Copy Markdown

Would argue this PR fixes a bug and is not a FR

pydantic validators is a core reason for the existence of both pydantic and SQLModel.

SQLModel currently only supports validators using decorator-pattern

Which is neither DRY nor reusable

SQLModel does not support reusable Validators using annotated-pattern

This PR would fix this.

Example pydantic validator

def validator_is_dead_or_cursed(value: int) -> int:
if value > 120:
msg_warn = (
f"{value!s} indicates this person very likely dead "
"or bibically cursed"
)
raise ValueError(msg_warn)
return value
NotDead = Annotated[int, pydantic.AfterValidator(validator_is_dead_or_cursed)]

Apply to Field

age: Optional[NotDead] = Field(default=None, gt=0)

or

age: Annotated[Optional[int], pydantic.AfterValidator(validator_is_dead_or_cursed)] = Field(default=None, gt=0)

Equivalent to age: Optional[int] = Field(default=None, gt=0, le=120)

@YuriiMotovYuriiMotov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As I see it, this PR is basically about fixing the following scenario:

MyType: TypeAlias=Annotated[str, Field(max_length=5)]
classPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
working: MyType# VARCHAR(5)not_working: Optional[MyType] =None# VARCHAR(255)

You are going to open a Pandora's box :)
I think this PR only handles one specific case, but in general we need to merge all nested Field annotations and handle conflicts. This seems to be a lot of work and quite error-prone.
Although, it would be nice to support this one day.

I suggest we skip it for now and get back to this feature later.

Seems to be related: #1281


As for initial code example, you can make it working by moving | None inside Annotated:

price: Annotated[Decimal|None, Field(decimal_places=2, max_digits=9)] =None

@proever, thanks for your interest and efforts!

Comment threadsqlmodel/main.py
type_ = get_sa_type_from_field(field)
metadata = get_field_metadata(field)
if isinstance(type_, _AnnotatedAlias):
class_to_compare = type_.__origin__

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Rename to use_type?

@tiangolo

Copy link
Copy Markdown
Member

Thanks for the interest!

And thanks a lot for the help @YuriiMotov 🙌

I haven't checked the code in this PR, but I was trying to run the original example and it seems to work, so this might have been solved at some point.

fromdecimalimportDecimalfromtypingimportAnnotatedfromsqlmodelimportField, SQLModelclassPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
price: Annotated[Decimal, Field(decimal_places=2, max_digits=9)] |None=Noneprice=PriceModel()
print(price)

Given that, and as the tests are failing, I'll pass on this one for now. If you have any other issue, please create a new discussion with the example to replicate it. 🤓

For now, I'll close this one. Thanks! 🚀

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

featureNew feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@proever@bootc@msftcangoblowm@tiangolo@YuriiMotov@Csaba1205@alejsdev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

support optional annotated field with default value - #862

Closed
proever wants to merge 4 commits into
fastapi:mainfrom
proever:annotated-type-support
Closed

support optional annotated field with default value#862
proever wants to merge 4 commits into
fastapi:mainfrom
proever:annotated-type-support

Conversation

@proever

Copy link
Copy Markdown

Currently, it is not possible to specify a SQLModel with an Optional complex type, such as a Decimal, as follows:

fromdecimalimportDecimalfromtypingimportAnnotatedfromsqlmodelimportField, SQLModelclassPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
price: Annotated[Decimal, Field(decimal_places=2, max_digits=9)] |None=None

Doing so results in the following error (see #67, #312) in get_sqlalchemy_type:

TypeError: issubclass() arg 1 must be a class

This PR attempts to fix this issue by adding a check in get_sqlalchemy_type for whether the type of the Field is a typing_extensions.AnnotatedAlias. If it is, instead of using the class of the AnnotatedAlias for the following comparisons (which results in the above error), it uses AnnotatedAlias.__origin__. Similarly, it infers the metadata from AnnotatedAlias.__metadata__ instead of the AnnotatedAlias itself.

In my testing, this approach seems to work well. I also added a simple test here that checks whether restrictions on an optional annotated field are enforced on the database side. It could probably be improved or extended.

@proever

Copy link
Copy Markdown
Author

looks like some (many) tests are failing, I'll try to fix them!

Is this feature something that should work with Pydantic v1 too? I'm not too familiar with it.

@bootc

bootc commented Jun 4, 2024

Copy link
Copy Markdown

I've just hit exactly this problem; is there any chance you could update your MR please @proever?

@alejsdevalejsdev added the feature New feature or request label Jul 12, 2024
@msftcangoblowm

msftcangoblowm commented Jul 7, 2025

Copy link
Copy Markdown

Would argue this PR fixes a bug and is not a FR

pydantic validators is a core reason for the existence of both pydantic and SQLModel.

SQLModel currently only supports validators using decorator-pattern

Which is neither DRY nor reusable

SQLModel does not support reusable Validators using annotated-pattern

This PR would fix this.

Example pydantic validator

def validator_is_dead_or_cursed(value: int) -> int:
if value > 120:
msg_warn = (
f"{value!s} indicates this person very likely dead "
"or bibically cursed"
)
raise ValueError(msg_warn)
return value
NotDead = Annotated[int, pydantic.AfterValidator(validator_is_dead_or_cursed)]

Apply to Field

age: Optional[NotDead] = Field(default=None, gt=0)

or

age: Annotated[Optional[int], pydantic.AfterValidator(validator_is_dead_or_cursed)] = Field(default=None, gt=0)

Equivalent to age: Optional[int] = Field(default=None, gt=0, le=120)

@YuriiMotovYuriiMotov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As I see it, this PR is basically about fixing the following scenario:

MyType: TypeAlias=Annotated[str, Field(max_length=5)]
classPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
working: MyType# VARCHAR(5)not_working: Optional[MyType] =None# VARCHAR(255)

You are going to open a Pandora's box :)
I think this PR only handles one specific case, but in general we need to merge all nested Field annotations and handle conflicts. This seems to be a lot of work and quite error-prone.
Although, it would be nice to support this one day.

I suggest we skip it for now and get back to this feature later.

Seems to be related: #1281


As for initial code example, you can make it working by moving | None inside Annotated:

price: Annotated[Decimal|None, Field(decimal_places=2, max_digits=9)] =None

@proever, thanks for your interest and efforts!

Comment threadsqlmodel/main.py
type_ = get_sa_type_from_field(field)
metadata = get_field_metadata(field)
if isinstance(type_, _AnnotatedAlias):
class_to_compare = type_.__origin__

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Rename to use_type?

@tiangolo

Copy link
Copy Markdown
Member

Thanks for the interest!

And thanks a lot for the help @YuriiMotov 🙌

I haven't checked the code in this PR, but I was trying to run the original example and it seems to work, so this might have been solved at some point.

fromdecimalimportDecimalfromtypingimportAnnotatedfromsqlmodelimportField, SQLModelclassPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
price: Annotated[Decimal, Field(decimal_places=2, max_digits=9)] |None=Noneprice=PriceModel()
print(price)

Given that, and as the tests are failing, I'll pass on this one for now. If you have any other issue, please create a new discussion with the example to replicate it. 🤓

For now, I'll close this one. Thanks! 🚀

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

featureNew feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@proever@bootc@msftcangoblowm@tiangolo@YuriiMotov@Csaba1205@alejsdev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

support optional annotated field with default value - #862

Closed
proever wants to merge 4 commits into
fastapi:mainfrom
proever:annotated-type-support
Closed

support optional annotated field with default value#862
proever wants to merge 4 commits into
fastapi:mainfrom
proever:annotated-type-support

Conversation

@proever

Copy link
Copy Markdown

Currently, it is not possible to specify a SQLModel with an Optional complex type, such as a Decimal, as follows:

fromdecimalimportDecimalfromtypingimportAnnotatedfromsqlmodelimportField, SQLModelclassPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
price: Annotated[Decimal, Field(decimal_places=2, max_digits=9)] |None=None

Doing so results in the following error (see #67, #312) in get_sqlalchemy_type:

TypeError: issubclass() arg 1 must be a class

This PR attempts to fix this issue by adding a check in get_sqlalchemy_type for whether the type of the Field is a typing_extensions.AnnotatedAlias. If it is, instead of using the class of the AnnotatedAlias for the following comparisons (which results in the above error), it uses AnnotatedAlias.__origin__. Similarly, it infers the metadata from AnnotatedAlias.__metadata__ instead of the AnnotatedAlias itself.

In my testing, this approach seems to work well. I also added a simple test here that checks whether restrictions on an optional annotated field are enforced on the database side. It could probably be improved or extended.

@proever

Copy link
Copy Markdown
Author

looks like some (many) tests are failing, I'll try to fix them!

Is this feature something that should work with Pydantic v1 too? I'm not too familiar with it.

@bootc

bootc commented Jun 4, 2024

Copy link
Copy Markdown

I've just hit exactly this problem; is there any chance you could update your MR please @proever?

@alejsdevalejsdev added the feature New feature or request label Jul 12, 2024
@msftcangoblowm

msftcangoblowm commented Jul 7, 2025

Copy link
Copy Markdown

Would argue this PR fixes a bug and is not a FR

pydantic validators is a core reason for the existence of both pydantic and SQLModel.

SQLModel currently only supports validators using decorator-pattern

Which is neither DRY nor reusable

SQLModel does not support reusable Validators using annotated-pattern

This PR would fix this.

Example pydantic validator

def validator_is_dead_or_cursed(value: int) -> int:
if value > 120:
msg_warn = (
f"{value!s} indicates this person very likely dead "
"or bibically cursed"
)
raise ValueError(msg_warn)
return value
NotDead = Annotated[int, pydantic.AfterValidator(validator_is_dead_or_cursed)]

Apply to Field

age: Optional[NotDead] = Field(default=None, gt=0)

or

age: Annotated[Optional[int], pydantic.AfterValidator(validator_is_dead_or_cursed)] = Field(default=None, gt=0)

Equivalent to age: Optional[int] = Field(default=None, gt=0, le=120)

@YuriiMotovYuriiMotov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As I see it, this PR is basically about fixing the following scenario:

MyType: TypeAlias=Annotated[str, Field(max_length=5)]
classPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
working: MyType# VARCHAR(5)not_working: Optional[MyType] =None# VARCHAR(255)

You are going to open a Pandora's box :)
I think this PR only handles one specific case, but in general we need to merge all nested Field annotations and handle conflicts. This seems to be a lot of work and quite error-prone.
Although, it would be nice to support this one day.

I suggest we skip it for now and get back to this feature later.

Seems to be related: #1281


As for initial code example, you can make it working by moving | None inside Annotated:

price: Annotated[Decimal|None, Field(decimal_places=2, max_digits=9)] =None

@proever, thanks for your interest and efforts!

Comment threadsqlmodel/main.py
type_ = get_sa_type_from_field(field)
metadata = get_field_metadata(field)
if isinstance(type_, _AnnotatedAlias):
class_to_compare = type_.__origin__

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Rename to use_type?

@tiangolo

Copy link
Copy Markdown
Member

Thanks for the interest!

And thanks a lot for the help @YuriiMotov 🙌

I haven't checked the code in this PR, but I was trying to run the original example and it seems to work, so this might have been solved at some point.

fromdecimalimportDecimalfromtypingimportAnnotatedfromsqlmodelimportField, SQLModelclassPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
price: Annotated[Decimal, Field(decimal_places=2, max_digits=9)] |None=Noneprice=PriceModel()
print(price)

Given that, and as the tests are failing, I'll pass on this one for now. If you have any other issue, please create a new discussion with the example to replicate it. 🤓

For now, I'll close this one. Thanks! 🚀

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

featureNew feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@proever@bootc@msftcangoblowm@tiangolo@YuriiMotov@Csaba1205@alejsdev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

support optional annotated field with default value - #862

Closed
proever wants to merge 4 commits into
fastapi:mainfrom
proever:annotated-type-support
Closed

support optional annotated field with default value#862
proever wants to merge 4 commits into
fastapi:mainfrom
proever:annotated-type-support

Conversation

@proever

Copy link
Copy Markdown

Currently, it is not possible to specify a SQLModel with an Optional complex type, such as a Decimal, as follows:

fromdecimalimportDecimalfromtypingimportAnnotatedfromsqlmodelimportField, SQLModelclassPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
price: Annotated[Decimal, Field(decimal_places=2, max_digits=9)] |None=None

Doing so results in the following error (see #67, #312) in get_sqlalchemy_type:

TypeError: issubclass() arg 1 must be a class

This PR attempts to fix this issue by adding a check in get_sqlalchemy_type for whether the type of the Field is a typing_extensions.AnnotatedAlias. If it is, instead of using the class of the AnnotatedAlias for the following comparisons (which results in the above error), it uses AnnotatedAlias.__origin__. Similarly, it infers the metadata from AnnotatedAlias.__metadata__ instead of the AnnotatedAlias itself.

In my testing, this approach seems to work well. I also added a simple test here that checks whether restrictions on an optional annotated field are enforced on the database side. It could probably be improved or extended.

@proever

Copy link
Copy Markdown
Author

looks like some (many) tests are failing, I'll try to fix them!

Is this feature something that should work with Pydantic v1 too? I'm not too familiar with it.

@bootc

bootc commented Jun 4, 2024

Copy link
Copy Markdown

I've just hit exactly this problem; is there any chance you could update your MR please @proever?

@alejsdevalejsdev added the feature New feature or request label Jul 12, 2024
@msftcangoblowm

msftcangoblowm commented Jul 7, 2025

Copy link
Copy Markdown

Would argue this PR fixes a bug and is not a FR

pydantic validators is a core reason for the existence of both pydantic and SQLModel.

SQLModel currently only supports validators using decorator-pattern

Which is neither DRY nor reusable

SQLModel does not support reusable Validators using annotated-pattern

This PR would fix this.

Example pydantic validator

def validator_is_dead_or_cursed(value: int) -> int:
if value > 120:
msg_warn = (
f"{value!s} indicates this person very likely dead "
"or bibically cursed"
)
raise ValueError(msg_warn)
return value
NotDead = Annotated[int, pydantic.AfterValidator(validator_is_dead_or_cursed)]

Apply to Field

age: Optional[NotDead] = Field(default=None, gt=0)

or

age: Annotated[Optional[int], pydantic.AfterValidator(validator_is_dead_or_cursed)] = Field(default=None, gt=0)

Equivalent to age: Optional[int] = Field(default=None, gt=0, le=120)

@YuriiMotovYuriiMotov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As I see it, this PR is basically about fixing the following scenario:

MyType: TypeAlias=Annotated[str, Field(max_length=5)]
classPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
working: MyType# VARCHAR(5)not_working: Optional[MyType] =None# VARCHAR(255)

You are going to open a Pandora's box :)
I think this PR only handles one specific case, but in general we need to merge all nested Field annotations and handle conflicts. This seems to be a lot of work and quite error-prone.
Although, it would be nice to support this one day.

I suggest we skip it for now and get back to this feature later.

Seems to be related: #1281


As for initial code example, you can make it working by moving | None inside Annotated:

price: Annotated[Decimal|None, Field(decimal_places=2, max_digits=9)] =None

@proever, thanks for your interest and efforts!

Comment threadsqlmodel/main.py
type_ = get_sa_type_from_field(field)
metadata = get_field_metadata(field)
if isinstance(type_, _AnnotatedAlias):
class_to_compare = type_.__origin__

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Rename to use_type?

@tiangolo

Copy link
Copy Markdown
Member

Thanks for the interest!

And thanks a lot for the help @YuriiMotov 🙌

I haven't checked the code in this PR, but I was trying to run the original example and it seems to work, so this might have been solved at some point.

fromdecimalimportDecimalfromtypingimportAnnotatedfromsqlmodelimportField, SQLModelclassPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
price: Annotated[Decimal, Field(decimal_places=2, max_digits=9)] |None=Noneprice=PriceModel()
print(price)

Given that, and as the tests are failing, I'll pass on this one for now. If you have any other issue, please create a new discussion with the example to replicate it. 🤓

For now, I'll close this one. Thanks! 🚀

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

featureNew feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@proever@bootc@msftcangoblowm@tiangolo@YuriiMotov@Csaba1205@alejsdev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

support optional annotated field with default value - #862

Closed
proever wants to merge 4 commits into
fastapi:mainfrom
proever:annotated-type-support
Closed

support optional annotated field with default value#862
proever wants to merge 4 commits into
fastapi:mainfrom
proever:annotated-type-support

Conversation

@proever

Copy link
Copy Markdown

Currently, it is not possible to specify a SQLModel with an Optional complex type, such as a Decimal, as follows:

fromdecimalimportDecimalfromtypingimportAnnotatedfromsqlmodelimportField, SQLModelclassPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
price: Annotated[Decimal, Field(decimal_places=2, max_digits=9)] |None=None

Doing so results in the following error (see #67, #312) in get_sqlalchemy_type:

TypeError: issubclass() arg 1 must be a class

This PR attempts to fix this issue by adding a check in get_sqlalchemy_type for whether the type of the Field is a typing_extensions.AnnotatedAlias. If it is, instead of using the class of the AnnotatedAlias for the following comparisons (which results in the above error), it uses AnnotatedAlias.__origin__. Similarly, it infers the metadata from AnnotatedAlias.__metadata__ instead of the AnnotatedAlias itself.

In my testing, this approach seems to work well. I also added a simple test here that checks whether restrictions on an optional annotated field are enforced on the database side. It could probably be improved or extended.

@proever

Copy link
Copy Markdown
Author

looks like some (many) tests are failing, I'll try to fix them!

Is this feature something that should work with Pydantic v1 too? I'm not too familiar with it.

@bootc

bootc commented Jun 4, 2024

Copy link
Copy Markdown

I've just hit exactly this problem; is there any chance you could update your MR please @proever?

@alejsdevalejsdev added the feature New feature or request label Jul 12, 2024
@msftcangoblowm

msftcangoblowm commented Jul 7, 2025

Copy link
Copy Markdown

Would argue this PR fixes a bug and is not a FR

pydantic validators is a core reason for the existence of both pydantic and SQLModel.

SQLModel currently only supports validators using decorator-pattern

Which is neither DRY nor reusable

SQLModel does not support reusable Validators using annotated-pattern

This PR would fix this.

Example pydantic validator

def validator_is_dead_or_cursed(value: int) -> int:
if value > 120:
msg_warn = (
f"{value!s} indicates this person very likely dead "
"or bibically cursed"
)
raise ValueError(msg_warn)
return value
NotDead = Annotated[int, pydantic.AfterValidator(validator_is_dead_or_cursed)]

Apply to Field

age: Optional[NotDead] = Field(default=None, gt=0)

or

age: Annotated[Optional[int], pydantic.AfterValidator(validator_is_dead_or_cursed)] = Field(default=None, gt=0)

Equivalent to age: Optional[int] = Field(default=None, gt=0, le=120)

@YuriiMotovYuriiMotov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As I see it, this PR is basically about fixing the following scenario:

MyType: TypeAlias=Annotated[str, Field(max_length=5)]
classPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
working: MyType# VARCHAR(5)not_working: Optional[MyType] =None# VARCHAR(255)

You are going to open a Pandora's box :)
I think this PR only handles one specific case, but in general we need to merge all nested Field annotations and handle conflicts. This seems to be a lot of work and quite error-prone.
Although, it would be nice to support this one day.

I suggest we skip it for now and get back to this feature later.

Seems to be related: #1281


As for initial code example, you can make it working by moving | None inside Annotated:

price: Annotated[Decimal|None, Field(decimal_places=2, max_digits=9)] =None

@proever, thanks for your interest and efforts!

Comment threadsqlmodel/main.py
type_ = get_sa_type_from_field(field)
metadata = get_field_metadata(field)
if isinstance(type_, _AnnotatedAlias):
class_to_compare = type_.__origin__

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Rename to use_type?

@tiangolo

Copy link
Copy Markdown
Member

Thanks for the interest!

And thanks a lot for the help @YuriiMotov 🙌

I haven't checked the code in this PR, but I was trying to run the original example and it seems to work, so this might have been solved at some point.

fromdecimalimportDecimalfromtypingimportAnnotatedfromsqlmodelimportField, SQLModelclassPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
price: Annotated[Decimal, Field(decimal_places=2, max_digits=9)] |None=Noneprice=PriceModel()
print(price)

Given that, and as the tests are failing, I'll pass on this one for now. If you have any other issue, please create a new discussion with the example to replicate it. 🤓

For now, I'll close this one. Thanks! 🚀

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

featureNew feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@proever@bootc@msftcangoblowm@tiangolo@YuriiMotov@Csaba1205@alejsdev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

support optional annotated field with default value - #862

Closed
proever wants to merge 4 commits into
fastapi:mainfrom
proever:annotated-type-support
Closed

support optional annotated field with default value#862
proever wants to merge 4 commits into
fastapi:mainfrom
proever:annotated-type-support

Conversation

@proever

Copy link
Copy Markdown

Currently, it is not possible to specify a SQLModel with an Optional complex type, such as a Decimal, as follows:

fromdecimalimportDecimalfromtypingimportAnnotatedfromsqlmodelimportField, SQLModelclassPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
price: Annotated[Decimal, Field(decimal_places=2, max_digits=9)] |None=None

Doing so results in the following error (see #67, #312) in get_sqlalchemy_type:

TypeError: issubclass() arg 1 must be a class

This PR attempts to fix this issue by adding a check in get_sqlalchemy_type for whether the type of the Field is a typing_extensions.AnnotatedAlias. If it is, instead of using the class of the AnnotatedAlias for the following comparisons (which results in the above error), it uses AnnotatedAlias.__origin__. Similarly, it infers the metadata from AnnotatedAlias.__metadata__ instead of the AnnotatedAlias itself.

In my testing, this approach seems to work well. I also added a simple test here that checks whether restrictions on an optional annotated field are enforced on the database side. It could probably be improved or extended.

@proever

Copy link
Copy Markdown
Author

looks like some (many) tests are failing, I'll try to fix them!

Is this feature something that should work with Pydantic v1 too? I'm not too familiar with it.

@bootc

bootc commented Jun 4, 2024

Copy link
Copy Markdown

I've just hit exactly this problem; is there any chance you could update your MR please @proever?

@alejsdevalejsdev added the feature New feature or request label Jul 12, 2024
@msftcangoblowm

msftcangoblowm commented Jul 7, 2025

Copy link
Copy Markdown

Would argue this PR fixes a bug and is not a FR

pydantic validators is a core reason for the existence of both pydantic and SQLModel.

SQLModel currently only supports validators using decorator-pattern

Which is neither DRY nor reusable

SQLModel does not support reusable Validators using annotated-pattern

This PR would fix this.

Example pydantic validator

def validator_is_dead_or_cursed(value: int) -> int:
if value > 120:
msg_warn = (
f"{value!s} indicates this person very likely dead "
"or bibically cursed"
)
raise ValueError(msg_warn)
return value
NotDead = Annotated[int, pydantic.AfterValidator(validator_is_dead_or_cursed)]

Apply to Field

age: Optional[NotDead] = Field(default=None, gt=0)

or

age: Annotated[Optional[int], pydantic.AfterValidator(validator_is_dead_or_cursed)] = Field(default=None, gt=0)

Equivalent to age: Optional[int] = Field(default=None, gt=0, le=120)

@YuriiMotovYuriiMotov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As I see it, this PR is basically about fixing the following scenario:

MyType: TypeAlias=Annotated[str, Field(max_length=5)]
classPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
working: MyType# VARCHAR(5)not_working: Optional[MyType] =None# VARCHAR(255)

You are going to open a Pandora's box :)
I think this PR only handles one specific case, but in general we need to merge all nested Field annotations and handle conflicts. This seems to be a lot of work and quite error-prone.
Although, it would be nice to support this one day.

I suggest we skip it for now and get back to this feature later.

Seems to be related: #1281


As for initial code example, you can make it working by moving | None inside Annotated:

price: Annotated[Decimal|None, Field(decimal_places=2, max_digits=9)] =None

@proever, thanks for your interest and efforts!

Comment threadsqlmodel/main.py
type_ = get_sa_type_from_field(field)
metadata = get_field_metadata(field)
if isinstance(type_, _AnnotatedAlias):
class_to_compare = type_.__origin__

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Rename to use_type?

@tiangolo

Copy link
Copy Markdown
Member

Thanks for the interest!

And thanks a lot for the help @YuriiMotov 🙌

I haven't checked the code in this PR, but I was trying to run the original example and it seems to work, so this might have been solved at some point.

fromdecimalimportDecimalfromtypingimportAnnotatedfromsqlmodelimportField, SQLModelclassPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
price: Annotated[Decimal, Field(decimal_places=2, max_digits=9)] |None=Noneprice=PriceModel()
print(price)

Given that, and as the tests are failing, I'll pass on this one for now. If you have any other issue, please create a new discussion with the example to replicate it. 🤓

For now, I'll close this one. Thanks! 🚀

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

featureNew feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@proever@bootc@msftcangoblowm@tiangolo@YuriiMotov@Csaba1205@alejsdev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

support optional annotated field with default value - #862

Closed
proever wants to merge 4 commits into
fastapi:mainfrom
proever:annotated-type-support
Closed

support optional annotated field with default value#862
proever wants to merge 4 commits into
fastapi:mainfrom
proever:annotated-type-support

Conversation

@proever

Copy link
Copy Markdown

Currently, it is not possible to specify a SQLModel with an Optional complex type, such as a Decimal, as follows:

fromdecimalimportDecimalfromtypingimportAnnotatedfromsqlmodelimportField, SQLModelclassPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
price: Annotated[Decimal, Field(decimal_places=2, max_digits=9)] |None=None

Doing so results in the following error (see #67, #312) in get_sqlalchemy_type:

TypeError: issubclass() arg 1 must be a class

This PR attempts to fix this issue by adding a check in get_sqlalchemy_type for whether the type of the Field is a typing_extensions.AnnotatedAlias. If it is, instead of using the class of the AnnotatedAlias for the following comparisons (which results in the above error), it uses AnnotatedAlias.__origin__. Similarly, it infers the metadata from AnnotatedAlias.__metadata__ instead of the AnnotatedAlias itself.

In my testing, this approach seems to work well. I also added a simple test here that checks whether restrictions on an optional annotated field are enforced on the database side. It could probably be improved or extended.

@proever

Copy link
Copy Markdown
Author

looks like some (many) tests are failing, I'll try to fix them!

Is this feature something that should work with Pydantic v1 too? I'm not too familiar with it.

@bootc

bootc commented Jun 4, 2024

Copy link
Copy Markdown

I've just hit exactly this problem; is there any chance you could update your MR please @proever?

@alejsdevalejsdev added the feature New feature or request label Jul 12, 2024
@msftcangoblowm

msftcangoblowm commented Jul 7, 2025

Copy link
Copy Markdown

Would argue this PR fixes a bug and is not a FR

pydantic validators is a core reason for the existence of both pydantic and SQLModel.

SQLModel currently only supports validators using decorator-pattern

Which is neither DRY nor reusable

SQLModel does not support reusable Validators using annotated-pattern

This PR would fix this.

Example pydantic validator

def validator_is_dead_or_cursed(value: int) -> int:
if value > 120:
msg_warn = (
f"{value!s} indicates this person very likely dead "
"or bibically cursed"
)
raise ValueError(msg_warn)
return value
NotDead = Annotated[int, pydantic.AfterValidator(validator_is_dead_or_cursed)]

Apply to Field

age: Optional[NotDead] = Field(default=None, gt=0)

or

age: Annotated[Optional[int], pydantic.AfterValidator(validator_is_dead_or_cursed)] = Field(default=None, gt=0)

Equivalent to age: Optional[int] = Field(default=None, gt=0, le=120)

@YuriiMotovYuriiMotov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As I see it, this PR is basically about fixing the following scenario:

MyType: TypeAlias=Annotated[str, Field(max_length=5)]
classPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
working: MyType# VARCHAR(5)not_working: Optional[MyType] =None# VARCHAR(255)

You are going to open a Pandora's box :)
I think this PR only handles one specific case, but in general we need to merge all nested Field annotations and handle conflicts. This seems to be a lot of work and quite error-prone.
Although, it would be nice to support this one day.

I suggest we skip it for now and get back to this feature later.

Seems to be related: #1281


As for initial code example, you can make it working by moving | None inside Annotated:

price: Annotated[Decimal|None, Field(decimal_places=2, max_digits=9)] =None

@proever, thanks for your interest and efforts!

Comment threadsqlmodel/main.py
type_ = get_sa_type_from_field(field)
metadata = get_field_metadata(field)
if isinstance(type_, _AnnotatedAlias):
class_to_compare = type_.__origin__

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Rename to use_type?

@tiangolo

Copy link
Copy Markdown
Member

Thanks for the interest!

And thanks a lot for the help @YuriiMotov 🙌

I haven't checked the code in this PR, but I was trying to run the original example and it seems to work, so this might have been solved at some point.

fromdecimalimportDecimalfromtypingimportAnnotatedfromsqlmodelimportField, SQLModelclassPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
price: Annotated[Decimal, Field(decimal_places=2, max_digits=9)] |None=Noneprice=PriceModel()
print(price)

Given that, and as the tests are failing, I'll pass on this one for now. If you have any other issue, please create a new discussion with the example to replicate it. 🤓

For now, I'll close this one. Thanks! 🚀

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

featureNew feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@proever@bootc@msftcangoblowm@tiangolo@YuriiMotov@Csaba1205@alejsdev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

support optional annotated field with default value - #862

Closed
proever wants to merge 4 commits into
fastapi:mainfrom
proever:annotated-type-support
Closed

support optional annotated field with default value#862
proever wants to merge 4 commits into
fastapi:mainfrom
proever:annotated-type-support

Conversation

@proever

Copy link
Copy Markdown

Currently, it is not possible to specify a SQLModel with an Optional complex type, such as a Decimal, as follows:

fromdecimalimportDecimalfromtypingimportAnnotatedfromsqlmodelimportField, SQLModelclassPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
price: Annotated[Decimal, Field(decimal_places=2, max_digits=9)] |None=None

Doing so results in the following error (see #67, #312) in get_sqlalchemy_type:

TypeError: issubclass() arg 1 must be a class

This PR attempts to fix this issue by adding a check in get_sqlalchemy_type for whether the type of the Field is a typing_extensions.AnnotatedAlias. If it is, instead of using the class of the AnnotatedAlias for the following comparisons (which results in the above error), it uses AnnotatedAlias.__origin__. Similarly, it infers the metadata from AnnotatedAlias.__metadata__ instead of the AnnotatedAlias itself.

In my testing, this approach seems to work well. I also added a simple test here that checks whether restrictions on an optional annotated field are enforced on the database side. It could probably be improved or extended.

@proever

Copy link
Copy Markdown
Author

looks like some (many) tests are failing, I'll try to fix them!

Is this feature something that should work with Pydantic v1 too? I'm not too familiar with it.

@bootc

bootc commented Jun 4, 2024

Copy link
Copy Markdown

I've just hit exactly this problem; is there any chance you could update your MR please @proever?

@alejsdevalejsdev added the feature New feature or request label Jul 12, 2024
@msftcangoblowm

msftcangoblowm commented Jul 7, 2025

Copy link
Copy Markdown

Would argue this PR fixes a bug and is not a FR

pydantic validators is a core reason for the existence of both pydantic and SQLModel.

SQLModel currently only supports validators using decorator-pattern

Which is neither DRY nor reusable

SQLModel does not support reusable Validators using annotated-pattern

This PR would fix this.

Example pydantic validator

def validator_is_dead_or_cursed(value: int) -> int:
if value > 120:
msg_warn = (
f"{value!s} indicates this person very likely dead "
"or bibically cursed"
)
raise ValueError(msg_warn)
return value
NotDead = Annotated[int, pydantic.AfterValidator(validator_is_dead_or_cursed)]

Apply to Field

age: Optional[NotDead] = Field(default=None, gt=0)

or

age: Annotated[Optional[int], pydantic.AfterValidator(validator_is_dead_or_cursed)] = Field(default=None, gt=0)

Equivalent to age: Optional[int] = Field(default=None, gt=0, le=120)

@YuriiMotovYuriiMotov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As I see it, this PR is basically about fixing the following scenario:

MyType: TypeAlias=Annotated[str, Field(max_length=5)]
classPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
working: MyType# VARCHAR(5)not_working: Optional[MyType] =None# VARCHAR(255)

You are going to open a Pandora's box :)
I think this PR only handles one specific case, but in general we need to merge all nested Field annotations and handle conflicts. This seems to be a lot of work and quite error-prone.
Although, it would be nice to support this one day.

I suggest we skip it for now and get back to this feature later.

Seems to be related: #1281


As for initial code example, you can make it working by moving | None inside Annotated:

price: Annotated[Decimal|None, Field(decimal_places=2, max_digits=9)] =None

@proever, thanks for your interest and efforts!

Comment threadsqlmodel/main.py
type_ = get_sa_type_from_field(field)
metadata = get_field_metadata(field)
if isinstance(type_, _AnnotatedAlias):
class_to_compare = type_.__origin__

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Rename to use_type?

@tiangolo

Copy link
Copy Markdown
Member

Thanks for the interest!

And thanks a lot for the help @YuriiMotov 🙌

I haven't checked the code in this PR, but I was trying to run the original example and it seems to work, so this might have been solved at some point.

fromdecimalimportDecimalfromtypingimportAnnotatedfromsqlmodelimportField, SQLModelclassPriceModel(SQLModel, table=True):
id: int|None=Field(primary_key=True, default=None)
price: Annotated[Decimal, Field(decimal_places=2, max_digits=9)] |None=Noneprice=PriceModel()
print(price)

Given that, and as the tests are failing, I'll pass on this one for now. If you have any other issue, please create a new discussion with the example to replicate it. 🤓

For now, I'll close this one. Thanks! 🚀

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

featureNew feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@proever@bootc@msftcangoblowm@tiangolo@YuriiMotov@Csaba1205@alejsdev