From 08fa085e468e1e1149676cf37e6b262043fd0911 Mon Sep 17 00:00:00 2001 From: Igor Magalhaes Date: Fri, 24 May 2024 02:02:08 -0300 Subject: [PATCH 1/4] nested join fixes, tests added --- docs/advanced/joins.md | 12 +- fastcrud/crud/fast_crud.py | 135 +++++- fastcrud/crud/helper.py | 399 +++++++++++++++++- tests/sqlalchemy/conftest.py | 29 ++ tests/sqlalchemy/crud/test_get_joined.py | 43 +- .../sqlalchemy/crud/test_get_multi_joined.py | 257 ++++++++++- tests/sqlmodel/conftest.py | 27 ++ tests/sqlmodel/crud/test_get_joined.py | 43 +- tests/sqlmodel/crud/test_get_multi_joined.py | 257 ++++++++++- 9 files changed, 1164 insertions(+), 38 deletions(-) diff --git a/docs/advanced/joins.md b/docs/advanced/joins.md index 54745b6..fa8caf4 100644 --- a/docs/advanced/joins.md +++ b/docs/advanced/joins.md @@ -211,12 +211,6 @@ from sqlalchemy.ext.declarative import declarative_base Base = declarative_base() -# Association table for the many-to-many relationship -projects_participants_association = Table('projects_participants_association', Base.metadata, - Column('project_id', Integer, ForeignKey('projects.id'), primary_key=True), - Column('participant_id', Integer, ForeignKey('participants.id'), primary_key=True) -) - class Project(Base): __tablename__ = 'projects' id = Column(Integer, primary_key=True) @@ -232,6 +226,12 @@ class Participant(Base): role = Column(String) # Relationship to Project through the association table projects = relationship("Project", secondary=projects_participants_association) + +# Association table for the many-to-many relationship +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) ``` ##### Fetching Data with `get_multi_joined` diff --git a/fastcrud/crud/fast_crud.py b/fastcrud/crud/fast_crud.py index ab58dc6..507ca0a 100644 --- a/fastcrud/crud/fast_crud.py +++ b/fastcrud/crud/fast_crud.py @@ -16,6 +16,7 @@ _extract_matching_columns_from_schema, _auto_detect_join_condition, _nest_join_data, + _nest_multi_join_data, JoinConfig, ) @@ -339,7 +340,7 @@ def _prepare_and_apply_joins( stmt = stmt.outerjoin(model, join.join_on).add_columns(*join_select) elif join.join_type == "inner": stmt = stmt.join(model, join.join_on).add_columns(*join_select) - else: + else: # pragma: no cover raise ValueError(f"Unsupported join type: {join.join_type}.") if joined_model_filters: stmt = stmt.filter(*joined_model_filters) @@ -834,6 +835,7 @@ async def get_joined( join_filters: Optional[dict] = None, joins_config: Optional[list[JoinConfig]] = None, nest_joins: bool = False, + relationship_type: Optional[str] = None, **kwargs: Any, ) -> Optional[dict[str, Any]]: """ @@ -860,6 +862,7 @@ async def get_joined( join_filters: Filters applied to the joined model, specified as a dictionary mapping column names to their expected values. joins_config: A list of JoinConfig instances, each specifying a model to join with, join condition, optional prefix for column names, schema for selecting specific columns, and the type of join. This parameter enables support for multiple joins. nest_joins: If True, nested data structures will be returned where joined model data are nested under the join_prefix as a dictionary. + relationship_type: Specifies the relationship type, such as 'one-to-one' or 'one-to-many'. Used to determine how to nest the joined data. If None, uses one-to-one. **kwargs: Filters to apply to the primary model query, supporting advanced comparison operators for refined searching. Returns: @@ -1015,6 +1018,32 @@ async def get_joined( ) # Expect 'result' to have 'tier' and 'dept' as nested dictionaries ``` + + Example using one-to-one relationship: + ```python + result = await crud_user.get_joined( + db=session, + join_model=Profile, + join_on=User.profile_id == Profile.id, + schema_to_select=UserSchema, + join_schema_to_select=ProfileSchema, + relationship_type='one-to-one' # note that this is the default behavior + ) + # Expect 'result' to have 'profile' as a nested dictionary + ``` + + Example using one-to-many relationship: + ```python + result = await crud_user.get_joined( + db=session, + join_model=Post, + join_on=User.id == Post.user_id, + schema_to_select=UserSchema, + join_schema_to_select=PostSchema, + relationship_type='one-to-many', + nest_joins=True + ) + # Expect 'result' to have 'posts' as a nested list of dictionaries """ if joins_config and ( join_model or join_prefix or join_on or join_schema_to_select or alias @@ -1042,6 +1071,7 @@ async def get_joined( join_type=join_type, alias=alias, filters=join_filters, + relationship_type=relationship_type, ) ) @@ -1052,14 +1082,32 @@ async def get_joined( if primary_filters: stmt = stmt.filter(*primary_filters) - db_row = await db.execute(stmt) - result: Optional[Row] = db_row.first() - if result is not None: - data: dict = dict(result._mapping) - if nest_joins: - data = _nest_join_data(data, join_definitions) + db_rows = await db.execute(stmt) + if any(join.relationship_type == "one-to-many" for join in join_definitions): + if nest_joins is False: # pragma: no cover + raise ValueError( + "Cannot use one-to-many relationship with nest_joins=False" + ) + results = db_rows.fetchall() + data_list = [dict(row._mapping) for row in results] + else: + result = db_rows.first() + if result is not None: + data_list = [dict(result._mapping)] + else: + data_list = [] - return data + if data_list: + if nest_joins: + nested_data: dict = {} + for data in data_list: + nested_data = _nest_join_data( + data, + join_definitions, + nested_data=nested_data, + ) + return nested_data + return data_list[0] return None @@ -1082,6 +1130,7 @@ async def get_multi_joined( return_as_model: bool = False, joins_config: Optional[list[JoinConfig]] = None, return_total_count: bool = True, + relationship_type: Optional[str] = None, **kwargs: Any, ) -> dict[str, Any]: """ @@ -1113,6 +1162,7 @@ async def get_multi_joined( return_as_model: If True, converts the fetched data to Pydantic models based on schema_to_select. Defaults to False. joins_config: List of JoinConfig instances for specifying multiple joins. Each instance defines a model to join with, join condition, optional prefix for column names, schema for selecting specific columns, and join type. return_total_count: If True, also returns the total count of rows with the selected filters. Useful for pagination. + relationship_type: Specifies the relationship type, such as 'one-to-one' or 'one-to-many'. Used to determine how to nest the joined data. If None, uses one-to-one. **kwargs: Filters to apply to the primary query, including advanced comparison operators for refined searching. Returns: @@ -1314,9 +1364,45 @@ async def get_multi_joined( sort_orders='asc' ) ``` + + Example using one-to-one relationship: + ```python + users = await crud_user.get_multi_joined( + db=session, + join_model=Profile, + join_on=User.profile_id == Profile.id, + schema_to_select=UserSchema, + join_schema_to_select=ProfileSchema, + relationship_type='one-to-one', # note that this is the default behavior + offset=0, + limit=10 + ) + # Expect 'profile' to be nested as a dictionary under each user + ``` + + Example using one-to-many relationship: + ```python + users = await crud_user.get_multi_joined( + db=session, + join_model=Post, + join_on=User.id == Post.user_id, + schema_to_select=UserSchema, + join_schema_to_select=PostSchema, + relationship_type='one-to-many', + nest_joins=True, + offset=0, + limit=10 + ) + # Expect 'posts' to be nested as a list of dictionaries under each user + ``` """ if joins_config and ( - join_model or join_prefix or join_on or join_schema_to_select or alias + join_model + or join_prefix + or join_on + or join_schema_to_select + or alias + or relationship_type ): raise ValueError( "Cannot use both single join parameters and joins_config simultaneously." @@ -1344,6 +1430,7 @@ async def get_multi_joined( join_type=join_type, alias=alias, filters=join_filters, + relationship_type=relationship_type, ) ) @@ -1365,11 +1452,15 @@ async def get_multi_joined( result = await db.execute(stmt) data: list[Union[dict, BaseModel]] = [] + for row in result.mappings().all(): row_dict = dict(row) if nest_joins: - row_dict = _nest_join_data(row_dict, join_definitions) + row_dict = _nest_join_data( + data=row_dict, + join_definitions=join_definitions, + ) if return_as_model: if schema_to_select is None: @@ -1386,7 +1477,29 @@ async def get_multi_joined( else: data.append(row_dict) - response: dict[str, Any] = {"data": data} + if nest_joins and any( + join.relationship_type == "one-to-many" for join in join_definitions + ): + nested_data = _nest_multi_join_data( + base_primary_key=self._primary_keys[0].name, + data=data, + joins_config=join_definitions, + return_as_model=return_as_model, + schema_to_select=schema_to_select if return_as_model else None, + nested_schema_to_select={ + ( + join.join_prefix.rstrip("_") + if join.join_prefix + else join.model.__name__ + ): join.schema_to_select + for join in join_definitions + if join.schema_to_select + }, + ) + else: + nested_data = data + + response: dict[str, Any] = {"data": nested_data} if return_total_count: total_count: int = await self.count( diff --git a/fastcrud/crud/helper.py b/fastcrud/crud/helper.py index 19cfae6..ae43c2c 100644 --- a/fastcrud/crud/helper.py +++ b/fastcrud/crud/helper.py @@ -1,13 +1,16 @@ -from typing import Any, Optional, NamedTuple, Union +from typing import Any, Optional, Union, Sequence from sqlalchemy import inspect from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm.util import AliasedClass from sqlalchemy.sql import ColumnElement -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict +from pydantic.functional_validators import field_validator +from ..endpoint.helper import _get_primary_key -class JoinConfig(NamedTuple): + +class JoinConfig(BaseModel): model: Any join_on: Any join_prefix: Optional[str] = None @@ -15,6 +18,23 @@ class JoinConfig(NamedTuple): join_type: str = "left" alias: Optional[AliasedClass] = None filters: Optional[dict] = None + relationship_type: Optional[str] = None + + model_config = ConfigDict(arbitrary_types_allowed=True) + + @field_validator("relationship_type") + def check_valid_relationship_type(cls, value): + valid_relationship_types = {"one-to-one", "one-to-many"} + if value is not None and value not in valid_relationship_types: + raise ValueError(f"Invalid relationship type: {value}") # pragma: no cover + return value + + @field_validator("join_type") + def check_valid_join_type(cls, value): + valid_join_types = {"left", "inner"} + if value not in valid_join_types: + raise ValueError(f"Unsupported join type: {value}") + return value def _extract_matching_columns_from_schema( @@ -118,34 +138,379 @@ def _auto_detect_join_condition( return join_on +def _handle_one_to_one(nested_data, nested_key, nested_field, value): + """ + Handles the nesting of one-to-one relationships in the data. + + Args: + nested_data: The current state of the nested data. + nested_key: The key under which the nested data should be stored. + nested_field: The field name of the nested data to be added. + value: The value of the nested data to be added. + + Returns: + dict[str, Any]: The updated nested data dictionary. + + Examples: + Input: + nested_data = { + 'id': 1, + 'name': 'Test User' + } + nested_key = 'profile' + nested_field = 'bio' + value = 'This is a bio.' + + Output: + { + 'id': 1, + 'name': 'Test User', + 'profile': { + 'bio': 'This is a bio.' + } + } + """ + if nested_key not in nested_data: + nested_data[nested_key] = {} + nested_data[nested_key][nested_field] = value + return nested_data + + +def _handle_one_to_many(nested_data, nested_key, nested_field, value): + """ + Handles the nesting of one-to-many relationships in the data. + + Args: + nested_data: The current state of the nested data. + nested_key: The key under which the nested data should be stored. + nested_field: The field name of the nested data to be added. + value: The value of the nested data to be added. + + Returns: + dict[str, Any]: The updated nested data dictionary. + + Examples: + Input: + nested_data = { + 'id': 1, + 'name': 'Test User', + 'posts': [ + { + 'title': 'First Post', + 'content': 'Content of the first post' + } + ] + } + nested_key = 'posts' + nested_field = 'title' + value = 'Second Post' + + Output: + { + 'id': 1, + 'name': 'Test User', + 'posts': [ + { + 'title': 'First Post', + 'content': 'Content of the first post' + }, + { + 'title': 'Second Post' + } + ] + } + + Input: + nested_data = { + 'id': 1, + 'name': 'Test User', + 'posts': [] + } + nested_key = 'posts' + nested_field = 'title' + value = 'First Post' + + Output: + { + 'id': 1, + 'name': 'Test User', + 'posts': [ + { + 'title': 'First Post' + } + ] + } + """ + if nested_key not in nested_data or not isinstance(nested_data[nested_key], list): + nested_data[nested_key] = [] + + if not nested_data[nested_key] or nested_field in nested_data[nested_key][-1]: + nested_data[nested_key].append({nested_field: value}) + else: + nested_data[nested_key][-1][nested_field] = value + + return nested_data + + def _nest_join_data( - data: dict[str, Any], + data: dict, join_definitions: list[JoinConfig], temp_prefix: str = "joined__", -) -> dict[str, Any]: - nested_data: dict = {} + nested_data: Optional[dict[str, Any]] = None, +) -> dict: + """ + Nests joined data based on join definitions provided. This function processes the input `data` dictionary, identifying keys + that correspond to joined tables using the provided `join_definitions` and nest them under their respective table keys. + + Args: + data: The flat dictionary containing data with potentially prefixed keys from joined tables. + join_definitions: A list of JoinConfig instances defining the join configurations, including prefixes. + temp_prefix: The temporary prefix applied to joined columns to differentiate them. Defaults to "joined__". + nested_data: The nested dictionary to which the data will be added. If None, a new dictionary is created. Defaults to None. + + Returns: + dict[str, Any]: A dictionary with nested structures for joined table data. + + Examples: + Input: + data = { + 'id': 1, + 'title': 'Test Card', + 'joined__articles_id': 1, + 'joined__articles_title': 'Article 1', + 'joined__articles_card_id': 1 + } + + join_definitions = [ + JoinConfig( + model=Article, + join_prefix='articles_', + relationship_type='one-to-many' + ) + ] + + Output: + { + 'id': 1, + 'title': 'Test Card', + 'articles': [ + { + 'id': 1, + 'title': 'Article 1', + 'card_id': 1 + } + ] + } + + Input: + data = { + 'id': 1, + 'title': 'Test Card', + 'joined__author_id': 1, + 'joined__author_name': 'Author 1' + } + + join_definitions = [ + JoinConfig( + model=Author, + join_prefix='author_', + relationship_type='one-to-one' + ) + ] + + Output: + { + 'id': 1, + 'title': 'Test Card', + 'author': { + 'id': 1, + 'name': 'Author 1' + } + } + """ + if nested_data is None: + nested_data = {} + for key, value in data.items(): nested = False for join in join_definitions: - full_prefix = ( - f"{temp_prefix}{join.join_prefix}" if join.join_prefix else temp_prefix - ) - if key.startswith(full_prefix): + join_prefix = join.join_prefix or "" + full_prefix = f"{temp_prefix}{join_prefix}" + + if isinstance(key, str) and key.startswith(full_prefix): nested_key = ( - join.join_prefix.rstrip("_") - if join.join_prefix - else join.model.__tablename__ + join_prefix.rstrip("_") if join_prefix else join.model.__tablename__ ) nested_field = key[len(full_prefix) :] - if nested_key not in nested_data: - nested_data[nested_key] = {} - nested_data[nested_key][nested_field] = value + + if join.relationship_type == "one-to-many": + nested_data = _handle_one_to_many( + nested_data, nested_key, nested_field, value + ) + else: + nested_data = _handle_one_to_one( + nested_data, nested_key, nested_field, value + ) + nested = True break + if not nested: stripped_key = ( - key[len(temp_prefix) :] if key.startswith(temp_prefix) else key + key[len(temp_prefix) :] + if isinstance(key, str) and key.startswith(temp_prefix) + else key ) + if nested_data is None: # pragma: no cover + nested_data = {} + nested_data[stripped_key] = value + if nested_data is None: # pragma: no cover + nested_data = {} + + for join in join_definitions: + join_primary_key = _get_primary_key(join.model) + nested_key = ( + join.join_prefix.rstrip("_") + if join.join_prefix + else join.model.__tablename__ + ) + if join.relationship_type == "one-to-many" and nested_key in nested_data: + if isinstance(nested_data.get(nested_key, []), list): + if any( + item[join_primary_key] is None for item in nested_data[nested_key] + ): + nested_data[nested_key] = [] + + assert nested_data is not None, "Couldn't nest the data." + return nested_data + + +def _nest_multi_join_data( + base_primary_key: str, + data: list[Union[dict, BaseModel]], + joins_config: Sequence[JoinConfig], + return_as_model: bool = False, + schema_to_select: Optional[type[BaseModel]] = None, + nested_schema_to_select: Optional[dict[str, type[BaseModel]]] = None, +) -> Sequence[Union[dict, BaseModel]]: + """ + Nests joined data based on join definitions provided for multiple records. This function processes the input list of + dictionaries, identifying keys that correspond to joined tables using the provided joins_config, and nests them + under their respective table keys. + + Args: + base_primary_key: The primary key of the base model. + data: The list of dictionaries containing the records with potentially nested data. + joins_config: The list of join configurations containing the joined model classes and related settings. + schema_to_select: Pydantic schema for selecting specific columns from the primary model. Used for converting + dictionaries back to Pydantic models. + return_as_model: If True, converts the fetched data to Pydantic models based on schema_to_select. Defaults to False. + nested_schema_to_select: A dictionary mapping join prefixes to their corresponding Pydantic schemas. + + Returns: + Sequence[Union[dict, BaseModel]]: A list of dictionaries with nested structures for joined table data or Pydantic models. + + Example: + Input: + data = [ + {'id': 1, 'title': 'Test Card', 'articles': [{'id': 1, 'title': 'Article 1', 'card_id': 1}]}, + {'id': 2, 'title': 'Test Card 2', 'articles': [{'id': 2, 'title': 'Article 2', 'card_id': 2}]}, + {'id': 2, 'title': 'Test Card 2', 'articles': [{'id': 3, 'title': 'Article 3', 'card_id': 2}]}, + {'id': 3, 'title': 'Test Card 3', 'articles': [{'id': None, 'title': None, 'card_id': None}]} + ] + + joins_config = [ + JoinConfig(model=Article, join_prefix='articles_', relationship_type='one-to-many') + ] + + Output: + [ + { + 'id': 1, + 'title': 'Test Card', + 'articles': [ + { + 'id': 1, + 'title': 'Article 1', + 'card_id': 1 + } + ] + }, + { + 'id': 2, + 'title': 'Test Card 2', + 'articles': [ + { + 'id': 2, + 'title': 'Article 2', + 'card_id': 2 + }, + { + 'id': 3, + 'title': 'Article 3', + 'card_id': 2 + } + ] + }, + { + 'id': 3, + 'title': 'Test Card 3', + 'articles': [] + } + ] + """ + pre_nested_data = {} + + for join_config in joins_config: + join_primary_key = _get_primary_key(join_config.model) + + for row in data: + if isinstance(row, BaseModel): + new_row = { + key: (value[:] if isinstance(value, list) else value) + for key, value in row.model_dump().items() + } + else: + new_row = { + key: (value[:] if isinstance(value, list) else value) + for key, value in row.items() + } + + primary_key_value = new_row[base_primary_key] + + if primary_key_value not in pre_nested_data: + for key, value in new_row.items(): + if isinstance(value, list) and any( + item[join_primary_key] is None for item in value + ): + new_row[key] = [] + + pre_nested_data[primary_key_value] = new_row + else: + existing_row = pre_nested_data[primary_key_value] + for key, value in new_row.items(): + if isinstance(value, list): + if any(item[join_primary_key] is None for item in value): + existing_row[key] = [] + else: + existing_row[key].extend(value) + + nested_data: list = list(pre_nested_data.values()) + + if return_as_model: + for i, item in enumerate(nested_data): + if nested_schema_to_select: + for prefix, schema in nested_schema_to_select.items(): + if prefix in item: + if isinstance(item[prefix], list): + item[prefix] = [ + schema(**nested_item) for nested_item in item[prefix] + ] + else: + item[prefix] = schema(**item[prefix]) + if schema_to_select: + nested_data[i] = schema_to_select(**item) + return nested_data diff --git a/tests/sqlalchemy/conftest.py b/tests/sqlalchemy/conftest.py index b245109..31f9269 100644 --- a/tests/sqlalchemy/conftest.py +++ b/tests/sqlalchemy/conftest.py @@ -113,6 +113,23 @@ class ProjectsParticipantsAssociation(Base): participant_id = Column(Integer, ForeignKey("participants.id"), primary_key=True) +class Card(Base): + __tablename__ = "cards" + id = Column(Integer, primary_key=True) + title = Column(String) + + +class Article(Base): + __tablename__ = "articles" + id = Column(Integer, primary_key=True) + title = Column(String) + card_id = Column(Integer, ForeignKey("cards.id")) + card = relationship("Card", back_populates="articles") + + +Card.articles = relationship("Article", order_by=Article.id, back_populates="card") + + class CreateSchemaTest(BaseModel): model_config = ConfigDict(extra="forbid") name: str @@ -165,6 +182,18 @@ class BookingSchema(BaseModel): booking_date: datetime +class ArticleSchema(BaseModel): + id: int + title: str + card_id: int + + +class CardSchema(BaseModel): + id: int + title: str + articles: Optional[list[ArticleSchema]] = [] + + async_engine = create_async_engine( "sqlite+aiosqlite:///:memory:", echo=True, future=True ) diff --git a/tests/sqlalchemy/crud/test_get_joined.py b/tests/sqlalchemy/crud/test_get_joined.py index 7e67335..5f40e93 100644 --- a/tests/sqlalchemy/crud/test_get_joined.py +++ b/tests/sqlalchemy/crud/test_get_joined.py @@ -11,6 +11,8 @@ BookingModel, BookingSchema, ReadSchemaTest, + Article, + Card, ) @@ -386,7 +388,7 @@ async def test_get_joined_with_unsupported_join_type_raises_value_error( join_type="unsupported_type", ) - assert "Unsupported join type: unsupported_type." in str(excinfo.value) + assert "Unsupported join type" in str(excinfo.value) @pytest.mark.asyncio @@ -524,3 +526,42 @@ async def test_get_joined_no_prefix_no_nesting( assert ( "tier_name" not in result ), "Field 'tier_name' should not exist unless specifically prefixed or nested." + + +@pytest.mark.asyncio +async def test_get_joined_card_with_articles(async_session): + card = Card(title="Test Card") + async_session.add(card) + async_session.add_all( + [ + Article(title="Article 1", card=card), + Article(title="Article 2", card=card), + Article(title="Article 3", card=card), + ] + ) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_joined( + db=async_session, + nest_joins=True, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "title" in result, "Card title should be present in the result." + assert "articles" in result, "Articles should be nested under 'articles'." + assert isinstance(result["articles"], list), "Articles should be a list." + assert len(result["articles"]) == 3, "There should be three articles." + assert all( + "title" in article for article in result["articles"] + ), "Each article should have a title." diff --git a/tests/sqlalchemy/crud/test_get_multi_joined.py b/tests/sqlalchemy/crud/test_get_multi_joined.py index 41665ff..a9ec4b9 100644 --- a/tests/sqlalchemy/crud/test_get_multi_joined.py +++ b/tests/sqlalchemy/crud/test_get_multi_joined.py @@ -15,6 +15,10 @@ Project, Participant, ProjectsParticipantsAssociation, + Article, + Card, + ArticleSchema, + CardSchema, ) @@ -780,7 +784,6 @@ async def test_get_multi_joined_no_prefix_regular( limit=10, ) - print(result) assert result and result["data"], "Expected data in the result." for item in result["data"]: assert "name" in item, "Expected user name in each item." @@ -819,3 +822,255 @@ async def test_get_multi_joined_no_prefix_nested( assert ( "name" in item[TierModel.__tablename__] ), f"Expected 'name' field inside nested '{TierModel.__tablename__}' dictionary." + + +@pytest.mark.asyncio +async def test_get_multi_joined_card_with_articles(async_session): + cards = [ + Card(title="Test Card"), + Card(title="Test Card 2"), + Card(title="Test Card 3"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + Article(title="Article 2", card_id=cards[1].id), + Article(title="Article 3", card_id=cards[1].id), + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + assert isinstance(data, list), "Result data should be a list." + assert len(data) == 3, "Expected three card records." + + card1 = next((c for c in data if c["id"] == cards[0].id), None) + card2 = next((c for c in data if c["id"] == cards[1].id), None) + card3 = next((c for c in data if c["id"] == cards[2].id), None) + + assert ( + card1 is not None and "articles" in card1 + ), "Card 1 should have nested articles." + assert len(card1["articles"]) == 1, "Card 1 should have one article." + assert ( + card1["articles"][0]["title"] == "Article 1" + ), "Card 1's article title should be 'Article 1'." + + assert ( + card2 is not None and "articles" in card2 + ), "Card 2 should have nested articles." + assert len(card2["articles"]) == 2, "Card 2 should have two articles." + assert ( + card2["articles"][0]["title"] == "Article 2" + ), "Card 2's first article title should be 'Article 2'." + assert ( + card2["articles"][1]["title"] == "Article 3" + ), "Card 2's second article title should be 'Article 3'." + + assert ( + card3 is not None and "articles" in card3 + ), "Card 3 should have nested articles." + assert len(card3["articles"]) == 0, "Card 3 should have no articles." + + +@pytest.mark.asyncio +async def test_get_multi_joined_card_with_multiple_articles(async_session): + cards = [ + Card(title="Card A"), + Card(title="Card B"), + Card(title="Card C"), + Card(title="Card D"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + Article(title="Article 2", card_id=cards[0].id), + Article(title="Article 3", card_id=cards[1].id), + Article(title="Article 4", card_id=cards[1].id), + Article(title="Article 5", card_id=cards[2].id), + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + assert isinstance(data, list), "Result data should be a list." + assert len(data) == 4, "Expected four card records." + + card_a = next((c for c in data if c["id"] == cards[0].id), None) + card_b = next((c for c in data if c["id"] == cards[1].id), None) + card_c = next((c for c in data if c["id"] == cards[2].id), None) + card_d = next((c for c in data if c["id"] == cards[3].id), None) + + assert ( + card_a is not None and "articles" in card_a + ), "Card A should have nested articles." + assert len(card_a["articles"]) == 2, "Card A should have two articles." + assert ( + card_a["articles"][0]["title"] == "Article 1" + ), "Card A's first article title should be 'Article 1'." + assert ( + card_a["articles"][1]["title"] == "Article 2" + ), "Card A's second article title should be 'Article 2'." + + assert ( + card_b is not None and "articles" in card_b + ), "Card B should have nested articles." + assert len(card_b["articles"]) == 2, "Card B should have two articles." + assert ( + card_b["articles"][0]["title"] == "Article 3" + ), "Card B's first article title should be 'Article 3'." + assert ( + card_b["articles"][1]["title"] == "Article 4" + ), "Card B's second article title should be 'Article 4'." + + assert ( + card_c is not None and "articles" in card_c + ), "Card C should have nested articles." + assert len(card_c["articles"]) == 1, "Card C should have one article." + assert ( + card_c["articles"][0]["title"] == "Article 5" + ), "Card C's article title should be 'Article 5'." + + assert ( + card_d is not None and "articles" in card_d + ), "Card D should have nested articles." + assert len(card_d["articles"]) == 0, "Card D should have no articles." + + +@pytest.mark.asyncio +async def test_get_multi_joined_card_with_multiple_articles_as_models(async_session): + cards = [ + Card(title="Card A"), + Card(title="Card B"), + Card(title="Card C"), + Card(title="Card D"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + Article(title="Article 2", card_id=cards[0].id), + Article(title="Article 3", card_id=cards[1].id), + Article(title="Article 4", card_id=cards[1].id), + Article(title="Article 5", card_id=cards[2].id), + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + return_as_model=True, + schema_to_select=CardSchema, + nested_schema_to_select={"articles_": ArticleSchema}, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + assert isinstance(data, list), "Result data should be a list." + assert len(data) == 4, "Expected four card records." + assert all( + isinstance(card, CardSchema) for card in data + ), "All items should be instances of CardSchema." + + card_a = next((c for c in data if c.id == cards[0].id), None) + card_b = next((c for c in data if c.id == cards[1].id), None) + card_c = next((c for c in data if c.id == cards[2].id), None) + card_d = next((c for c in data if c.id == cards[3].id), None) + + assert card_a is not None and hasattr( + card_a, "articles" + ), "Card A should have nested articles." + assert len(card_a.articles) == 2, "Card A should have two articles." + assert ( + card_a.articles[0].title == "Article 1" + ), "Card A's first article title should be 'Article 1'." + assert ( + card_a.articles[1].title == "Article 2" + ), "Card A's second article title should be 'Article 2'." + assert all( + isinstance(article, ArticleSchema) for article in card_a.articles + ), "All articles in Card A should be instances of ArticleSchema." + + assert card_b is not None and hasattr( + card_b, "articles" + ), "Card B should have nested articles." + assert len(card_b.articles) == 2, "Card B should have two articles." + assert ( + card_b.articles[0].title == "Article 3" + ), "Card B's first article title should be 'Article 3'." + assert ( + card_b.articles[1].title == "Article 4" + ), "Card B's second article title should be 'Article 4'." + assert all( + isinstance(article, ArticleSchema) for article in card_b.articles + ), "All articles in Card B should be instances of ArticleSchema." + + assert card_c is not None and hasattr( + card_c, "articles" + ), "Card C should have nested articles." + assert len(card_c.articles) == 1, "Card C should have one article." + assert ( + card_c.articles[0].title == "Article 5" + ), "Card C's article title should be 'Article 5'." + assert all( + isinstance(article, ArticleSchema) for article in card_c.articles + ), "All articles in Card C should be instances of ArticleSchema." + + assert card_d is not None and hasattr( + card_d, "articles" + ), "Card D should have nested articles." + assert len(card_d.articles) == 0, "Card D should have no articles." diff --git a/tests/sqlmodel/conftest.py b/tests/sqlmodel/conftest.py index b4f89cc..7103333 100644 --- a/tests/sqlmodel/conftest.py +++ b/tests/sqlmodel/conftest.py @@ -90,6 +90,21 @@ class Participant(SQLModel, table=True): ) +class Card(SQLModel, table=True): + __tablename__ = "cards" + id: Optional[int] = Field(default=None, primary_key=True) + title: str + articles: list["Article"] = Relationship(back_populates="card") + + +class Article(SQLModel, table=True): + __tablename__ = "articles" + id: Optional[int] = Field(default=None, primary_key=True) + title: str + card_id: int = Field(foreign_key="cards.id") + card: Optional[Card] = Relationship(back_populates="articles") + + class CreateSchemaTest(SQLModel): model_config = ConfigDict(extra="forbid") name: str @@ -156,6 +171,18 @@ class MultiPkSchema(SQLModel): test_id: int = None +class ArticleSchema(SQLModel): + id: int + title: str + card_id: int + + +class CardSchema(SQLModel): + id: int + title: str + articles: Optional[list[ArticleSchema]] = [] + + async_engine = create_async_engine( "sqlite+aiosqlite:///:memory:", echo=True, future=True ) diff --git a/tests/sqlmodel/crud/test_get_joined.py b/tests/sqlmodel/crud/test_get_joined.py index 5bab616..2d63490 100644 --- a/tests/sqlmodel/crud/test_get_joined.py +++ b/tests/sqlmodel/crud/test_get_joined.py @@ -11,6 +11,8 @@ BookingModel, BookingSchema, ReadSchemaTest, + Article, + Card, ) @@ -386,7 +388,7 @@ async def test_get_joined_with_unsupported_join_type_raises_value_error( join_type="unsupported_type", ) - assert "Unsupported join type: unsupported_type." in str(excinfo.value) + assert "Unsupported join type" in str(excinfo.value) @pytest.mark.asyncio @@ -525,3 +527,42 @@ async def test_get_joined_no_prefix_no_nesting( assert ( "tier_name" not in result ), "Field 'tier_name' should not exist unless specifically prefixed or nested." + + +@pytest.mark.asyncio +async def test_get_joined_card_with_articles(async_session): + card = Card(title="Test Card") + async_session.add(card) + async_session.add_all( + [ + Article(title="Article 1", card=card), + Article(title="Article 2", card=card), + Article(title="Article 3", card=card), + ] + ) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_joined( + db=async_session, + nest_joins=True, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "title" in result, "Card title should be present in the result." + assert "articles" in result, "Articles should be nested under 'articles'." + assert isinstance(result["articles"], list), "Articles should be a list." + assert len(result["articles"]) == 3, "There should be three articles." + assert all( + "title" in article for article in result["articles"] + ), "Each article should have a title." diff --git a/tests/sqlmodel/crud/test_get_multi_joined.py b/tests/sqlmodel/crud/test_get_multi_joined.py index 2b5ddd0..79e4fad 100644 --- a/tests/sqlmodel/crud/test_get_multi_joined.py +++ b/tests/sqlmodel/crud/test_get_multi_joined.py @@ -15,6 +15,10 @@ Project, Participant, ProjectsParticipantsAssociation, + Article, + Card, + ArticleSchema, + CardSchema, ) @@ -780,7 +784,6 @@ async def test_get_multi_joined_no_prefix_regular( limit=10, ) - print(result) assert result and result["data"], "Expected data in the result." for item in result["data"]: assert "name" in item, "Expected user name in each item." @@ -819,3 +822,255 @@ async def test_get_multi_joined_no_prefix_nested( assert ( "name" in item[TierModel.__tablename__] ), f"Expected 'name' field inside nested '{TierModel.__tablename__}' dictionary." + + +@pytest.mark.asyncio +async def test_get_multi_joined_card_with_articles(async_session): + cards = [ + Card(title="Test Card"), + Card(title="Test Card 2"), + Card(title="Test Card 3"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + Article(title="Article 2", card_id=cards[1].id), + Article(title="Article 3", card_id=cards[1].id), + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + assert isinstance(data, list), "Result data should be a list." + assert len(data) == 3, "Expected three card records." + + card1 = next((c for c in data if c["id"] == cards[0].id), None) + card2 = next((c for c in data if c["id"] == cards[1].id), None) + card3 = next((c for c in data if c["id"] == cards[2].id), None) + + assert ( + card1 is not None and "articles" in card1 + ), "Card 1 should have nested articles." + assert len(card1["articles"]) == 1, "Card 1 should have one article." + assert ( + card1["articles"][0]["title"] == "Article 1" + ), "Card 1's article title should be 'Article 1'." + + assert ( + card2 is not None and "articles" in card2 + ), "Card 2 should have nested articles." + assert len(card2["articles"]) == 2, "Card 2 should have two articles." + assert ( + card2["articles"][0]["title"] == "Article 2" + ), "Card 2's first article title should be 'Article 2'." + assert ( + card2["articles"][1]["title"] == "Article 3" + ), "Card 2's second article title should be 'Article 3'." + + assert ( + card3 is not None and "articles" in card3 + ), "Card 3 should have nested articles." + assert len(card3["articles"]) == 0, "Card 3 should have no articles." + + +@pytest.mark.asyncio +async def test_get_multi_joined_card_with_multiple_articles(async_session): + cards = [ + Card(title="Card A"), + Card(title="Card B"), + Card(title="Card C"), + Card(title="Card D"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + Article(title="Article 2", card_id=cards[0].id), + Article(title="Article 3", card_id=cards[1].id), + Article(title="Article 4", card_id=cards[1].id), + Article(title="Article 5", card_id=cards[2].id), + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + assert isinstance(data, list), "Result data should be a list." + assert len(data) == 4, "Expected four card records." + + card_a = next((c for c in data if c["id"] == cards[0].id), None) + card_b = next((c for c in data if c["id"] == cards[1].id), None) + card_c = next((c for c in data if c["id"] == cards[2].id), None) + card_d = next((c for c in data if c["id"] == cards[3].id), None) + + assert ( + card_a is not None and "articles" in card_a + ), "Card A should have nested articles." + assert len(card_a["articles"]) == 2, "Card A should have two articles." + assert ( + card_a["articles"][0]["title"] == "Article 1" + ), "Card A's first article title should be 'Article 1'." + assert ( + card_a["articles"][1]["title"] == "Article 2" + ), "Card A's second article title should be 'Article 2'." + + assert ( + card_b is not None and "articles" in card_b + ), "Card B should have nested articles." + assert len(card_b["articles"]) == 2, "Card B should have two articles." + assert ( + card_b["articles"][0]["title"] == "Article 3" + ), "Card B's first article title should be 'Article 3'." + assert ( + card_b["articles"][1]["title"] == "Article 4" + ), "Card B's second article title should be 'Article 4'." + + assert ( + card_c is not None and "articles" in card_c + ), "Card C should have nested articles." + assert len(card_c["articles"]) == 1, "Card C should have one article." + assert ( + card_c["articles"][0]["title"] == "Article 5" + ), "Card C's article title should be 'Article 5'." + + assert ( + card_d is not None and "articles" in card_d + ), "Card D should have nested articles." + assert len(card_d["articles"]) == 0, "Card D should have no articles." + + +@pytest.mark.asyncio +async def test_get_multi_joined_card_with_multiple_articles_as_models(async_session): + cards = [ + Card(title="Card A"), + Card(title="Card B"), + Card(title="Card C"), + Card(title="Card D"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + Article(title="Article 2", card_id=cards[0].id), + Article(title="Article 3", card_id=cards[1].id), + Article(title="Article 4", card_id=cards[1].id), + Article(title="Article 5", card_id=cards[2].id), + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + return_as_model=True, + schema_to_select=CardSchema, + nested_schema_to_select={"articles_": ArticleSchema}, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + assert isinstance(data, list), "Result data should be a list." + assert len(data) == 4, "Expected four card records." + assert all( + isinstance(card, CardSchema) for card in data + ), "All items should be instances of CardSchema." + + card_a = next((c for c in data if c.id == cards[0].id), None) + card_b = next((c for c in data if c.id == cards[1].id), None) + card_c = next((c for c in data if c.id == cards[2].id), None) + card_d = next((c for c in data if c.id == cards[3].id), None) + + assert card_a is not None and hasattr( + card_a, "articles" + ), "Card A should have nested articles." + assert len(card_a.articles) == 2, "Card A should have two articles." + assert ( + card_a.articles[0].title == "Article 1" + ), "Card A's first article title should be 'Article 1'." + assert ( + card_a.articles[1].title == "Article 2" + ), "Card A's second article title should be 'Article 2'." + assert all( + isinstance(article, ArticleSchema) for article in card_a.articles + ), "All articles in Card A should be instances of ArticleSchema." + + assert card_b is not None and hasattr( + card_b, "articles" + ), "Card B should have nested articles." + assert len(card_b.articles) == 2, "Card B should have two articles." + assert ( + card_b.articles[0].title == "Article 3" + ), "Card B's first article title should be 'Article 3'." + assert ( + card_b.articles[1].title == "Article 4" + ), "Card B's second article title should be 'Article 4'." + assert all( + isinstance(article, ArticleSchema) for article in card_b.articles + ), "All articles in Card B should be instances of ArticleSchema." + + assert card_c is not None and hasattr( + card_c, "articles" + ), "Card C should have nested articles." + assert len(card_c.articles) == 1, "Card C should have one article." + assert ( + card_c.articles[0].title == "Article 5" + ), "Card C's article title should be 'Article 5'." + assert all( + isinstance(article, ArticleSchema) for article in card_c.articles + ), "All articles in Card C should be instances of ArticleSchema." + + assert card_d is not None and hasattr( + card_d, "articles" + ), "Card D should have nested articles." + assert len(card_d.articles) == 0, "Card D should have no articles." From 86375ae03e4322cfd2edf95b5ed93521d0db0eed Mon Sep 17 00:00:00 2001 From: Igor Magalhaes Date: Sat, 25 May 2024 23:57:21 -0300 Subject: [PATCH 2/4] test settings fix --- tests/sqlalchemy/conftest.py | 6 ++++-- tests/sqlmodel/conftest.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/sqlalchemy/conftest.py b/tests/sqlalchemy/conftest.py index 31f9269..b644358 100644 --- a/tests/sqlalchemy/conftest.py +++ b/tests/sqlalchemy/conftest.py @@ -204,8 +204,10 @@ class CardSchema(BaseModel): ) -def get_session_local(): - yield local_session() +async def get_session_local(): + async with local_session() as session: + yield session + await session.close() # Ensure the session is properly closed @pytest_asyncio.fixture(scope="function") diff --git a/tests/sqlmodel/conftest.py b/tests/sqlmodel/conftest.py index 7103333..46ae955 100644 --- a/tests/sqlmodel/conftest.py +++ b/tests/sqlmodel/conftest.py @@ -193,8 +193,10 @@ class CardSchema(SQLModel): ) -def get_session_local(): - yield local_session() +async def get_session_local(): + async with local_session() as session: + yield session + await session.close() @pytest_asyncio.fixture(scope="function") From 1de88ce5784ae4de56a0b68845269df5de0d7900 Mon Sep 17 00:00:00 2001 From: Igor Magalhaes Date: Sun, 26 May 2024 18:34:40 -0300 Subject: [PATCH 3/4] test coverage --- fastcrud/crud/helper.py | 8 +- .../core/test_nest_multi_join_data.py | 365 ++++++++++++++++++ tests/sqlmodel/conftest.py | 2 +- .../core/test_nest_multi_join_data.py | 365 ++++++++++++++++++ 4 files changed, 736 insertions(+), 4 deletions(-) create mode 100644 tests/sqlalchemy/core/test_nest_multi_join_data.py create mode 100644 tests/sqlmodel/core/test_nest_multi_join_data.py diff --git a/fastcrud/crud/helper.py b/fastcrud/crud/helper.py index ae43c2c..25ea3e5 100644 --- a/fastcrud/crud/helper.py +++ b/fastcrud/crud/helper.py @@ -484,7 +484,7 @@ def _nest_multi_join_data( for key, value in new_row.items(): if isinstance(value, list) and any( item[join_primary_key] is None for item in value - ): + ): # pragma: no cover new_row[key] = [] pre_nested_data[primary_key_value] = new_row @@ -492,7 +492,9 @@ def _nest_multi_join_data( existing_row = pre_nested_data[primary_key_value] for key, value in new_row.items(): if isinstance(value, list): - if any(item[join_primary_key] is None for item in value): + if any( + item[join_primary_key] is None for item in value + ): # pragma: no cover existing_row[key] = [] else: existing_row[key].extend(value) @@ -508,7 +510,7 @@ def _nest_multi_join_data( item[prefix] = [ schema(**nested_item) for nested_item in item[prefix] ] - else: + else: # pragma: no cover item[prefix] = schema(**item[prefix]) if schema_to_select: nested_data[i] = schema_to_select(**item) diff --git a/tests/sqlalchemy/core/test_nest_multi_join_data.py b/tests/sqlalchemy/core/test_nest_multi_join_data.py new file mode 100644 index 0000000..47d1ae4 --- /dev/null +++ b/tests/sqlalchemy/core/test_nest_multi_join_data.py @@ -0,0 +1,365 @@ +import pytest + +from fastcrud.crud.fast_crud import FastCRUD, JoinConfig + +from ..conftest import ( + Article, + Card, + ArticleSchema, + CardSchema, +) + + +@pytest.mark.asyncio +async def test_nest_multi_join_data_new_row_none(async_session): + cards = [ + Card(title="Card A"), + Card(title="Card B"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + Article( + title="Article 2", card_id=None + ), # This should trigger new_row[key] = [] + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + schema_to_select=CardSchema, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + + card_a = next((c for c in data if c["id"] == cards[0].id), None) + assert ( + card_a is not None and "articles" in card_a + ), "Card A should have nested articles." + assert len(card_a["articles"]) == 1, "Card A should have one valid article." + assert ( + card_a["articles"][0]["title"] == "Article 1" + ), "Card A's article title should be 'Article 1'." + + card_b = next((c for c in data if c["id"] == cards[1].id), None) + assert ( + card_b is not None and "articles" in card_b + ), "Card B should have nested articles." + assert ( + len(card_b["articles"]) == 0 + ), "Card B should have no articles due to None card_id in Article." + + +@pytest.mark.asyncio +async def test_nest_multi_join_data_existing_row_none(async_session): + cards = [ + Card(title="Card A"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + Article(title="Article 2", card_id=cards[0].id), + Article( + title="Article 3", card_id=None + ), # This will trigger existing_row[key] = [] + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + schema_to_select=CardSchema, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + + card_a = next((c for c in data if c["id"] == cards[0].id), None) + assert ( + card_a is not None and "articles" in card_a + ), "Card A should have nested articles." + assert len(card_a["articles"]) == 2, "Card A should have two valid articles." + assert ( + card_a["articles"][0]["title"] == "Article 1" + ), "Card A's first article title should be 'Article 1'." + assert ( + card_a["articles"][1]["title"] == "Article 2" + ), "Card A's second article title should be 'Article 2'." + + +@pytest.mark.asyncio +async def test_nest_multi_join_data_nested_schema(async_session): + cards = [ + Card(title="Card A"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + return_as_model=True, + schema_to_select=CardSchema, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + schema_to_select=ArticleSchema, + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + + assert len(data) == 1, "Expected one card record." + card_a = data[0] + assert isinstance(card_a, CardSchema), "Card should be an instance of CardSchema." + assert hasattr(card_a, "articles"), "Card should have nested articles." + assert len(card_a.articles) == 1, "Card should have one article." + assert isinstance( + card_a.articles[0], ArticleSchema + ), "Article should be an instance of ArticleSchema." + assert ( + card_a.articles[0].title == "Article 1" + ), "Article title should be 'Article 1'." + + +@pytest.mark.asyncio +async def test_nest_multi_join_data_prefix_in_item(async_session): + cards = [ + Card(title="Card A"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + return_as_model=True, + schema_to_select=CardSchema, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + schema_to_select=ArticleSchema, + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + + assert len(data) == 1, "Expected one card record." + card_a = data[0] + assert isinstance(card_a, CardSchema), "Card should be an instance of CardSchema." + assert hasattr(card_a, "articles"), "Card should have nested articles." + assert len(card_a.articles) == 1, "Card should have one article." + assert isinstance( + card_a.articles[0], ArticleSchema + ), "Article should be an instance of ArticleSchema." + assert ( + card_a.articles[0].title == "Article 1" + ), "Article title should be 'Article 1'." + + +@pytest.mark.asyncio +async def test_nest_multi_join_data_isinstance_list(async_session): + cards = [ + Card(title="Card A"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + Article(title="Article 2", card_id=cards[0].id), + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + return_as_model=True, + schema_to_select=CardSchema, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + schema_to_select=ArticleSchema, + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + + assert len(data) == 1, "Expected one card record." + card_a = data[0] + assert isinstance(card_a, CardSchema), "Card should be an instance of CardSchema." + assert hasattr(card_a, "articles"), "Card should have nested articles." + assert len(card_a.articles) == 2, "Card should have two articles." + assert all( + isinstance(article, ArticleSchema) for article in card_a.articles + ), "All articles should be instances of ArticleSchema." + + +@pytest.mark.asyncio +async def test_nest_multi_join_data_convert_list_to_schema(async_session): + cards = [ + Card(title="Card A"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + Article(title="Article 2", card_id=cards[0].id), + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + return_as_model=True, + schema_to_select=CardSchema, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + schema_to_select=ArticleSchema, + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + + assert len(data) == 1, "Expected one card record." + card_a = data[0] + assert isinstance(card_a, CardSchema), "Card should be an instance of CardSchema." + assert hasattr(card_a, "articles"), "Card should have nested articles." + assert len(card_a.articles) == 2, "Card should have two articles." + assert all( + isinstance(article, ArticleSchema) for article in card_a.articles + ), "All articles should be instances of ArticleSchema." + + +@pytest.mark.asyncio +async def test_nest_multi_join_data_convert_dict_to_schema(async_session): + cards = [ + Card(title="Card A"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + return_as_model=True, + schema_to_select=CardSchema, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + schema_to_select=ArticleSchema, + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + + assert len(data) == 1, "Expected one card record." + card_a = data[0] + assert isinstance(card_a, CardSchema), "Card should be an instance of CardSchema." + assert hasattr(card_a, "articles"), "Card should have nested articles." + assert len(card_a.articles) == 1, "Card should have one article." + assert isinstance( + card_a.articles[0], ArticleSchema + ), "Article should be an instance of ArticleSchema." + assert ( + card_a.articles[0].title == "Article 1" + ), "Article title should be 'Article 1'." diff --git a/tests/sqlmodel/conftest.py b/tests/sqlmodel/conftest.py index 46ae955..2d512ae 100644 --- a/tests/sqlmodel/conftest.py +++ b/tests/sqlmodel/conftest.py @@ -101,7 +101,7 @@ class Article(SQLModel, table=True): __tablename__ = "articles" id: Optional[int] = Field(default=None, primary_key=True) title: str - card_id: int = Field(foreign_key="cards.id") + card_id: Optional[int] = Field(foreign_key="cards.id") card: Optional[Card] = Relationship(back_populates="articles") diff --git a/tests/sqlmodel/core/test_nest_multi_join_data.py b/tests/sqlmodel/core/test_nest_multi_join_data.py new file mode 100644 index 0000000..47d1ae4 --- /dev/null +++ b/tests/sqlmodel/core/test_nest_multi_join_data.py @@ -0,0 +1,365 @@ +import pytest + +from fastcrud.crud.fast_crud import FastCRUD, JoinConfig + +from ..conftest import ( + Article, + Card, + ArticleSchema, + CardSchema, +) + + +@pytest.mark.asyncio +async def test_nest_multi_join_data_new_row_none(async_session): + cards = [ + Card(title="Card A"), + Card(title="Card B"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + Article( + title="Article 2", card_id=None + ), # This should trigger new_row[key] = [] + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + schema_to_select=CardSchema, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + + card_a = next((c for c in data if c["id"] == cards[0].id), None) + assert ( + card_a is not None and "articles" in card_a + ), "Card A should have nested articles." + assert len(card_a["articles"]) == 1, "Card A should have one valid article." + assert ( + card_a["articles"][0]["title"] == "Article 1" + ), "Card A's article title should be 'Article 1'." + + card_b = next((c for c in data if c["id"] == cards[1].id), None) + assert ( + card_b is not None and "articles" in card_b + ), "Card B should have nested articles." + assert ( + len(card_b["articles"]) == 0 + ), "Card B should have no articles due to None card_id in Article." + + +@pytest.mark.asyncio +async def test_nest_multi_join_data_existing_row_none(async_session): + cards = [ + Card(title="Card A"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + Article(title="Article 2", card_id=cards[0].id), + Article( + title="Article 3", card_id=None + ), # This will trigger existing_row[key] = [] + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + schema_to_select=CardSchema, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + + card_a = next((c for c in data if c["id"] == cards[0].id), None) + assert ( + card_a is not None and "articles" in card_a + ), "Card A should have nested articles." + assert len(card_a["articles"]) == 2, "Card A should have two valid articles." + assert ( + card_a["articles"][0]["title"] == "Article 1" + ), "Card A's first article title should be 'Article 1'." + assert ( + card_a["articles"][1]["title"] == "Article 2" + ), "Card A's second article title should be 'Article 2'." + + +@pytest.mark.asyncio +async def test_nest_multi_join_data_nested_schema(async_session): + cards = [ + Card(title="Card A"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + return_as_model=True, + schema_to_select=CardSchema, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + schema_to_select=ArticleSchema, + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + + assert len(data) == 1, "Expected one card record." + card_a = data[0] + assert isinstance(card_a, CardSchema), "Card should be an instance of CardSchema." + assert hasattr(card_a, "articles"), "Card should have nested articles." + assert len(card_a.articles) == 1, "Card should have one article." + assert isinstance( + card_a.articles[0], ArticleSchema + ), "Article should be an instance of ArticleSchema." + assert ( + card_a.articles[0].title == "Article 1" + ), "Article title should be 'Article 1'." + + +@pytest.mark.asyncio +async def test_nest_multi_join_data_prefix_in_item(async_session): + cards = [ + Card(title="Card A"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + return_as_model=True, + schema_to_select=CardSchema, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + schema_to_select=ArticleSchema, + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + + assert len(data) == 1, "Expected one card record." + card_a = data[0] + assert isinstance(card_a, CardSchema), "Card should be an instance of CardSchema." + assert hasattr(card_a, "articles"), "Card should have nested articles." + assert len(card_a.articles) == 1, "Card should have one article." + assert isinstance( + card_a.articles[0], ArticleSchema + ), "Article should be an instance of ArticleSchema." + assert ( + card_a.articles[0].title == "Article 1" + ), "Article title should be 'Article 1'." + + +@pytest.mark.asyncio +async def test_nest_multi_join_data_isinstance_list(async_session): + cards = [ + Card(title="Card A"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + Article(title="Article 2", card_id=cards[0].id), + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + return_as_model=True, + schema_to_select=CardSchema, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + schema_to_select=ArticleSchema, + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + + assert len(data) == 1, "Expected one card record." + card_a = data[0] + assert isinstance(card_a, CardSchema), "Card should be an instance of CardSchema." + assert hasattr(card_a, "articles"), "Card should have nested articles." + assert len(card_a.articles) == 2, "Card should have two articles." + assert all( + isinstance(article, ArticleSchema) for article in card_a.articles + ), "All articles should be instances of ArticleSchema." + + +@pytest.mark.asyncio +async def test_nest_multi_join_data_convert_list_to_schema(async_session): + cards = [ + Card(title="Card A"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + Article(title="Article 2", card_id=cards[0].id), + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + return_as_model=True, + schema_to_select=CardSchema, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + schema_to_select=ArticleSchema, + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + + assert len(data) == 1, "Expected one card record." + card_a = data[0] + assert isinstance(card_a, CardSchema), "Card should be an instance of CardSchema." + assert hasattr(card_a, "articles"), "Card should have nested articles." + assert len(card_a.articles) == 2, "Card should have two articles." + assert all( + isinstance(article, ArticleSchema) for article in card_a.articles + ), "All articles should be instances of ArticleSchema." + + +@pytest.mark.asyncio +async def test_nest_multi_join_data_convert_dict_to_schema(async_session): + cards = [ + Card(title="Card A"), + ] + async_session.add_all(cards) + await async_session.flush() + + articles = [ + Article(title="Article 1", card_id=cards[0].id), + ] + async_session.add_all(articles) + await async_session.commit() + + card_crud = FastCRUD(Card) + + result = await card_crud.get_multi_joined( + db=async_session, + nest_joins=True, + return_as_model=True, + schema_to_select=CardSchema, + joins_config=[ + JoinConfig( + model=Article, + join_on=Article.card_id == Card.id, + join_prefix="articles_", + join_type="left", + schema_to_select=ArticleSchema, + relationship_type="one-to-many", + ) + ], + ) + + assert result is not None, "No data returned from the database." + assert "data" in result, "Result should contain 'data' key." + data = result["data"] + + assert len(data) == 1, "Expected one card record." + card_a = data[0] + assert isinstance(card_a, CardSchema), "Card should be an instance of CardSchema." + assert hasattr(card_a, "articles"), "Card should have nested articles." + assert len(card_a.articles) == 1, "Card should have one article." + assert isinstance( + card_a.articles[0], ArticleSchema + ), "Article should be an instance of ArticleSchema." + assert ( + card_a.articles[0].title == "Article 1" + ), "Article title should be 'Article 1'." From 81915432158f9151e6fe2e50bdcdb7d4c40654a8 Mon Sep 17 00:00:00 2001 From: Igor Magalhaes Date: Mon, 27 May 2024 04:28:37 -0300 Subject: [PATCH 4/4] docs updated --- docs/advanced/crud.md | 24 ++++ docs/advanced/joins.md | 246 +++++++++++++++++++++++++++++++++++++++++ docs/usage/crud.md | 2 + 3 files changed, 272 insertions(+) diff --git a/docs/advanced/crud.md b/docs/advanced/crud.md index 6459b2d..d5ce021 100644 --- a/docs/advanced/crud.md +++ b/docs/advanced/crud.md @@ -225,6 +225,30 @@ In this example, users are joined with the `Tier` and `Department` models. The ` 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`** (with `nest_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`** (with `nest_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`** (with `nest_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](joins.md#handling-one-to-one-and-one-to-many-joins-in-fastcrud). + ### 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. diff --git a/docs/advanced/joins.md b/docs/advanced/joins.md index fa8caf4..6bc3d78 100644 --- a/docs/advanced/joins.md +++ b/docs/advanced/joins.md @@ -14,6 +14,12 @@ FastCRUD simplifies CRUD operations while offering capabilities for handling com - **`join_type`**: The type of join (e.g., "left", "inner"). - **`alias`**: An optional SQLAlchemy `AliasedClass` for complex scenarios like self-referential joins or multiple joins on the same model. - **`filters`**: An optional dictionary to apply filters directly to the joined model. +- **`relationship_type`**: Specifies the relationship type, such as `one-to-one` or `one-to-many`. Default is `one-to-one`. + +!!! TIP + + For `many-to-many`, you don't need to pass a `relationship_type`. + ## Applying Joins in FastCRUD Methods @@ -149,6 +155,8 @@ This works for both `get_joined` and `get_multi_joined`. When dealing with more complex join conditions, such as multiple joins, self-referential joins, or needing to specify aliases and filters, `JoinConfig` instances become the norm. They offer granular control over each join's aspects, enabling precise and efficient data retrieval. +Example: + ```python # Fetch users with details from related departments and roles, using aliases for self-referential joins from fastcrud import aliased @@ -178,6 +186,148 @@ users = await user_crud.get_multi_joined( ) ``` + +### 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 `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 Relationships +- **`get_joined`**: Fetch a single record and its directly associated record (e.g., a user and their profile). +- **`get_multi_joined`** (with `nest_joins=False`): Retrieve multiple records, each linked to a single related record from another table (e.g., users and their profiles). + + +##### Example + +Let's define two tables: + +```python +class User(Base): + __tablename__ = "user" + id = Column(Integer, primary_key=True) + name = Column(String) + tier_id = Column(Integer, ForeignKey("tier.id")) + +class Tier(Base): + __tablename__ = "tier" + id = Column(Integer, primary_key=True) + name = Column(String, unique=True) +``` + +Fetch a user and their tier: + +```python +user_tier = await user_crud.get_joined( + db=db, + join_model=Tier, + join_on=User.tier_id == Tier.id, + join_type="left", + join_prefix="tier_", + id=1 +) +``` + +The result will be: + +```json +{ + "id": 1, + "name": "Example", + "tier_id": 1, + "tier_name": "Free" +} +``` + +###### One-to-One Relationship with Nested Joins + +To get the joined data in a nested dictionary: + +```python +user_tier = await user_crud.get_joined( + db=db, + join_model=Tier, + join_on=User.tier_id == Tier.id, + join_type="left", + join_prefix="tier_", + nest_joins=True, + id=1 +) +``` + +The result will be: + +```json +{ + "id": 1, + "name": "Example", + "tier": { + "id": 1, + "name": "Free" + } +} +``` + + +#### One-to-Many Relationships +- **`get_joined`** (with `nest_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`** (with `nest_joins=True`): Fetch multiple primary records, each with their related records nested (e.g., multiple users and all their blog posts). + +!!! WARNING + + When using `nest_joins=True`, the performance will always be a bit worse than when using `nest_joins=False`. For cases where more performance is necessary, consider using `nest_joins=False` and remodeling your database. + + +##### Example + +To demonstrate a one-to-many relationship, let's assume `User` and `Post` tables: + +```python +class User(Base): + __tablename__ = "user" + id = Column(Integer, primary key=True) + name = Column(String) + +class Post(Base): + __tablename__ = "post" + id = Column(Integer, primary key=True) + user_id = Column(Integer, ForeignKey("user.id")) + content = Column(String) +``` + +Fetch a user and all their posts: + +```python +user_posts = await user_crud.get_joined( + db=db, + join_model=Post, + join_on=User.id == Post.user_id, + join_type="left", + join_prefix="post_", + nest_joins=True, + id=1 +) +``` + +The result will be: + +```json +{ + "id": 1, + "name": "Example User", + "posts": [ + { + "id": 101, + "user_id": 1, + "content": "First post content" + }, + { + "id": 102, + "user_id": 1, + "content": "Second post content" + } + ] +} +``` + #### 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. @@ -269,6 +419,102 @@ projects_with_participants = await crud_project.get_multi_joined( # Now, `projects_with_participants['data']` will contain projects along with their participant information. ``` +##### Example + +Imagine a scenario where projects have multiple participants, and participants can be involved in multiple projects. This many-to-many relationship is facilitated through an association table. + +Define the models: + +```python +class Project(Base): + __tablename__ = 'projects' + id = Column(Integer, primary key=True) + name = Column(String) + description = Column(String) + participants = relationship("Participant", secondary=projects_participants_association) + +class Participant(Base): + __tablename__ = 'participants' + id = Column(Integer, primary key=True) + name = Column(String) + role = Column(String) + projects = relationship("Project", secondary=projects_participants_association) + +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 along with their participants: + +```python +from fastcrud import FastCRUD, JoinConfig + +crud_project = FastCRUD(Project) + +joins_config = [ + JoinConfig( + model=ProjectsParticipantsAssociation, + join_on=Project.id == ProjectsParticipantsAssociation.project_id, + join_type="inner", + join_prefix="pp_" + ), + JoinConfig( + model=Participant, + join_on=ProjectsParticipantsAssociation.participant_id == Participant.id, + join_type="inner", + join_prefix="participant_" + ) +] + +projects_with_participants = await crud_project.get_multi_joined( + db_session, + joins_config=joins_config +) +``` + +The result will be: + +```json +[ + { + "id": 1, + "name": "Project A", + "description": "Description of Project A", + "participants": [ + { + "id": 1, + "name": "Participant 1", + "role": "Developer" + }, + { + "id": 2, + "name": "Participant 2", + "role": "Designer" + } + ] + }, + { + "id": 2, + "name": "Project B", + "description": "Description of Project B", + "participants": [ + { + "id": 3, + "name": "Participant 3", + "role": "Manager" + }, + { + "id": 4, + "name": "Participant 4", + "role": "Tester" + } + ] + } +] +``` + #### Practical Tips for Advanced Joins - **Prefixing**: Always use the `join_prefix` attribute to avoid column name collisions, especially in complex joins involving multiple models or self-referential joins. diff --git a/docs/usage/crud.md b/docs/usage/crud.md index 633a279..8a02192 100644 --- a/docs/usage/crud.md +++ b/docs/usage/crud.md @@ -237,6 +237,7 @@ get_joined( join_filters: Optional[dict] = None, joins_config: Optional[list[JoinConfig]] = None, nest_joins: bool = False, + relationship_type: Optional[str] = None, **kwargs: Any, ) -> Optional[dict[str, Any]] ``` @@ -275,6 +276,7 @@ get_multi_joined( return_as_model: bool = False, joins_config: Optional[list[JoinConfig]] = None, return_total_count: bool = True, + relationship_type: Optional[str] = None, **kwargs: Any, ) -> dict[str, Any] ```