Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ If you want to see a fully worked Postgres example, check out the [Postgres Quic

### Install

**NB:** Pydantic is optional, see the docs on [using Embar without Pydantic](https://embar.rdrn.me/no-pydantic).

```bash
uv add embar
uv add embar pydantic
```

### Set up database models
Expand Down
132 changes: 132 additions & 0 deletions docs/no-pydantic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Without Pydantic

Pydantic is an optional dependency. If you don't need validation or coercion,
you can skip it entirely — embar will load query results into plain Python
objects instead.

Install without pydantic:

```bash
uv add embar
```

Install with pydantic:

```bash
uv add "embar[pydantic]"
```

## Define your schema

Table definitions are identical regardless of whether pydantic is installed.

```python
import sqlite3
from typing import Annotated

from embar.column.common import Integer, Text, integer, text
from embar.config import EmbarConfig
from embar.db.sqlite import SqliteDb
from embar.table import Table


class User(Table):
embar_config: EmbarConfig = EmbarConfig(table_name="users")
id: Integer = integer(primary=True)
email: Text = text("user_email", not_null=True)


class Message(Table):
id: Integer = integer()
user_id: Integer = integer(fk=lambda: User.id)
content: Text = text()


conn = sqlite3.connect(":memory:")
db = SqliteDb(conn)
db.migrate([User, Message]).run()
```

## Insert and select all columns

Pass `use_pydantic=False` to get plain dataclass objects back with no validation.
`Table.all()` defaults to `use_pydantic=True`; opt out explicitly:

```{.python continuation}
user = User(id=1, email="alice@example.com")
message = Message(id=1, user_id=1, content="Hello!")
db.insert(User).values(user).run()
db.insert(Message).values(message).run()

results = db.select(User.all(use_pydantic=False)).from_(User).run()
assert results[0].id == 1
assert results[0].email == "alice@example.com"
```

## Query with a plain model class

Define a plain class with `Annotated` fields — no `BaseModel` required.
embar reads the annotations to build the SQL and to load results:

```{.python continuation}
from embar.query.where import Eq


class UserSel:
id: Annotated[int, User.id]
email: Annotated[str, User.email]


results = db.select(UserSel).from_(User).where(Eq(User.id, 1)).run()
assert results[0].email == "alice@example.com"
```

## Nested results

Nested tables work the same way — the plain loader parses the JSON
produced by the DB and builds the nested objects recursively:

```{.python continuation}
class UserWithMessages:
id: Annotated[int, User.id]
messages: Annotated[list[Message], Message.many()]


results = (
db.select(UserWithMessages)
.from_(User)
.left_join(Message, Eq(User.id, Message.user_id))
.group_by(User.id)
.run()
)
assert results[0].messages[0].content == "Hello!"
```

## Insert with returning

`.returning()` also accepts `use_pydantic=False`.
This example uses a table with no custom column names so the returned fields map directly:

```{.python continuation}
class Tag(Table):
id: Integer = integer(primary=True)
name: Text = text()


db.migrate([Tag]).run()

tag = Tag(id=1, name="python")
inserted = db.insert(Tag).values(tag).returning(use_pydantic=False).run()
assert inserted[0].name == "python"
```

## What you give up

Without pydantic:

- No type coercion — values are stored as-is from the database driver.
- No field validators or `BeforeValidator` transforms.
- No `ValidationError` on bad data — invalid values pass through silently.

If you need any of these, install `embar[pydantic]` and use the default
`use_pydantic=True` (or omit the argument entirely).
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ nav:
- About: index.md
- Quickstart: quickstart.md
- Postgres Quickstart: postgres-quickstart.md
- Without Pydantic: no-pydantic.md

- Schemas:
- Basics: schemas/basics.md
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@ requires-python = ">=3.14"
dependencies = [
"psycopg[binary]>=3.2.11",
"psycopg-pool>=3.3.0",
"pydantic>=2.12.4",
]

[project.optional-dependencies]
pydantic = ["pydantic~=2.10"]

[project.urls]
homepage = "https://github.com/carderne/embar"
repository = "https://github.com/carderne/embar"
Expand All @@ -52,6 +54,7 @@ embar = "embar.tools.commands:main"

[dependency-groups]
dev = [
"pydantic~=2.10",
"mkdocs-gen-files>=0.5.0",
"mkdocs-literate-nav>=0.6.2",
"mkdocs-material>=9.6.23",
Expand Down
17 changes: 1 addition & 16 deletions src/embar/custom_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
from decimal import Decimal
from typing import Any, TypeAliasType

from pydantic import Json

Undefined: Any = ...


Expand All @@ -32,18 +30,5 @@ def __bool__(self) -> bool:

# All the types that are allowed to ser/de to/from the DB.
type PyType = (
str
| int
| float
| Decimal
| bool
| bytes
| date
| time
| datetime
| timedelta
| dict[str, Any]
| Json[Any]
| list[PyType]
| None
str | int | float | Decimal | bool | bytes | date | time | datetime | timedelta | dict[str, Any] | list[Any] | None
)
10 changes: 5 additions & 5 deletions src/embar/db/pg.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@
from psycopg import AsyncConnection, AsyncTransaction, Connection, Transaction
from psycopg.types.json import Json
from psycopg_pool import AsyncConnectionPool, ConnectionPool
from pydantic import BaseModel

from embar.column.base import EnumBase
from embar.db._util import get_migration_defs, merge_ddls
from embar.db.base import AsyncDbBase, DbBase
from embar.migration import Migration, MigrationDefs
from embar.model import DataModel
from embar.query.delete import DeleteQueryReady
from embar.query.insert import InsertQuery
from embar.query.query import QueryMany, QuerySingle
Expand Down Expand Up @@ -134,13 +134,13 @@ def transaction(self) -> PgDbTransaction:
"""
return PgDbTransaction(self)

def select[M: BaseModel](self, model: type[M]) -> SelectQuery[M, Self]:
def select[M: DataModel](self, model: type[M]) -> SelectQuery[M, Self]:
"""
Create a SELECT query.
"""
return SelectQuery[M, Self](db=self, model=model)

def select_distinct[M: BaseModel](self, model: type[M]) -> SelectDistinctQuery[M, Self]:
def select_distinct[M: DataModel](self, model: type[M]) -> SelectDistinctQuery[M, Self]:
"""
Create a SELECT query.
"""
Expand Down Expand Up @@ -358,13 +358,13 @@ def transaction(self) -> AsyncPgDbTransaction:
"""
return AsyncPgDbTransaction(self)

def select[M: BaseModel](self, model: type[M]) -> SelectQuery[M, Self]:
def select[M: DataModel](self, model: type[M]) -> SelectQuery[M, Self]:
"""
Create a SELECT query.
"""
return SelectQuery[M, Self](db=self, model=model)

def select_distinct[M: BaseModel](self, model: type[M]) -> SelectDistinctQuery[M, Self]:
def select_distinct[M: DataModel](self, model: type[M]) -> SelectDistinctQuery[M, Self]:
"""
Create a SELECT query.
"""
Expand Down
7 changes: 3 additions & 4 deletions src/embar/db/sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,11 @@
override,
)

from pydantic import BaseModel

from embar.column.base import EnumBase
from embar.db._util import get_migration_defs, merge_ddls
from embar.db.base import DbBase
from embar.migration import Migration, MigrationDefs
from embar.model import DataModel
from embar.query.delete import DeleteQueryReady
from embar.query.insert import InsertQuery
from embar.query.query import QueryMany, QuerySingle
Expand Down Expand Up @@ -60,13 +59,13 @@ def transaction(self) -> SqliteDbTransaction:
db_copy._commit_after_execute = False
return SqliteDbTransaction(db_copy)

def select[M: BaseModel](self, model: type[M]) -> SelectQuery[M, Self]:
def select[M: DataModel](self, model: type[M]) -> SelectQuery[M, Self]:
"""
Create a SELECT query.
"""
return SelectQuery[M, Self](db=self, model=model)

def select_distinct[M: BaseModel](self, model: type[M]) -> SelectDistinctQuery[M, Self]:
def select_distinct[M: DataModel](self, model: type[M]) -> SelectDistinctQuery[M, Self]:
"""
Create a SELECT query.
"""
Expand Down
Loading
Loading