Merged
148 changes: 148 additions & 0 deletions docs/advanced/decimal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
# Decimal Numbers

In some cases you might need to be able to store decimal numbers with guarantees about the precision.

This is particularly important if you are storing things like **currencies**, **prices**, **accounts**, and others, as you would want to know that you wouldn't have rounding errors.

As an example, if you open Python and sum `1.1` + `2.2` you would expect to see `3.3`, but you will actually get `3.3000000000000003`:

```Python
>>> 1.1 + 2.2
3.3000000000000003
```

This is because of the way numbers are stored in "ones and zeros" (binary). But Python has a module and some types to have strict decimal values. You can read more about it in the official <a href="https://docs.python.org/3/library/decimal.html" class="external-link" target="_blank">Python docs for Decimal</a>.

Because databases store data in the same ways as computers (in binary), they would have the same types of issues. And because of that, they also have a special **decimal** type.

In most cases this would probably not be a problem, for example measuring views in a video, or the life bar in a videogame. But as you can imagine, this is particularly important when dealing with **money** and **finances**.

## Decimal Types

Pydantic has special support for `Decimal` types using the <a href="https://pydantic-docs.helpmanual.io/usage/types/#arguments-to-condecimal" class="external-link" target="_blank">`condecimal()` special function</a>.

!!! tip
Pydantic 1.9, that will be released soon, has improved support for `Decimal` types, without needing to use the `condecimal()` function.

But meanwhile, you can already use this feature with `condecimal()` in **SQLModel** it as it's explained here.

When you use `condecimal()` you can specify the number of digits and decimal places to support. They will be validated by Pydantic (for example when using FastAPI) and the same information will also be used for the database columns.

!!! info
For the database, **SQLModel** will use <a href="https://docs.sqlalchemy.org/en/14/core/type_basics.html#sqlalchemy.types.DECIMAL" class="external-link" target="_blank">SQLAlchemy's `DECIMAL` type</a>.

## Decimals in SQLModel

Let's say that each hero in the database will have an amount of money. We could make that field a `Decimal` type using the `condecimal()` function:

```{.python .annotate hl_lines="12" }
{!./docs_src/advanced/decimal/tutorial001.py[ln:1-12]!}

# More code here later 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

Here we are saying that `money` can have at most `5` digits with `max_digits`, **this includes the integers** (to the left of the decimal dot) **and the decimals** (to the right of the decimal dot).

We are also saying that the number of decimal places (to the right of the decimal dot) is `3`, so we can have **3 decimal digits** for these numbers in the `money` field. This means that we will have **2 digits for the integer part** and **3 digits for the decimal part**.

✅ So, for example, these are all valid numbers for the `money` field:

* `12.345`
* `12.3`
* `12`
* `1.2`
* `0.123`
* `0`

🚫 But these are all invalid numbers for that `money` field:

* `1.2345`
* This number has more than 3 decimal places.
* `123.234`
* This number has more than 5 digits in total (integer and decimal part).
* `123`
* Even though this number doesn't have any decimals, we still have 3 places saved for them, which means that we can **only use 2 places** for the **integer part**, and this number has 3 integer digits. So, the allowed number of integer digits is `max_digits` - `decimal_places` = 2.

!!! tip
Make sure you adjust the number of digits and decimal places for your own needs, in your own application. 🤓

## Create models with Decimals

When creating new models you can actually pass normal (`float`) numbers, Pydantic will automatically convert them to `Decimal` types, and **SQLModel** will store them as `Decimal` types in the database (using SQLAlchemy).

```Python hl_lines="4-6"
# Code above omitted 👆

{!./docs_src/advanced/decimal/tutorial001.py[ln:25-35]!}

# Code below omitted 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

## Select Decimal data

Then, when working with Decimal types, you can confirm that they indeed avoid those rounding errors from floats:

```Python hl_lines="15-16"
# Code above omitted 👆

{!./docs_src/advanced/decimal/tutorial001.py[ln:38-51]!}

# Code below omitted 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

## Review the results

Now if you run this, instead of printing the unexpected number `3.3000000000000003`, it prints `3.300`:

<div class="termy">

```console
$ python app.py

// Some boilerplate and previous output omitted 😉

// The type of money is Decimal('1.100')
Hero 1: id=1 secret_name='Dive Wilson' age=None name='Deadpond' money=Decimal('1.100')

// More output omitted here 🤓

// The type of money is Decimal('1.100')
Hero 2: id=3 secret_name='Tommy Sharp' age=48 name='Rusty-Man' money=Decimal('2.200')

// No rounding errors, just 3.3! 🎉
Total money: 3.300
```

</div>

!!! warning
Although Decimal types are supported and used in the Python side, not all databases support it. In particular, SQLite doesn't support decimals, so it will convert them to the same floating `NUMERIC` type it supports.

But decimals are supported by most of the other SQL databases. 🎉
10 changes: 4 additions & 6 deletions docs/advanced/index.md
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
# Advanced User Guide

The **Advanced User Guide** will be coming soon to a <del>theater</del> **documentation** near you... 😅
The **Advanced User Guide** is gradually growing, you can already read about some advanced topics.

I just have to `add` it, `commit` it, and `refresh` it. 😉
At some point it will include:

It will include:

* How to use the `async` and `await` with the async session.
* How to use `async` and `await` with the async session.
* How to run migrations.
* How to combine **SQLModel** models with SQLAlchemy.
* ...and more.
* ...and more. 🤓
Empty file.
Empty file.
61 changes: 61 additions & 0 deletions docs_src/advanced/decimal/tutorial001.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
from typing import Optional

from pydantic import condecimal
from sqlmodel import Field, Session, SQLModel, create_engine, select


class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
secret_name: str
age: Optional[int] = None
money: condecimal(max_digits=6, decimal_places=3) = Field(default=0)


sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"

engine = create_engine(sqlite_url, echo=True)


def create_db_and_tables():
SQLModel.metadata.create_all(engine)


def create_heroes():
hero_1 = Hero(name="Deadpond", secret_name="Dive Wilson", money=1.1)
hero_2 = Hero(name="Spider-Boy", secret_name="Pedro Parqueador", money=0.001)
hero_3 = Hero(name="Rusty-Man", secret_name="Tommy Sharp", age=48, money=2.2)

with Session(engine) as session:
session.add(hero_1)
session.add(hero_2)
session.add(hero_3)

session.commit()


def select_heroes():
with Session(engine) as session:
statement = select(Hero).where(Hero.name == "Deadpond")
results = session.exec(statement)
hero_1 = results.one()
print("Hero 1:", hero_1)

statement = select(Hero).where(Hero.name == "Rusty-Man")
results = session.exec(statement)
hero_2 = results.one()
print("Hero 2:", hero_2)

total_money = hero_1.money + hero_2.money
print(f"Total money: {total_money}")


def main():
create_db_and_tables()
create_heroes()
select_heroes()


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,7 @@ nav:
- tutorial/fastapi/tests.md
- Advanced User Guide:
- advanced/index.md
- advanced/decimal.md
- alternatives.md
- help.md
- contributing.md
Expand Down
5 changes: 4 additions & 1 deletion sqlmodel/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -399,7 +399,10 @@ def get_sqlachemy_type(field: ModelField) -> Any:
if issubclass(field.type_, bytes):
return LargeBinary
if issubclass(field.type_, Decimal):
return Numeric
return Numeric(
precision=getattr(field.type_, "max_digits", None),
scale=getattr(field.type_, "decimal_places", None),
)
if issubclass(field.type_, ipaddress.IPv4Address):
return AutoString
if issubclass(field.type_, ipaddress.IPv4Network):
Expand Down
Empty file.
Empty file.
44 changes: 44 additions & 0 deletions tests/test_advanced/test_decimal/test_tutorial001.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
from decimal import Decimal
from unittest.mock import patch

from sqlmodel import create_engine

from ...conftest import get_testing_print_function

expected_calls = [
[
"Hero 1:",
{
"name": "Deadpond",
"age": None,
"id": 1,
"secret_name": "Dive Wilson",
"money": Decimal("1.100"),
},
],
[
"Hero 2:",
{
"name": "Rusty-Man",
"age": 48,
"id": 3,
"secret_name": "Tommy Sharp",
"money": Decimal("2.200"),
},
],
["Total money: 3.300"],
]


def test_tutorial(clear_sqlmodel):
from docs_src.advanced.decimal import tutorial001 as mod

mod.sqlite_url = "sqlite://"
mod.engine = create_engine(mod.sqlite_url)
calls = []

new_print = get_testing_print_function(calls)

with patch("builtins.print", new=new_print):
mod.main()
assert calls == expected_calls
, '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
Merged
148 changes: 148 additions & 0 deletions docs/advanced/decimal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
# Decimal Numbers

In some cases you might need to be able to store decimal numbers with guarantees about the precision.

This is particularly important if you are storing things like **currencies**, **prices**, **accounts**, and others, as you would want to know that you wouldn't have rounding errors.

As an example, if you open Python and sum `1.1` + `2.2` you would expect to see `3.3`, but you will actually get `3.3000000000000003`:

```Python
>>> 1.1 + 2.2
3.3000000000000003
```

This is because of the way numbers are stored in "ones and zeros" (binary). But Python has a module and some types to have strict decimal values. You can read more about it in the official <a href="https://docs.python.org/3/library/decimal.html" class="external-link" target="_blank">Python docs for Decimal</a>.

Because databases store data in the same ways as computers (in binary), they would have the same types of issues. And because of that, they also have a special **decimal** type.

In most cases this would probably not be a problem, for example measuring views in a video, or the life bar in a videogame. But as you can imagine, this is particularly important when dealing with **money** and **finances**.

## Decimal Types

Pydantic has special support for `Decimal` types using the <a href="https://pydantic-docs.helpmanual.io/usage/types/#arguments-to-condecimal" class="external-link" target="_blank">`condecimal()` special function</a>.

!!! tip
Pydantic 1.9, that will be released soon, has improved support for `Decimal` types, without needing to use the `condecimal()` function.

But meanwhile, you can already use this feature with `condecimal()` in **SQLModel** it as it's explained here.

When you use `condecimal()` you can specify the number of digits and decimal places to support. They will be validated by Pydantic (for example when using FastAPI) and the same information will also be used for the database columns.

!!! info
For the database, **SQLModel** will use <a href="https://docs.sqlalchemy.org/en/14/core/type_basics.html#sqlalchemy.types.DECIMAL" class="external-link" target="_blank">SQLAlchemy's `DECIMAL` type</a>.

## Decimals in SQLModel

Let's say that each hero in the database will have an amount of money. We could make that field a `Decimal` type using the `condecimal()` function:

```{.python .annotate hl_lines="12" }
{!./docs_src/advanced/decimal/tutorial001.py[ln:1-12]!}

# More code here later 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

Here we are saying that `money` can have at most `5` digits with `max_digits`, **this includes the integers** (to the left of the decimal dot) **and the decimals** (to the right of the decimal dot).

We are also saying that the number of decimal places (to the right of the decimal dot) is `3`, so we can have **3 decimal digits** for these numbers in the `money` field. This means that we will have **2 digits for the integer part** and **3 digits for the decimal part**.

✅ So, for example, these are all valid numbers for the `money` field:

* `12.345`
* `12.3`
* `12`
* `1.2`
* `0.123`
* `0`

🚫 But these are all invalid numbers for that `money` field:

* `1.2345`
* This number has more than 3 decimal places.
* `123.234`
* This number has more than 5 digits in total (integer and decimal part).
* `123`
* Even though this number doesn't have any decimals, we still have 3 places saved for them, which means that we can **only use 2 places** for the **integer part**, and this number has 3 integer digits. So, the allowed number of integer digits is `max_digits` - `decimal_places` = 2.

!!! tip
Make sure you adjust the number of digits and decimal places for your own needs, in your own application. 🤓

## Create models with Decimals

When creating new models you can actually pass normal (`float`) numbers, Pydantic will automatically convert them to `Decimal` types, and **SQLModel** will store them as `Decimal` types in the database (using SQLAlchemy).

```Python hl_lines="4-6"
# Code above omitted 👆

{!./docs_src/advanced/decimal/tutorial001.py[ln:25-35]!}

# Code below omitted 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

## Select Decimal data

Then, when working with Decimal types, you can confirm that they indeed avoid those rounding errors from floats:

```Python hl_lines="15-16"
# Code above omitted 👆

{!./docs_src/advanced/decimal/tutorial001.py[ln:38-51]!}

# Code below omitted 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

## Review the results

Now if you run this, instead of printing the unexpected number `3.3000000000000003`, it prints `3.300`:

<div class="termy">

```console
$ python app.py

// Some boilerplate and previous output omitted 😉

// The type of money is Decimal('1.100')
Hero 1: id=1 secret_name='Dive Wilson' age=None name='Deadpond' money=Decimal('1.100')

// More output omitted here 🤓

// The type of money is Decimal('1.100')
Hero 2: id=3 secret_name='Tommy Sharp' age=48 name='Rusty-Man' money=Decimal('2.200')

// No rounding errors, just 3.3! 🎉
Total money: 3.300
```

</div>

!!! warning
Although Decimal types are supported and used in the Python side, not all databases support it. In particular, SQLite doesn't support decimals, so it will convert them to the same floating `NUMERIC` type it supports.

But decimals are supported by most of the other SQL databases. 🎉
10 changes: 4 additions & 6 deletions docs/advanced/index.md
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
# Advanced User Guide

The **Advanced User Guide** will be coming soon to a <del>theater</del> **documentation** near you... 😅
The **Advanced User Guide** is gradually growing, you can already read about some advanced topics.

I just have to `add` it, `commit` it, and `refresh` it. 😉
At some point it will include:

It will include:

* How to use the `async` and `await` with the async session.
* How to use `async` and `await` with the async session.
* How to run migrations.
* How to combine **SQLModel** models with SQLAlchemy.
* ...and more.
* ...and more. 🤓
Empty file.
Empty file.
61 changes: 61 additions & 0 deletions docs_src/advanced/decimal/tutorial001.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
from typing import Optional

from pydantic import condecimal
from sqlmodel import Field, Session, SQLModel, create_engine, select


class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
secret_name: str
age: Optional[int] = None
money: condecimal(max_digits=6, decimal_places=3) = Field(default=0)


sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"

engine = create_engine(sqlite_url, echo=True)


def create_db_and_tables():
SQLModel.metadata.create_all(engine)


def create_heroes():
hero_1 = Hero(name="Deadpond", secret_name="Dive Wilson", money=1.1)
hero_2 = Hero(name="Spider-Boy", secret_name="Pedro Parqueador", money=0.001)
hero_3 = Hero(name="Rusty-Man", secret_name="Tommy Sharp", age=48, money=2.2)

with Session(engine) as session:
session.add(hero_1)
session.add(hero_2)
session.add(hero_3)

session.commit()


def select_heroes():
with Session(engine) as session:
statement = select(Hero).where(Hero.name == "Deadpond")
results = session.exec(statement)
hero_1 = results.one()
print("Hero 1:", hero_1)

statement = select(Hero).where(Hero.name == "Rusty-Man")
results = session.exec(statement)
hero_2 = results.one()
print("Hero 2:", hero_2)

total_money = hero_1.money + hero_2.money
print(f"Total money: {total_money}")


def main():
create_db_and_tables()
create_heroes()
select_heroes()


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,7 @@ nav:
- tutorial/fastapi/tests.md
- Advanced User Guide:
- advanced/index.md
- advanced/decimal.md
- alternatives.md
- help.md
- contributing.md
Expand Down
5 changes: 4 additions & 1 deletion sqlmodel/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -399,7 +399,10 @@ def get_sqlachemy_type(field: ModelField) -> Any:
if issubclass(field.type_, bytes):
return LargeBinary
if issubclass(field.type_, Decimal):
return Numeric
return Numeric(
precision=getattr(field.type_, "max_digits", None),
scale=getattr(field.type_, "decimal_places", None),
)
if issubclass(field.type_, ipaddress.IPv4Address):
return AutoString
if issubclass(field.type_, ipaddress.IPv4Network):
Expand Down
Empty file.
Empty file.
44 changes: 44 additions & 0 deletions tests/test_advanced/test_decimal/test_tutorial001.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
from decimal import Decimal
from unittest.mock import patch

from sqlmodel import create_engine

from ...conftest import get_testing_print_function

expected_calls = [
[
"Hero 1:",
{
"name": "Deadpond",
"age": None,
"id": 1,
"secret_name": "Dive Wilson",
"money": Decimal("1.100"),
},
],
[
"Hero 2:",
{
"name": "Rusty-Man",
"age": 48,
"id": 3,
"secret_name": "Tommy Sharp",
"money": Decimal("2.200"),
},
],
["Total money: 3.300"],
]


def test_tutorial(clear_sqlmodel):
from docs_src.advanced.decimal import tutorial001 as mod

mod.sqlite_url = "sqlite://"
mod.engine = create_engine(mod.sqlite_url)
calls = []

new_print = get_testing_print_function(calls)

with patch("builtins.print", new=new_print):
mod.main()
assert calls == expected_calls
, '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
Merged
148 changes: 148 additions & 0 deletions docs/advanced/decimal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
# Decimal Numbers

In some cases you might need to be able to store decimal numbers with guarantees about the precision.

This is particularly important if you are storing things like **currencies**, **prices**, **accounts**, and others, as you would want to know that you wouldn't have rounding errors.

As an example, if you open Python and sum `1.1` + `2.2` you would expect to see `3.3`, but you will actually get `3.3000000000000003`:

```Python
>>> 1.1 + 2.2
3.3000000000000003
```

This is because of the way numbers are stored in "ones and zeros" (binary). But Python has a module and some types to have strict decimal values. You can read more about it in the official <a href="https://docs.python.org/3/library/decimal.html" class="external-link" target="_blank">Python docs for Decimal</a>.

Because databases store data in the same ways as computers (in binary), they would have the same types of issues. And because of that, they also have a special **decimal** type.

In most cases this would probably not be a problem, for example measuring views in a video, or the life bar in a videogame. But as you can imagine, this is particularly important when dealing with **money** and **finances**.

## Decimal Types

Pydantic has special support for `Decimal` types using the <a href="https://pydantic-docs.helpmanual.io/usage/types/#arguments-to-condecimal" class="external-link" target="_blank">`condecimal()` special function</a>.

!!! tip
Pydantic 1.9, that will be released soon, has improved support for `Decimal` types, without needing to use the `condecimal()` function.

But meanwhile, you can already use this feature with `condecimal()` in **SQLModel** it as it's explained here.

When you use `condecimal()` you can specify the number of digits and decimal places to support. They will be validated by Pydantic (for example when using FastAPI) and the same information will also be used for the database columns.

!!! info
For the database, **SQLModel** will use <a href="https://docs.sqlalchemy.org/en/14/core/type_basics.html#sqlalchemy.types.DECIMAL" class="external-link" target="_blank">SQLAlchemy's `DECIMAL` type</a>.

## Decimals in SQLModel

Let's say that each hero in the database will have an amount of money. We could make that field a `Decimal` type using the `condecimal()` function:

```{.python .annotate hl_lines="12" }
{!./docs_src/advanced/decimal/tutorial001.py[ln:1-12]!}

# More code here later 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

Here we are saying that `money` can have at most `5` digits with `max_digits`, **this includes the integers** (to the left of the decimal dot) **and the decimals** (to the right of the decimal dot).

We are also saying that the number of decimal places (to the right of the decimal dot) is `3`, so we can have **3 decimal digits** for these numbers in the `money` field. This means that we will have **2 digits for the integer part** and **3 digits for the decimal part**.

✅ So, for example, these are all valid numbers for the `money` field:

* `12.345`
* `12.3`
* `12`
* `1.2`
* `0.123`
* `0`

🚫 But these are all invalid numbers for that `money` field:

* `1.2345`
* This number has more than 3 decimal places.
* `123.234`
* This number has more than 5 digits in total (integer and decimal part).
* `123`
* Even though this number doesn't have any decimals, we still have 3 places saved for them, which means that we can **only use 2 places** for the **integer part**, and this number has 3 integer digits. So, the allowed number of integer digits is `max_digits` - `decimal_places` = 2.

!!! tip
Make sure you adjust the number of digits and decimal places for your own needs, in your own application. 🤓

## Create models with Decimals

When creating new models you can actually pass normal (`float`) numbers, Pydantic will automatically convert them to `Decimal` types, and **SQLModel** will store them as `Decimal` types in the database (using SQLAlchemy).

```Python hl_lines="4-6"
# Code above omitted 👆

{!./docs_src/advanced/decimal/tutorial001.py[ln:25-35]!}

# Code below omitted 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

## Select Decimal data

Then, when working with Decimal types, you can confirm that they indeed avoid those rounding errors from floats:

```Python hl_lines="15-16"
# Code above omitted 👆

{!./docs_src/advanced/decimal/tutorial001.py[ln:38-51]!}

# Code below omitted 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

## Review the results

Now if you run this, instead of printing the unexpected number `3.3000000000000003`, it prints `3.300`:

<div class="termy">

```console
$ python app.py

// Some boilerplate and previous output omitted 😉

// The type of money is Decimal('1.100')
Hero 1: id=1 secret_name='Dive Wilson' age=None name='Deadpond' money=Decimal('1.100')

// More output omitted here 🤓

// The type of money is Decimal('1.100')
Hero 2: id=3 secret_name='Tommy Sharp' age=48 name='Rusty-Man' money=Decimal('2.200')

// No rounding errors, just 3.3! 🎉
Total money: 3.300
```

</div>

!!! warning
Although Decimal types are supported and used in the Python side, not all databases support it. In particular, SQLite doesn't support decimals, so it will convert them to the same floating `NUMERIC` type it supports.

But decimals are supported by most of the other SQL databases. 🎉
10 changes: 4 additions & 6 deletions docs/advanced/index.md
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
# Advanced User Guide

The **Advanced User Guide** will be coming soon to a <del>theater</del> **documentation** near you... 😅
The **Advanced User Guide** is gradually growing, you can already read about some advanced topics.

I just have to `add` it, `commit` it, and `refresh` it. 😉
At some point it will include:

It will include:

* How to use the `async` and `await` with the async session.
* How to use `async` and `await` with the async session.
* How to run migrations.
* How to combine **SQLModel** models with SQLAlchemy.
* ...and more.
* ...and more. 🤓
Empty file.
Empty file.
61 changes: 61 additions & 0 deletions docs_src/advanced/decimal/tutorial001.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
from typing import Optional

from pydantic import condecimal
from sqlmodel import Field, Session, SQLModel, create_engine, select


class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
secret_name: str
age: Optional[int] = None
money: condecimal(max_digits=6, decimal_places=3) = Field(default=0)


sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"

engine = create_engine(sqlite_url, echo=True)


def create_db_and_tables():
SQLModel.metadata.create_all(engine)


def create_heroes():
hero_1 = Hero(name="Deadpond", secret_name="Dive Wilson", money=1.1)
hero_2 = Hero(name="Spider-Boy", secret_name="Pedro Parqueador", money=0.001)
hero_3 = Hero(name="Rusty-Man", secret_name="Tommy Sharp", age=48, money=2.2)

with Session(engine) as session:
session.add(hero_1)
session.add(hero_2)
session.add(hero_3)

session.commit()


def select_heroes():
with Session(engine) as session:
statement = select(Hero).where(Hero.name == "Deadpond")
results = session.exec(statement)
hero_1 = results.one()
print("Hero 1:", hero_1)

statement = select(Hero).where(Hero.name == "Rusty-Man")
results = session.exec(statement)
hero_2 = results.one()
print("Hero 2:", hero_2)

total_money = hero_1.money + hero_2.money
print(f"Total money: {total_money}")


def main():
create_db_and_tables()
create_heroes()
select_heroes()


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,7 @@ nav:
- tutorial/fastapi/tests.md
- Advanced User Guide:
- advanced/index.md
- advanced/decimal.md
- alternatives.md
- help.md
- contributing.md
Expand Down
5 changes: 4 additions & 1 deletion sqlmodel/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -399,7 +399,10 @@ def get_sqlachemy_type(field: ModelField) -> Any:
if issubclass(field.type_, bytes):
return LargeBinary
if issubclass(field.type_, Decimal):
return Numeric
return Numeric(
precision=getattr(field.type_, "max_digits", None),
scale=getattr(field.type_, "decimal_places", None),
)
if issubclass(field.type_, ipaddress.IPv4Address):
return AutoString
if issubclass(field.type_, ipaddress.IPv4Network):
Expand Down
Empty file.
Empty file.
44 changes: 44 additions & 0 deletions tests/test_advanced/test_decimal/test_tutorial001.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
from decimal import Decimal
from unittest.mock import patch

from sqlmodel import create_engine

from ...conftest import get_testing_print_function

expected_calls = [
[
"Hero 1:",
{
"name": "Deadpond",
"age": None,
"id": 1,
"secret_name": "Dive Wilson",
"money": Decimal("1.100"),
},
],
[
"Hero 2:",
{
"name": "Rusty-Man",
"age": 48,
"id": 3,
"secret_name": "Tommy Sharp",
"money": Decimal("2.200"),
},
],
["Total money: 3.300"],
]


def test_tutorial(clear_sqlmodel):
from docs_src.advanced.decimal import tutorial001 as mod

mod.sqlite_url = "sqlite://"
mod.engine = create_engine(mod.sqlite_url)
calls = []

new_print = get_testing_print_function(calls)

with patch("builtins.print", new=new_print):
mod.main()
assert calls == expected_calls
, '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
Merged
148 changes: 148 additions & 0 deletions docs/advanced/decimal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
# Decimal Numbers

In some cases you might need to be able to store decimal numbers with guarantees about the precision.

This is particularly important if you are storing things like **currencies**, **prices**, **accounts**, and others, as you would want to know that you wouldn't have rounding errors.

As an example, if you open Python and sum `1.1` + `2.2` you would expect to see `3.3`, but you will actually get `3.3000000000000003`:

```Python
>>> 1.1 + 2.2
3.3000000000000003
```

This is because of the way numbers are stored in "ones and zeros" (binary). But Python has a module and some types to have strict decimal values. You can read more about it in the official <a href="https://docs.python.org/3/library/decimal.html" class="external-link" target="_blank">Python docs for Decimal</a>.

Because databases store data in the same ways as computers (in binary), they would have the same types of issues. And because of that, they also have a special **decimal** type.

In most cases this would probably not be a problem, for example measuring views in a video, or the life bar in a videogame. But as you can imagine, this is particularly important when dealing with **money** and **finances**.

## Decimal Types

Pydantic has special support for `Decimal` types using the <a href="https://pydantic-docs.helpmanual.io/usage/types/#arguments-to-condecimal" class="external-link" target="_blank">`condecimal()` special function</a>.

!!! tip
Pydantic 1.9, that will be released soon, has improved support for `Decimal` types, without needing to use the `condecimal()` function.

But meanwhile, you can already use this feature with `condecimal()` in **SQLModel** it as it's explained here.

When you use `condecimal()` you can specify the number of digits and decimal places to support. They will be validated by Pydantic (for example when using FastAPI) and the same information will also be used for the database columns.

!!! info
For the database, **SQLModel** will use <a href="https://docs.sqlalchemy.org/en/14/core/type_basics.html#sqlalchemy.types.DECIMAL" class="external-link" target="_blank">SQLAlchemy's `DECIMAL` type</a>.

## Decimals in SQLModel

Let's say that each hero in the database will have an amount of money. We could make that field a `Decimal` type using the `condecimal()` function:

```{.python .annotate hl_lines="12" }
{!./docs_src/advanced/decimal/tutorial001.py[ln:1-12]!}

# More code here later 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

Here we are saying that `money` can have at most `5` digits with `max_digits`, **this includes the integers** (to the left of the decimal dot) **and the decimals** (to the right of the decimal dot).

We are also saying that the number of decimal places (to the right of the decimal dot) is `3`, so we can have **3 decimal digits** for these numbers in the `money` field. This means that we will have **2 digits for the integer part** and **3 digits for the decimal part**.

✅ So, for example, these are all valid numbers for the `money` field:

* `12.345`
* `12.3`
* `12`
* `1.2`
* `0.123`
* `0`

🚫 But these are all invalid numbers for that `money` field:

* `1.2345`
* This number has more than 3 decimal places.
* `123.234`
* This number has more than 5 digits in total (integer and decimal part).
* `123`
* Even though this number doesn't have any decimals, we still have 3 places saved for them, which means that we can **only use 2 places** for the **integer part**, and this number has 3 integer digits. So, the allowed number of integer digits is `max_digits` - `decimal_places` = 2.

!!! tip
Make sure you adjust the number of digits and decimal places for your own needs, in your own application. 🤓

## Create models with Decimals

When creating new models you can actually pass normal (`float`) numbers, Pydantic will automatically convert them to `Decimal` types, and **SQLModel** will store them as `Decimal` types in the database (using SQLAlchemy).

```Python hl_lines="4-6"
# Code above omitted 👆

{!./docs_src/advanced/decimal/tutorial001.py[ln:25-35]!}

# Code below omitted 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

## Select Decimal data

Then, when working with Decimal types, you can confirm that they indeed avoid those rounding errors from floats:

```Python hl_lines="15-16"
# Code above omitted 👆

{!./docs_src/advanced/decimal/tutorial001.py[ln:38-51]!}

# Code below omitted 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

## Review the results

Now if you run this, instead of printing the unexpected number `3.3000000000000003`, it prints `3.300`:

<div class="termy">

```console
$ python app.py

// Some boilerplate and previous output omitted 😉

// The type of money is Decimal('1.100')
Hero 1: id=1 secret_name='Dive Wilson' age=None name='Deadpond' money=Decimal('1.100')

// More output omitted here 🤓

// The type of money is Decimal('1.100')
Hero 2: id=3 secret_name='Tommy Sharp' age=48 name='Rusty-Man' money=Decimal('2.200')

// No rounding errors, just 3.3! 🎉
Total money: 3.300
```

</div>

!!! warning
Although Decimal types are supported and used in the Python side, not all databases support it. In particular, SQLite doesn't support decimals, so it will convert them to the same floating `NUMERIC` type it supports.

But decimals are supported by most of the other SQL databases. 🎉
10 changes: 4 additions & 6 deletions docs/advanced/index.md
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
# Advanced User Guide

The **Advanced User Guide** will be coming soon to a <del>theater</del> **documentation** near you... 😅
The **Advanced User Guide** is gradually growing, you can already read about some advanced topics.

I just have to `add` it, `commit` it, and `refresh` it. 😉
At some point it will include:

It will include:

* How to use the `async` and `await` with the async session.
* How to use `async` and `await` with the async session.
* How to run migrations.
* How to combine **SQLModel** models with SQLAlchemy.
* ...and more.
* ...and more. 🤓
Empty file.
Empty file.
61 changes: 61 additions & 0 deletions docs_src/advanced/decimal/tutorial001.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
from typing import Optional

from pydantic import condecimal
from sqlmodel import Field, Session, SQLModel, create_engine, select


class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
secret_name: str
age: Optional[int] = None
money: condecimal(max_digits=6, decimal_places=3) = Field(default=0)


sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"

engine = create_engine(sqlite_url, echo=True)


def create_db_and_tables():
SQLModel.metadata.create_all(engine)


def create_heroes():
hero_1 = Hero(name="Deadpond", secret_name="Dive Wilson", money=1.1)
hero_2 = Hero(name="Spider-Boy", secret_name="Pedro Parqueador", money=0.001)
hero_3 = Hero(name="Rusty-Man", secret_name="Tommy Sharp", age=48, money=2.2)

with Session(engine) as session:
session.add(hero_1)
session.add(hero_2)
session.add(hero_3)

session.commit()


def select_heroes():
with Session(engine) as session:
statement = select(Hero).where(Hero.name == "Deadpond")
results = session.exec(statement)
hero_1 = results.one()
print("Hero 1:", hero_1)

statement = select(Hero).where(Hero.name == "Rusty-Man")
results = session.exec(statement)
hero_2 = results.one()
print("Hero 2:", hero_2)

total_money = hero_1.money + hero_2.money
print(f"Total money: {total_money}")


def main():
create_db_and_tables()
create_heroes()
select_heroes()


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,7 @@ nav:
- tutorial/fastapi/tests.md
- Advanced User Guide:
- advanced/index.md
- advanced/decimal.md
- alternatives.md
- help.md
- contributing.md
Expand Down
5 changes: 4 additions & 1 deletion sqlmodel/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -399,7 +399,10 @@ def get_sqlachemy_type(field: ModelField) -> Any:
if issubclass(field.type_, bytes):
return LargeBinary
if issubclass(field.type_, Decimal):
return Numeric
return Numeric(
precision=getattr(field.type_, "max_digits", None),
scale=getattr(field.type_, "decimal_places", None),
)
if issubclass(field.type_, ipaddress.IPv4Address):
return AutoString
if issubclass(field.type_, ipaddress.IPv4Network):
Expand Down
Empty file.
Empty file.
44 changes: 44 additions & 0 deletions tests/test_advanced/test_decimal/test_tutorial001.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
from decimal import Decimal
from unittest.mock import patch

from sqlmodel import create_engine

from ...conftest import get_testing_print_function

expected_calls = [
[
"Hero 1:",
{
"name": "Deadpond",
"age": None,
"id": 1,
"secret_name": "Dive Wilson",
"money": Decimal("1.100"),
},
],
[
"Hero 2:",
{
"name": "Rusty-Man",
"age": 48,
"id": 3,
"secret_name": "Tommy Sharp",
"money": Decimal("2.200"),
},
],
["Total money: 3.300"],
]


def test_tutorial(clear_sqlmodel):
from docs_src.advanced.decimal import tutorial001 as mod

mod.sqlite_url = "sqlite://"
mod.engine = create_engine(mod.sqlite_url)
calls = []

new_print = get_testing_print_function(calls)

with patch("builtins.print", new=new_print):
mod.main()
assert calls == expected_calls
, '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
Merged
148 changes: 148 additions & 0 deletions docs/advanced/decimal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
# Decimal Numbers

In some cases you might need to be able to store decimal numbers with guarantees about the precision.

This is particularly important if you are storing things like **currencies**, **prices**, **accounts**, and others, as you would want to know that you wouldn't have rounding errors.

As an example, if you open Python and sum `1.1` + `2.2` you would expect to see `3.3`, but you will actually get `3.3000000000000003`:

```Python
>>> 1.1 + 2.2
3.3000000000000003
```

This is because of the way numbers are stored in "ones and zeros" (binary). But Python has a module and some types to have strict decimal values. You can read more about it in the official <a href="https://docs.python.org/3/library/decimal.html" class="external-link" target="_blank">Python docs for Decimal</a>.

Because databases store data in the same ways as computers (in binary), they would have the same types of issues. And because of that, they also have a special **decimal** type.

In most cases this would probably not be a problem, for example measuring views in a video, or the life bar in a videogame. But as you can imagine, this is particularly important when dealing with **money** and **finances**.

## Decimal Types

Pydantic has special support for `Decimal` types using the <a href="https://pydantic-docs.helpmanual.io/usage/types/#arguments-to-condecimal" class="external-link" target="_blank">`condecimal()` special function</a>.

!!! tip
Pydantic 1.9, that will be released soon, has improved support for `Decimal` types, without needing to use the `condecimal()` function.

But meanwhile, you can already use this feature with `condecimal()` in **SQLModel** it as it's explained here.

When you use `condecimal()` you can specify the number of digits and decimal places to support. They will be validated by Pydantic (for example when using FastAPI) and the same information will also be used for the database columns.

!!! info
For the database, **SQLModel** will use <a href="https://docs.sqlalchemy.org/en/14/core/type_basics.html#sqlalchemy.types.DECIMAL" class="external-link" target="_blank">SQLAlchemy's `DECIMAL` type</a>.

## Decimals in SQLModel

Let's say that each hero in the database will have an amount of money. We could make that field a `Decimal` type using the `condecimal()` function:

```{.python .annotate hl_lines="12" }
{!./docs_src/advanced/decimal/tutorial001.py[ln:1-12]!}

# More code here later 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

Here we are saying that `money` can have at most `5` digits with `max_digits`, **this includes the integers** (to the left of the decimal dot) **and the decimals** (to the right of the decimal dot).

We are also saying that the number of decimal places (to the right of the decimal dot) is `3`, so we can have **3 decimal digits** for these numbers in the `money` field. This means that we will have **2 digits for the integer part** and **3 digits for the decimal part**.

✅ So, for example, these are all valid numbers for the `money` field:

* `12.345`
* `12.3`
* `12`
* `1.2`
* `0.123`
* `0`

🚫 But these are all invalid numbers for that `money` field:

* `1.2345`
* This number has more than 3 decimal places.
* `123.234`
* This number has more than 5 digits in total (integer and decimal part).
* `123`
* Even though this number doesn't have any decimals, we still have 3 places saved for them, which means that we can **only use 2 places** for the **integer part**, and this number has 3 integer digits. So, the allowed number of integer digits is `max_digits` - `decimal_places` = 2.

!!! tip
Make sure you adjust the number of digits and decimal places for your own needs, in your own application. 🤓

## Create models with Decimals

When creating new models you can actually pass normal (`float`) numbers, Pydantic will automatically convert them to `Decimal` types, and **SQLModel** will store them as `Decimal` types in the database (using SQLAlchemy).

```Python hl_lines="4-6"
# Code above omitted 👆

{!./docs_src/advanced/decimal/tutorial001.py[ln:25-35]!}

# Code below omitted 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

## Select Decimal data

Then, when working with Decimal types, you can confirm that they indeed avoid those rounding errors from floats:

```Python hl_lines="15-16"
# Code above omitted 👆

{!./docs_src/advanced/decimal/tutorial001.py[ln:38-51]!}

# Code below omitted 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

## Review the results

Now if you run this, instead of printing the unexpected number `3.3000000000000003`, it prints `3.300`:

<div class="termy">

```console
$ python app.py

// Some boilerplate and previous output omitted 😉

// The type of money is Decimal('1.100')
Hero 1: id=1 secret_name='Dive Wilson' age=None name='Deadpond' money=Decimal('1.100')

// More output omitted here 🤓

// The type of money is Decimal('1.100')
Hero 2: id=3 secret_name='Tommy Sharp' age=48 name='Rusty-Man' money=Decimal('2.200')

// No rounding errors, just 3.3! 🎉
Total money: 3.300
```

</div>

!!! warning
Although Decimal types are supported and used in the Python side, not all databases support it. In particular, SQLite doesn't support decimals, so it will convert them to the same floating `NUMERIC` type it supports.

But decimals are supported by most of the other SQL databases. 🎉
10 changes: 4 additions & 6 deletions docs/advanced/index.md
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
# Advanced User Guide

The **Advanced User Guide** will be coming soon to a <del>theater</del> **documentation** near you... 😅
The **Advanced User Guide** is gradually growing, you can already read about some advanced topics.

I just have to `add` it, `commit` it, and `refresh` it. 😉
At some point it will include:

It will include:

* How to use the `async` and `await` with the async session.
* How to use `async` and `await` with the async session.
* How to run migrations.
* How to combine **SQLModel** models with SQLAlchemy.
* ...and more.
* ...and more. 🤓
Empty file.
Empty file.
61 changes: 61 additions & 0 deletions docs_src/advanced/decimal/tutorial001.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
from typing import Optional

from pydantic import condecimal
from sqlmodel import Field, Session, SQLModel, create_engine, select


class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
secret_name: str
age: Optional[int] = None
money: condecimal(max_digits=6, decimal_places=3) = Field(default=0)


sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"

engine = create_engine(sqlite_url, echo=True)


def create_db_and_tables():
SQLModel.metadata.create_all(engine)


def create_heroes():
hero_1 = Hero(name="Deadpond", secret_name="Dive Wilson", money=1.1)
hero_2 = Hero(name="Spider-Boy", secret_name="Pedro Parqueador", money=0.001)
hero_3 = Hero(name="Rusty-Man", secret_name="Tommy Sharp", age=48, money=2.2)

with Session(engine) as session:
session.add(hero_1)
session.add(hero_2)
session.add(hero_3)

session.commit()


def select_heroes():
with Session(engine) as session:
statement = select(Hero).where(Hero.name == "Deadpond")
results = session.exec(statement)
hero_1 = results.one()
print("Hero 1:", hero_1)

statement = select(Hero).where(Hero.name == "Rusty-Man")
results = session.exec(statement)
hero_2 = results.one()
print("Hero 2:", hero_2)

total_money = hero_1.money + hero_2.money
print(f"Total money: {total_money}")


def main():
create_db_and_tables()
create_heroes()
select_heroes()


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,7 @@ nav:
- tutorial/fastapi/tests.md
- Advanced User Guide:
- advanced/index.md
- advanced/decimal.md
- alternatives.md
- help.md
- contributing.md
Expand Down
5 changes: 4 additions & 1 deletion sqlmodel/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -399,7 +399,10 @@ def get_sqlachemy_type(field: ModelField) -> Any:
if issubclass(field.type_, bytes):
return LargeBinary
if issubclass(field.type_, Decimal):
return Numeric
return Numeric(
precision=getattr(field.type_, "max_digits", None),
scale=getattr(field.type_, "decimal_places", None),
)
if issubclass(field.type_, ipaddress.IPv4Address):
return AutoString
if issubclass(field.type_, ipaddress.IPv4Network):
Expand Down
Empty file.
Empty file.
44 changes: 44 additions & 0 deletions tests/test_advanced/test_decimal/test_tutorial001.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
from decimal import Decimal
from unittest.mock import patch

from sqlmodel import create_engine

from ...conftest import get_testing_print_function

expected_calls = [
[
"Hero 1:",
{
"name": "Deadpond",
"age": None,
"id": 1,
"secret_name": "Dive Wilson",
"money": Decimal("1.100"),
},
],
[
"Hero 2:",
{
"name": "Rusty-Man",
"age": 48,
"id": 3,
"secret_name": "Tommy Sharp",
"money": Decimal("2.200"),
},
],
["Total money: 3.300"],
]


def test_tutorial(clear_sqlmodel):
from docs_src.advanced.decimal import tutorial001 as mod

mod.sqlite_url = "sqlite://"
mod.engine = create_engine(mod.sqlite_url)
calls = []

new_print = get_testing_print_function(calls)

with patch("builtins.print", new=new_print):
mod.main()
assert calls == expected_calls
, '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
Merged
148 changes: 148 additions & 0 deletions docs/advanced/decimal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
# Decimal Numbers

In some cases you might need to be able to store decimal numbers with guarantees about the precision.

This is particularly important if you are storing things like **currencies**, **prices**, **accounts**, and others, as you would want to know that you wouldn't have rounding errors.

As an example, if you open Python and sum `1.1` + `2.2` you would expect to see `3.3`, but you will actually get `3.3000000000000003`:

```Python
>>> 1.1 + 2.2
3.3000000000000003
```

This is because of the way numbers are stored in "ones and zeros" (binary). But Python has a module and some types to have strict decimal values. You can read more about it in the official <a href="https://docs.python.org/3/library/decimal.html" class="external-link" target="_blank">Python docs for Decimal</a>.

Because databases store data in the same ways as computers (in binary), they would have the same types of issues. And because of that, they also have a special **decimal** type.

In most cases this would probably not be a problem, for example measuring views in a video, or the life bar in a videogame. But as you can imagine, this is particularly important when dealing with **money** and **finances**.

## Decimal Types

Pydantic has special support for `Decimal` types using the <a href="https://pydantic-docs.helpmanual.io/usage/types/#arguments-to-condecimal" class="external-link" target="_blank">`condecimal()` special function</a>.

!!! tip
Pydantic 1.9, that will be released soon, has improved support for `Decimal` types, without needing to use the `condecimal()` function.

But meanwhile, you can already use this feature with `condecimal()` in **SQLModel** it as it's explained here.

When you use `condecimal()` you can specify the number of digits and decimal places to support. They will be validated by Pydantic (for example when using FastAPI) and the same information will also be used for the database columns.

!!! info
For the database, **SQLModel** will use <a href="https://docs.sqlalchemy.org/en/14/core/type_basics.html#sqlalchemy.types.DECIMAL" class="external-link" target="_blank">SQLAlchemy's `DECIMAL` type</a>.

## Decimals in SQLModel

Let's say that each hero in the database will have an amount of money. We could make that field a `Decimal` type using the `condecimal()` function:

```{.python .annotate hl_lines="12" }
{!./docs_src/advanced/decimal/tutorial001.py[ln:1-12]!}

# More code here later 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

Here we are saying that `money` can have at most `5` digits with `max_digits`, **this includes the integers** (to the left of the decimal dot) **and the decimals** (to the right of the decimal dot).

We are also saying that the number of decimal places (to the right of the decimal dot) is `3`, so we can have **3 decimal digits** for these numbers in the `money` field. This means that we will have **2 digits for the integer part** and **3 digits for the decimal part**.

✅ So, for example, these are all valid numbers for the `money` field:

* `12.345`
* `12.3`
* `12`
* `1.2`
* `0.123`
* `0`

🚫 But these are all invalid numbers for that `money` field:

* `1.2345`
* This number has more than 3 decimal places.
* `123.234`
* This number has more than 5 digits in total (integer and decimal part).
* `123`
* Even though this number doesn't have any decimals, we still have 3 places saved for them, which means that we can **only use 2 places** for the **integer part**, and this number has 3 integer digits. So, the allowed number of integer digits is `max_digits` - `decimal_places` = 2.

!!! tip
Make sure you adjust the number of digits and decimal places for your own needs, in your own application. 🤓

## Create models with Decimals

When creating new models you can actually pass normal (`float`) numbers, Pydantic will automatically convert them to `Decimal` types, and **SQLModel** will store them as `Decimal` types in the database (using SQLAlchemy).

```Python hl_lines="4-6"
# Code above omitted 👆

{!./docs_src/advanced/decimal/tutorial001.py[ln:25-35]!}

# Code below omitted 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

## Select Decimal data

Then, when working with Decimal types, you can confirm that they indeed avoid those rounding errors from floats:

```Python hl_lines="15-16"
# Code above omitted 👆

{!./docs_src/advanced/decimal/tutorial001.py[ln:38-51]!}

# Code below omitted 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

## Review the results

Now if you run this, instead of printing the unexpected number `3.3000000000000003`, it prints `3.300`:

<div class="termy">

```console
$ python app.py

// Some boilerplate and previous output omitted 😉

// The type of money is Decimal('1.100')
Hero 1: id=1 secret_name='Dive Wilson' age=None name='Deadpond' money=Decimal('1.100')

// More output omitted here 🤓

// The type of money is Decimal('1.100')
Hero 2: id=3 secret_name='Tommy Sharp' age=48 name='Rusty-Man' money=Decimal('2.200')

// No rounding errors, just 3.3! 🎉
Total money: 3.300
```

</div>

!!! warning
Although Decimal types are supported and used in the Python side, not all databases support it. In particular, SQLite doesn't support decimals, so it will convert them to the same floating `NUMERIC` type it supports.

But decimals are supported by most of the other SQL databases. 🎉
10 changes: 4 additions & 6 deletions docs/advanced/index.md
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
# Advanced User Guide

The **Advanced User Guide** will be coming soon to a <del>theater</del> **documentation** near you... 😅
The **Advanced User Guide** is gradually growing, you can already read about some advanced topics.

I just have to `add` it, `commit` it, and `refresh` it. 😉
At some point it will include:

It will include:

* How to use the `async` and `await` with the async session.
* How to use `async` and `await` with the async session.
* How to run migrations.
* How to combine **SQLModel** models with SQLAlchemy.
* ...and more.
* ...and more. 🤓
Empty file.
Empty file.
61 changes: 61 additions & 0 deletions docs_src/advanced/decimal/tutorial001.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
from typing import Optional

from pydantic import condecimal
from sqlmodel import Field, Session, SQLModel, create_engine, select


class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
secret_name: str
age: Optional[int] = None
money: condecimal(max_digits=6, decimal_places=3) = Field(default=0)


sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"

engine = create_engine(sqlite_url, echo=True)


def create_db_and_tables():
SQLModel.metadata.create_all(engine)


def create_heroes():
hero_1 = Hero(name="Deadpond", secret_name="Dive Wilson", money=1.1)
hero_2 = Hero(name="Spider-Boy", secret_name="Pedro Parqueador", money=0.001)
hero_3 = Hero(name="Rusty-Man", secret_name="Tommy Sharp", age=48, money=2.2)

with Session(engine) as session:
session.add(hero_1)
session.add(hero_2)
session.add(hero_3)

session.commit()


def select_heroes():
with Session(engine) as session:
statement = select(Hero).where(Hero.name == "Deadpond")
results = session.exec(statement)
hero_1 = results.one()
print("Hero 1:", hero_1)

statement = select(Hero).where(Hero.name == "Rusty-Man")
results = session.exec(statement)
hero_2 = results.one()
print("Hero 2:", hero_2)

total_money = hero_1.money + hero_2.money
print(f"Total money: {total_money}")


def main():
create_db_and_tables()
create_heroes()
select_heroes()


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,7 @@ nav:
- tutorial/fastapi/tests.md
- Advanced User Guide:
- advanced/index.md
- advanced/decimal.md
- alternatives.md
- help.md
- contributing.md
Expand Down
5 changes: 4 additions & 1 deletion sqlmodel/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -399,7 +399,10 @@ def get_sqlachemy_type(field: ModelField) -> Any:
if issubclass(field.type_, bytes):
return LargeBinary
if issubclass(field.type_, Decimal):
return Numeric
return Numeric(
precision=getattr(field.type_, "max_digits", None),
scale=getattr(field.type_, "decimal_places", None),
)
if issubclass(field.type_, ipaddress.IPv4Address):
return AutoString
if issubclass(field.type_, ipaddress.IPv4Network):
Expand Down
Empty file.
Empty file.
44 changes: 44 additions & 0 deletions tests/test_advanced/test_decimal/test_tutorial001.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
from decimal import Decimal
from unittest.mock import patch

from sqlmodel import create_engine

from ...conftest import get_testing_print_function

expected_calls = [
[
"Hero 1:",
{
"name": "Deadpond",
"age": None,
"id": 1,
"secret_name": "Dive Wilson",
"money": Decimal("1.100"),
},
],
[
"Hero 2:",
{
"name": "Rusty-Man",
"age": 48,
"id": 3,
"secret_name": "Tommy Sharp",
"money": Decimal("2.200"),
},
],
["Total money: 3.300"],
]


def test_tutorial(clear_sqlmodel):
from docs_src.advanced.decimal import tutorial001 as mod

mod.sqlite_url = "sqlite://"
mod.engine = create_engine(mod.sqlite_url)
calls = []

new_print = get_testing_print_function(calls)

with patch("builtins.print", new=new_print):
mod.main()
assert calls == expected_calls
, '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
Merged
148 changes: 148 additions & 0 deletions docs/advanced/decimal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
# Decimal Numbers

In some cases you might need to be able to store decimal numbers with guarantees about the precision.

This is particularly important if you are storing things like **currencies**, **prices**, **accounts**, and others, as you would want to know that you wouldn't have rounding errors.

As an example, if you open Python and sum `1.1` + `2.2` you would expect to see `3.3`, but you will actually get `3.3000000000000003`:

```Python
>>> 1.1 + 2.2
3.3000000000000003
```

This is because of the way numbers are stored in "ones and zeros" (binary). But Python has a module and some types to have strict decimal values. You can read more about it in the official <a href="https://docs.python.org/3/library/decimal.html" class="external-link" target="_blank">Python docs for Decimal</a>.

Because databases store data in the same ways as computers (in binary), they would have the same types of issues. And because of that, they also have a special **decimal** type.

In most cases this would probably not be a problem, for example measuring views in a video, or the life bar in a videogame. But as you can imagine, this is particularly important when dealing with **money** and **finances**.

## Decimal Types

Pydantic has special support for `Decimal` types using the <a href="https://pydantic-docs.helpmanual.io/usage/types/#arguments-to-condecimal" class="external-link" target="_blank">`condecimal()` special function</a>.

!!! tip
Pydantic 1.9, that will be released soon, has improved support for `Decimal` types, without needing to use the `condecimal()` function.

But meanwhile, you can already use this feature with `condecimal()` in **SQLModel** it as it's explained here.

When you use `condecimal()` you can specify the number of digits and decimal places to support. They will be validated by Pydantic (for example when using FastAPI) and the same information will also be used for the database columns.

!!! info
For the database, **SQLModel** will use <a href="https://docs.sqlalchemy.org/en/14/core/type_basics.html#sqlalchemy.types.DECIMAL" class="external-link" target="_blank">SQLAlchemy's `DECIMAL` type</a>.

## Decimals in SQLModel

Let's say that each hero in the database will have an amount of money. We could make that field a `Decimal` type using the `condecimal()` function:

```{.python .annotate hl_lines="12" }
{!./docs_src/advanced/decimal/tutorial001.py[ln:1-12]!}

# More code here later 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

Here we are saying that `money` can have at most `5` digits with `max_digits`, **this includes the integers** (to the left of the decimal dot) **and the decimals** (to the right of the decimal dot).

We are also saying that the number of decimal places (to the right of the decimal dot) is `3`, so we can have **3 decimal digits** for these numbers in the `money` field. This means that we will have **2 digits for the integer part** and **3 digits for the decimal part**.

✅ So, for example, these are all valid numbers for the `money` field:

* `12.345`
* `12.3`
* `12`
* `1.2`
* `0.123`
* `0`

🚫 But these are all invalid numbers for that `money` field:

* `1.2345`
* This number has more than 3 decimal places.
* `123.234`
* This number has more than 5 digits in total (integer and decimal part).
* `123`
* Even though this number doesn't have any decimals, we still have 3 places saved for them, which means that we can **only use 2 places** for the **integer part**, and this number has 3 integer digits. So, the allowed number of integer digits is `max_digits` - `decimal_places` = 2.

!!! tip
Make sure you adjust the number of digits and decimal places for your own needs, in your own application. 🤓

## Create models with Decimals

When creating new models you can actually pass normal (`float`) numbers, Pydantic will automatically convert them to `Decimal` types, and **SQLModel** will store them as `Decimal` types in the database (using SQLAlchemy).

```Python hl_lines="4-6"
# Code above omitted 👆

{!./docs_src/advanced/decimal/tutorial001.py[ln:25-35]!}

# Code below omitted 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

## Select Decimal data

Then, when working with Decimal types, you can confirm that they indeed avoid those rounding errors from floats:

```Python hl_lines="15-16"
# Code above omitted 👆

{!./docs_src/advanced/decimal/tutorial001.py[ln:38-51]!}

# Code below omitted 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

## Review the results

Now if you run this, instead of printing the unexpected number `3.3000000000000003`, it prints `3.300`:

<div class="termy">

```console
$ python app.py

// Some boilerplate and previous output omitted 😉

// The type of money is Decimal('1.100')
Hero 1: id=1 secret_name='Dive Wilson' age=None name='Deadpond' money=Decimal('1.100')

// More output omitted here 🤓

// The type of money is Decimal('1.100')
Hero 2: id=3 secret_name='Tommy Sharp' age=48 name='Rusty-Man' money=Decimal('2.200')

// No rounding errors, just 3.3! 🎉
Total money: 3.300
```

</div>

!!! warning
Although Decimal types are supported and used in the Python side, not all databases support it. In particular, SQLite doesn't support decimals, so it will convert them to the same floating `NUMERIC` type it supports.

But decimals are supported by most of the other SQL databases. 🎉
10 changes: 4 additions & 6 deletions docs/advanced/index.md
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
# Advanced User Guide

The **Advanced User Guide** will be coming soon to a <del>theater</del> **documentation** near you... 😅
The **Advanced User Guide** is gradually growing, you can already read about some advanced topics.

I just have to `add` it, `commit` it, and `refresh` it. 😉
At some point it will include:

It will include:

* How to use the `async` and `await` with the async session.
* How to use `async` and `await` with the async session.
* How to run migrations.
* How to combine **SQLModel** models with SQLAlchemy.
* ...and more.
* ...and more. 🤓
Empty file.
Empty file.
61 changes: 61 additions & 0 deletions docs_src/advanced/decimal/tutorial001.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
from typing import Optional

from pydantic import condecimal
from sqlmodel import Field, Session, SQLModel, create_engine, select


class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
secret_name: str
age: Optional[int] = None
money: condecimal(max_digits=6, decimal_places=3) = Field(default=0)


sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"

engine = create_engine(sqlite_url, echo=True)


def create_db_and_tables():
SQLModel.metadata.create_all(engine)


def create_heroes():
hero_1 = Hero(name="Deadpond", secret_name="Dive Wilson", money=1.1)
hero_2 = Hero(name="Spider-Boy", secret_name="Pedro Parqueador", money=0.001)
hero_3 = Hero(name="Rusty-Man", secret_name="Tommy Sharp", age=48, money=2.2)

with Session(engine) as session:
session.add(hero_1)
session.add(hero_2)
session.add(hero_3)

session.commit()


def select_heroes():
with Session(engine) as session:
statement = select(Hero).where(Hero.name == "Deadpond")
results = session.exec(statement)
hero_1 = results.one()
print("Hero 1:", hero_1)

statement = select(Hero).where(Hero.name == "Rusty-Man")
results = session.exec(statement)
hero_2 = results.one()
print("Hero 2:", hero_2)

total_money = hero_1.money + hero_2.money
print(f"Total money: {total_money}")


def main():
create_db_and_tables()
create_heroes()
select_heroes()


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,7 @@ nav:
- tutorial/fastapi/tests.md
- Advanced User Guide:
- advanced/index.md
- advanced/decimal.md
- alternatives.md
- help.md
- contributing.md
Expand Down
5 changes: 4 additions & 1 deletion sqlmodel/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -399,7 +399,10 @@ def get_sqlachemy_type(field: ModelField) -> Any:
if issubclass(field.type_, bytes):
return LargeBinary
if issubclass(field.type_, Decimal):
return Numeric
return Numeric(
precision=getattr(field.type_, "max_digits", None),
scale=getattr(field.type_, "decimal_places", None),
)
if issubclass(field.type_, ipaddress.IPv4Address):
return AutoString
if issubclass(field.type_, ipaddress.IPv4Network):
Expand Down
Empty file.
Empty file.
44 changes: 44 additions & 0 deletions tests/test_advanced/test_decimal/test_tutorial001.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
from decimal import Decimal
from unittest.mock import patch

from sqlmodel import create_engine

from ...conftest import get_testing_print_function

expected_calls = [
[
"Hero 1:",
{
"name": "Deadpond",
"age": None,
"id": 1,
"secret_name": "Dive Wilson",
"money": Decimal("1.100"),
},
],
[
"Hero 2:",
{
"name": "Rusty-Man",
"age": 48,
"id": 3,
"secret_name": "Tommy Sharp",
"money": Decimal("2.200"),
},
],
["Total money: 3.300"],
]


def test_tutorial(clear_sqlmodel):
from docs_src.advanced.decimal import tutorial001 as mod

mod.sqlite_url = "sqlite://"
mod.engine = create_engine(mod.sqlite_url)
calls = []

new_print = get_testing_print_function(calls)

with patch("builtins.print", new=new_print):
mod.main()
assert calls == expected_calls
, '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
Merged
148 changes: 148 additions & 0 deletions docs/advanced/decimal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
# Decimal Numbers

In some cases you might need to be able to store decimal numbers with guarantees about the precision.

This is particularly important if you are storing things like **currencies**, **prices**, **accounts**, and others, as you would want to know that you wouldn't have rounding errors.

As an example, if you open Python and sum `1.1` + `2.2` you would expect to see `3.3`, but you will actually get `3.3000000000000003`:

```Python
>>> 1.1 + 2.2
3.3000000000000003
```

This is because of the way numbers are stored in "ones and zeros" (binary). But Python has a module and some types to have strict decimal values. You can read more about it in the official <a href="https://docs.python.org/3/library/decimal.html" class="external-link" target="_blank">Python docs for Decimal</a>.

Because databases store data in the same ways as computers (in binary), they would have the same types of issues. And because of that, they also have a special **decimal** type.

In most cases this would probably not be a problem, for example measuring views in a video, or the life bar in a videogame. But as you can imagine, this is particularly important when dealing with **money** and **finances**.

## Decimal Types

Pydantic has special support for `Decimal` types using the <a href="https://pydantic-docs.helpmanual.io/usage/types/#arguments-to-condecimal" class="external-link" target="_blank">`condecimal()` special function</a>.

!!! tip
Pydantic 1.9, that will be released soon, has improved support for `Decimal` types, without needing to use the `condecimal()` function.

But meanwhile, you can already use this feature with `condecimal()` in **SQLModel** it as it's explained here.

When you use `condecimal()` you can specify the number of digits and decimal places to support. They will be validated by Pydantic (for example when using FastAPI) and the same information will also be used for the database columns.

!!! info
For the database, **SQLModel** will use <a href="https://docs.sqlalchemy.org/en/14/core/type_basics.html#sqlalchemy.types.DECIMAL" class="external-link" target="_blank">SQLAlchemy's `DECIMAL` type</a>.

## Decimals in SQLModel

Let's say that each hero in the database will have an amount of money. We could make that field a `Decimal` type using the `condecimal()` function:

```{.python .annotate hl_lines="12" }
{!./docs_src/advanced/decimal/tutorial001.py[ln:1-12]!}

# More code here later 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

Here we are saying that `money` can have at most `5` digits with `max_digits`, **this includes the integers** (to the left of the decimal dot) **and the decimals** (to the right of the decimal dot).

We are also saying that the number of decimal places (to the right of the decimal dot) is `3`, so we can have **3 decimal digits** for these numbers in the `money` field. This means that we will have **2 digits for the integer part** and **3 digits for the decimal part**.

✅ So, for example, these are all valid numbers for the `money` field:

* `12.345`
* `12.3`
* `12`
* `1.2`
* `0.123`
* `0`

🚫 But these are all invalid numbers for that `money` field:

* `1.2345`
* This number has more than 3 decimal places.
* `123.234`
* This number has more than 5 digits in total (integer and decimal part).
* `123`
* Even though this number doesn't have any decimals, we still have 3 places saved for them, which means that we can **only use 2 places** for the **integer part**, and this number has 3 integer digits. So, the allowed number of integer digits is `max_digits` - `decimal_places` = 2.

!!! tip
Make sure you adjust the number of digits and decimal places for your own needs, in your own application. 🤓

## Create models with Decimals

When creating new models you can actually pass normal (`float`) numbers, Pydantic will automatically convert them to `Decimal` types, and **SQLModel** will store them as `Decimal` types in the database (using SQLAlchemy).

```Python hl_lines="4-6"
# Code above omitted 👆

{!./docs_src/advanced/decimal/tutorial001.py[ln:25-35]!}

# Code below omitted 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

## Select Decimal data

Then, when working with Decimal types, you can confirm that they indeed avoid those rounding errors from floats:

```Python hl_lines="15-16"
# Code above omitted 👆

{!./docs_src/advanced/decimal/tutorial001.py[ln:38-51]!}

# Code below omitted 👇
```

<details>
<summary>👀 Full file preview</summary>

```Python
{!./docs_src/advanced/decimal/tutorial001.py!}
```

</details>

## Review the results

Now if you run this, instead of printing the unexpected number `3.3000000000000003`, it prints `3.300`:

<div class="termy">

```console
$ python app.py

// Some boilerplate and previous output omitted 😉

// The type of money is Decimal('1.100')
Hero 1: id=1 secret_name='Dive Wilson' age=None name='Deadpond' money=Decimal('1.100')

// More output omitted here 🤓

// The type of money is Decimal('1.100')
Hero 2: id=3 secret_name='Tommy Sharp' age=48 name='Rusty-Man' money=Decimal('2.200')

// No rounding errors, just 3.3! 🎉
Total money: 3.300
```

</div>

!!! warning
Although Decimal types are supported and used in the Python side, not all databases support it. In particular, SQLite doesn't support decimals, so it will convert them to the same floating `NUMERIC` type it supports.

But decimals are supported by most of the other SQL databases. 🎉
10 changes: 4 additions & 6 deletions docs/advanced/index.md
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
# Advanced User Guide

The **Advanced User Guide** will be coming soon to a <del>theater</del> **documentation** near you... 😅
The **Advanced User Guide** is gradually growing, you can already read about some advanced topics.

I just have to `add` it, `commit` it, and `refresh` it. 😉
At some point it will include:

It will include:

* How to use the `async` and `await` with the async session.
* How to use `async` and `await` with the async session.
* How to run migrations.
* How to combine **SQLModel** models with SQLAlchemy.
* ...and more.
* ...and more. 🤓
Empty file.
Empty file.
61 changes: 61 additions & 0 deletions docs_src/advanced/decimal/tutorial001.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
from typing import Optional

from pydantic import condecimal
from sqlmodel import Field, Session, SQLModel, create_engine, select


class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
secret_name: str
age: Optional[int] = None
money: condecimal(max_digits=6, decimal_places=3) = Field(default=0)


sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"

engine = create_engine(sqlite_url, echo=True)


def create_db_and_tables():
SQLModel.metadata.create_all(engine)


def create_heroes():
hero_1 = Hero(name="Deadpond", secret_name="Dive Wilson", money=1.1)
hero_2 = Hero(name="Spider-Boy", secret_name="Pedro Parqueador", money=0.001)
hero_3 = Hero(name="Rusty-Man", secret_name="Tommy Sharp", age=48, money=2.2)

with Session(engine) as session:
session.add(hero_1)
session.add(hero_2)
session.add(hero_3)

session.commit()


def select_heroes():
with Session(engine) as session:
statement = select(Hero).where(Hero.name == "Deadpond")
results = session.exec(statement)
hero_1 = results.one()
print("Hero 1:", hero_1)

statement = select(Hero).where(Hero.name == "Rusty-Man")
results = session.exec(statement)
hero_2 = results.one()
print("Hero 2:", hero_2)

total_money = hero_1.money + hero_2.money
print(f"Total money: {total_money}")


def main():
create_db_and_tables()
create_heroes()
select_heroes()


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,7 @@ nav:
- tutorial/fastapi/tests.md
- Advanced User Guide:
- advanced/index.md
- advanced/decimal.md
- alternatives.md
- help.md
- contributing.md
Expand Down
5 changes: 4 additions & 1 deletion sqlmodel/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -399,7 +399,10 @@ def get_sqlachemy_type(field: ModelField) -> Any:
if issubclass(field.type_, bytes):
return LargeBinary
if issubclass(field.type_, Decimal):
return Numeric
return Numeric(
precision=getattr(field.type_, "max_digits", None),
scale=getattr(field.type_, "decimal_places", None),
)
if issubclass(field.type_, ipaddress.IPv4Address):
return AutoString
if issubclass(field.type_, ipaddress.IPv4Network):
Expand Down
Empty file.
Empty file.
44 changes: 44 additions & 0 deletions tests/test_advanced/test_decimal/test_tutorial001.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
from decimal import Decimal
from unittest.mock import patch

from sqlmodel import create_engine

from ...conftest import get_testing_print_function

expected_calls = [
[
"Hero 1:",
{
"name": "Deadpond",
"age": None,
"id": 1,
"secret_name": "Dive Wilson",
"money": Decimal("1.100"),
},
],
[
"Hero 2:",
{
"name": "Rusty-Man",
"age": 48,
"id": 3,
"secret_name": "Tommy Sharp",
"money": Decimal("2.200"),
},
],
["Total money: 3.300"],
]


def test_tutorial(clear_sqlmodel):
from docs_src.advanced.decimal import tutorial001 as mod

mod.sqlite_url = "sqlite://"
mod.engine = create_engine(mod.sqlite_url)
calls = []

new_print = get_testing_print_function(calls)

with patch("builtins.print", new=new_print):
mod.main()
assert calls == expected_calls