mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-04-16 14:46:24 +02:00
Compare commits
4 Commits
8a65754f9f
...
2195c4cf2a
| Author | SHA1 | Date | |
|---|---|---|---|
|
2195c4cf2a
|
|||
|
7003b853cc
|
|||
|
8378410d91
|
|||
|
ee504ac85b
|
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -6,9 +6,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "3.0.3"
|
version = "3.0.1"
|
||||||
description = "Production-ready utilities for FastAPI applications"
|
description = "Production-ready utilities for FastAPI applications"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ Example usage:
|
|||||||
return Response(data={"user": user.username}, message="Success")
|
return Response(data={"user": user.username}, message="Success")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "3.0.3"
|
__version__ = "3.0.1"
|
||||||
|
|||||||
@@ -265,15 +265,7 @@ async def build_facets(
|
|||||||
else:
|
else:
|
||||||
q = q.order_by(column)
|
q = q.order_by(column)
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
col_type = column.property.columns[0].type
|
values = [row[0] for row in result.all() if row[0] is not None]
|
||||||
enum_class = getattr(col_type, "enum_class", None)
|
|
||||||
values = [
|
|
||||||
row[0].name
|
|
||||||
if (enum_class is not None and isinstance(row[0], enum_class))
|
|
||||||
else row[0]
|
|
||||||
for row in result.all()
|
|
||||||
if row[0] is not None
|
|
||||||
]
|
|
||||||
return key, values
|
return key, values
|
||||||
|
|
||||||
pairs = await asyncio.gather(
|
pairs = await asyncio.gather(
|
||||||
@@ -355,24 +347,6 @@ def build_filter_by(
|
|||||||
filters.append(column.overlap(value))
|
filters.append(column.overlap(value))
|
||||||
else:
|
else:
|
||||||
filters.append(column.any(value))
|
filters.append(column.any(value))
|
||||||
elif isinstance(col_type, Enum):
|
|
||||||
enum_class = col_type.enum_class
|
|
||||||
if enum_class is not None:
|
|
||||||
|
|
||||||
def _coerce_enum(v: Any) -> Any:
|
|
||||||
if isinstance(v, enum_class):
|
|
||||||
return v
|
|
||||||
return enum_class[v] # lookup by name: "PENDING", "RED"
|
|
||||||
|
|
||||||
if isinstance(value, list):
|
|
||||||
filters.append(column.in_([_coerce_enum(v) for v in value]))
|
|
||||||
else:
|
|
||||||
filters.append(column == _coerce_enum(value))
|
|
||||||
else: # pragma: no cover
|
|
||||||
if isinstance(value, list):
|
|
||||||
filters.append(column.in_(value))
|
|
||||||
else:
|
|
||||||
filters.append(column == value)
|
|
||||||
elif isinstance(col_type, _EQUALITY_TYPES):
|
elif isinstance(col_type, _EQUALITY_TYPES):
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
filters.append(column.in_(value))
|
filters.append(column.in_(value))
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -13,7 +12,6 @@ from sqlalchemy import (
|
|||||||
Column,
|
Column,
|
||||||
Date,
|
Date,
|
||||||
DateTime,
|
DateTime,
|
||||||
Enum as SAEnum,
|
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
JSON,
|
JSON,
|
||||||
@@ -141,35 +139,6 @@ class Post(Base):
|
|||||||
tags: Mapped[list[Tag]] = relationship(secondary=post_tags)
|
tags: Mapped[list[Tag]] = relationship(secondary=post_tags)
|
||||||
|
|
||||||
|
|
||||||
class OrderStatus(int, Enum):
|
|
||||||
"""Integer-backed enum for order status."""
|
|
||||||
|
|
||||||
PENDING = 1
|
|
||||||
PROCESSING = 2
|
|
||||||
SHIPPED = 3
|
|
||||||
CANCELLED = 4
|
|
||||||
|
|
||||||
|
|
||||||
class Color(str, Enum):
|
|
||||||
"""String-backed enum for color."""
|
|
||||||
|
|
||||||
RED = "red"
|
|
||||||
GREEN = "green"
|
|
||||||
BLUE = "blue"
|
|
||||||
|
|
||||||
|
|
||||||
class Order(Base):
|
|
||||||
"""Test model with an IntEnum column (Enum(int, Enum)) and a raw Integer column."""
|
|
||||||
|
|
||||||
__tablename__ = "orders"
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
|
||||||
name: Mapped[str] = mapped_column(String(100))
|
|
||||||
status: Mapped[OrderStatus] = mapped_column(SAEnum(OrderStatus))
|
|
||||||
priority: Mapped[int] = mapped_column(Integer)
|
|
||||||
color: Mapped[Color] = mapped_column(SAEnum(Color))
|
|
||||||
|
|
||||||
|
|
||||||
class Transfer(Base):
|
class Transfer(Base):
|
||||||
"""Test model with two FKs to the same table (users)."""
|
"""Test model with two FKs to the same table (users)."""
|
||||||
|
|
||||||
@@ -342,26 +311,6 @@ class ArticleRead(PydanticBase):
|
|||||||
labels: list[str]
|
labels: list[str]
|
||||||
|
|
||||||
|
|
||||||
class OrderCreate(BaseModel):
|
|
||||||
"""Schema for creating an order."""
|
|
||||||
|
|
||||||
id: uuid.UUID | None = None
|
|
||||||
name: str
|
|
||||||
status: OrderStatus
|
|
||||||
priority: int = 0
|
|
||||||
color: Color = Color.RED
|
|
||||||
|
|
||||||
|
|
||||||
class OrderRead(PydanticBase):
|
|
||||||
"""Schema for reading an order."""
|
|
||||||
|
|
||||||
id: uuid.UUID
|
|
||||||
name: str
|
|
||||||
status: OrderStatus
|
|
||||||
priority: int
|
|
||||||
color: Color
|
|
||||||
|
|
||||||
|
|
||||||
class TransferCreate(BaseModel):
|
class TransferCreate(BaseModel):
|
||||||
"""Schema for creating a transfer."""
|
"""Schema for creating a transfer."""
|
||||||
|
|
||||||
@@ -378,7 +327,6 @@ class TransferRead(PydanticBase):
|
|||||||
amount: str
|
amount: str
|
||||||
|
|
||||||
|
|
||||||
OrderCrud = CrudFactory(Order)
|
|
||||||
TransferCrud = CrudFactory(Transfer)
|
TransferCrud = CrudFactory(Transfer)
|
||||||
ArticleCrud = CrudFactory(Article)
|
ArticleCrud = CrudFactory(Article)
|
||||||
RoleCrud = CrudFactory(Role)
|
RoleCrud = CrudFactory(Role)
|
||||||
|
|||||||
@@ -23,12 +23,6 @@ from .conftest import (
|
|||||||
ArticleCreate,
|
ArticleCreate,
|
||||||
ArticleCrud,
|
ArticleCrud,
|
||||||
ArticleRead,
|
ArticleRead,
|
||||||
Color,
|
|
||||||
Order,
|
|
||||||
OrderCreate,
|
|
||||||
OrderCrud,
|
|
||||||
OrderRead,
|
|
||||||
OrderStatus,
|
|
||||||
Role,
|
Role,
|
||||||
RoleCreate,
|
RoleCreate,
|
||||||
RoleCrud,
|
RoleCrud,
|
||||||
@@ -1127,253 +1121,6 @@ class TestFilterBy:
|
|||||||
assert "JSON" in exc_info.value.col_type
|
assert "JSON" in exc_info.value.col_type
|
||||||
|
|
||||||
|
|
||||||
class TestFilterByIntEnum:
|
|
||||||
"""Tests for filter_by on columns typed as (int, Enum) / IntEnum."""
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_intenum_member(self, db_session: AsyncSession):
|
|
||||||
"""filter_by with an IntEnum member value filters correctly."""
|
|
||||||
OrderFacetCrud = CrudFactory(Order, facet_fields=[Order.status])
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-1", status=OrderStatus.PENDING)
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-2", status=OrderStatus.SHIPPED)
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-3", status=OrderStatus.PENDING)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await OrderFacetCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={"status": OrderStatus.PENDING},
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
|
||||||
assert result.pagination.total_count == 2
|
|
||||||
names = {o.name for o in result.data}
|
|
||||||
assert names == {"order-1", "order-3"}
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_plain_int_value_raises(self, db_session: AsyncSession):
|
|
||||||
"""filter_by with a plain int on an IntEnum column raises KeyError — use name or member."""
|
|
||||||
OrderFacetCrud = CrudFactory(Order, facet_fields=[Order.status])
|
|
||||||
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
await OrderFacetCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={"status": 1},
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_intenum_list(self, db_session: AsyncSession):
|
|
||||||
"""filter_by with a list of IntEnum members produces an IN filter."""
|
|
||||||
OrderFacetCrud = CrudFactory(Order, facet_fields=[Order.status])
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-1", status=OrderStatus.PENDING)
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-2", status=OrderStatus.SHIPPED)
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-3", status=OrderStatus.CANCELLED)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await OrderFacetCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={"status": [OrderStatus.PENDING, OrderStatus.SHIPPED]},
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
|
||||||
assert result.pagination.total_count == 2
|
|
||||||
names = {o.name for o in result.data}
|
|
||||||
assert names == {"order-1", "order-2"}
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_plain_int_list_raises(self, db_session: AsyncSession):
|
|
||||||
"""filter_by with a list of plain ints on an IntEnum column raises KeyError — use names or members."""
|
|
||||||
OrderFacetCrud = CrudFactory(Order, facet_fields=[Order.status])
|
|
||||||
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
await OrderFacetCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={"status": [1, 3]},
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_intenum_name_string(self, db_session: AsyncSession):
|
|
||||||
"""filter_by with the enum member name as a string filters correctly."""
|
|
||||||
OrderFacetCrud = CrudFactory(Order, facet_fields=[Order.status])
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-1", status=OrderStatus.PENDING)
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-2", status=OrderStatus.SHIPPED)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await OrderFacetCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={
|
|
||||||
"status": "PENDING"
|
|
||||||
}, # name as string, e.g. from HTTP query param
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
|
||||||
assert result.pagination.total_count == 1
|
|
||||||
assert result.data[0].name == "order-1"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_intenum_name_string_list(self, db_session: AsyncSession):
|
|
||||||
"""filter_by with a list of enum name strings produces an IN filter."""
|
|
||||||
OrderFacetCrud = CrudFactory(Order, facet_fields=[Order.status])
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-1", status=OrderStatus.PENDING)
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-2", status=OrderStatus.SHIPPED)
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-3", status=OrderStatus.CANCELLED)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await OrderFacetCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={"status": ["PENDING", "SHIPPED"]},
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
|
||||||
assert result.pagination.total_count == 2
|
|
||||||
names = {o.name for o in result.data}
|
|
||||||
assert names == {"order-1", "order-2"}
|
|
||||||
|
|
||||||
|
|
||||||
class TestFilterByStrEnum:
|
|
||||||
"""Tests for filter_by on columns typed as (str, Enum) / StrEnum (lines 364-367)."""
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_strenum_member(self, db_session: AsyncSession):
|
|
||||||
"""filter_by with a StrEnum member on a string Enum column filters correctly."""
|
|
||||||
OrderColorCrud = CrudFactory(Order, facet_fields=[Order.color])
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session,
|
|
||||||
OrderCreate(name="red-order", status=OrderStatus.PENDING, color=Color.RED),
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session,
|
|
||||||
OrderCreate(
|
|
||||||
name="blue-order", status=OrderStatus.PENDING, color=Color.BLUE
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await OrderColorCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={"color": Color.RED},
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
|
||||||
assert result.pagination.total_count == 1
|
|
||||||
assert result.data[0].name == "red-order"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_strenum_list(self, db_session: AsyncSession):
|
|
||||||
"""filter_by with a list of StrEnum members produces an IN filter."""
|
|
||||||
OrderColorCrud = CrudFactory(Order, facet_fields=[Order.color])
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session,
|
|
||||||
OrderCreate(name="red-order", status=OrderStatus.PENDING, color=Color.RED),
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session,
|
|
||||||
OrderCreate(
|
|
||||||
name="green-order", status=OrderStatus.PENDING, color=Color.GREEN
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session,
|
|
||||||
OrderCreate(
|
|
||||||
name="blue-order", status=OrderStatus.PENDING, color=Color.BLUE
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await OrderColorCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={"color": [Color.RED, Color.BLUE]},
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
|
||||||
assert result.pagination.total_count == 2
|
|
||||||
names = {o.name for o in result.data}
|
|
||||||
assert names == {"red-order", "blue-order"}
|
|
||||||
|
|
||||||
|
|
||||||
class TestFilterByIntegerColumn:
|
|
||||||
"""Tests for filter_by on plain Integer columns with IntEnum values."""
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_integer_column_with_intenum_member(
|
|
||||||
self, db_session: AsyncSession
|
|
||||||
):
|
|
||||||
"""filter_by with an IntEnum member on an Integer column works correctly."""
|
|
||||||
OrderPriorityCrud = CrudFactory(Order, facet_fields=[Order.priority])
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session,
|
|
||||||
OrderCreate(
|
|
||||||
name="order-1", status=OrderStatus.PENDING, priority=OrderStatus.PENDING
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session,
|
|
||||||
OrderCreate(
|
|
||||||
name="order-2", status=OrderStatus.SHIPPED, priority=OrderStatus.SHIPPED
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await OrderPriorityCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={
|
|
||||||
"priority": OrderStatus.PENDING
|
|
||||||
}, # IntEnum member on Integer col
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
|
||||||
assert result.pagination.total_count == 1
|
|
||||||
assert result.data[0].name == "order-1"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_integer_column_with_plain_int(
|
|
||||||
self, db_session: AsyncSession
|
|
||||||
):
|
|
||||||
"""filter_by with a plain int on an Integer column works correctly."""
|
|
||||||
OrderPriorityCrud = CrudFactory(Order, facet_fields=[Order.priority])
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session,
|
|
||||||
OrderCreate(name="order-1", status=OrderStatus.PENDING, priority=1),
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session,
|
|
||||||
OrderCreate(name="order-2", status=OrderStatus.SHIPPED, priority=3),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await OrderPriorityCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={"priority": 1},
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
|
||||||
assert result.pagination.total_count == 1
|
|
||||||
assert result.data[0].name == "order-1"
|
|
||||||
|
|
||||||
|
|
||||||
class TestFilterParamsViaConsolidated:
|
class TestFilterParamsViaConsolidated:
|
||||||
"""Tests for filter params via consolidated offset_paginate_params()."""
|
"""Tests for filter params via consolidated offset_paginate_params()."""
|
||||||
|
|
||||||
|
|||||||
2
uv.lock
generated
2
uv.lock
generated
@@ -321,7 +321,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "3.0.3"
|
version = "3.0.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
|
|||||||
Reference in New Issue
Block a user