From 0b5c7e5b7c78efa94fd4e38062d727c0282c895a Mon Sep 17 00:00:00 2001 From: Rafael Rego Date: Tue, 13 Jan 2026 17:44:44 -0300 Subject: [PATCH 1/3] feat(querymate): add join_type parameter for relationship queries - Add join_type param to apply_select() and build() methods - Support 'inner' (default), 'left', and 'outer' join types - Left/outer joins include parent records with empty relationship lists - Export JoinType type alias from main module - Add comprehensive tests for all join type scenarios --- querymate/__init__.py | 1 + querymate/core/config.py | 9 ++ querymate/core/query_builder.py | 96 ++++++++++-- querymate/core/querymate.py | 33 ++++- tests/test_query_builder.py | 146 ++++++++++++++++++ tests/test_querymate.py | 254 +++++++++++++++++++++++++++++++- uv.lock | 2 +- 7 files changed, 523 insertions(+), 18 deletions(-) diff --git a/querymate/__init__.py b/querymate/__init__.py index 1b8972e..fe95b16 100644 --- a/querymate/__init__.py +++ b/querymate/__init__.py @@ -65,6 +65,7 @@ from .core.grouping import GroupedResponse as GroupedResponse from .core.grouping import GroupKeyExtractor as GroupKeyExtractor from .core.grouping import GroupResult as GroupResult +from .core.query_builder import JoinType as JoinType from .core.query_builder import QueryBuilder as QueryBuilder from .core.querymate import Querymate as Querymate from .types import PaginatedResponse as PaginatedResponse diff --git a/querymate/core/config.py b/querymate/core/config.py index fbf16d0..94968d5 100644 --- a/querymate/core/config.py +++ b/querymate/core/config.py @@ -139,6 +139,15 @@ class QueryMateSettings(BaseSettings): description="Supported date granularities for grouping", ) + # Join type configuration + JOIN_TYPE_PARAM_NAME: str = Field( + default="join_type", description="Join type parameter name for relationship queries" + ) + DEFAULT_JOIN_TYPE: str = Field( + default="inner", + description="Default join type for relationship queries. Options: 'inner', 'left', 'outer'", + ) + model_config = SettingsConfigDict(env_prefix="QUERYMATE_", case_sensitive=False) diff --git a/querymate/core/query_builder.py b/querymate/core/query_builder.py index a5d548a..7f05947 100644 --- a/querymate/core/query_builder.py +++ b/querymate/core/query_builder.py @@ -1,6 +1,6 @@ from collections.abc import Sequence from logging import getLogger -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast from sqlalchemy import Join, case, func from sqlalchemy.ext.asyncio import AsyncSession @@ -21,6 +21,7 @@ # Type aliases for better readability FieldSelection = str | dict[str, list[str]] SelectResult = tuple[list[InstrumentedAttribute], list[Join]] +JoinType = Literal["inner", "left", "outer"] # Configure logger logger = getLogger(__name__) @@ -216,8 +217,25 @@ def _select( return select_columns, joins + def _normalize_join_type(self, join_type: JoinType | None) -> JoinType: + """Normalize join_type to a valid value. + + Args: + join_type: The join type to normalize. Can be 'inner', 'left', or 'outer'. + + Returns: + Normalized join type. 'outer' is treated as 'left'. + """ + if join_type is None: + return cast(JoinType, settings.DEFAULT_JOIN_TYPE) + if join_type == "outer": + return "left" + return join_type + def apply_select( - self, fields: list[str | dict[str, list[str]]] | None = None + self, + fields: list[str | dict[str, list[str]]] | None = None, + join_type: JoinType | None = None, ) -> "QueryBuilder": """ Select fields to be returned in the query. @@ -229,13 +247,24 @@ def apply_select( fields (list[str | dict[str, list[str]]] | None): List of fields to select. Can include nested dictionaries for relationship fields. If None, all fields are selected. + join_type (JoinType | None): Type of join to use for relationships. + - 'inner' (default): Uses INNER JOIN - excludes parent records without children + - 'left' or 'outer': Uses LEFT OUTER JOIN - includes parent records with empty + lists for relationships when no children exist Returns: QueryBuilder: The query builder instance for method chaining. Example: ```python - builder.select(["name", "email", {"posts": ["title", "content"]}]) + # Inner join (default) - excludes users without posts + builder.apply_select(["name", "email", {"posts": ["title", "content"]}]) + + # Left join - includes users without posts (posts will be empty list) + builder.apply_select( + ["name", "email", {"posts": ["title", "content"]}], + join_type="left" + ) ``` """ if not fields: @@ -244,8 +273,13 @@ def apply_select( self.select = normalized_fields select_columns, joins = self._select(self.model, normalized_fields) self.query = select(*select_columns) + + effective_join_type = self._normalize_join_type(join_type) for join in joins: - self.query = self.query.join(join) + if effective_join_type == "left": + self.query = self.query.outerjoin(join) + else: + self.query = self.query.join(join) return self def apply_filter(self, filter_dict: dict[str, Any] | None = None) -> "QueryBuilder": @@ -432,6 +466,7 @@ def build( sort: list[str | dict[str, Any]] | None = None, limit: int | None = None, offset: int | None = None, + join_type: JoinType | None = None, ) -> "QueryBuilder": """Build a complete query with all parameters. @@ -439,11 +474,14 @@ def build( into a single method call. Args: - fields (list[str | dict[str, list[str]]] | None): Fields to select. + select (list[str | dict[str, list[str]]] | None): Fields to select. filter (dict[str, Any] | None): Filter conditions. sort (list[str] | None): Sort parameters. limit (int | None): Maximum number of records. offset (int | None): Number of records to skip. + join_type (JoinType | None): Type of join for relationships. + - 'inner' (default): Uses INNER JOIN + - 'left' or 'outer': Uses LEFT OUTER JOIN Returns: QueryBuilder: The query builder instance for method chaining. @@ -451,16 +489,17 @@ def build( Example: ```python builder.build( - fields=["name", {"posts": ["title"]}], + select=["name", {"posts": ["title"]}], filter={"age": {"gt": 18}}, sort=["-name"], limit=10, - offset=0 + offset=0, + join_type="left" # Include users without posts ) ``` """ return ( - self.apply_select(select) + self.apply_select(select, join_type=join_type) .apply_filter(filter) .apply_sort(sort) .apply_limit(limit) @@ -559,10 +598,20 @@ def reconstruct_objects( id_field = next(field for field in mapper.primary_key) + # Collect relationship names that should be initialized as empty lists + relationship_names: list[str] = [] + for field in self.select: + if isinstance(field, dict): + relationship_names.extend(field.keys()) + for row in results: field_idx = [0] obj, field_idx = self.reconstruct_object(model, self.select, row, field_idx) + # Skip None objects (shouldn't happen for root objects) + if obj is None: + continue + # Get the ID of the object obj_id = getattr(obj, id_field.name) @@ -579,7 +628,14 @@ def reconstruct_objects( if new_rel not in existing_rels: existing_rels.append(new_rel) else: - # First time seeing this object, add it to our dictionary + # First time seeing this object + # Ensure relationship attributes are initialized as empty lists if not set + for rel_name in relationship_names: + rel_property = mapper.relationships.get(rel_name) + if rel_property and rel_property.uselist: + current_val = getattr(obj, rel_name, None) + if current_val is None: + setattr(obj, rel_name, []) reconstructed[obj_id] = obj return list(reconstructed.values()) @@ -634,7 +690,7 @@ def reconstruct_object( fields: list[FieldSelection], row: tuple[Any, ...], field_idx: list[int], - ) -> tuple[T, list[int]]: + ) -> tuple[T | None, list[int]]: """Reconstruct a model instance from a query result row. This method handles both direct fields and relationship fields. @@ -646,7 +702,8 @@ def reconstruct_object( field_idx (list[int]): Current field index for tracking position in row. Returns: - tuple[T, list[int]]: The reconstructed model instance and updated field index. + tuple[T | None, list[int]]: The reconstructed model instance (or None if all + fields are None, indicating no match in a LEFT JOIN) and updated field index. """ mapper: Mapper = inspect(model) obj_kwargs: dict[str, Any] = {} @@ -667,7 +724,14 @@ def reconstruct_object( row, field_idx, ) - related_objs.setdefault(relation_name, []).append(related_obj) + # Only add non-None related objects (None indicates LEFT JOIN with no match) + if related_obj is not None: + related_objs.setdefault(relation_name, []).append(related_obj) + + # Check if all direct field values are None (LEFT JOIN with no match) + all_fields_none = all(v is None for v in obj_kwargs.values()) + if all_fields_none and obj_kwargs: + return None, field_idx obj: T = model(**obj_kwargs) for relation_name, rel_objs in related_objs.items(): @@ -863,6 +927,7 @@ def fetch_for_group( group_key: Any, limit: int, offset: int = 0, + join_type: JoinType | None = None, ) -> list[T]: """Fetch items for a specific group. @@ -874,6 +939,7 @@ def fetch_for_group( group_key: The group key value to filter by. limit: Maximum items to return. offset: Number of items to skip. + join_type: Type of join for relationships ('inner', 'left', or 'outer'). Returns: List of model instances for the group. @@ -883,7 +949,7 @@ def fetch_for_group( # Build a fresh query for this group group_builder = QueryBuilder(model) - group_builder.apply_select(self.select if self.select else None) + group_builder.apply_select(self.select if self.select else None, join_type=join_type) # Combine existing filters with group filter combined_filter = dict(self.filter) if self.filter else {} @@ -912,6 +978,7 @@ async def fetch_for_group_async( group_key: Any, limit: int, offset: int = 0, + join_type: JoinType | None = None, ) -> list[T]: """Fetch items for a specific group asynchronously. @@ -923,6 +990,7 @@ async def fetch_for_group_async( group_key: The group key value to filter by. limit: Maximum items to return. offset: Number of items to skip. + join_type: Type of join for relationships ('inner', 'left', or 'outer'). Returns: List of model instances for the group. @@ -931,7 +999,7 @@ async def fetch_for_group_async( group_expr = extractor.get_group_key_expression(column, group_config) group_builder = QueryBuilder(model) - group_builder.apply_select(self.select if self.select else None) + group_builder.apply_select(self.select if self.select else None, join_type=join_type) combined_filter = dict(self.filter) if self.filter else {} group_builder.apply_filter(combined_filter) diff --git a/querymate/core/querymate.py b/querymate/core/querymate.py index 0db34d7..c139ddd 100644 --- a/querymate/core/querymate.py +++ b/querymate/core/querymate.py @@ -15,7 +15,7 @@ GroupKeyExtractor, GroupResult, ) -from querymate.core.query_builder import QueryBuilder +from querymate.core.query_builder import JoinType, QueryBuilder from querymate.types import PaginatedResponse, PaginationInfo T = TypeVar("T", bound=SQLModel) @@ -42,6 +42,8 @@ class Querymate(BaseModel): sort (list[str] | None): List of fields to sort by. Prefix with "-" for descending order. Default is []. limit (int | None): Maximum number of records to return. Default is 10, max is 200. offset (int | None): Number of records to skip. Default is 0. + join_type (JoinType | None): Type of join for relationship queries. Options: 'inner' (default), + 'left', 'outer'. Use 'left' or 'outer' to include parent records even when no children exist. Serialization: The Querymate class includes built-in serialization capabilities through the `run` and `run_async` methods. @@ -113,6 +115,11 @@ def get_users_raw( description="Group results by field. Can be a string or dict with field, granularity, tz_offset/timezone", alias=settings.GROUP_BY_PARAM_NAME, ) + join_type: JoinType | None = Field( # type: ignore[literal-required] + default=None, + description="Join type for relationship queries. Options: 'inner' (default), 'left', 'outer'", + alias=settings.JOIN_TYPE_PARAM_NAME, + ) @classmethod def from_qs(cls, query_params: QueryParams) -> "Querymate": @@ -227,6 +234,7 @@ def run_raw(self, db: Session, model: type[T]) -> list[T]: sort=self.sort, limit=self.limit, offset=self.offset, + join_type=self.join_type, ) return query_builder.fetch(db, model) @@ -253,6 +261,13 @@ def run( querymate = Querymate(select=["id", "name"]) # Returns serialized results results = querymate.run(db, User) + + # With left join to include records without relationships + querymate = Querymate( + select=["id", "name", {"posts": ["title"]}], + join_type="left" + ) + results = querymate.run(db, User) ``` """ query_builder = QueryBuilder(model=model) @@ -262,6 +277,7 @@ def run( sort=self.sort, limit=self.limit, offset=self.offset, + join_type=self.join_type, ) data = query_builder.fetch(db, model) return query_builder.serialize(data) @@ -287,6 +303,7 @@ def run_paginated( sort=self.sort, limit=self.limit, offset=self.offset, + join_type=self.join_type, ) data = query_builder.fetch(db, model) serialized = query_builder.serialize(data) @@ -320,6 +337,13 @@ async def run_async( querymate = Querymate(select=["id", "name"]) # Returns serialized results results = await querymate.run_async(db, User) + + # With left join + querymate = Querymate( + select=["id", "name", {"posts": ["title"]}], + join_type="left" + ) + results = await querymate.run_async(db, User) ``` """ query_builder = QueryBuilder(model=model) @@ -329,6 +353,7 @@ async def run_async( sort=self.sort, limit=self.limit, offset=self.offset, + join_type=self.join_type, ) data = await query_builder.fetch_async(db, model) return query_builder.serialize(data) @@ -354,6 +379,7 @@ async def run_async_paginated( sort=self.sort, limit=self.limit, offset=self.offset, + join_type=self.join_type, ) data = await query_builder.fetch_async(db, model) serialized = query_builder.serialize(data) @@ -384,6 +410,7 @@ async def run_raw_async(self, db: AsyncSession, model: type[T]) -> list[T]: sort=self.sort, limit=self.limit, offset=self.offset, + join_type=self.join_type, ) return await query_builder.fetch_async(db, model) @@ -453,6 +480,7 @@ def run_grouped( select=self.select, filter=self.filter, sort=self.sort, + join_type=self.join_type, ) # Get all distinct group keys with their counts @@ -486,6 +514,7 @@ def run_grouped( group_key, limit=effective_limit, offset=self.offset or 0, + join_type=self.join_type, ) serialized = query_builder.serialize(items) @@ -562,6 +591,7 @@ async def run_grouped_async( select=self.select, filter=self.filter, sort=self.sort, + join_type=self.join_type, ) group_keys = await query_builder.get_distinct_group_keys_async( @@ -594,6 +624,7 @@ async def run_grouped_async( group_key, limit=effective_limit, offset=self.offset or 0, + join_type=self.join_type, ) serialized = query_builder.serialize(items) diff --git a/tests/test_query_builder.py b/tests/test_query_builder.py index f87dc4c..bf6e766 100644 --- a/tests/test_query_builder.py +++ b/tests/test_query_builder.py @@ -910,3 +910,149 @@ async def test_serialize_with_non_list_relationships_async( "title": "Post 1", "user": {"id": 1, "name": "John"}, } + + +# ================================ +# Test cases for join_type parameter +# ================================ +def test_apply_select_join_type_inner() -> None: + """Test apply_select with inner join type generates correct SQL.""" + query_builder = QueryBuilder(model=User) + query_builder.apply_select(["id", "name", {"posts": ["id", "title"]}], join_type="inner") + + compiled = str(query_builder.query.compile(compile_kwargs={"literal_binds": True})) + assert "JOIN post ON" in compiled + assert "LEFT OUTER JOIN" not in compiled + + +def test_apply_select_join_type_left() -> None: + """Test apply_select with left join type generates correct SQL.""" + query_builder = QueryBuilder(model=User) + query_builder.apply_select(["id", "name", {"posts": ["id", "title"]}], join_type="left") + + compiled = str(query_builder.query.compile(compile_kwargs={"literal_binds": True})) + assert "LEFT OUTER JOIN post ON" in compiled + + +def test_apply_select_join_type_outer() -> None: + """Test apply_select with outer join type generates correct SQL (same as left).""" + query_builder = QueryBuilder(model=User) + query_builder.apply_select(["id", "name", {"posts": ["id", "title"]}], join_type="outer") + + compiled = str(query_builder.query.compile(compile_kwargs={"literal_binds": True})) + assert "LEFT OUTER JOIN post ON" in compiled + + +def test_build_with_join_type() -> None: + """Test build method passes join_type correctly.""" + query_builder = QueryBuilder(model=User) + query_builder.build( + select=["id", "name", {"posts": ["id", "title"]}], + join_type="left", + ) + + compiled = str(query_builder.query.compile(compile_kwargs={"literal_binds": True})) + assert "LEFT OUTER JOIN post ON" in compiled + + +def test_join_type_inner_excludes_records_without_relationships(db: Session) -> None: + """Inner join should exclude parent records without related children.""" + user_with_posts = User( + id=1, name="John", is_active=True, email="john@example.com", age=30 + ) + user_without_posts = User( + id=2, name="Jane", is_active=True, email="jane@example.com", age=25 + ) + post = Post(id=1, title="Post 1", content="Content 1", user_id=1) + + db.add(user_with_posts) + db.add(user_without_posts) + db.add(post) + db.commit() + + query_builder = QueryBuilder(model=User) + query_builder.apply_select(["id", "name", {"posts": ["id", "title"]}], join_type="inner") + results = query_builder.fetch(db, User) + + assert len(results) == 1 + assert results[0].name == "John" + + +def test_join_type_left_includes_records_without_relationships(db: Session) -> None: + """Left join should include parent records without related children.""" + user_with_posts = User( + id=1, name="John", is_active=True, email="john@example.com", age=30 + ) + user_without_posts = User( + id=2, name="Jane", is_active=True, email="jane@example.com", age=25 + ) + post = Post(id=1, title="Post 1", content="Content 1", user_id=1) + + db.add(user_with_posts) + db.add(user_without_posts) + db.add(post) + db.commit() + + query_builder = QueryBuilder(model=User) + query_builder.apply_select(["id", "name", {"posts": ["id", "title"]}], join_type="left") + results = query_builder.fetch(db, User) + + assert len(results) == 2 + user_names = {u.name for u in results} + assert user_names == {"John", "Jane"} + + +def test_join_type_left_serialization_empty_list(db: Session) -> None: + """Left join should serialize missing relationships as empty list.""" + user_with_posts = User( + id=1, name="John", is_active=True, email="john@example.com", age=30 + ) + user_without_posts = User( + id=2, name="Jane", is_active=True, email="jane@example.com", age=25 + ) + post = Post(id=1, title="Post 1", content="Content 1", user_id=1) + + db.add(user_with_posts) + db.add(user_without_posts) + db.add(post) + db.commit() + + query_builder = QueryBuilder(model=User) + query_builder.apply_select(["id", "name", {"posts": ["id", "title"]}], join_type="left") + results = query_builder.fetch(db, User) + serialized = query_builder.serialize(results) + + assert len(serialized) == 2 + + # User with posts should have posts list + john_result = next(r for r in serialized if r["name"] == "John") + assert len(john_result["posts"]) == 1 + assert john_result["posts"][0]["title"] == "Post 1" + + # User without posts should have empty posts list + jane_result = next(r for r in serialized if r["name"] == "Jane") + assert jane_result["posts"] == [] + + +async def test_join_type_left_async(async_db: AsyncSession) -> None: + """Left join should work correctly in async mode.""" + user_with_posts = User( + id=1, name="John", is_active=True, email="john@example.com", age=30 + ) + user_without_posts = User( + id=2, name="Jane", is_active=True, email="jane@example.com", age=25 + ) + post = Post(id=1, title="Post 1", content="Content 1", user_id=1) + + async_db.add(user_with_posts) + async_db.add(user_without_posts) + async_db.add(post) + await async_db.commit() + + query_builder = QueryBuilder(model=User) + query_builder.apply_select(["id", "name", {"posts": ["id", "title"]}], join_type="left") + results = await query_builder.fetch_async(async_db, User) + + assert len(results) == 2 + user_names = {u.name for u in results} + assert user_names == {"John", "Jane"} diff --git a/tests/test_querymate.py b/tests/test_querymate.py index 340e8ff..71f0d5c 100644 --- a/tests/test_querymate.py +++ b/tests/test_querymate.py @@ -84,7 +84,7 @@ def test_to_qs() -> None: assert ( qs - == "q=%7B%22select%22%3A%5B%22id%22%2C%22name%22%5D%2C%22filter%22%3A%7B%22age%22%3A%7B%22gt%22%3A25%7D%7D%2C%22sort%22%3A%5B%22-age%22%5D%2C%22limit%22%3A10%2C%22offset%22%3A0%2C%22include_pagination%22%3Afalse%2C%22group_by%22%3Anull%7D" + == "q=%7B%22select%22%3A%5B%22id%22%2C%22name%22%5D%2C%22filter%22%3A%7B%22age%22%3A%7B%22gt%22%3A25%7D%7D%2C%22sort%22%3A%5B%22-age%22%5D%2C%22limit%22%3A10%2C%22offset%22%3A0%2C%22include_pagination%22%3Afalse%2C%22group_by%22%3Anull%2C%22join_type%22%3Anull%7D" ) @@ -99,7 +99,7 @@ def test_to_query_param() -> None: qp = querymate.to_query_param() assert ( qp - == "%7B%22select%22%3A%5B%22id%22%2C%22name%22%5D%2C%22filter%22%3A%7B%22age%22%3A%7B%22gt%22%3A25%7D%7D%2C%22sort%22%3A%5B%22-age%22%5D%2C%22limit%22%3A10%2C%22offset%22%3A0%2C%22include_pagination%22%3Afalse%2C%22group_by%22%3Anull%7D" + == "%7B%22select%22%3A%5B%22id%22%2C%22name%22%5D%2C%22filter%22%3A%7B%22age%22%3A%7B%22gt%22%3A25%7D%7D%2C%22sort%22%3A%5B%22-age%22%5D%2C%22limit%22%3A10%2C%22offset%22%3A0%2C%22include_pagination%22%3Afalse%2C%22group_by%22%3Anull%2C%22join_type%22%3Anull%7D" ) @@ -743,3 +743,253 @@ async def test_run_with_pagination_async(async_db: AsyncSession) -> None: assert p.page == 2 assert p.previous_page == 1 assert p.next_page == 3 + + +# ================================ +# Test cases for join_type parameter +# ================================ +def test_join_type_inner_excludes_users_without_posts(db: Session) -> None: + """Inner join (default) should exclude users without any posts.""" + user_with_posts = User( + id=1, name="John", is_active=True, email="john@example.com", age=30 + ) + user_without_posts = User( + id=2, name="Jane", is_active=True, email="jane@example.com", age=25 + ) + post = Post(id=1, title="Post 1", content="Content 1", user_id=1) + + db.add(user_with_posts) + db.add(user_without_posts) + db.add(post) + db.commit() + + # Default (inner join) - should only return user with posts + querymate = Querymate( + select=["id", "name", {"posts": ["id", "title"]}], + join_type="inner", + ) + results = querymate.run(db=db, model=User) + + assert len(results) == 1 + assert results[0]["name"] == "John" + assert len(results[0]["posts"]) == 1 + + +def test_join_type_left_includes_users_without_posts(db: Session) -> None: + """Left join should include users without posts (with empty posts list).""" + user_with_posts = User( + id=1, name="John", is_active=True, email="john@example.com", age=30 + ) + user_without_posts = User( + id=2, name="Jane", is_active=True, email="jane@example.com", age=25 + ) + post = Post(id=1, title="Post 1", content="Content 1", user_id=1) + + db.add(user_with_posts) + db.add(user_without_posts) + db.add(post) + db.commit() + + # Left join - should return both users + querymate = Querymate( + select=["id", "name", {"posts": ["id", "title"]}], + join_type="left", + ) + results = querymate.run(db=db, model=User) + + assert len(results) == 2 + user_names = {r["name"] for r in results} + assert user_names == {"John", "Jane"} + + # User with posts should have posts + john_result = next(r for r in results if r["name"] == "John") + assert len(john_result["posts"]) == 1 + + # User without posts should have empty posts list + jane_result = next(r for r in results if r["name"] == "Jane") + assert jane_result["posts"] == [] + + +def test_join_type_outer_same_as_left(db: Session) -> None: + """Outer join should behave the same as left join.""" + user_with_posts = User( + id=1, name="John", is_active=True, email="john@example.com", age=30 + ) + user_without_posts = User( + id=2, name="Jane", is_active=True, email="jane@example.com", age=25 + ) + post = Post(id=1, title="Post 1", content="Content 1", user_id=1) + + db.add(user_with_posts) + db.add(user_without_posts) + db.add(post) + db.commit() + + # Outer join should work same as left + querymate = Querymate( + select=["id", "name", {"posts": ["id", "title"]}], + join_type="outer", + ) + results = querymate.run(db=db, model=User) + + assert len(results) == 2 + user_names = {r["name"] for r in results} + assert user_names == {"John", "Jane"} + + +def test_join_type_default_is_inner(db: Session) -> None: + """Default join_type should be inner (excluding users without posts).""" + user_with_posts = User( + id=1, name="John", is_active=True, email="john@example.com", age=30 + ) + user_without_posts = User( + id=2, name="Jane", is_active=True, email="jane@example.com", age=25 + ) + post = Post(id=1, title="Post 1", content="Content 1", user_id=1) + + db.add(user_with_posts) + db.add(user_without_posts) + db.add(post) + db.commit() + + # No join_type specified - default should be inner + querymate = Querymate( + select=["id", "name", {"posts": ["id", "title"]}], + ) + results = querymate.run(db=db, model=User) + + assert len(results) == 1 + assert results[0]["name"] == "John" + + +def test_join_type_left_with_filter(db: Session) -> None: + """Left join should work correctly with filters.""" + user1 = User(id=1, name="John", is_active=True, email="john@example.com", age=30) + user2 = User(id=2, name="Jane", is_active=True, email="jane@example.com", age=25) + user3 = User(id=3, name="Bob", is_active=True, email="bob@example.com", age=35) + post = Post(id=1, title="Post 1", content="Content 1", user_id=1) + + db.add_all([user1, user2, user3, post]) + db.commit() + + # Left join with age filter + querymate = Querymate( + select=["id", "name", {"posts": ["id", "title"]}], + filter={"age": {"lt": 35}}, + join_type="left", + ) + results = querymate.run(db=db, model=User) + + # Should return John (with post) and Jane (without post), but not Bob + assert len(results) == 2 + user_names = {r["name"] for r in results} + assert user_names == {"John", "Jane"} + + +def test_join_type_in_querystring(db: Session) -> None: + """Join type should be parseable from query string.""" + user_with_posts = User( + id=1, name="John", is_active=True, email="john@example.com", age=30 + ) + user_without_posts = User( + id=2, name="Jane", is_active=True, email="jane@example.com", age=25 + ) + post = Post(id=1, title="Post 1", content="Content 1", user_id=1) + + db.add(user_with_posts) + db.add(user_without_posts) + db.add(post) + db.commit() + + # Parse from query string + query_params = QueryParams( + {"q": '{"select": ["id", "name", {"posts": ["id", "title"]}], "join_type": "left"}'} + ) + querymate = Querymate.from_qs(query_params) + + assert querymate.join_type == "left" + results = querymate.run(db=db, model=User) + + assert len(results) == 2 + + +@pytest.mark.asyncio +async def test_join_type_left_async(async_db: AsyncSession) -> None: + """Left join should work correctly in async mode.""" + user_with_posts = User( + id=1, name="John", is_active=True, email="john@example.com", age=30 + ) + user_without_posts = User( + id=2, name="Jane", is_active=True, email="jane@example.com", age=25 + ) + post = Post(id=1, title="Post 1", content="Content 1", user_id=1) + + async_db.add(user_with_posts) + async_db.add(user_without_posts) + async_db.add(post) + await async_db.commit() + + # Left join async + querymate = Querymate( + select=["id", "name", {"posts": ["id", "title"]}], + join_type="left", + ) + results = await querymate.run_async(async_db, User) + + assert len(results) == 2 + user_names = {r["name"] for r in results} + assert user_names == {"John", "Jane"} + + +@pytest.mark.asyncio +async def test_join_type_inner_async(async_db: AsyncSession) -> None: + """Inner join should work correctly in async mode.""" + user_with_posts = User( + id=1, name="John", is_active=True, email="john@example.com", age=30 + ) + user_without_posts = User( + id=2, name="Jane", is_active=True, email="jane@example.com", age=25 + ) + post = Post(id=1, title="Post 1", content="Content 1", user_id=1) + + async_db.add(user_with_posts) + async_db.add(user_without_posts) + async_db.add(post) + await async_db.commit() + + # Inner join async + querymate = Querymate( + select=["id", "name", {"posts": ["id", "title"]}], + join_type="inner", + ) + results = await querymate.run_async(async_db, User) + + assert len(results) == 1 + assert results[0]["name"] == "John" + + +def test_join_type_raw_methods(db: Session) -> None: + """Join type should work with run_raw method.""" + user_with_posts = User( + id=1, name="John", is_active=True, email="john@example.com", age=30 + ) + user_without_posts = User( + id=2, name="Jane", is_active=True, email="jane@example.com", age=25 + ) + post = Post(id=1, title="Post 1", content="Content 1", user_id=1) + + db.add(user_with_posts) + db.add(user_without_posts) + db.add(post) + db.commit() + + # run_raw with left join + querymate = Querymate( + select=["id", "name", {"posts": ["id", "title"]}], + join_type="left", + ) + results = querymate.run_raw(db=db, model=User) + + assert len(results) == 2 + user_names = {u.name for u in results} + assert user_names == {"John", "Jane"} diff --git a/uv.lock b/uv.lock index 528f3a8..04d59f4 100644 --- a/uv.lock +++ b/uv.lock @@ -1150,7 +1150,7 @@ wheels = [ [[package]] name = "querymate" -version = "0.5.1" +version = "0.5.2" source = { editable = "." } dependencies = [ { name = "fastapi" }, From e856914098a7b41ae73c951c80a3de23298364ca Mon Sep 17 00:00:00 2001 From: Rafael Rego Date: Wed, 14 Jan 2026 11:57:13 -0300 Subject: [PATCH 2/3] test: add validation for invalid join_type values --- querymate/core/query_builder.py | 7 +++++++ tests/test_query_builder.py | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/querymate/core/query_builder.py b/querymate/core/query_builder.py index 7f05947..f34ce18 100644 --- a/querymate/core/query_builder.py +++ b/querymate/core/query_builder.py @@ -225,11 +225,18 @@ def _normalize_join_type(self, join_type: JoinType | None) -> JoinType: Returns: Normalized join type. 'outer' is treated as 'left'. + + Raises: + ValueError: If join_type is not a valid option. """ if join_type is None: return cast(JoinType, settings.DEFAULT_JOIN_TYPE) if join_type == "outer": return "left" + if join_type not in ("inner", "left"): + raise ValueError( + f"Invalid join_type: '{join_type}'. Must be 'inner', 'left', or 'outer'." + ) return join_type def apply_select( diff --git a/tests/test_query_builder.py b/tests/test_query_builder.py index bf6e766..0f21382 100644 --- a/tests/test_query_builder.py +++ b/tests/test_query_builder.py @@ -1056,3 +1056,13 @@ async def test_join_type_left_async(async_db: AsyncSession) -> None: assert len(results) == 2 user_names = {u.name for u in results} assert user_names == {"John", "Jane"} + + +def test_apply_select_join_type_invalid_raises_error() -> None: + """Test that invalid join_type raises ValueError.""" + query_builder = QueryBuilder(model=User) + with pytest.raises(ValueError, match="Invalid join_type"): + query_builder.apply_select( + ["id", "name", {"posts": ["id", "title"]}], + join_type="banana", # type: ignore + ) From f73cd7493b71dcadc6ea2a5c317e79b6dc710619 Mon Sep 17 00:00:00 2001 From: Rafael Rego Date: Wed, 14 Jan 2026 12:03:11 -0300 Subject: [PATCH 3/3] docs: add join_type documentation to relationships.rst --- docs/source/usage/relationships.rst | 41 ++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/docs/source/usage/relationships.rst b/docs/source/usage/relationships.rst index 8d79fd1..510ce67 100644 --- a/docs/source/usage/relationships.rst +++ b/docs/source/usage/relationships.rst @@ -95,10 +95,10 @@ Example: exclude posts where status is not equal to ``archived`` (i.e., keep all Notes: -- The relationship filter applies to the joined rows. Root records that have no related rows - matching the filter will not be returned due to the inner join behavior. If you need to include - root records with an empty list of related items, you currently need a left outer join — which - is not yet configurable in QueryMate. +- By default, QueryMate uses inner joins for relationships. Root records without + matching related rows will not be returned. +- Use ``join_type="left"`` to include root records even when they have no related items + (they will have an empty list for the relationship field). - Combine with other operators like ``in``/``nin`` for multiple statuses. Python usage is equivalent when constructing queries programmatically: @@ -110,3 +110,36 @@ Python usage is equivalent when constructing queries programmatically: filter={"posts.status": {"ne": "archived"}}, ) results = qm.run(db, User) + +Join Types +---------- + +By default, QueryMate uses **inner joins** for relationships, excluding parent records +that have no children. Use ``join_type`` to change this behavior: + +.. code-block:: text + + # Include users even if they have no posts (posts will be empty list) + /users?q={"select":["id","name",{"posts":["title"]}],"join_type":"left"} + +Available options: + +- ``inner`` (default): Excludes parent records without children +- ``left`` or ``outer``: Includes all parent records; children will be ``[]`` if none exist + +Python usage: + +.. code-block:: python + + # Inner join (default) - only users with posts + qm = Querymate( + select=["id", "name", {"posts": ["title"]}], + ) + results = qm.run(db, User) + + # Left join - all users, posts=[] for those without + qm = Querymate( + select=["id", "name", {"posts": ["title"]}], + join_type="left", + ) + results = qm.run(db, User)