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
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
name: lint ${{ matrix.python-version }}
strategy:
matrix:
python-version: [ '3.10', '3.11', '3.12' ]
python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14' ]
fail-fast: false
steps:
- uses: actions/checkout@v4
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
name: test ${{ matrix.python-version }}
strategy:
matrix:
python-version: [ '3.10', '3.11', '3.12' ]
python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14' ]
fail-fast: false
steps:
- uses: actions/checkout@v4
Expand Down
7 changes: 3 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: check-added-large-files
- id: end-of-file-fixer
- id: check-toml

- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.11.5
rev: v0.14.0
hooks:
- id: ruff
args:
Expand All @@ -16,7 +15,7 @@ repos:
- id: ruff-format

- repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.6.14
rev: 0.9.0
hooks:
- id: uv-lock
- id: uv-export
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "sqlalchemy-crud-plus"
description = "Asynchronous CRUD operation based on SQLAlchemy2 model"
description = "Advanced asynchronous CRUD SDK built on SQLAlchemy 2.0"
authors = [
{ name = "Wu Clan", email = "jianhengwu0407@gmail.com" },
]
Expand All @@ -14,6 +14,8 @@ classifiers = [
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = [
"sqlalchemy>=2.0.0",
Expand Down
50 changes: 30 additions & 20 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,52 +1,62 @@
# This file was autogenerated by uv via the following command:
# uv export -o requirements.txt --no-hashes
-e .
aiosqlite==0.20.0
aiosqlite==0.21.0
annotated-types==0.7.0
# via pydantic
backports-asyncio-runner==1.2.0 ; python_full_version < '3.11'
# via pytest-asyncio
cfgv==3.4.0
# via pre-commit
colorama==0.4.6 ; sys_platform == 'win32'
# via pytest
distlib==0.3.9
distlib==0.4.0
# via virtualenv
exceptiongroup==1.2.2 ; python_full_version < '3.11'
exceptiongroup==1.3.0 ; python_full_version < '3.11'
# via pytest
filelock==3.16.1
filelock==3.20.0
# via virtualenv
greenlet==3.1.1 ; (python_full_version < '3.13' and platform_machine == 'AMD64') or (python_full_version < '3.13' and platform_machine == 'WIN32') or (python_full_version < '3.13' and platform_machine == 'aarch64') or (python_full_version < '3.13' and platform_machine == 'amd64') or (python_full_version < '3.13' and platform_machine == 'ppc64le') or (python_full_version < '3.13' and platform_machine == 'win32') or (python_full_version < '3.13' and platform_machine == 'x86_64')
greenlet==3.2.4 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'
# via sqlalchemy
identify==2.6.1
identify==2.6.15
# via pre-commit
iniconfig==2.0.0
iniconfig==2.1.0
# via pytest
nodeenv==1.9.1
# via pre-commit
packaging==24.1
packaging==25.0
# via pytest
platformdirs==4.3.6
platformdirs==4.5.0
# via virtualenv
pluggy==1.5.0
pluggy==1.6.0
# via pytest
pre-commit==4.0.1
pydantic==2.9.2
pre-commit==4.3.0
pydantic==2.12.0
# via sqlalchemy-crud-plus
pydantic-core==2.23.4
pydantic-core==2.41.1
# via pydantic
pytest==8.3.3
pygments==2.19.2
# via pytest
pytest==8.4.2
# via pytest-asyncio
pytest-asyncio==0.24.0
pyyaml==6.0.2
pytest-asyncio==1.2.0
pyyaml==6.0.3
# via pre-commit
sqlalchemy==2.0.36
sqlalchemy==2.0.44
# via sqlalchemy-crud-plus
tomli==2.0.2 ; python_full_version < '3.11'
tomli==2.3.0 ; python_full_version < '3.11'
# via pytest
typing-extensions==4.12.2
typing-extensions==4.15.0
# via
# aiosqlite
# exceptiongroup
# pydantic
# pydantic-core
# pytest-asyncio
# sqlalchemy
virtualenv==20.27.1
# typing-inspection
# virtualenv
typing-inspection==0.4.2
# via pydantic
virtualenv==20.35.3
# via pre-commit
13 changes: 7 additions & 6 deletions sqlalchemy_crud_plus/crud.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime, timezone
from typing import Any, Generic, Sequence
from typing import Any, Generic, Sequence, cast

from sqlalchemy import (
Column,
ColumnExpressionArgument,
CursorResult,
Row,
RowMapping,
Select,
Expand Down Expand Up @@ -49,7 +50,7 @@ def _get_primary_key(self) -> Column | list[Column]:
else:
return list(primary_key)

def _get_pk_filter(self, pk: Any | Sequence[Any]) -> list[ColumnExpressionArgument[bool]]:
def _get_pk_filter(self, pk: Any | list[Any]) -> list[ColumnExpressionArgument[bool]]:
"""
Get the primary key filter(s).

Expand Down Expand Up @@ -478,7 +479,7 @@ async def update_model(
data = obj if isinstance(obj, dict) else obj.model_dump(exclude_unset=True)
data.update(kwargs)
stmt = update(self.model).where(*filters).values(**data)
result = await session.execute(stmt)
result = cast(CursorResult[Any], await session.execute(stmt))

if flush:
await session.flush()
Expand Down Expand Up @@ -519,7 +520,7 @@ async def update_model_by_column(

data = obj if isinstance(obj, dict) else obj.model_dump(exclude_unset=True)
stmt = update(self.model).where(*filters).values(**data)
result = await session.execute(stmt)
result = cast(CursorResult[Any], await session.execute(stmt))

if flush:
await session.flush()
Expand Down Expand Up @@ -588,7 +589,7 @@ async def delete_model(
filters = self._get_pk_filter(pk)

stmt = delete(self.model).where(*filters)
result = await session.execute(stmt)
result = cast(CursorResult[Any], await session.execute(stmt))

if flush:
await session.flush()
Expand Down Expand Up @@ -648,7 +649,7 @@ async def delete_model_by_column(
else delete(self.model).where(*filters)
)

result = await session.execute(stmt)
result = cast(CursorResult[Any], await session.execute(stmt))

if flush:
await session.flush()
Expand Down
132 changes: 63 additions & 69 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,26 +45,23 @@ async def async_db_session() -> AsyncGenerator[AsyncSession, None]:


@pytest_asyncio.fixture
async def sample_ins(async_db_session: AsyncSession, crud_ins: CRUDPlus[Ins]) -> list[Ins]:
async def sample_ins(async_db_session: AsyncSession) -> list[Ins]:
"""Provide a database populated with test data."""
async with async_db_session.begin():
test_data = [Ins(name=f'item_{i}', is_deleted=(i % 2 == 0)) for i in range(1, 11)]
async_db_session.add_all(test_data)
return test_data
test_data = [Ins(name=f'item_{i}', is_deleted=(i % 2 == 0)) for i in range(1, 11)]
async_db_session.add_all(test_data)
await async_db_session.commit()
return test_data


@pytest_asyncio.fixture
async def sample_ins_pks(async_db_session: AsyncSession) -> dict[str, list[InsPks]]:
"""Provide a database populated with composite key test data."""
async with async_db_session.begin():
men_data = [InsPks(id=i, name=f'man_{i}', sex='men') for i in range(1, 4)]
women_data = [InsPks(id=i, name=f'woman_{i}', sex='women') for i in range(1, 4)]
all_data = men_data + women_data

async_db_session.add_all(all_data)
await async_db_session.flush()

return {'men': men_data, 'women': women_data, 'all': all_data}
men_data = [InsPks(id=i, name=f'man_{i}', sex='men') for i in range(1, 4)]
women_data = [InsPks(id=i, name=f'woman_{i}', sex='women') for i in range(1, 4)]
all_data = men_data + women_data
async_db_session.add_all(all_data)
await async_db_session.commit()
return {'men': men_data, 'women': women_data, 'all': all_data}


@pytest.fixture
Expand Down Expand Up @@ -100,74 +97,72 @@ def rel_crud_role() -> CRUDPlus[RelRole]:
@pytest_asyncio.fixture
async def rel_sample_users(async_db_session: AsyncSession) -> list[RelUser]:
"""Create sample relation users."""
async with async_db_session.begin():
users = []
for i in range(1, 4):
user_data = CreateRelUser(name=f'user_{i}')
user = RelUser(**user_data.model_dump())
async_db_session.add(user)
users.append(user)
return users
users = []
for i in range(1, 4):
user_data = CreateRelUser(name=f'user_{i}')
user = RelUser(**user_data.model_dump())
async_db_session.add(user)
users.append(user)
await async_db_session.commit()
return users


@pytest_asyncio.fixture
async def rel_sample_profiles(async_db_session: AsyncSession, rel_sample_users: list[RelUser]) -> list[RelProfile]:
"""Create sample relation profiles."""
async with async_db_session.begin():
profiles = []
for i, user in enumerate(rel_sample_users[:2]):
profile_data = CreateRelProfile(bio=f'Bio for {user.name}')
profile = RelProfile(user_id=user.id, **profile_data.model_dump())
async_db_session.add(profile)
profiles.append(profile)
return profiles
profiles = []
for i, user in enumerate(rel_sample_users[:2]):
profile_data = CreateRelProfile(bio=f'Bio for {user.name}')
profile = RelProfile(user_id=user.id, **profile_data.model_dump())
async_db_session.add(profile)
profiles.append(profile)
await async_db_session.commit()
return profiles


@pytest_asyncio.fixture
async def rel_sample_categories(async_db_session: AsyncSession) -> list[RelCategory]:
"""Create sample relation categories."""
async with async_db_session.begin():
tech = RelCategory(name='Technology', parent_id=None)
science = RelCategory(name='Science', parent_id=None)
async_db_session.add_all([tech, science])
await async_db_session.flush()

programming = RelCategory(name='Programming', parent_id=tech.id)
ai = RelCategory(name='AI', parent_id=tech.id)
async_db_session.add_all([programming, ai])
categories = [tech, science, programming, ai]
return categories
tech = RelCategory(name='Technology', parent_id=None)
science = RelCategory(name='Science', parent_id=None)
async_db_session.add_all([tech, science])
await async_db_session.flush()
programming = RelCategory(name='Programming', parent_id=tech.id)
ai = RelCategory(name='AI', parent_id=tech.id)
async_db_session.add_all([programming, ai])
await async_db_session.commit()
return [tech, science, programming, ai]


@pytest_asyncio.fixture
async def rel_sample_posts(
async_db_session: AsyncSession, rel_sample_users: list[RelUser], rel_sample_categories: list[RelCategory]
) -> list[RelPost]:
"""Create sample relation posts."""
async with async_db_session.begin():
posts = []
for i in range(6):
post_data = CreateRelPost(
title=f'Post {i + 1}',
category_id=rel_sample_categories[i % len(rel_sample_categories)].id if i < 4 else None,
)
post = RelPost(author_id=rel_sample_users[i % len(rel_sample_users)].id, **post_data.model_dump())
async_db_session.add(post)
posts.append(post)
return posts
posts = []
for i in range(6):
post_data = CreateRelPost(
title=f'Post {i + 1}',
category_id=rel_sample_categories[i % len(rel_sample_categories)].id if i < 4 else None,
)
post = RelPost(author_id=rel_sample_users[i % len(rel_sample_users)].id, **post_data.model_dump())
async_db_session.add(post)
posts.append(post)
await async_db_session.commit()
return posts


@pytest_asyncio.fixture
async def rel_sample_roles(async_db_session: AsyncSession) -> list[RelRole]:
"""Create sample relation roles."""
async with async_db_session.begin():
roles = []
for role_name in ['admin', 'editor']:
role_data = CreateRelRole(name=role_name)
role = RelRole(**role_data.model_dump())
async_db_session.add(role)
roles.append(role)
return roles
roles = []
for role_name in ['admin', 'editor']:
role_data = CreateRelRole(name=role_name)
role = RelRole(**role_data.model_dump())
async_db_session.add(role)
roles.append(role)
await async_db_session.commit()
return roles


@pytest_asyncio.fixture
Expand All @@ -180,16 +175,15 @@ async def rel_sample_data(
rel_sample_roles: list[RelRole],
) -> dict:
"""Create complete relation sample data with relationships."""
async with async_db_session.begin():
await async_db_session.execute(
insert(user_role).values(
[
{'user_id': rel_sample_users[0].id, 'role_id': rel_sample_roles[0].id},
{'user_id': rel_sample_users[1].id, 'role_id': rel_sample_roles[1].id},
]
)
await async_db_session.execute(
insert(user_role).values(
[
{'user_id': rel_sample_users[0].id, 'role_id': rel_sample_roles[0].id},
{'user_id': rel_sample_users[1].id, 'role_id': rel_sample_roles[1].id},
]
)

)
await async_db_session.commit()
return {
'users': rel_sample_users,
'profiles': rel_sample_profiles,
Expand Down
Loading