Repository: igorbenav/fastcrud (python)

Issue:
How to perform JOIN across many to many relation?
**Describe the bug or question**
I have 2 models, A and B, in M2M relationship linked via an ABAssociation table. The documentation explains a way to perform left and outer joins using JoinConfig. It lacks details on how to proceed with M2M relationships. Is that possible?

**To Reproduce**
Please provide a self-contained, minimal, and reproducible example of your use case
```python
class A(SQLModel, table=True):
    id: int | None = Field(primary_key=True, index=True)

class B(SQLModel, table=True):
    id: int | None = Field(primary_key=True, index=True)

class VideoSearchAssociation(SQLModel, table=True):
    a_id: int | None = Field(primary_key=True, foreign_key="a.id")
    b_id: int | None = Field(primary_key=True, foreign_key="b.id")
```

**Description**

**Screenshots**

**Additional context**


Pull request description:
Improved Joins
## Improved Joins Summary

## Fixed
- `count` method now correctly counts Many-to-Many joined models

## Added
- Support for joined models in `count` method (passing `joins_config`)
- Filters added for joined models (as `filters` in `JoinConfig`)
- Relevant tests
- Relevant docs (including for many-to-many)

## Detailed
___
`JoinConfig` is a detailed configuration mechanism for specifying joins between models in FastCRUD queries. It contains the following key attributes:

- **`model`**: The SQLAlchemy model to join.
- **`join_on`**: The condition defining how the join connects to other models.
- **`join_prefix`**: An optional prefix for the joined columns to avoid column name conflicts.
- **`schema_to_select`**: An optional Pydantic schema for selecting specific columns from the joined model.
- **`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.

## Applying Joins in FastCRUD Methods

### The `count` Method with Joins

The `count` method can be enhanced with join operations to perform complex aggregate queries. While `count` primarily returns the number of records matching a given condition, introducing joins allows for counting records across related models based on specific relationships and conditions.

#### Using `JoinConfig`

For join requirements, the `count` method can be invoked with join parameters passed as a list of `JoinConfig` to the `joins_config` parameter:

```python
from fastcrud import JoinConfig
# Count the number of tasks assigned to users in a specific department
task_count = await task_crud.count(
    db=db,
    joins_config=[
        JoinConfig(
            model=User, 
            join_on=Task.assigned_user_id == User.id
        ),
        JoinConfig(
            model=Department, 
            join_on=User.department_id == Department.id, 
            filters={"name": "Engineering"}
        )
    ]
)
```

### Fetching Data with `get_joined` and `get_multi_joined`

These methods are essential for retrieving records from a primary model while including related data from one or more joined models. They support both simple and complex joining scenarios, including self-referential joins and many-to-many relationships.

#### Simple Joins Using Base Parameters

For simpler join requirements, FastCRUD allows specifying join parameters directly:

- **`model`**: The target model to join.
- **`join_on`**: The join condition.
- **`join_type`**: Specifies the SQL join type.
- **`aliased`**: When `True`, uses an alias for the model in the join.
- **`join_prefix`**: Optional prefix for columns from the joined model.
- **`filters`**: Additional filters for the joined model.

#### Examples of Simple Joining

```python
# Fetch tasks with user details, specifying a left join
tasks_with_users = await task_crud.get_joined(
    db=db,
    model=User,
    join_on=Task.user_id == User.id,
    join_type="left"
)
```

### Complex Joins Using `JoinConfig`

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.

```python
# Fetch users with details from related departments and roles, using aliases for self-referential joins
users = await user_crud.get_multi_joined(
    db=db,
    schema_to_select=UserSchema,
    joins_config=[
        JoinConfig(
            model=Department, 
            join_on=User.department_id == Department.id, 
            join_prefix="dept_"
        ),
        JoinConfig(
            model=Role, 
            join_on=User.role_id == Role.id, 
            join_prefix="role_"
        ),
        JoinConfig(
            model=User, 
            alias=manager_alias, 
            join_on=User.manager_id == manager_alias.id, 
            join_prefix="manager_"
        )
    ]
)
```

#### 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`: 

1. **First**, specify the main table you're querying from.
2. **Next**, include the association table that links your main table to the other table involved in the many-to-many relationship.
3. **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)`.

##### Scenario

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.

##### Models

Our models include `Project`, `Participant`, and an association model `ProjectsParticipantsAssociation`:

```python
from sqlalchemy import Column, Integer, String, ForeignKey, Table
from sqlalchemy.orm import relationship
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)
    name = Column(String)
    description = Column(String)
    # Relationship to Participant through the association table
    participants = relationship("Participant", secondary=projects_participants_association)

class Participant(Base):
    __tablename__ = 'participants'
    id = Column(Integer, primary_key=True)
    name = Column(String)
    role = Column(String)
    # Relationship to Project through the association table
    projects = relationship("Project", secondary=projects_participants_association)
```

##### Fetching Data with `get_multi_joined`

To fetch projects along with their participants, we utilize `get_multi_joined` with appropriate `JoinConfig` settings:

```python
from fastcrud import FastCRUD, JoinConfig

# Initialize FastCRUD for the Project model
crud_project = FastCRUD(Project)

# Define join conditions and configuration
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_"
    )
]

# Fetch projects with their participants
projects_with_participants = await crud_project.get_multi_joined(
    db_session, 
    joins_config=joins_config
)

# Now, `projects_with_participants['data']` will contain projects along with their participant information.
```

#### 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.
- **Aliasing**: Utilize the `alias` attribute for disambiguating joins on the same model or for self-referential joins.
- **Filtering Joined Models**: Apply filters directly to joined models using the `filters` attribute in `JoinConfig` to refine the data set returned by the query.
- **Ordering Joins**: In many-to-many relationships or complex join scenarios, carefully sequence your `JoinConfig` entries to ensure logical and efficient SQL join construction.


Closes #33 

Patch:
diff --git a/docs/advanced/crud.md b/docs/advanced/crud.md
index ab26024..1ea545a 100644
--- a/docs/advanced/crud.md
+++ b/docs/advanced/crud.md
@@ -212,6 +212,53 @@ tasks = await task_crud.get_multi_joined(
 
 In this example, `owner_alias` and `assigned_user_alias` are created from `UserModel` 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`: 
+
+1. **First**, specify the main table you're querying from.
+2. **Next**, include the association table that links your main table to the other table involved in the many-to-many relationship.
+3. **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)`.
+
+```python
+# Fetch projects with their participants via a many-to-many relationship
+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_"
+    )
+]
+
+crud_project = FastCRUD(Project)
+
+projects_with_participants = await project_crud.get_multi_joined(
+    db=db,
+    schema_to_select=ProjectSchema,
+    joins_config=joins_config
+)
+```
+
+For a more detailed explanation, read [this part of the docs](joins.md#many-to-many-relationships-with-get_multi_joined).
+
+
 ## Enhanced Query Capabilities with Method Chaining
 
 The `select` method in FastCRUD is designed for flexibility, enabling you to build complex queries through method chaining.
diff --git a/docs/advanced/joins.md b/docs/advanced/joins.md
new file mode 100644
index 0000000..7ea04fc
--- /dev/null
+++ b/docs/advanced/joins.md
@@ -0,0 +1,204 @@
+
+# Comprehensive Guide to Joins in FastCRUD
+
+FastCRUD simplifies CRUD operations while offering capabilities for handling complex data relationships. This guide thoroughly explores the use of `JoinConfig` for executing join operations in FastCRUD methods such as `count`, `get_joined`, and `get_multi_joined`, alongside simplified join techniques for straightforward scenarios.
+
+## Understanding `JoinConfig`
+
+`JoinConfig` is a detailed configuration mechanism for specifying joins between models in FastCRUD queries. It contains the following key attributes:
+
+- **`model`**: The SQLAlchemy model to join.
+- **`join_on`**: The condition defining how the join connects to other models.
+- **`join_prefix`**: An optional prefix for the joined columns to avoid column name conflicts.
+- **`schema_to_select`**: An optional Pydantic schema for selecting specific columns from the joined model.
+- **`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.
+
+## Applying Joins in FastCRUD Methods
+
+### The `count` Method with Joins
+
+The `count` method can be enhanced with join operations to perform complex aggregate queries. While `count` primarily returns the number of records matching a given condition, introducing joins allows for counting records across related models based on specific relationships and conditions.
+
+#### Using `JoinConfig`
+
+For join requirements, the `count` method can be invoked with join parameters passed as a list of `JoinConfig` to the `joins_config` parameter:
+
+```python
+from fastcrud import JoinConfig
+# Count the number of tasks assigned to users in a specific department
+task_count = await task_crud.count(
+    db=db,
+    joins_config=[
+        JoinConfig(
+            model=User, 
+            join_on=Task.assigned_user_id == User.id
+        ),
+        JoinConfig(
+            model=Department, 
+            join_on=User.department_id == Department.id, 
+            filters={"name": "Engineering"}
+        )
+    ]
+)
+```
+
+### Fetching Data with `get_joined` and `get_multi_joined`
+
+These methods are essential for retrieving records from a primary model while including related data from one or more joined models. They support both simple and complex joining scenarios, including self-referential joins and many-to-many relationships.
+
+#### Simple Joins Using Base Parameters
+
+For simpler join requirements, FastCRUD allows specifying join parameters directly:
+
+- **`model`**: The target model to join.
+- **`join_on`**: The join condition.
+- **`join_type`**: Specifies the SQL join type.
+- **`aliased`**: When `True`, uses an alias for the model in the join.
+- **`join_prefix`**: Optional prefix for columns from the joined model.
+- **`filters`**: Additional filters for the joined model.
+
+#### Examples of Simple Joining
+
+```python
+# Fetch tasks with user details, specifying a left join
+tasks_with_users = await task_crud.get_joined(
+    db=db,
+    model=User,
+    join_on=Task.user_id == User.id,
+    join_type="left"
+)
+```
+
+### Complex Joins Using `JoinConfig`
+
+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.
+
+```python
+# Fetch users with details from related departments and roles, using aliases for self-referential joins
+users = await user_crud.get_multi_joined(
+    db=db,
+    schema_to_select=UserSchema,
+    joins_config=[
+        JoinConfig(
+            model=Department, 
+            join_on=User.department_id == Department.id, 
+            join_prefix="dept_"
+        ),
+        JoinConfig(
+            model=Role, 
+            join_on=User.role_id == Role.id, 
+            join_prefix="role_"
+        ),
+        JoinConfig(
+            model=User, 
+            alias=manager_alias, 
+            join_on=User.manager_id == manager_alias.id, 
+            join_prefix="manager_"
+        )
+    ]
+)
+```
+
+#### 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`: 
+
+1. **First**, specify the main table you're querying from.
+2. **Next**, include the association table that links your main table to the other table involved in the many-to-many relationship.
+3. **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)`.
+
+##### Scenario
+
+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.
+
+##### Models
+
+Our models include `Project`, `Participant`, and an association model `ProjectsParticipantsAssociation`:
+
+```python
+from sqlalchemy import Column, Integer, String, ForeignKey, Table
+from sqlalchemy.orm import relationship
+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)
+    name = Column(String)
+    description = Column(String)
+    # Relationship to Participant through the association table
+    participants = relationship("Participant", secondary=projects_participants_association)
+
+class Participant(Base):
+    __tablename__ = 'participants'
+    id = Column(Integer, primary_key=True)
+    name = Column(String)
+    role = Column(String)
+    # Relationship to Project through the association table
+    projects = relationship("Project", secondary=projects_participants_association)
+```
+
+##### Fetching Data with `get_multi_joined`
+
+To fetch projects along with their participants, we utilize `get_multi_joined` with appropriate `JoinConfig` settings:
+
+```python
+from fastcrud import FastCRUD, JoinConfig
+
+# Initialize FastCRUD for the Project model
+crud_project = FastCRUD(Project)
+
+# Define join conditions and configuration
+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_"
+    )
+]
+
+# Fetch projects with their participants
+projects_with_participants = await crud_project.get_multi_joined(
+    db_session, 
+    joins_config=joins_config
+)
+
+# Now, `projects_with_participants['data']` will contain projects along with their participant information.
+```
+
+#### 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.
+- **Aliasing**: Utilize the `alias` attribute for disambiguating joins on the same model or for self-referential joins.
+- **Filtering Joined Models**: Apply filters directly to joined models using the `filters` attribute in `JoinConfig` to refine the data set returned by the query.
+- **Ordering Joins**: In many-to-many relationships or complex join scenarios, carefully sequence your `JoinConfig` entries to ensure logical and efficient SQL join construction.
+
+## Conclusion
+
+FastCRUD's support for join operations enhances the ability to perform complex queries across related models in FastAPI applications. By understanding and utilizing the `JoinConfig` class within the `count`, `get_joined`, and `get_multi_joined` methods, developers can craft powerful data retrieval queries.
\ No newline at end of file
diff --git a/docs/advanced/overview.md b/docs/advanced/overview.md
index 5fa6ec6..d05f9f9 100644
--- a/docs/advanced/overview.md
+++ b/docs/advanced/overview.md
@@ -34,5 +34,10 @@ FastCRUD's `select` method introduces method chaining, allowing for the construc
 
 - [Method Chaining Guide](crud.md#enhanced-query-capabilities-with-method-chaining)
 
+### 7. In depth explanation of Joined methods
+Explore different ways of joining models in FastCRUD with examples and tips.
+
+- [Joining Models](joins.md#applying-joins-in-fastcrud-methods)
+
 ## Prerequisites
 Advanced usage assumes a solid understanding of the basic features and functionalities of our application. Knowledge of FastAPI, SQLAlchemy, and Pydantic is highly recommended to fully grasp the concepts discussed.
diff --git a/docs/usage/crud.md b/docs/usage/crud.md
index 9e5fa22..93a1141 100644
--- a/docs/usage/crud.md
+++ b/docs/usage/crud.md
@@ -101,6 +101,7 @@ exists = await item_crud.exists(db, name="Existing Item")
 ```python
 count(
     db: AsyncSession,
+    joins_config: Optional[list[JoinConfig]] = None,
     **kwargs: Any
 ) -> int
 ```
@@ -226,6 +227,7 @@ get_joined(
     schema_to_select: Optional[type[BaseModel]] = None,
     join_schema_to_select: Optional[type[BaseModel]] = None,
     join_type: str = "left",
+    join_filters: Optional[dict] = None,
     joins_config: Optional[list[JoinConfig]] = None,
     **kwargs: Any,
 ) -> Optional[dict[str, Any]]
@@ -255,6 +257,8 @@ get_multi_joined(
     schema_to_select: Optional[type[BaseModel]] = None,
     join_schema_to_select: Optional[type[BaseModel]] = None,
     join_type: str = "left",
+    alias: Optional[str] = None,
+    join_filters: Optional[dict] = None,
     offset: int = 0,
     limit: int = 100,
     sort_columns: Optional[Union[str, list[str]]] = None,
@@ -326,6 +330,34 @@ stmt = await item_crud.select(schema_to_select=ItemSchema, sort_columns='id', na
 # Note: This method returns a SQL Alchemy Selectable object, not the actual query result.
 ```
 
+### 6. Count for Joined Models
+
+```python
+count(
+    db: AsyncSession,
+    joins_config: Optional[list[JoinConfig]] = None,
+    **kwargs: Any
+) -> int
+```
+
+**Purpose**: To count records that match specified filters, especially useful in scenarios involving joins between models. This method supports counting unique entities across relationships, a common requirement in many-to-many or complex relationships.
+**Usage Example**: Count the number of unique projects a participant is involved in, considering a many-to-many relationship between Project and Participant models.
+
+```python
+# Assuming a Project model related to a Participant model through a many-to-many relationship
+projects_count = await project_crud.count(
+    db=session,
+    joins_config=[
+        JoinConfig(
+            model=Participant,
+            join_on=ProjectsParticipantsAssociation.project_id == Project.id,
+            join_type="inner"
+        )
+    ],
+    participant_id=specific_participant_id
+)
+```
+
 ## Error Handling
 
 FastCRUD provides mechanisms to handle common database errors, ensuring robust API behavior.
diff --git a/fastcrud/crud/fast_crud.py b/fastcrud/crud/fast_crud.py
index 952b616..6fd3e60 100644
--- a/fastcrud/crud/fast_crud.py
+++ b/fastcrud/crud/fast_crud.py
@@ -18,6 +18,8 @@ from .helper import (
     JoinConfig,
 )
 
+from ..endpoint.helper import _get_primary_key
+
 ModelType = TypeVar("ModelType", bound=DeclarativeBase)
 CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
 UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
@@ -52,7 +54,7 @@ class FastCRUD(
 
         select:
             Generates a SQL Alchemy `Select` statement with optional filtering and sorting.
-        
+
         get:
             Retrieves a single record based on filters. Supports advanced filtering through comparison operators like '__gt', '__lt', etc.
 
@@ -164,12 +166,15 @@ class FastCRUD(
         self.deleted_at_column = deleted_at_column
         self.updated_at_column = updated_at_column
 
-    def _parse_filters(self, **kwargs) -> list[BinaryExpression]:
+    def _parse_filters(
+        self, model: Optional[type[ModelType]] = None, **kwargs
+    ) -> list[BinaryExpression]:
+        model = model or self.model
         filters = []
         for key, value in kwargs.items():
             if "__" in key:
                 field_name, op = key.rsplit("__", 1)
-                column = getattr(self.model, field_name, None)
+                column = getattr(model, field_name, None)
                 if column is None:
                     raise ValueError(f"Invalid filter column: {field_name}")
 
@@ -184,7 +189,7 @@ class FastCRUD(
                 elif op == "ne":
                     filters.append(column != value)
             else:
-                column = getattr(self.model, key, None)
+                column = getattr(model, key, None)
                 if column is not None:
                     filters.append(column == value)
 
@@ -460,17 +465,22 @@ class FastCRUD(
         result = await db.execute(stmt)
         return result.first() is not None
 
-    async def count(self, db: AsyncSession, **kwargs: Any) -> int:
+    async def count(
+        self,
+        db: AsyncSession,
+        joins_config: Optional[list[JoinConfig]] = None,
+        **kwargs: Any,
+    ) -> int:
         """
         Counts records that match specified filters, supporting advanced filtering through comparison operators:
-            '__gt' (greater than),
-            '__lt' (less than),
+            '__gt' (greater than), '__lt' (less than),
             '__gte' (greater than or equal to),
-            '__lte' (less than or equal to), and
-            '__ne' (not equal).
+            '__lte' (less than or equal to), and '__ne' (not equal).
+        Can also count records based on a configuration of joins, useful for complex queries involving relationships.
 
         Args:
             db: The database session to use for the operation.
+            joins_config: Optional configuration for applying joins in the count query.
             **kwargs: Filters to apply for the count, including field names for equality checks or with comparison operators for advanced queries.
 
         Returns:
@@ -479,29 +489,90 @@ class FastCRUD(
         Examples:
             Count users by ID:
             ```python
-            exists = await crud.count(db, id=1)
+            count = await crud.count(db, id=1)
             ```
 
             Count users older than 30:
             ```python
-            exists = await crud.count(db, age__gt=30)
+            count = await crud.count(db, age__gt=30)
             ```
 
-            Count users who registered before Jan 1, 2020:
+            Count users with a username other than 'admin':
             ```python
-            exists = await crud.count(db, registration_date__lt=datetime(2020, 1, 1))
+            count = await crud.count(db, username__ne='admin')
             ```
 
-            Count users with a username other than 'admin':
+            Count projects with at least one participant (many-to-many relationship):
             ```python
-            exists = await crud.count(db, username__ne='admin')
+            joins_config = [
+                JoinConfig(
+                    model=ProjectsParticipantsAssociation,
+                    join_on=Project.id == ProjectsParticipantsAssociation.project_id,
+                    join_type="inner"
+                ),
+                JoinConfig(
+                    model=Participant,
+                    join_on=ProjectsParticipantsAssociation.participant_id == Participant.id,
+                    join_type="inner"
+                )
+            ]
+            count = await crud.count(db, joins_config=joins_config)
+            ```
+
+            Count projects by a specific participant name (filter applied on a joined model):
+            ```python
+            joins_config = [
+                JoinConfig(
+                    model=ProjectsParticipantsAssociation,
+                    join_on=Project.id == ProjectsParticipantsAssociation.project_id,
+                    join_type="inner"
+                ),
+                JoinConfig(
+                    model=Participant,
+                    join_on=ProjectsParticipantsAssociation.participant_id == Participant.id,
+                    join_type="inner",
+                    filters={'name': 'Jane Doe'}
+                )
+            ]
+            count = await crud.count(db, joins_config=joins_config)
             ```
         """
-        filters = self._parse_filters(**kwargs)
-        if filters:
-            count_query = select(func.count()).select_from(self.model).filter(*filters)
+        primary_filters = self._parse_filters(**kwargs)
+
+        if joins_config is not None:
+            primary_key = _get_primary_key(self.model)
+            if not primary_key:
+                raise ValueError(
+                    f"The model '{self.model.__name__}' does not have a primary key defined, which is required for counting with joins."
+                )
+
+            base_query = select(getattr(self.model, primary_key).label("distinct_id"))
+
+            for join in joins_config:
+                join_model = join.alias or join.model
+                join_filters = (
+                    self._parse_filters(model=join_model, **join.filters)
+                    if join.filters
+                    else []
+                )
+
+                if join.join_type == "inner":
+                    base_query = base_query.join(join_model, join.join_on)
+                else:
+                    base_query = base_query.outerjoin(join_model, join.join_on)
+
+                if join_filters:
+                    base_query = base_query.where(*join_filters)
+
+            if primary_filters:
+                base_query = base_query.where(*primary_filters)
+
+            subquery = base_query.subquery()
+            count_query = select(func.count()).select_from(subquery)
         else:
             count_query = select(func.count()).select_from(self.model)
+            if primary_filters:
+                count_query = count_query.where(*primary_filters)
 
         total_count: int = await db.scalar(count_query)
         return total_count
@@ -611,6 +682,7 @@ class FastCRUD(
         join_schema_to_select: Optional[type[BaseModel]] = None,
         join_type: str = "left",
         alias: Optional[AliasedClass] = None,
+        join_filters: Optional[dict] = None,
         joins_config: Optional[list[JoinConfig]] = None,
         **kwargs: Any,
     ) -> Optional[dict[str, Any]]:
@@ -633,6 +705,7 @@ class FastCRUD(
             join_schema_to_select: Pydantic schema for selecting specific columns from the joined model.
             join_type: Specifies the type of join operation to perform. Can be "left" for a left outer join or "inner" for an inner join.
             alias: An instance of `AliasedClass` for the join model, useful for self-joins or multiple joins on the same model. Result of `aliased(join_model)`.
+            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.
             **kwargs: Filters to apply to the primary model query, supporting advanced comparison operators for refined searching.
 
@@ -739,6 +812,28 @@ class FastCRUD(
                 id=1
             )
             ```
+
+            Fetching a single project and its associated participants where a participant has a specific role:
+            ```python
+            joins_config = [
+                JoinConfig(
+                    model=ProjectsParticipantsAssociation,
+                    join_on=Project.id == ProjectsParticipantsAssociation.project_id,
+                    join_type="inner"
+                ),
+                JoinConfig(
+                    model=Participant,
+                    join_on=ProjectsParticipantsAssociation.participant_id == Participant.id,
+                    join_type="inner",
+                    filters={'role': 'Designer'}
+                )
+            ]
+            project = await crud.get_joined(
+                db=session,
+                schema_to_select=ProjectSchema,
+                joins_config=joins_config
+            )
+            ```
         """
         if joins_config and (
             join_model or join_prefix or join_on or join_schema_to_select or alias
@@ -765,11 +860,17 @@ class FastCRUD(
                     schema_to_select=join_schema_to_select,
                     join_type=join_type,
                     alias=alias,
+                    filters=join_filters,
                 )
             )
 
         for join in join_definitions:
             model = join.alias or join.model
+            join_filters = (
+                self._parse_filters(model=model, **join.filters)
+                if join.filters
+                else None
+            )
 
             join_select = _extract_matching_columns_from_schema(
                 model=join.model,
@@ -785,9 +886,12 @@ class FastCRUD(
             else:
                 raise ValueError(f"Unsupported join type: {join.join_type}.")
 
-        filters = self._parse_filters(**kwargs)
-        if filters:
-            stmt = stmt.filter(*filters)
+            if join_filters is not None:
+                stmt = stmt.filter(*join_filters)
+
+        primary_filters = self._parse_filters(**kwargs)
+        if primary_filters:
+            stmt = stmt.filter(*primary_filters)
 
         db_row = await db.execute(stmt)
         result: Row = db_row.first()
@@ -807,6 +911,7 @@ class FastCRUD(
         join_schema_to_select: Optional[type[BaseModel]] = None,
         join_type: str = "left",
         alias: Optional[str] = None,
+        join_filters: Optional[dict] = None,
         offset: int = 0,
         limit: int = 100,
         sort_columns: Optional[Union[str, list[str]]] = None,
@@ -833,6 +938,7 @@ class FastCRUD(
             join_schema_to_select: Pydantic schema for selecting specific columns from the joined model.
             join_type: Specifies the type of join operation to perform. Can be "left" for a left outer join or "inner" for an inner join.
             alias: An instance of `AliasedClass` for the join model, useful for self-joins or multiple joins on the same model. Result of `aliased(join_model)`.
+            join_filters: Filters applied to the joined model, specified as a dictionary mapping column names to their expected values.
             offset: The offset (number of records to skip) for pagination.
             limit: The limit (maximum number of records to return) for pagination.
             sort_columns: A single column name or a list of column names on which to apply sorting.
@@ -987,6 +1093,29 @@ class FastCRUD(
                 sort_orders=['desc']  # In descending order
             )
             ```
+
+            Fetching multiple project records and their associated participants where participants have a specific role:
+            ```python
+            joins_config = [
+                JoinConfig(
+                    model=ProjectsParticipantsAssociation,
+                    join_on=Project.id == ProjectsParticipantsAssociation.project_id,
+                    join_type="inner"
+                ),
+                JoinConfig(
+                    model=Participant,
+                    join_on=ProjectsParticipantsAssociation.participant_id == Participant.id,
+                    join_type="inner",
+                    filters={'role': 'Developer'}
+                )
+            ]
+            projects = await crud.get_multi_joined(
+                db=session,
+                schema_to_select=ProjectSchema,
+                joins_config=joins_config,
+                limit=10
+            )
+            ```
         """
         if joins_config and (
             join_model or join_prefix or join_on or join_schema_to_select or alias
@@ -1016,11 +1145,17 @@ class FastCRUD(
                     schema_to_select=join_schema_to_select,
                     join_type=join_type,
                     alias=alias,
+                    filters=join_filters,
                 )
             )
 
         for join in join_definitions:
             model = join.alias or join.model
+            join_filters = (
+                self._parse_filters(model=model, **join.filters)
+                if join.filters
+                else None
+            )
 
             join_select = _extract_matching_columns_from_schema(
                 model=join.model,
@@ -1036,9 +1171,12 @@ class FastCRUD(
             else:
                 raise ValueError(f"Unsupported join type: {join.join_type}.")
 
-        filters = self._parse_filters(**kwargs)
-        if filters:
-            stmt = stmt.filter(*filters)
+            if join_filters is not None:
+                stmt = stmt.filter(*join_filters)
+
+        primary_filters = self._parse_filters(**kwargs)
+        if primary_filters:
+            stmt = stmt.filter(*primary_filters)
 
         if sort_columns:
             stmt = self._apply_sorting(stmt, sort_columns, sort_orders)
@@ -1051,7 +1189,7 @@ class FastCRUD(
         if return_as_model and schema_to_select:
             data = [schema_to_select.model_construct(**row) for row in data]
 
-        total_count = await self.count(db=db, **kwargs)
+        total_count = await self.count(db=db, joins_config=joins_config, **kwargs)
         return {"data": data, "total_count": total_count}
 
     async def get_multi_by_cursor(
diff --git a/fastcrud/crud/helper.py b/fastcrud/crud/helper.py
index 63179bb..d4aa656 100644
--- a/fastcrud/crud/helper.py
+++ b/fastcrud/crud/helper.py
@@ -14,6 +14,7 @@ class JoinConfig(NamedTuple):
     schema_to_select: Optional[type[BaseModel]] = None
     join_type: str = "left"
     alias: Optional[AliasedClass] = None
+    filters: Optional[dict] = None
 
 
 def _extract_matching_columns_from_schema(
diff --git a/mkdocs.yml b/mkdocs.yml
index 8cf1b07..e20aab8 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -54,6 +54,7 @@ nav:
     - Overview: advanced/overview.md
     - Custom Endpoints: advanced/endpoint.md
     - Advanced CRUD Usage: advanced/crud.md
+    - Joining Models: advanced/joins.md
   - API Reference: 
     - Overview: api/overview.md
     - FastCRUD: api/fastcrud.md
diff --git a/pyproject.toml b/pyproject.toml
index 786f2fd..4f8c6a9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "fastcrud"
-version = "0.9.0"
+version = "0.9.1"
 description = "FastCRUD is a Python package for FastAPI, offering robust async CRUD operations and flexible endpoint creation utilities."
 authors = ["Igor Benav <igor.magalhaes.r@gmail.com>"]
 license = "MIT"


Test patch:
diff --git a/tests/sqlalchemy/conftest.py b/tests/sqlalchemy/conftest.py
index 644fe89..92dff2b 100644
--- a/tests/sqlalchemy/conftest.py
+++ b/tests/sqlalchemy/conftest.py
@@ -56,6 +56,36 @@ class BookingModel(Base):
     user = relationship("ModelTest", foreign_keys=[user_id], backref="user_bookings")
 
 
+class Project(Base):
+    __tablename__ = "projects"
+    id = Column(Integer, primary_key=True)
+    name = Column(String, nullable=False)
+    description = Column(String)
+    participants = relationship(
+        "Participant",
+        secondary="projects_participants_association",
+        back_populates="projects",
+    )
+
+
+class Participant(Base):
+    __tablename__ = "participants"
+    id = Column(Integer, primary_key=True)
+    name = Column(String, nullable=False)
+    role = Column(String)
+    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)
+
+
 class CreateSchemaTest(BaseModel):
     model_config = ConfigDict(extra="forbid")
     name: str
diff --git a/tests/sqlalchemy/crud/test_count.py b/tests/sqlalchemy/crud/test_count.py
index 051b6ce..47c7a04 100644
--- a/tests/sqlalchemy/crud/test_count.py
+++ b/tests/sqlalchemy/crud/test_count.py
@@ -1,5 +1,7 @@
 import pytest
 from fastcrud.crud.fast_crud import FastCRUD
+from fastcrud import JoinConfig
+from ..conftest import Project, Participant, ProjectsParticipantsAssociation
 
 
 @pytest.mark.asyncio
@@ -101,3 +103,56 @@ async def test_soft_delete_custom_columns(async_session, test_model, test_data):
             "custom_deleted_at" in deleted_record
             and deleted_record["custom_deleted_at"] is not None
         ), "Deletion timestamp should be set"
+
+
+@pytest.mark.asyncio
+async def test_count_with_joins_config_many_to_many(async_session):
+    project1 = Project(name="Project Alpha", description="First Project")
+    project2 = Project(name="Project Beta", description="Second Project")
+    project3 = Project(name="Project Gamma", description="Third Project")
+    participant1 = Participant(name="John Doe", role="Developer")
+    participant2 = Participant(name="Jane Doe", role="Designer")
+
+    async_session.add_all([project1, project2, project3, participant1, participant2])
+    await async_session.commit()
+
+    async_session.add_all(
+        [
+            ProjectsParticipantsAssociation(
+                project_id=project1.id, participant_id=participant1.id
+            ),
+            ProjectsParticipantsAssociation(
+                project_id=project2.id, participant_id=participant1.id
+            ),
+            ProjectsParticipantsAssociation(
+                project_id=project3.id, participant_id=participant2.id
+            ),
+        ]
+    )
+    await async_session.commit()
+
+    crud_project = FastCRUD(Project)
+
+    joins_config = [
+        JoinConfig(
+            model=ProjectsParticipantsAssociation,
+            join_on=Project.id == ProjectsParticipantsAssociation.project_id,
+            join_type="inner",
+            join_prefix="association_",
+        ),
+        JoinConfig(
+            model=Participant,
+            join_on=ProjectsParticipantsAssociation.participant_id == Participant.id,
+            join_type="inner",
+            join_prefix="participant_",
+            filters={"id": 1},
+        ),
+    ]
+
+    count = await crud_project.count(
+        async_session, joins_config=joins_config, participant_id=1
+    )
+
+    assert (
+        count == 2
+    ), f"Expected to find 2 projects associated with 'John Doe', found {count}"
diff --git a/tests/sqlalchemy/crud/test_get_multi_by_cursor.py b/tests/sqlalchemy/crud/test_get_multi_by_cursor.py
index daa8e14..64453c1 100644
--- a/tests/sqlalchemy/crud/test_get_multi_by_cursor.py
+++ b/tests/sqlalchemy/crud/test_get_multi_by_cursor.py
@@ -74,7 +74,6 @@ async def test_get_multi_by_cursor_edge_cases(async_session, test_data):
     crud = FastCRUD(ModelTest)
 
     all_records = await crud.get_multi_by_cursor(db=async_session)
-    print("All records:", all_records)
 
     highest_id = max(record["id"] for record in all_records["data"])
 
diff --git a/tests/sqlalchemy/crud/test_get_multi_joined.py b/tests/sqlalchemy/crud/test_get_multi_joined.py
index 900ea2a..94749bc 100644
--- a/tests/sqlalchemy/crud/test_get_multi_joined.py
+++ b/tests/sqlalchemy/crud/test_get_multi_joined.py
@@ -10,6 +10,9 @@ from ...sqlalchemy.conftest import (
     CategorySchemaTest,
     BookingModel,
     BookingSchema,
+    Project,
+    Participant,
+    ProjectsParticipantsAssociation,
 )
 
 
@@ -440,3 +443,95 @@ async def test_get_multi_joined_with_aliases_no_schema(
     assert (
         first_result["user_name"] == expected_user_name
     ), "User name does not match expected value"
+
+
+@pytest.mark.asyncio
+async def test_many_to_many_joined(async_session):
+    project1 = Project(id=1, name="Project 1", description="First Project")
+    project2 = Project(id=2, name="Project 2", description="Second Project")
+
+    participant1 = Participant(id=1, name="Participant 1", role="Developer")
+    participant2 = Participant(id=2, name="Participant 2", role="Designer")
+
+    async_session.add_all([project1, project2, participant1, participant2])
+    await async_session.commit()
+
+    projects_participants1 = ProjectsParticipantsAssociation(
+        project_id=1, participant_id=1
+    )
+    projects_participants2 = ProjectsParticipantsAssociation(
+        project_id=1, participant_id=2
+    )
+    projects_participants3 = ProjectsParticipantsAssociation(
+        project_id=2, participant_id=1
+    )
+
+    async_session.add_all(
+        [projects_participants1, projects_participants2, projects_participants3]
+    )
+    await async_session.commit()
+
+    crud_project = FastCRUD(Project)
+
+    join_condition_1 = Project.id == ProjectsParticipantsAssociation.project_id
+    join_condition_2 = ProjectsParticipantsAssociation.participant_id == Participant.id
+
+    joins_config = [
+        JoinConfig(
+            model=ProjectsParticipantsAssociation,
+            join_on=join_condition_1,
+            join_type="inner",
+            join_prefix="pp_",
+        ),
+        JoinConfig(
+            model=Participant,
+            join_on=join_condition_2,
+            join_type="inner",
+            join_prefix="participant_",
+        ),
+    ]
+
+    records = await crud_project.get_multi_joined(
+        db=async_session,
+        joins_config=joins_config,
+    )
+
+    expected_results = [
+        {
+            "project_id": 1,
+            "participant_id": 1,
+            "participant_name": "Participant 1",
+            "participant_role": "Developer",
+        },
+        {
+            "project_id": 1,
+            "participant_id": 2,
+            "participant_name": "Participant 2",
+            "participant_role": "Designer",
+        },
+        {
+            "project_id": 2,
+            "participant_id": 1,
+            "participant_name": "Participant 1",
+            "participant_role": "Developer",
+        },
+    ]
+
+    assert len(records["data"]) == 3, "Expected three project-participant associations"
+    assert (
+        len(records["data"]) == records["total_count"]
+    ), "Number of records should be the same in total_count and len"
+
+    for expected, actual in zip(expected_results, records["data"]):
+        assert (
+            actual["id"] == expected["project_id"]
+        ), f"Project ID mismatch. Expected: {expected['project_id']}, Got: {actual['id']}"
+        assert (
+            actual["participant_id"] == expected["participant_id"]
+        ), f"Participant ID mismatch. Expected: {expected['participant_id']}, Got: {actual['participant_id']}"
+        assert (
+            actual["participant_name"] == expected["participant_name"]
+        ), f"Participant name mismatch. Expected: {expected['participant_name']}, Got: {actual['participant_name']}"
+        assert (
+            actual["participant_role"] == expected["participant_role"]
+        ), f"Participant role mismatch. Expected: {expected['participant_role']}, Got: {actual['participant_role']}"
diff --git a/tests/sqlalchemy/crud/test_select.py b/tests/sqlalchemy/crud/test_select.py
index fc66e9c..38a8c87 100644
--- a/tests/sqlalchemy/crud/test_select.py
+++ b/tests/sqlalchemy/crud/test_select.py
@@ -68,35 +68,41 @@ async def test_select_with_greater_than_filter(async_session, test_model, test_d
 
     crud = FastCRUD(test_model)
 
-    stmt = await crud.select(name__gt='Charlie')
+    stmt = await crud.select(name__gt="Charlie")
     res = await async_session.execute(stmt)
     filtered_items = [dict(r) for r in res.mappings()]
-    expected_items = [item for item in test_data if item['name'] > 'Charlie']
+    expected_items = [item for item in test_data if item["name"] > "Charlie"]
 
-    assert len(filtered_items) == len(expected_items), "Filtering with greater than operator failed"
+    assert len(filtered_items) == len(
+        expected_items
+    ), "Filtering with greater than operator failed"
 
 
 @pytest.mark.asyncio
-async def test_select_with_less_than_or_equal_filter(async_session, test_model, test_data):
+async def test_select_with_less_than_or_equal_filter(
+    async_session, test_model, test_data
+):
     for item in test_data:
         async_session.add(test_model(**item))
     await async_session.commit()
-    
+
     crud = FastCRUD(test_model)
 
     stmt = await crud.select(id__lte=5)
     res = await async_session.execute(stmt)
     filtered_items = [dict(r) for r in res.mappings()]
-    expected_items = [item for item in test_data if item['id'] <= 5]
+    expected_items = [item for item in test_data if item["id"] <= 5]
 
-    assert len(filtered_items) == len(expected_items), "Filtering with less than or equal operator failed"
+    assert len(filtered_items) == len(
+        expected_items
+    ), "Filtering with less than or equal operator failed"
 
 
 @pytest.mark.asyncio
 async def test_select_with_descending_sort(async_session, test_model, test_data):
     for item in test_data:
-        item.setdefault('is_deleted', False)
-        item.setdefault('deleted_at', None)
+        item.setdefault("is_deleted", False)
+        item.setdefault("deleted_at", None)
         async_session.add(test_model(**item))
     await async_session.commit()
 
@@ -106,22 +112,30 @@ async def test_select_with_descending_sort(async_session, test_model, test_data)
     res = await async_session.execute(stmt)
     sorted_items = [dict(r) for r in res.mappings()]
 
-    assert sorted_items == sorted(test_data, key=lambda x: x['name'], reverse=True), "Sorting in descending order failed"
+    assert sorted_items == sorted(
+        test_data, key=lambda x: x["name"], reverse=True
+    ), "Sorting in descending order failed"
 
 
 @pytest.mark.asyncio
-async def test_select_combining_filters_and_sorting(async_session, test_model, test_data):
+async def test_select_combining_filters_and_sorting(
+    async_session, test_model, test_data
+):
     for item in test_data:
-        item.setdefault('is_deleted', False)
-        item.setdefault('deleted_at', None)
+        item.setdefault("is_deleted", False)
+        item.setdefault("deleted_at", None)
         async_session.add(test_model(**item))
     await async_session.commit()
 
     crud = FastCRUD(test_model)
 
-    stmt = await crud.select(name__gte='Eve', sort_columns="id", sort_orders="asc")
+    stmt = await crud.select(name__gte="Eve", sort_columns="id", sort_orders="asc")
     res = await async_session.execute(stmt)
     filtered_and_sorted_items = [dict(r) for r in res.mappings()]
-    expected_items = sorted([item for item in test_data if item['name'] >= 'Eve'], key=lambda x: x['id'])
+    expected_items = sorted(
+        [item for item in test_data if item["name"] >= "Eve"], key=lambda x: x["id"]
+    )
 
-    assert filtered_and_sorted_items == expected_items, "Combining filters and sorting failed"
+    assert (
+        filtered_and_sorted_items == expected_items
+    ), "Combining filters and sorting failed"
diff --git a/tests/sqlalchemy/endpoint/test_get_items.py b/tests/sqlalchemy/endpoint/test_get_items.py
index a05d3bd..020f4de 100644
--- a/tests/sqlalchemy/endpoint/test_get_items.py
+++ b/tests/sqlalchemy/endpoint/test_get_items.py
@@ -14,7 +14,6 @@ async def test_read_items(client: TestClient, async_session, test_model, test_da
     assert response.status_code == 200
     data = response.json()
 
-    print(data)
     assert "data" in data
     assert len(data["data"]) > 0
 
diff --git a/tests/sqlalchemy/endpoint/test_update_item.py b/tests/sqlalchemy/endpoint/test_update_item.py
index af0413d..42a4f52 100644
--- a/tests/sqlalchemy/endpoint/test_update_item.py
+++ b/tests/sqlalchemy/endpoint/test_update_item.py
@@ -17,7 +17,6 @@ async def test_update_item(client: TestClient, async_session, test_model, test_d
 
     update_response = client.patch(f"/test/update/{min_id}", json=updated_data)
     assert update_response.status_code == 200
-    print(update_response.status_code)
 
     stmt = select(test_model).filter_by(id=min_id)
     result = await async_session.execute(stmt)
diff --git a/tests/sqlmodel/conftest.py b/tests/sqlmodel/conftest.py
index be5c066..01887f3 100644
--- a/tests/sqlmodel/conftest.py
+++ b/tests/sqlmodel/conftest.py
@@ -41,6 +41,32 @@ class TierModel(SQLModel, table=True):
     tests: list["ModelTest"] = Relationship(back_populates="tier")
 
 
+class ProjectsParticipantsAssociation(SQLModel, table=True):
+    __tablename__ = "projects_participants_association"
+    project_id: int = Field(foreign_key="projects.id", primary_key=True)
+    participant_id: int = Field(foreign_key="participants.id", primary_key=True)
+
+
+class Project(SQLModel, table=True):
+    __tablename__ = "projects"
+    id: Optional[int] = Field(default=None, primary_key=True)
+    name: str
+    description: Optional[str] = None
+    participants: list["Participant"] = Relationship(
+        back_populates="projects", link_model=ProjectsParticipantsAssociation
+    )
+
+
+class Participant(SQLModel, table=True):
+    __tablename__ = "participants"
+    id: Optional[int] = Field(default=None, primary_key=True)
+    name: str
+    role: Optional[str] = None
+    projects: list["Project"] = Relationship(
+        back_populates="participants", link_model=ProjectsParticipantsAssociation
+    )
+
+
 class CreateSchemaTest(SQLModel):
     model_config = ConfigDict(extra="forbid")
     name: str
diff --git a/tests/sqlmodel/crud/test_count.py b/tests/sqlmodel/crud/test_count.py
index 051b6ce..47c7a04 100644
--- a/tests/sqlmodel/crud/test_count.py
+++ b/tests/sqlmodel/crud/test_count.py
@@ -1,5 +1,7 @@
 import pytest
 from fastcrud.crud.fast_crud import FastCRUD
+from fastcrud import JoinConfig
+from ..conftest import Project, Participant, ProjectsParticipantsAssociation
 
 
 @pytest.mark.asyncio
@@ -101,3 +103,56 @@ async def test_soft_delete_custom_columns(async_session, test_model, test_data):
             "custom_deleted_at" in deleted_record
             and deleted_record["custom_deleted_at"] is not None
         ), "Deletion timestamp should be set"
+
+
+@pytest.mark.asyncio
+async def test_count_with_joins_config_many_to_many(async_session):
+    project1 = Project(name="Project Alpha", description="First Project")
+    project2 = Project(name="Project Beta", description="Second Project")
+    project3 = Project(name="Project Gamma", description="Third Project")
+    participant1 = Participant(name="John Doe", role="Developer")
+    participant2 = Participant(name="Jane Doe", role="Designer")
+
+    async_session.add_all([project1, project2, project3, participant1, participant2])
+    await async_session.commit()
+
+    async_session.add_all(
+        [
+            ProjectsParticipantsAssociation(
+                project_id=project1.id, participant_id=participant1.id
+            ),
+            ProjectsParticipantsAssociation(
+                project_id=project2.id, participant_id=participant1.id
+            ),
+            ProjectsParticipantsAssociation(
+                project_id=project3.id, participant_id=participant2.id
+            ),
+        ]
+    )
+    await async_session.commit()
+
+    crud_project = FastCRUD(Project)
+
+    joins_config = [
+        JoinConfig(
+            model=ProjectsParticipantsAssociation,
+            join_on=Project.id == ProjectsParticipantsAssociation.project_id,
+            join_type="inner",
+            join_prefix="association_",
+        ),
+        JoinConfig(
+            model=Participant,
+            join_on=ProjectsParticipantsAssociation.participant_id == Participant.id,
+            join_type="inner",
+            join_prefix="participant_",
+            filters={"id": 1},
+        ),
+    ]
+
+    count = await crud_project.count(
+        async_session, joins_config=joins_config, participant_id=1
+    )
+
+    assert (
+        count == 2
+    ), f"Expected to find 2 projects associated with 'John Doe', found {count}"
diff --git a/tests/sqlmodel/crud/test_get_multi_by_cursor.py b/tests/sqlmodel/crud/test_get_multi_by_cursor.py
index 0ff57e6..32c8289 100644
--- a/tests/sqlmodel/crud/test_get_multi_by_cursor.py
+++ b/tests/sqlmodel/crud/test_get_multi_by_cursor.py
@@ -74,7 +74,6 @@ async def test_get_multi_by_cursor_edge_cases(async_session, test_data):
     crud = FastCRUD(ModelTest)
 
     all_records = await crud.get_multi_by_cursor(db=async_session)
-    print("All records:", all_records)
 
     highest_id = max(record["id"] for record in all_records["data"])
 
diff --git a/tests/sqlmodel/crud/test_get_multi_joined.py b/tests/sqlmodel/crud/test_get_multi_joined.py
index 4129f04..e9f103f 100644
--- a/tests/sqlmodel/crud/test_get_multi_joined.py
+++ b/tests/sqlmodel/crud/test_get_multi_joined.py
@@ -10,6 +10,9 @@ from ...sqlmodel.conftest import (
     CategorySchemaTest,
     BookingModel,
     BookingSchema,
+    Project,
+    Participant,
+    ProjectsParticipantsAssociation,
 )
 
 
@@ -440,3 +443,95 @@ async def test_get_multi_joined_with_aliases_no_schema(
     assert (
         first_result["user_name"] == expected_user_name
     ), "User name does not match expected value"
+
+
+@pytest.mark.asyncio
+async def test_many_to_many_joined(async_session):
+    project1 = Project(id=1, name="Project 1", description="First Project")
+    project2 = Project(id=2, name="Project 2", description="Second Project")
+
+    participant1 = Participant(id=1, name="Participant 1", role="Developer")
+    participant2 = Participant(id=2, name="Participant 2", role="Designer")
+
+    async_session.add_all([project1, project2, participant1, participant2])
+    await async_session.commit()
+
+    projects_participants1 = ProjectsParticipantsAssociation(
+        project_id=1, participant_id=1
+    )
+    projects_participants2 = ProjectsParticipantsAssociation(
+        project_id=1, participant_id=2
+    )
+    projects_participants3 = ProjectsParticipantsAssociation(
+        project_id=2, participant_id=1
+    )
+
+    async_session.add_all(
+        [projects_participants1, projects_participants2, projects_participants3]
+    )
+    await async_session.commit()
+
+    crud_project = FastCRUD(Project)
+
+    join_condition_1 = Project.id == ProjectsParticipantsAssociation.project_id
+    join_condition_2 = ProjectsParticipantsAssociation.participant_id == Participant.id
+
+    joins_config = [
+        JoinConfig(
+            model=ProjectsParticipantsAssociation,
+            join_on=join_condition_1,
+            join_type="inner",
+            join_prefix="pp_",
+        ),
+        JoinConfig(
+            model=Participant,
+            join_on=join_condition_2,
+            join_type="inner",
+            join_prefix="participant_",
+        ),
+    ]
+
+    records = await crud_project.get_multi_joined(
+        db=async_session,
+        joins_config=joins_config,
+    )
+
+    expected_results = [
+        {
+            "project_id": 1,
+            "participant_id": 1,
+            "participant_name": "Participant 1",
+            "participant_role": "Developer",
+        },
+        {
+            "project_id": 1,
+            "participant_id": 2,
+            "participant_name": "Participant 2",
+            "participant_role": "Designer",
+        },
+        {
+            "project_id": 2,
+            "participant_id": 1,
+            "participant_name": "Participant 1",
+            "participant_role": "Developer",
+        },
+    ]
+
+    assert len(records["data"]) == 3, "Expected three project-participant associations"
+    assert (
+        len(records["data"]) == records["total_count"]
+    ), "Number of records should be the same in total_count and len"
+
+    for expected, actual in zip(expected_results, records["data"]):
+        assert (
+            actual["id"] == expected["project_id"]
+        ), f"Project ID mismatch. Expected: {expected['project_id']}, Got: {actual['id']}"
+        assert (
+            actual["participant_id"] == expected["participant_id"]
+        ), f"Participant ID mismatch. Expected: {expected['participant_id']}, Got: {actual['participant_id']}"
+        assert (
+            actual["participant_name"] == expected["participant_name"]
+        ), f"Participant name mismatch. Expected: {expected['participant_name']}, Got: {actual['participant_name']}"
+        assert (
+            actual["participant_role"] == expected["participant_role"]
+        ), f"Participant role mismatch. Expected: {expected['participant_role']}, Got: {actual['participant_role']}"
diff --git a/tests/sqlmodel/crud/test_select.py b/tests/sqlmodel/crud/test_select.py
index fc66e9c..38a8c87 100644
--- a/tests/sqlmodel/crud/test_select.py
+++ b/tests/sqlmodel/crud/test_select.py
@@ -68,35 +68,41 @@ async def test_select_with_greater_than_filter(async_session, test_model, test_d
 
     crud = FastCRUD(test_model)
 
-    stmt = await crud.select(name__gt='Charlie')
+    stmt = await crud.select(name__gt="Charlie")
     res = await async_session.execute(stmt)
     filtered_items = [dict(r) for r in res.mappings()]
-    expected_items = [item for item in test_data if item['name'] > 'Charlie']
+    expected_items = [item for item in test_data if item["name"] > "Charlie"]
 
-    assert len(filtered_items) == len(expected_items), "Filtering with greater than operator failed"
+    assert len(filtered_items) == len(
+        expected_items
+    ), "Filtering with greater than operator failed"
 
 
 @pytest.mark.asyncio
-async def test_select_with_less_than_or_equal_filter(async_session, test_model, test_data):
+async def test_select_with_less_than_or_equal_filter(
+    async_session, test_model, test_data
+):
     for item in test_data:
         async_session.add(test_model(**item))
     await async_session.commit()
-    
+
     crud = FastCRUD(test_model)
 
     stmt = await crud.select(id__lte=5)
     res = await async_session.execute(stmt)
     filtered_items = [dict(r) for r in res.mappings()]
-    expected_items = [item for item in test_data if item['id'] <= 5]
+    expected_items = [item for item in test_data if item["id"] <= 5]
 
-    assert len(filtered_items) == len(expected_items), "Filtering with less than or equal operator failed"
+    assert len(filtered_items) == len(
+        expected_items
+    ), "Filtering with less than or equal operator failed"
 
 
 @pytest.mark.asyncio
 async def test_select_with_descending_sort(async_session, test_model, test_data):
     for item in test_data:
-        item.setdefault('is_deleted', False)
-        item.setdefault('deleted_at', None)
+        item.setdefault("is_deleted", False)
+        item.setdefault("deleted_at", None)
         async_session.add(test_model(**item))
     await async_session.commit()
 
@@ -106,22 +112,30 @@ async def test_select_with_descending_sort(async_session, test_model, test_data)
     res = await async_session.execute(stmt)
     sorted_items = [dict(r) for r in res.mappings()]
 
-    assert sorted_items == sorted(test_data, key=lambda x: x['name'], reverse=True), "Sorting in descending order failed"
+    assert sorted_items == sorted(
+        test_data, key=lambda x: x["name"], reverse=True
+    ), "Sorting in descending order failed"
 
 
 @pytest.mark.asyncio
-async def test_select_combining_filters_and_sorting(async_session, test_model, test_data):
+async def test_select_combining_filters_and_sorting(
+    async_session, test_model, test_data
+):
     for item in test_data:
-        item.setdefault('is_deleted', False)
-        item.setdefault('deleted_at', None)
+        item.setdefault("is_deleted", False)
+        item.setdefault("deleted_at", None)
         async_session.add(test_model(**item))
     await async_session.commit()
 
     crud = FastCRUD(test_model)
 
-    stmt = await crud.select(name__gte='Eve', sort_columns="id", sort_orders="asc")
+    stmt = await crud.select(name__gte="Eve", sort_columns="id", sort_orders="asc")
     res = await async_session.execute(stmt)
     filtered_and_sorted_items = [dict(r) for r in res.mappings()]
-    expected_items = sorted([item for item in test_data if item['name'] >= 'Eve'], key=lambda x: x['id'])
+    expected_items = sorted(
+        [item for item in test_data if item["name"] >= "Eve"], key=lambda x: x["id"]
+    )
 
-    assert filtered_and_sorted_items == expected_items, "Combining filters and sorting failed"
+    assert (
+        filtered_and_sorted_items == expected_items
+    ), "Combining filters and sorting failed"
diff --git a/tests/sqlmodel/endpoint/test_get_items.py b/tests/sqlmodel/endpoint/test_get_items.py
index a05d3bd..020f4de 100644
--- a/tests/sqlmodel/endpoint/test_get_items.py
+++ b/tests/sqlmodel/endpoint/test_get_items.py
@@ -14,7 +14,6 @@ async def test_read_items(client: TestClient, async_session, test_model, test_da
     assert response.status_code == 200
     data = response.json()
 
-    print(data)
     assert "data" in data
     assert len(data["data"]) > 0
 
diff --git a/tests/sqlmodel/endpoint/test_update_item.py b/tests/sqlmodel/endpoint/test_update_item.py
index af0413d..42a4f52 100644
--- a/tests/sqlmodel/endpoint/test_update_item.py
+++ b/tests/sqlmodel/endpoint/test_update_item.py
@@ -17,7 +17,6 @@ async def test_update_item(client: TestClient, async_session, test_model, test_d
 
     update_response = client.patch(f"/test/update/{min_id}", json=updated_data)
     assert update_response.status_code == 200
-    print(update_response.status_code)
 
     stmt = select(test_model).filter_by(id=min_id)
     result = await async_session.execute(stmt)