A Python repository pattern implementation for SQLAlchemy and SQLModel, inspired by Spring Data JPA repositories.
Project description
sqlrepository
A Python repository pattern implementation for SQLAlchemy and SQLModel, inspired by Spring Data's JPA Repositories.
Overview
sqlrepository provides a clean, type-safe repository pattern for database operations, eliminating boilerplate CRUD code and promoting consistent data access patterns across your application. Whether you're using SQLAlchemy's DeclarativeBase or SQLModel's enhanced models with validation, this library offers a unified interface for your data access layer.
Key Features
- 🎯 Type-safe - Full type hints and generic support for IDE autocomplete
- 🔄 Dual ORM support - Works with both SQLAlchemy and SQLModel
- ⚡ Async support - First-class async/await support with AsyncRepository
- 🚀 Zero boilerplate - Common CRUD operations out of the box
- 🧩 Extensible - Easy to add custom query methods
- ✅ Well-tested - Comprehensive test suite with high coverage
- 📦 Lightweight - Minimal dependencies
Installation
uv add sqlrepository # SQLAlchemy only
uv add 'sqlrepository[sqlmodel]' # + SQLModel support
uv add 'sqlrepository[async]' # + async support
uv add 'sqlrepository[async,sqlmodel]' # everything
pip install works the same way for non-uv projects.
Usage
SQLAlchemy
Define your models using SQLAlchemy's DeclarativeBase and create a repository by subclassing Repository[ModelType, IdType]:
from enum import StrEnum
from sqlalchemy import Boolean, Integer, NVARCHAR
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlrepository import Repository
class Base(DeclarativeBase): ...
class Genre(StrEnum):
POP = "pop"
ROCK = "rock"
JAZZ = "jazz"
OTHER = "other"
class Artist(Base):
__tablename__ = "artists"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str | None] = mapped_column(NVARCHAR(120))
genre: Mapped[Genre] = mapped_column(default=Genre.OTHER)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
class ArtistRepository(Repository[Artist, int]):
pass
Then use it with a session:
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
engine = create_engine("sqlite:///music.db")
Base.metadata.create_all(engine)
with Session(engine) as session:
repo = ArtistRepository(session)
# Create
artist = Artist(name="Jimi Hendrix", genre=Genre.ROCK, is_active=False)
repo.save(artist)
session.commit()
# Read
found = repo.find_by_id(artist.id)
all_artists = repo.find_all()
# Update
found.is_active = True
repo.save(found)
session.commit()
# Delete
repo.delete_by_id(artist.id)
session.commit()
SQLModel
SQLModel combines SQLAlchemy's power with Pydantic's validation. Import Repository from sqlrepository.sqlmodel. The Genre enum is the same as in the SQLAlchemy example.
from sqlmodel import Field, SQLModel
from sqlrepository.sqlmodel import Repository
class Artist(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str | None = Field(default=None, max_length=120)
genre: Genre | None = Field(default=Genre.OTHER)
is_active: bool = Field(default=True)
class ArtistRepository(Repository[Artist, int]):
pass
Then use it with a session:
from sqlmodel import create_engine, Session, SQLModel
engine = create_engine("sqlite:///music.db")
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
repo = ArtistRepository(session)
# Bulk create
artists = [
Artist(name="Jimi Hendrix", genre=Genre.ROCK),
Artist(name="Amy Winehouse", genre=Genre.JAZZ),
Artist(name="The Weeknd", genre=Genre.POP, is_active=True),
]
repo.save_all(artists)
session.commit()
print(repo.count()) # 3
print(repo.exists_by_id(1)) # True
Async Repositories
For async applications, use AsyncRepository with SQLAlchemy's AsyncSession. Always set expire_on_commit=False on the session to avoid lazy-loading errors after commit.
SQLAlchemy
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlrepository import AsyncRepository
class ArtistRepository(AsyncRepository[Artist, int]):
pass
async def main():
engine = create_async_engine("sqlite+aiosqlite:///music.db")
async with AsyncSession(engine, expire_on_commit=False) as session:
repo = ArtistRepository(session)
artist = Artist(name="Herbie Hancock", genre=Genre.JAZZ, is_active=True)
await repo.save(artist)
await session.commit()
all_artists = await repo.find_all()
count = await repo.count()
await repo.delete_by_id(artist.id)
await session.commit()
SQLModel
The pattern is identical — just import AsyncRepository from sqlrepository.sqlmodel instead:
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlrepository.sqlmodel import AsyncRepository
class ArtistRepository(AsyncRepository[Artist, int]):
pass
Transaction Management
Repositories never commit, rollback, or expose transaction control — that responsibility belongs to the caller. The recommended pattern is to use the session as a context manager, which handles commit and rollback automatically:
# Sync — multiple repositories in one transaction
with Session(engine) as session:
artist_repo = ArtistRepository(session)
album_repo = AlbumRepository(session)
artist = Artist(name="Nirvana", genre=Genre.ROCK)
artist_repo.save(artist)
album = Album(title="Nevermind", artist_id=artist.id)
album_repo.save(album)
session.commit() # both committed together, or neither on exception
# Async — same pattern with await
async with AsyncSession(engine, expire_on_commit=False) as session:
artist_repo = ArtistRepository(session)
album_repo = AlbumRepository(session)
artist = Artist(name="Amy Winehouse", genre=Genre.JAZZ)
await artist_repo.save(artist)
album = Album(title="Back to Black", artist_id=artist.id)
await album_repo.save(album)
await session.commit() # both committed together, or neither on exception
Available Methods
All repository classes expose the same interface out of the box. Async variants are async def and must be awaited.
| Method | Returns | Notes |
|---|---|---|
save(entity) |
EntityType |
Insert or merge; flushes to populate generated IDs |
save_all(entities) |
Sequence[EntityType] |
Save a collection |
find_by_id(id) |
EntityType | None |
|
find_all(order_by=None) |
Sequence[EntityType] |
Optionally pass a column expression to order results |
find_all_by_id(ids) |
Sequence[EntityType] |
Efficient batch lookup for a known set of IDs |
exists_by_id(id) |
bool |
Issues SELECT 1 LIMIT 1; never loads the entity |
count() |
int |
|
delete(entity) |
None |
|
delete_by_id(id) |
None |
Issues a single DELETE statement; bypasses ORM mapper events — override in your subclass if you need before_delete/after_delete hooks or Python-side cascades |
delete_all(entities=None) |
None |
Pass None to delete all rows in the table |
delete_all_by_id(ids) |
None |
Adding Custom Query Methods
Extend the repository class with your own query methods using self.session:
from sqlalchemy import select
from sqlrepository import Repository
class ArtistRepository(Repository[Artist, int]):
def find_by_genre(self, genre: Genre) -> list[Artist]:
"""Find all artists in a given genre."""
stmt = select(Artist).where(Artist.genre == genre)
return list(self.session.scalars(stmt))
def find_active(self) -> list[Artist]:
"""Find all active artists."""
stmt = select(Artist).where(Artist.is_active.is_(True))
return list(self.session.scalars(stmt))
For async repositories, use the same pattern with async def and await self.session.scalars(...).
Contributing
Setting Up
git clone https://github.com/cstotzer/sqlrepository.git
cd sqlrepository
uv sync --all-groups
Running Tests
# All tests
uv run pytest
# With coverage
uv run pytest --cov=sqlrepository --cov-report=term-missing
# One suite only
uv run pytest tests/sqlalchemy -v
uv run pytest tests/sqlmodel -v
Code Quality
uv run ruff check src tests # lint
uv run ruff format src tests # format
uv run pyright src # type check
Submitting Changes
- Fork the repository and create a branch from
main - Make your changes with clear, conventional commit messages
- Ensure all tests pass and code is properly formatted
- Open a Pull Request — CI will run lint, type checks, security scan, and tests on Python 3.11–3.14
Release Process
Push a tag — the version is derived from it at build time via hatch-vcs, and the release workflow triggers automatically:
git tag vX.Y.Z
git push origin main vX.Y.Z
The workflow runs the quality gate, builds the package, publishes a GitHub release with generated release notes, and uploads to PyPI — all without further intervention.
Dry run: trigger
release.ymlmanually viaworkflow_dispatchwithdry_run: trueto validate the quality gate and build without publishing.
License
This project is licensed under the MIT License.
- ✅ Free to use — commercially or personally
- ✅ Modify and distribute — make changes and share
- ✅ No share-alike requirement — use in proprietary projects
See the LICENSE file for the full license text.
Made with ❤️ by the sqlrepository contributors
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sqlrepository-0.3.0.tar.gz.
File metadata
- Download URL: sqlrepository-0.3.0.tar.gz
- Upload date:
- Size: 104.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9fb327e9c682951e8ba196e57cfe20fcd991b2d26be8824002683c29d7a811e1
|
|
| MD5 |
1275e938faeff2d165cf3bf87bf40831
|
|
| BLAKE2b-256 |
17bf809e12a8e4f2b4cc87ae5b4b7a0c8aebc0601a228352a7d59706f7c2c1c8
|
Provenance
The following attestation bundles were made for sqlrepository-0.3.0.tar.gz:
Publisher:
release.yml on cstotzer/sqlrepository
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sqlrepository-0.3.0.tar.gz -
Subject digest:
9fb327e9c682951e8ba196e57cfe20fcd991b2d26be8824002683c29d7a811e1 - Sigstore transparency entry: 1219754832
- Sigstore integration time:
-
Permalink:
cstotzer/sqlrepository@deb7b7872bbf05df17ed30c2a1ca6df5156e746a -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/cstotzer
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@deb7b7872bbf05df17ed30c2a1ca6df5156e746a -
Trigger Event:
push
-
Statement type:
File details
Details for the file sqlrepository-0.3.0-py3-none-any.whl.
File metadata
- Download URL: sqlrepository-0.3.0-py3-none-any.whl
- Upload date:
- Size: 13.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0eb3ddf67c70818ec295cc1aabd9e63b9947422c0cc1fca139bb843f29e7c6ad
|
|
| MD5 |
8d9218529f8a47844fd0b4b8e2cebce6
|
|
| BLAKE2b-256 |
7d9e13e808ccdc106d8d611806679716334ded1f122a6c92b1db1b0931b2dbec
|
Provenance
The following attestation bundles were made for sqlrepository-0.3.0-py3-none-any.whl:
Publisher:
release.yml on cstotzer/sqlrepository
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sqlrepository-0.3.0-py3-none-any.whl -
Subject digest:
0eb3ddf67c70818ec295cc1aabd9e63b9947422c0cc1fca139bb843f29e7c6ad - Sigstore transparency entry: 1219754840
- Sigstore integration time:
-
Permalink:
cstotzer/sqlrepository@deb7b7872bbf05df17ed30c2a1ca6df5156e746a -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/cstotzer
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@deb7b7872bbf05df17ed30c2a1ca6df5156e746a -
Trigger Event:
push
-
Statement type: