Advanced Use of FastCRUD¶
FastCRUD offers a flexible and powerful approach to handling CRUD operations in FastAPI applications, leveraging the SQLAlchemy ORM. Beyond basic CRUD functionality, FastCRUD provides advanced features like allow_multiple for updates and deletes, and support for advanced filters (e.g., less than, greater than). These features enable more complex and fine-grained data manipulation and querying capabilities.
Typing Options for FastCRUD¶
Note that when initializing FastCRUD, assuming you have a model like:
Simplified user/model.py
Warning
Note that naive datetime such as datetime.utcnow is not supported by FastCRUD as it was deprecated.
Use timezone aware datetime, such as datetime.now(UTC) instead.
You could just pass it to FastCRUD:
But you also may want a more robust typing, for that purpose, you may also pass the relevant pydantic schemas in the following way:
Simplified user/schemas.py
from .user.model import User
from .user.schemas import CreateUserSchema, ReadUserSchema, UpdateUserSchema, DeleteUserSchema
# Just pass None if you don't have one of the schemas
UserCRUD = FastCRUD[User, CreateUserSchema, UpdateUserSchema, None, DeleteUserSchema]
Then you can initialize UserCRUD like you would any FastCRUD instance, but with the relevant types:
Allow Multiple Updates and Deletes¶
One of FastCRUD's advanced features is the ability to update or delete multiple records at once based on specified conditions. This is particularly useful for batch operations where you need to modify or remove several records that match certain criteria.
Updating Multiple Records¶
To update multiple records, you can set the allow_multiple=True parameter in the update method. This allows FastCRUD to apply the update to all records matching the given filters.
# Assuming setup for FastCRUD instance `item_crud` and SQLAlchemy async session `db`
# Update all items priced below $10 to a new price
await item_crud.update(
db=db,
object={"price": 9.99},
allow_multiple=True,
price__lt=10,
)
Deleting Multiple Records¶
Similarly, you can delete multiple records by using the allow_multiple=True parameter in the delete or db_delete method, depending on whether you're performing a soft or hard delete.
# Soft delete all items not sold in the last year
await item_crud.delete(
db=db,
allow_multiple=True,
last_sold__lt=datetime.datetime.now() - datetime.timedelta(days=365),
)
Advanced Filters¶
FastCRUD supports advanced filtering options, allowing you to query records using operators such as greater than (__gt), less than (__lt), and their inclusive counterparts (__gte, __lte). These filters can be used in any method that retrieves or operates on records, including get, get_multi, exists, count, update, and delete.
Single parameter filters¶
Most filter operators require a single string or integer value.
Currently supported single parameter filters are:
__gt- greater than__lt- less than__gte- greater than or equal to__lte- less than or equal to__ne- not equal__is- used to test True, False and None identity__is_not- negation of "is"__like- SQL "like" search for specific text pattern__notlike- negation of "like"__ilike- case insensitive "like"__notilike- case insensitive "notlike"__startswith- text starts with given string__endswith- text ends with given string__contains- text contains given string__match- database-specific match expression
Complex parameter filters¶
Some operators require multiple values. They must be passed as a python tuple, list or set.
# Fetch items priced between $5 and $20
items = await item_crud.get_multi(
db=db,
price__between=(5, 20),
)
__between- between 2 numeric values__in- included in__not_in- not included in
OR clauses¶
More complex OR filters are supported. They must be passed as dictionary, where each key is a library-supported operator to be used in OR expression and values is what get's passed as the parameter.
# Fetch items priced under $5 or above $20
items = await item_crud.get_multi(
db=db,
price__or={'lt': 5, 'gt': 20},
)
AND clauses¶
AND clauses can be achieved by chaining multiple filters together.
# Fetch items priced under $20 and over 2 years of warranty.
items = await item_crud.get_multi(
db=db,
price__lt=20,
warranty_years__gt=2,
)
Counting Records¶
# Count items created in the last month
item_count = await item_crud.count(
db=db,
created_at__gte=datetime.datetime.now() - datetime.timedelta(days=30),
)
Skipping Database Commit¶
For create, update, db_delete and delete methods of FastCRUD, you have the option of passing commit=False so you don't commit the operations immediately.
from fastcrud import FastCRUD
from .database import session as db
from .item.model import Item
item_crud = FastCRUD(Item)
await item_crud.delete(
db=db,
commit=False,
id=1,
)
# this will not actually delete until you run a db.commit()
Returning clause in update¶
In update method, you can pass return_columns parameter containing a list of columns you want to return after the update.
from fastcrud import FastCRUD
from .database import session as db
from .item.model import Item
item_crud = FastCRUD(Item)
item = await item_crud.update(
db=db,
object={"price": 9.99},
return_columns=["price"],
price__lt=10,
)
# this will return the updated price
You can also pass schema_to_select parameter and return_as_model to return the updated data in the form of a Pydantic schema.
from fastcrud import FastCRUD
from .database import session as db
from .item.model import Base, Item
from .item.schemas import ItemSchema
item_crud = FastCRUD(Item)
item = await item_crud.update(
db=db,
object={"price": 9.99},
schema_to_select=ItemSchema,
return_as_model=True,
price__lt=10,
)
# this will return the updated data in the form of ItemSchema
Unpaginated get_multi and get_multi_joined¶
If you pass None to limit in get_multi and get_multi_joined, you get the whole unpaginated set of data that matches the filters. Use this with caution.
from fastcrud import FastCRUD
from .database import session as db
from .item.model import Item
item_crud = FastCRUD(Item)
items = await item_crud.get_multi(db=db, limit=None)
# this will return all items in the db
Caution
Be cautious when returning all the data in your database, and you should almost never allow your user API to do this.
Using get_joined and get_multi_joined for multiple models¶
To facilitate complex data relationships, get_joined and get_multi_joined can be configured to handle joins with multiple models. This is achieved using the joins_config parameter, where you can specify a list of JoinConfig instances, each representing a distinct join configuration.
Upserting multiple records using upsert_multi¶
FastCRUD provides an upsert_multi method to efficiently upsert multiple records in a single operation. This method is particularly useful when you need to insert new records or update existing ones based on a unique constraint.
from fastcrud import FastCRUD
from .database import session as db
from .item.model import Item
from .item.schemas import CreateItemSchema, ItemSchema
item_crud = FastCRUD(Item)
items = await item_crud.upsert_multi(
db=db,
instances=[
CreateItemSchema(price=9.99),
],
schema_to_select=ItemSchema,
return_as_model=True,
)
# this will return the upserted data in the form of ItemSchema
Customizing the Update Logic¶
To allow more granular control over the SQL UPDATE operation during an upsert, upsert_multi can accept an update_override argument. This allows for the specification of custom update logic using SQL expressions, like the case statement, to handle complex conditions.
from sqlalchemy.sql import case
update_override = {
"name": case(
(Item.name.is_(None), stmt.excluded.name),
else_=Item.name
)
}
items = await item_crud.upsert_multi(
db=db,
instances=[
CreateItemSchema(name="Gadget", price=15.99),
],
update_override=update_override,
schema_to_select=ItemSchema,
return_as_model=True,
)
In the example above, the name field of the Item model will be updated to the new value only if the existing name field is None. Otherwise, it retains the existing name.
Example: Joining User, Tier, and Department Models¶
Consider a scenario where you want to retrieve users along with their associated tier and department information. Here's how you can achieve this using get_multi_joined.
Start by creating the models and schemas, followed by a description of how they're to be joined:
Models and Schemas
tier/model.py
tier/schemas.py
department/model.py
department/schemas.py
user/model.py
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
String,
func,
)
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "user"
id = Column(Integer, primary_key=True)
name = Column(String)
username = Column(String)
email = Column(String)
age = Column(Integer)
role = Column(String)
tier_id = Column(Integer, ForeignKey("tier.id"))
department_id = Column(Integer, ForeignKey("department.id"))
manager_id = Column(Integer, ForeignKey("user.id"))
is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False)
registration_date = Column(DateTime, default=func.now())
archived = Column(Boolean, default=False)
archived_at = Column(DateTime)
user/schemas.py
import datetime
from pydantic import BaseModel
class CreateUserSchema(BaseModel):
name: str | None = None
username: str | None = None
email: str | None = None
age: int | None = None
role: str | None = None
tier_id: int | None = None
department_id: int | None = None
manager_id: int | None = None
is_active: bool | None = None
is_superuser: bool | None = None
class ReadUserSchema(BaseModel):
id: int
name: str | None = None
username: str | None = None
email: str | None = None
age: int | None = None
role: str | None = None
tier_id: int | None = None
department_id: int | None = None
manager_id: int | None = None
is_active: bool
is_superuser: bool
registration_date: datetime.datetime
archived: bool
archived_at: datetime.datetime | None = None
class UpdateUserSchema(BaseModel):
name: str | None = None
username: str | None = None
email: str | None = None
age: int | None = None
role: str | None = None
tier_id: int | None = None
department_id: int | None = None
manager_id: int | None = None
is_active: bool | None = None
is_superuser: bool | None = None
class DeleteUserSchema(BaseModel):
pass
story/model.py
story/schemas.py
task/model.py
from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
class Task(Base):
__tablename__ = "task"
id = Column(Integer, primary_key=True)
creator_id = Column(Integer, ForeignKey("user.id"))
owner_id = Column(Integer, ForeignKey("user.id"))
assigned_user_id = Column(Integer, ForeignKey("user.id"))
story_id = Column(Integer, ForeignKey("story.id"))
status = Column(String)
priority = Column(String)
task/schemas.py
from pydantic import BaseModel
class CreateTaskSchema(BaseModel):
creator_id: int | None = None
owner_id: int | None = None
assigned_user_id: int | None = None
story_id: int | None = None
status: str | None = None
priority: str | None = None
class ReadTaskSchema(BaseModel):
id: int
creator_id: int | None = None
owner_id: int | None = None
assigned_user_id: int | None = None
story_id: int | None = None
status: str | None = None
priority: str | None = None
class UpdateTaskSchema(BaseModel):
creator_id: int | None = None
owner_id: int | None = None
assigned_user_id: int | None = None
story_id: int | None = None
status: str | None = None
priority: str | None = None
class DeleteTaskSchema(BaseModel):
pass
from fastcrud import JoinConfig
joins_config = [
JoinConfig(
model=Tier,
join_on=User.tier_id == Tier.id,
join_prefix="tier_",
schema_to_select=ReadTierSchema,
join_type="left",
),
JoinConfig(
model=Department,
join_on=User.department_id == Department.id,
join_prefix="dept_",
schema_to_select=ReadDepartmentSchema,
join_type="inner",
),
]
users = await user_crud.get_multi_joined(
db=session,
schema_to_select=ReadUserSchema,
offset=0,
limit=10,
sort_columns='username',
sort_orders='asc',
joins_config=joins_config,
)
Then just pass this list to joins_config:
from fastcrud import JoinConfig
joins_config = [
...
]
users = await user_crud.get_multi_joined(
db=session,
schema_to_select=ReadUserSchema,
offset=0,
limit=10,
sort_columns='username',
sort_orders='asc',
joins_config=joins_config,
)
In this example, users are joined with the Tier and Department models. The join_on parameter specifies the condition for the join, join_prefix assigns a prefix to columns from the joined models (to avoid naming conflicts), and join_type determines whether it's a left or inner join.
Warning
If both single join parameters and joins_config are used simultaneously, an error will be raised.
Handling One-to-One and One-to-Many Joins in FastCRUD¶
FastCRUD provides flexibility in handling one-to-one and one-to-many relationships through its get_joined and get_multi_joined methods, along with the ability to specify how joined data should be structured using both the relationship_type (default one-to-one) and the nest_joins (default False) parameters.
One-to-One Joins¶
One-to-one relationships can be efficiently managed using either get_joined or get_multi_joined. The get_joined method is typically used when you want to fetch a single record from the database along with its associated record from another table, such as a user and their corresponding profile details. If you're retrieving multiple records, get_multi_joined can also be used for one-to-one joins. The parameter that deals with it, relationship_type, defaults to one-on-one.
One-to-Many Joins¶
For one-to-many relationships, where a single record can be associated with multiple records in another table, get_joined can be used with nest_joins set to True. This setup allows the primary record to include a nested list of associated records, making it suitable for scenarios such as retrieving a user and all their blog posts. Alternatively, get_multi_joined is also applicable here for fetching multiple primary records, each with their nested lists of related records.
Warning
When using nested_joins=True, the performance will always be a bit worse than when using nested_joins=False. For cases where more performance is necessary, consider using nested_joins=False and remodeling your database.
One-to-One Relationships¶
get_joined: Fetch a single record and its directly associated record (e.g., a user and their profile).get_multi_joined(withnest_joins=False): Retrieve multiple records, each linked to a single related record from another table (e.g., users and their profiles).
One-to-Many Relationships¶
get_joined(withnest_joins=True): Retrieve a single record with all its related records nested within it (e.g., a user and all their blog posts).get_multi_joined(withnest_joins=True): Fetch multiple primary records, each with their related records nested (e.g., multiple users and all their blog posts).
For a more detailed explanation, you may check the joins docs.
Using aliases¶
In complex query scenarios, particularly when you need to join a table to itself or perform multiple joins on the same table for different purposes, aliasing becomes crucial. Aliasing allows you to refer to the same table in different contexts with unique identifiers, avoiding conflicts and ambiguity in your queries.
For both get_joined and get_multi_joined methods, when you need to join the same model multiple times, you can utilize the alias parameter within your JoinConfig to differentiate between the joins. This parameter expects an instance of AliasedClass, which can be created using the aliased function from SQLAlchemy (also in FastCRUD for convenience).
Example: Joining the Same Model Multiple Times¶
Consider a task management application where tasks have both an owner and an assigned user, represented by the same User model. To fetch tasks with details of both users, we use aliases to join the User model twice, distinguishing between owners and assigned users.
Let's start by creating the aliases and passing them to the join configuration. Don't forget to use the alias for join_on:
from fastcrud import FastCRUD, JoinConfig, aliased
# Create aliases for User to distinguish between the owner and the assigned user
owner_alias = aliased(User, name="owner")
assigned_user_alias = aliased(User, name="assigned_user")
# Configure joins with aliases
joins_config = [
JoinConfig(
model=User,
join_on=Task.owner_id == owner_alias.id,
join_prefix="owner_",
schema_to_select=ReadUserSchema,
join_type="inner",
alias=owner_alias, # Pass the aliased class instance
),
JoinConfig(
model=User,
join_on=Task.assigned_user_id == assigned_user_alias.id,
join_prefix="assigned_",
schema_to_select=ReadUserSchema,
join_type="inner",
alias=assigned_user_alias, # Pass the aliased class instance
),
]
# Initialize your FastCRUD instance for Task
task_crud = FastCRUD(Task)
# Fetch tasks with joined user details
tasks = await task_crud.get_multi_joined(
db=session,
schema_to_select=ReadTaskSchema,
offset=0,
limit=10,
joins_config=joins_config,
)
Then just pass this joins_config to get_multi_joined:
from fastcrud import FastCRUD, JoinConfig, aliased
...
# Configure joins with aliases
joins_config = [
...
]
# Initialize your FastCRUD instance for Task
task_crud = FastCRUD(Task)
# Fetch tasks with joined user details
tasks = await task_crud.get_multi_joined(
db=session,
schema_to_select=ReadTaskSchema,
offset=0,
limit=10,
joins_config=joins_config,
)
In this example, owner_alias and assigned_user_alias are created from User to distinguish between the task's owner and the assigned user within the task management system. By using aliases, you can join the same model multiple times for different purposes in your queries, enhancing expressiveness and eliminating ambiguity.
Many-to-Many Relationships with get_multi_joined¶
FastCRUD simplifies dealing with many-to-many relationships by allowing easy fetch operations with joined models. Here, we demonstrate using get_multi_joined to handle a many-to-many relationship between Project and Participant models, linked through an association table.
Note on Handling Many-to-Many Relationships:
When using get_multi_joined for many-to-many relationships, it's essential to maintain a specific order in your joins_config:
- First, specify the main table you're querying from.
- Next, include the association table that links your main table to the other table involved in the many-to-many relationship.
- Finally, specify the other table that is connected via the association table.
This order ensures that the SQL joins are structured correctly to reflect the many-to-many relationship and retrieve the desired data accurately.
Tip
Note that the first one can be the model defined in FastCRUD(Model).
Models
class Project(Base):
__tablename__ = "projects"
id = Column(Integer, primary_key=True)
name = Column(String(32), nullable=False)
description = Column(String(32))
participants = relationship(
"Participant",
secondary="projects_participants_association",
back_populates="projects",
)
class Participant(Base):
__tablename__ = "participants"
id = Column(Integer, primary_key=True)
name = Column(String(32), nullable=False)
role = Column(String(32))
projects = relationship(
"Project",
secondary="projects_participants_association",
back_populates="participants",
)
class ProjectsParticipantsAssociation(Base):
__tablename__ = "projects_participants_association"
project_id = Column(Integer, ForeignKey("projects.id"), primary_key=True)
participant_id = Column(Integer, ForeignKey("participants.id"), primary_key=True)
# Fetch projects with their participants via a many-to-many relationship
joins_config = [
JoinConfig(
model=ProjectsParticipantsAssociation,
join_on=Project.id == ProjectsParticipantsAssociation.project_id,
join_prefix="pp_",
join_type="inner",
),
JoinConfig(
model=Participant,
join_on=ProjectsParticipantsAssociation.participant_id == Participant.id,
join_prefix="participant_",
join_type="inner",
),
]
project_crud = FastCRUD(Project)
class ReadProjectSchema(BaseModel):
id: int
name: str
description: Optional[str] = None
projects_with_participants = await project_crud.get_multi_joined(
db=db,
schema_to_select=ReadProjectSchema,
joins_config=joins_config,
)
For a more detailed explanation, read this part of the docs.
Enhanced Query Capabilities with Method Chaining¶
The select method in FastCRUD is designed for flexibility, enabling you to build complex queries through method chaining.
The select Method¶
async def select(
db: AsyncSession,
schema_to_select: Optional[type[BaseModel]] = None,
sort_columns: Optional[Union[str, list[str]]] = None,
sort_orders: Optional[Union[str, list[str]]] = None,
**kwargs: Any
) -> Selectable
This method constructs a SQL Alchemy Select statement, offering optional column selection, filtering, and sorting. It's designed for flexibility, allowing you to chain additional SQLAlchemy methods for even more complex queries.
Features:¶
- Column Selection: Specify columns with a Pydantic schema.
- Sorting: Define one or more columns for sorting, along with their sort order.
- Filtering: Apply filters directly through keyword arguments.
- Chaining: Chain with other SQLAlchemy methods for advanced query construction.
Usage Example:¶
class ReadMyModelSchema(BaseModel):
id: int
name: str | None = None
archived: bool
archived_at: datetime.datetime
date_updated: datetime.datetime
stmt = await my_model_crud.select(
schema_to_select=ReadMyModelSchema,
sort_columns='name',
name__like='%example%',
)
stmt = stmt.where(additional_conditions).limit(10)
results = await db.execute(stmt)
This example demonstrates selecting a subset of columns, applying a filter, and chaining additional conditions like where and limit. Note that select returns a Select object, allowing for further modifications before execution.
Conclusion¶
The advanced features of FastCRUD, such as allow_multiple and support for advanced filters, empower developers to efficiently manage database records with complex conditions. By leveraging these capabilities, you can build more dynamic, robust, and scalable FastAPI applications that effectively interact with your data model.