Entity Framework (SQLModel)¶
pydantic_linq.orm is Entity Framework Core for SQLModel. DbContext is the unit of work, and Queryable is IQueryable<T>: LINQ operators that build one SQL statement and run it when you enumerate the query or call a terminal operator.
pip install "pydantic-linq[sqlmodel]"
pip install aiosqlite # or asyncpg, aiomysql, ... for async
from sqlmodel import Field, SQLModel, create_engine
from pydantic_linq.orm import DbContext
class Hero(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
age: int
with DbContext(create_engine("sqlite:///heroes.db")) as db:
db.ensure_created()
db.add(Hero(name="Tony", age=48))
db.save_changes()
db.set(Hero).where(lambda h: h.age > 30).order_by(lambda h: h.name).select(lambda h: h.name).to_list()
# SELECT hero.name FROM hero WHERE hero.age > 30 ORDER BY hero.name
How lambdas become SQL¶
EF Core reads expression trees. Python has none, so a lambda is called once with the query's shape instead of a row: the model class, so lambda h: h.age > 30 returns SQLAlchemy's Hero.age > 30. After select, the shape is the selected column or tuple of columns. After join, it is the pair the result selector built.
That gives three rules:
- Combine conditions with
&,|and~. Python'sand,orandnotcan't be overloaded, solambda h: h.age > 1 and h.age < 9can't be translated. Wrap each comparison in parentheses:(h.age > 1) & (h.age < 9). - Use SQLAlchemy's column methods where Python has syntax:
col(h.name).in_([...])forin,col(h.team_id).is_(None)foris None,h.name.contains("x")/startswith/endswithforLIKE.colcomes fromsqlmodel. - A
where,order_byor join key lambda that can't be translated raisesInvalidOperationError, as EF Core does. Callas_enumerable()first to run the rest of the query in memory.
to_query_string() shows the SQL.
Client evaluation¶
As in EF Core:
- A
selectwhose result isn't SQL (an f-string, a dict, a model, a method call such ash.name.upper()) runs in memory on the fetched rows. That is EF's client projection. Every operator after it also runs in memory, with the same lambdas now receiving real objects. - Every LINQ operator EF can't translate (
zip,aggregate,chunk,take_while,skip_last,sequence_equal, ...) is still there. It comes fromEnumerableand runs over the query's results, as if you had calledAsEnumerable()first. as_enumerable()andas_async_enumerable()switch to in-memory LINQ explicitly.
Translated operators¶
| C# / EF Core | Python | SQL |
|---|---|---|
Where |
where(pred) |
WHERE |
Select |
select(sel) |
column list |
SelectMany (over a collection navigation) |
select_many(lambda t: t.heroes[, result]) |
JOIN |
OrderBy / OrderByDescending / ThenBy / ThenByDescending |
same, snake_case | ORDER BY |
Order / OrderDescending |
order() / order_descending() |
ORDER BY |
Reverse (after OrderBy) |
reverse() |
flips ORDER BY |
Skip / Take / Take(Range) |
skip(n) / take(n) / take(slice(a, b)) |
OFFSET / LIMIT |
Distinct |
distinct() |
DISTINCT |
Union / Concat / Intersect / Except |
union / concat / intersect / except_ |
UNION / UNION ALL / INTERSECT / EXCEPT |
Join |
join(inner, outer_key, inner_key, result) |
INNER JOIN |
LeftJoin / RightJoin (EF 10) |
left_join / right_join |
LEFT OUTER JOIN |
GroupBy(...).Select(g => ...) |
group_by(key[, element]).select(lambda g: ...) |
GROUP BY |
GroupBy(...).Where(g => g.Count() > 1) |
.where(lambda g: g.count() > 1) |
HAVING |
CountBy |
count_by(key) |
GROUP BY + COUNT |
Count / LongCount / Any / All / Contains |
same | COUNT, LIMIT 1, primary-key compare for entities |
First / FirstOrDefault / Single / SingleOrDefault |
same | LIMIT 1 / LIMIT 2 |
Last / LastOrDefault (after OrderBy) |
same | reversed ORDER BY + LIMIT 1 |
ElementAt / ElementAtOrDefault |
same | OFFSET n LIMIT 1 |
Sum / Min / Max / Average |
same | SUM / MIN / MAX / AVG |
MinBy / MaxBy |
min_by / max_by |
ORDER BY + LIMIT 1 |
Inside a group lambda, g.key is the key, and g.count([pred]), g.long_count, g.any([pred]), g.sum(sel), g.min(sel), g.max(sel) and g.average(sel) are SQL aggregates. A composite key is a tuple: group_by(lambda h: (h.team_id, h.age)). A group lambda that SQL can't express (for example lambda g: {g.key: g.count()}) runs the grouping in memory with the same lambda. Enumerating group_by(...) directly also groups in memory and yields Grouping objects.
Like LINQ, operators compose in order: take(3).where(...) filters the three rows take returned (the query is wrapped in a subquery), and take(5).skip(2) returns three rows.
Last, LastOrDefault and Reverse need an order_by first, and raise InvalidOperationError without one, as in EF Core. Min, Max and Average of an empty query raise InvalidOperationError; Sum returns 0.
EF Core query features¶
| EF Core | Python |
|---|---|
Include(h => h.Team) |
include(lambda h: h.team) |
ThenInclude(t => t.Members) |
then_include(lambda t: t.members) |
AsSplitQuery() / AsSingleQuery() |
as_split_query() (default: one SELECT ... IN per include) / as_single_query() (LEFT OUTER JOINs) |
AsNoTracking() / AsTracking() |
as_no_tracking() / as_tracking() |
TagWith("x") |
tag_with("x") (a /* x */ comment in the SQL) |
ToQueryString() |
to_query_string() |
AsEnumerable() / AsAsyncEnumerable() |
as_enumerable() / as_async_enumerable() |
Load() |
load() |
ExecuteDelete() |
execute_delete() → row count |
ExecuteUpdate(s => s.SetProperty(h => h.Age, h => h.Age + 1)) |
execute_update(lambda h: {h.age: h.age + 1}) → row count |
execute_update and execute_delete don't change entities already loaded, as in EF Core. They run inside the session's transaction, so save_changes() commits them.
DbContext¶
| EF Core | Python |
|---|---|
new AppDbContext(options) |
DbContext(engine), DbContext(session), DbContext(async_engine) |
Set<T>() / DbSet<T> |
db.set(Model) |
Add / AddRange |
add(e) / add_range(es) |
Attach / Update / UpdateRange |
attach(e) / update(e) / update_range(es) |
Remove / RemoveRange |
remove(e) / remove_range(es) |
Find(key) |
find(Model, *key) |
SaveChanges() |
save_changes() → entities written |
ChangeTracker.Clear() |
clear() |
FromSql($"...") |
from_sql(Model, "SELECT ... WHERE x > :x", x=1) |
Database.ExecuteSql(...) |
execute_sql("UPDATE ...", **params) → row count |
Database.BeginTransaction() |
begin_transaction() (commit() / rollback(), or use it in with) |
Database.EnsureCreated() / EnsureDeleted() |
ensure_created() / ensure_deleted() |
using / Dispose |
with DbContext(...) / async with DbContext(...) |
As in EF Core, a query doesn't flush pending changes (the session has autoflush off), and entities keep their values after save_changes(). Change tracking itself is SQLAlchemy's: db.session is the underlying Session.
from_sql binds values as :name parameters. Don't format values into the SQL string. Operators after from_sql compose over it as a subquery.
Async¶
Pass an AsyncEngine or AsyncSession, use async with, and await the *_async operators. They keep EF's Async suffix because the same Queryable also has the sync ones.
from sqlalchemy.ext.asyncio import create_async_engine
async with DbContext(create_async_engine("sqlite+aiosqlite:///heroes.db")) as db:
await db.ensure_created_async()
db.add(Hero(name="Tony", age=48))
await db.save_changes_async()
adults = await db.set(Hero).where(lambda h: h.age >= 18).to_list_async()
| Async terminal operators |
|---|
to_list_async, to_array_async, to_hash_set_async, to_dictionary_async, to_lookup_async |
count_async, long_count_async, any_async, all_async, contains_async |
first_async, first_or_default_async, single_async, single_or_default_async, last_async, last_or_default_async, element_at_async, element_at_or_default_async |
sum_async, min_async, max_async, average_async, min_by_async, max_by_async |
for_each_async (sync or async action), load_async, execute_delete_async, execute_update_async |
async for x in query, as_async_enumerable() (every System.Linq.Async operator, in memory) |
On the context: save_changes_async, find_async, execute_sql_async, begin_transaction_async, ensure_created_async, ensure_deleted_async.
A sync terminal operator on an async context, or the other way round, raises InvalidOperationError. Lazy loading doesn't work with async sessions, so load relationships with include.
Not ported¶
- Migrations. Use Alembic.
- Model configuration (
OnModelCreating, fluent API, owned types, value converters, global query filters). Configure the SQLModel / SQLAlchemy model instead. - Filtered include (
Include(b => b.Posts.Where(...))),GroupJointranslation, andEntry(e).State.group_joinruns in memory; usedb.sessionfor the change tracker. - Compiled queries. SQLAlchemy caches compiled statements already.