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.
Inspired by Spring Data JPA, this package brings the elegant repository pattern from the Java ecosystem to Python, adapted for SQLAlchemy's powerful ORM capabilities.
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
# Basic installation with SQLAlchemy support
pip install sqlrepository
# Or with uv
uv add sqlrepository
# For SQLModel support (optional)
pip install 'sqlrepository[sqlmodel]'
# or with uv
uv add 'sqlrepository[sqlmodel]'
# For async support (SQLAlchemy async)
pip install 'sqlrepository[async]'
# or with uv
uv add 'sqlrepository[async]'
# For full async + SQLModel support
pip install 'sqlrepository[async,sqlmodel]'
# or with uv
uv add 'sqlrepository[async,sqlmodel]'
Usage
Creating Repositories with SQLAlchemy
Define your model using SQLAlchemy's DeclarativeBase:
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from sqlrepository import Base, Repository
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True)
email: Mapped[str] = mapped_column(String(100))
age: Mapped[int | None] = mapped_column(Integer, nullable=True)
class UserRepository(Repository[User, int]):
"""Repository for User model."""
pass
Use the repository in your application:
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
# Setup
engine = create_engine("sqlite:///app.db")
Base.metadata.create_all(engine)
with Session(engine) as session:
user_repo = UserRepository(session)
# Create
new_user = User(username="john_doe", email="john@example.com", age=30)
user_repo.save(new_user)
session.commit()
# Read
user = user_repo.find_by_id(1)
all_users = user_repo.find_all()
# Update
user.email = "newemail@example.com"
user_repo.save(user)
session.commit()
# Delete
user_repo.delete_by_id(1)
session.commit()
# Count
total = user_repo.count()
Creating Repositories with SQLModel
SQLModel combines SQLAlchemy's power with Pydantic's validation:
from sqlmodel import Field, SQLModel
from sqlrepository import SQLModelRepository
class Artist(SQLModel, table=True):
"""SQLModel artist with built-in validation."""
__tablename__ = "artists"
ArtistId: int | None = Field(default=None, primary_key=True)
Name: str = Field(index=True, min_length=1, max_length=120)
class ArtistRepository(SQLModelRepository[Artist, int]):
"""Repository for Artist model."""
pass
Use with SQLModel:
from sqlmodel import create_engine, Session, SQLModel
# Setup
engine = create_engine("sqlite:///music.db")
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
artist_repo = ArtistRepository(session)
# Create with validation
artist = Artist(Name="AC/DC")
artist_repo.save(artist)
session.commit()
# Bulk operations
artists = [
Artist(Name="Led Zeppelin"),
Artist(Name="Pink Floyd"),
]
artist_repo.save_all(artists)
session.commit()
Using Async Repositories
For asynchronous database operations, use AsyncRepository with SQLAlchemy's async capabilities:
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlrepository import Base
from sqlrepository.async_repository import AsyncRepository
class Artist(Base):
__tablename__ = "artists"
ArtistId: Mapped[int] = mapped_column(Integer, primary_key=True)
Name: Mapped[str] = mapped_column(String(120))
class ArtistRepository(AsyncRepository[Artist, int]):
pass
# Usage
async def main():
engine = create_async_engine("sqlite+aiosqlite:///music.db")
async with AsyncSession(engine) as session:
artist_repo = ArtistRepository(session)
# All repository methods are async
artist = Artist(Name="AC/DC")
await artist_repo.save(artist)
await session.commit()
# Find operations
found = await artist_repo.find_by_id(1)
all_artists = await artist_repo.find_all()
count = await artist_repo.count()
# Delete operations
await artist_repo.delete_by_id(1)
await session.commit()
SQLModel also supports async operations:
from sqlmodel import Field, SQLModel
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlrepository.async_repository import AsyncSQLModelRepository
class Artist(SQLModel, table=True):
__tablename__ = "artists"
ArtistId: int | None = Field(default=None, primary_key=True)
Name: str = Field(max_length=120)
class ArtistRepository(AsyncSQLModelRepository[Artist, int]):
pass
async def main():
engine = create_async_engine("sqlite+aiosqlite:///music.db")
async with AsyncSession(engine, expire_on_commit=False) as session:
artist_repo = ArtistRepository(session)
artist = Artist(Name="Led Zeppelin")
await artist_repo.save(artist)
await session.commit()
Note: When using async sessions, set expire_on_commit=False to avoid lazy-loading issues after commit.
Transaction Management
Following the Spring Data JPA pattern, repositories do not expose transaction control methods (commit(), flush(), rollback()). Transaction boundaries should be managed by the caller (e.g., service layer or application code).
Why This Pattern?
This separation of concerns provides several benefits:
- Clear Responsibility: Repositories handle data access, not transaction boundaries
- Flexibility: The caller controls when to commit or rollback
- Composability: Multiple repository calls can participate in a single transaction
- Testability: Easier to test with controlled transaction boundaries
Synchronous Transaction Management
Using Context Managers (Recommended)
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlrepository import Repository
engine = create_engine("sqlite:///chinook.db")
# Context manager handles commit/rollback automatically
with Session(engine) as session:
repo = Repository[Artist, int](Artist, session)
artist = Artist(Name="Pink Floyd")
repo.save(artist)
# Commit happens automatically when exiting the context
# Rollback happens automatically on exception
# For multiple operations in one transaction:
with Session(engine) as session:
artist_repo = Repository[Artist, int](Artist, session)
album_repo = Repository[Album, int](Album, session)
artist = Artist(Name="The Beatles")
artist_repo.save(artist)
album = Album(Title="Abbey Road", ArtistId=artist.ArtistId)
album_repo.save(album)
# Both operations committed together
Manual Transaction Control
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlrepository import Repository
engine = create_engine("sqlite:///chinook.db")
session = Session(engine)
try:
repo = Repository[Artist, int](Artist, session)
artist = Artist(Name="Queen")
repo.save(artist)
# Explicitly commit
session.commit()
except Exception as e:
# Explicitly rollback on error
session.rollback()
raise
finally:
session.close()
Asynchronous Transaction Management
Using Async Context Managers (Recommended)
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlrepository import AsyncRepository
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
# Context manager handles commit/rollback automatically
async with AsyncSession(engine, expire_on_commit=False) as session:
repo = AsyncRepository[AsyncArtist, int](AsyncArtist, session)
artist = AsyncArtist(Name="Radiohead")
await repo.save(artist)
# Commit happens automatically when exiting the context
# Rollback happens automatically on exception
# For multiple operations in one transaction:
async with AsyncSession(engine, expire_on_commit=False) as session:
artist_repo = AsyncRepository[AsyncArtist, int](AsyncArtist, session)
album_repo = AsyncRepository[AsyncAlbum, int](AsyncAlbum, session)
artist = AsyncArtist(Name="Nirvana")
await artist_repo.save(artist)
album = AsyncAlbum(Title="Nevermind", ArtistId=artist.ArtistId)
await album_repo.save(album)
# Both operations committed together
Manual Async Transaction Control
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlrepository import AsyncRepository
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
session = AsyncSession(engine, expire_on_commit=False)
try:
repo = AsyncRepository[AsyncArtist, int](AsyncArtist, session)
artist = AsyncArtist(Name="Metallica")
await repo.save(artist)
# Explicitly commit
await session.commit()
except Exception as e:
# Explicitly rollback on error
await session.rollback()
raise
finally:
await session.close()
Best Practices
- Use Context Managers: They automatically handle commit/rollback and cleanup
- One Transaction per Use Case: Group related operations in a single transaction
- Handle Errors Gracefully: Always rollback on exceptions
- Set expire_on_commit=False: For async sessions to avoid lazy-loading issues
- Service Layer Pattern: Let services manage transactions, repositories manage data
Adding Custom Query Methods
Extend the repository with your own query methods:
from sqlalchemy import select
from sqlrepository import Repository
class UserRepository(Repository[User, int]):
def find_by_username(self, username: str) -> User | None:
"""Find user by username."""
statement = select(User).where(User.username == username)
return self.session.scalar(statement)
def find_by_age_range(self, min_age: int, max_age: int) -> list[User]:
"""Find users within age range."""
statement = (
select(User)
.where(User.age >= min_age, User.age <= max_age)
.order_by(User.age)
)
return list(self.session.scalars(statement))
def find_active_users(self) -> list[User]:
"""Custom business logic query."""
statement = (
select(User)
.where(User.is_active == True)
.order_by(User.username)
)
return list(self.session.scalars(statement))
For async repositories, use the same pattern with async methods:
from sqlalchemy import select
from sqlrepository.async_repository import AsyncRepository
class AsyncUserRepository(AsyncRepository[User, int]):
async def find_by_username(self, username: str) -> User | None:
"""Find user by username."""
statement = select(User).where(User.username == username)
result = await self.session.scalar(statement)
return result
async def find_by_age_range(self, min_age: int, max_age: int) -> list[User]:
"""Find users within age range."""
statement = (
select(User)
.where(User.age >= min_age, User.age <= max_age)
.order_by(User.age)
)
result = await self.session.scalars(statement)
return list(result)
async def find_active_users(self) -> list[User]:
"""Custom business logic query."""
statement = (
select(User)
.where(User.is_active == True)
.order_by(User.username)
)
result = await self.session.scalars(statement)
return list(result)
# Usage
async def main():
async with AsyncSession(engine) as session:
user_repo = AsyncUserRepository(session)
# Use custom async methods
user = await user_repo.find_by_username("john_doe")
active_users = await user_repo.find_active_users()
young_users = await user_repo.find_by_age_range(18, 30)
### Available Repository Methods
All repositories provide these methods out of the box:
**Create/Update:**
- `save(entity)` - Save or update a single entity
- `save_all(entities)` - Save or update multiple entities
**Read:**
- `find_by_id(id)` - Find entity by primary key
- `find_all()` - Get all entities
- `find_all_by_id(ids)` - Find multiple entities by IDs
- `exists_by_id(id)` - Check if entity exists
- `count()` - Count total entities
**Delete:**
- `delete(entity)` - Delete a single entity
- `delete_by_id(id)` - Delete by primary key
- `delete_all()` - Delete all entities
- `delete_all_by_id(ids)` - Delete multiple by IDs
**Transaction Control:**
- `flush()` - Flush pending changes
- `commit()` - Commit transaction
- `rollback()` - Rollback transaction
## Contributing
We welcome contributions! Here's how to get started:
### Setting Up Development Environment
```bash
# Clone the repository
git clone https://github.com/cstotzer/sqlrepository.git
cd sqlrepository
# Install dependencies with uv
uv sync --all-groups
# The virtual environment is automatically managed by uv
Running Tests
# Run all tests
uv run pytest
# Run with coverage
uv run pytest --cov=sqlrepository --cov-report=term-missing
# Run specific test file
uv run pytest tests/test_repository.py -v
# Run specific test
uv run pytest tests/test_repository.py::test_save -v
# Run async tests only
uv run pytest tests/test_async_repository.py tests/test_async_sqlmodel_repository.py -v
Code Quality Checks
# Run linter
uv run ruff check src tests
# Auto-fix linting issues
uv run ruff check --fix src tests
# Format code
uv run ruff format src tests
# Type checking
uv run pyright src
Submitting Changes
- Fork the repository on GitHub
- Create a feature branch from
main:git checkout -b feature/your-feature-name
- Make your changes with clear, descriptive commits
- Ensure all tests pass and code is properly formatted
- Push to your fork:
git push origin feature/your-feature-name
- Open a Pull Request on GitHub with:
- Clear description of changes
- Reference to any related issues
- Test coverage for new features
Pull Request Guidelines
- Follow existing code style and conventions
- Add tests for new functionality
- Update documentation if needed
- Keep changes focused and atomic
- Ensure CI checks pass before requesting review
All pull requests trigger automated checks:
- ✅ Linting and formatting (ruff)
- ✅ Type checking (pyright)
- ✅ Security scanning (Trivy — results in GitHub Security tab)
- ✅ Tests on Python 3.11, 3.12, 3.13, and 3.14
- ✅ Coverage reporting (80% minimum threshold)
Release Process
Releases are triggered by pushing a version tag. The workflow handles the GitHub release and PyPI publishing automatically.
Quick release steps:
uv version --bump patch # or minor, major
git add pyproject.toml uv.lock
git commit -m "chore: bump version to X.Y.Z"
git tag vX.Y.Z
git push origin main vX.Y.Z
For detailed instructions, see .github/CICD.md.
License
This project is licensed under the GNU General Public License v3.0.
What This Means
- ✅ Free to use - Use commercially or personally
- ✅ Modify and distribute - Make changes and share
- ⚠️ Share alike - Derivative works must use GPL-3.0
- ⚠️ Disclose source - Source code must be available
- ⚠️ Include license - Copy of GPL-3.0 must be included
See the LICENSE file for the full license text.
Why GPL-3.0?
We believe in open source software and want to ensure that improvements to this library remain open and available to everyone. The GPL-3.0 license guarantees that all derivatives and modifications stay free and open source.
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.2.1.tar.gz.
File metadata
- Download URL: sqlrepository-0.2.1.tar.gz
- Upload date:
- Size: 114.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
76476b286975ad26f71d1158414cefc55efab28058cfe1f58202bf31f8c3c9e7
|
|
| MD5 |
34d7ba101693876fa7e934eed26c3e38
|
|
| BLAKE2b-256 |
1455fddaf374e9a8dca798bba3f00a9cc3f6c2e87b4ee7dee62d92b7fa1d06d0
|
Provenance
The following attestation bundles were made for sqlrepository-0.2.1.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.2.1.tar.gz -
Subject digest:
76476b286975ad26f71d1158414cefc55efab28058cfe1f58202bf31f8c3c9e7 - Sigstore transparency entry: 1195481184
- Sigstore integration time:
-
Permalink:
cstotzer/sqlrepository@ceda0b869411a6f61ad459e1e2c17eae9be84104 -
Branch / Tag:
refs/tags/v0.2.1 - Owner: https://github.com/cstotzer
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@ceda0b869411a6f61ad459e1e2c17eae9be84104 -
Trigger Event:
push
-
Statement type:
File details
Details for the file sqlrepository-0.2.1-py3-none-any.whl.
File metadata
- Download URL: sqlrepository-0.2.1-py3-none-any.whl
- Upload date:
- Size: 25.0 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 |
d52d35c4df7bf543dd86f3869d8718564e0328effa17fed74acef4009e9801ad
|
|
| MD5 |
cc3f6a4cc0ad88f6cf06cbe90e75100f
|
|
| BLAKE2b-256 |
0e0661b77a96ff38871dcde497eb5facb2bd7dd85b0731da3f5a79e960db30ee
|
Provenance
The following attestation bundles were made for sqlrepository-0.2.1-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.2.1-py3-none-any.whl -
Subject digest:
d52d35c4df7bf543dd86f3869d8718564e0328effa17fed74acef4009e9801ad - Sigstore transparency entry: 1195481186
- Sigstore integration time:
-
Permalink:
cstotzer/sqlrepository@ceda0b869411a6f61ad459e1e2c17eae9be84104 -
Branch / Tag:
refs/tags/v0.2.1 - Owner: https://github.com/cstotzer
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@ceda0b869411a6f61ad459e1e2c17eae9be84104 -
Trigger Event:
push
-
Statement type: