From 583aa98cce2d708c7cfb98f51485678bf2fea1f4 Mon Sep 17 00:00:00 2001 From: William Chen Date: Fri, 18 Jul 2025 20:19:33 +0800 Subject: [PATCH 1/8] feat: Enhance query handling with IN operator support and related tests - Updated `CrudRepositoryImplementationService` to support IN operations in query filtering, including validation for parameter types and handling of empty lists. - Modified `_Query` class to include `field_operations` for mapping field names to their respective operations. - Enhanced `_MetodQueryBuilder` to parse method names with IN operations and generate appropriate query structures. - Added comprehensive unit tests for IN operator functionality, including cases for single fields, multiple fields with AND/OR conditions, and error handling for invalid types and empty lists. - Updated existing tests to reflect changes in query structure and ensure correct SQL statement generation. --- .../crud_repository_implementation_service.py | 32 +++++++- .../method_query_builder.py | 24 +++++- ..._crud_repository_implementation_service.py | 76 +++++++++++++++++-- tests/test_method_query_builder.py | 40 +++++++++- 4 files changed, 157 insertions(+), 15 deletions(-) diff --git a/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py b/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py index 45a03d4..df6af50 100644 --- a/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py +++ b/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py @@ -133,10 +133,34 @@ def _get_sql_statement( parsed_query: _Query, params: dict[str, Any], ) -> SelectOfScalar[PySpringModelT]: - filter_condition_stack: list[ColumnElement[bool]] = [ - getattr(model_type, field) == params[field] - for field in parsed_query.required_fields - ] + filter_condition_stack: list[ColumnElement[bool]] = [] + + for field in parsed_query.required_fields: + column = getattr(model_type, field) + param_value = params[field] + + # Check if this field has a specific operation + if field in parsed_query.field_operations: + operation = parsed_query.field_operations[field] + if operation == "in": + # Handle IN operation + if not isinstance(param_value, (list, tuple, set)): + raise ValueError(f"Parameter for IN operation must be a collection (list, tuple, or set), got {type(param_value)}") + + # Handle empty list case - return no results + if len(param_value) == 0: + # Create a condition that's always false + filter_condition_stack.append(column == None) + continue + + filter_condition_stack.append(column.in_(param_value)) + else: + # Default to equality for unknown operations + filter_condition_stack.append(column == param_value) + else: + # Default equality operation + filter_condition_stack.append(column == param_value) + for notation in parsed_query.notations: right_condition = filter_condition_stack.pop(0) left_condition = filter_condition_stack.pop(0) diff --git a/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/method_query_builder.py b/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/method_query_builder.py index 16fa2bf..9db70f8 100644 --- a/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/method_query_builder.py +++ b/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/method_query_builder.py @@ -1,4 +1,5 @@ import re +from typing import Dict, Any from pydantic import BaseModel @@ -9,12 +10,14 @@ class _Query(BaseModel): - `conditions`: A list of string conditions that will be used to filter the query. - `is_one_result`: A boolean indicating whether the query should return a single result or a list of results. - `required_fields`: A list of string field names that should be included in the query result. + - `field_operations`: A dictionary mapping field names to their operations (e.g., "in" for IN operator). """ raw_query_list: list[str] is_one_result: bool notations: list[str] required_fields: list[str] + field_operations: Dict[str, str] = {} class _MetodQueryBuilder: @@ -36,6 +39,7 @@ def parse_query(self) -> _Query: Example: - 'find_by_name_and_age' -> Query(raw_query_list=['name', '_and_', 'age'], is_one_result=True, required_fields=['name', 'age']) - 'find_all_by_name_or_age' -> Query(raw_query_list=['name', '_or_', 'age'], is_one_result=False, required_fields=['name', 'age']) + - 'find_by_status_in' -> Query(raw_query_list=['status'], is_one_result=True, required_fields=['status'], field_operations={'status': 'in'}) """ is_one = False pattern = "" @@ -53,7 +57,6 @@ def parse_query(self) -> _Query: if len(pattern) == 0: raise ValueError(f"Method name must start with 'get_by', 'find_by', 'find_all_by', or 'get_all_by': {self.method_name}") - match = re.match(pattern, self.method_name) if not match: raise ValueError(f"Invalid method name: {self.method_name}") @@ -62,13 +65,26 @@ def parse_query(self) -> _Query: # Split fields by '_and_' or '_or_' and keep logical operators raw_query_list = re.split(r"(_and_|_or_)", raw_query) + # Extract required fields and detect operations + required_fields = [] + field_operations = {} + + for field in raw_query_list: + if field not in ["_and_", "_or_"]: + # Check for IN operation + if field.endswith("_in"): + base_field = field[:-3] # Remove "_in" suffix + required_fields.append(base_field) + field_operations[base_field] = "in" + else: + required_fields.append(field) + return _Query( raw_query_list=raw_query_list, is_one_result=is_one, - required_fields=[ - field for field in raw_query_list if field not in ["_and_", "_or_"] - ], + required_fields=required_fields, notations=[ notation for notation in raw_query_list if notation in ["_and_", "_or_"] ], + field_operations=field_operations, ) diff --git a/tests/test_crud_repository_implementation_service.py b/tests/test_crud_repository_implementation_service.py index 1be059c..c3aacbb 100644 --- a/tests/test_crud_repository_implementation_service.py +++ b/tests/test_crud_repository_implementation_service.py @@ -15,12 +15,17 @@ class User(PySpringModel, table=True): id: int = Field(default=None, primary_key=True) name: str email: str + status: str = Field(default="active") + category: str = Field(default="general") class UserView(BaseModel): name: str class UserRepository(CrudRepository[int,User]): def find_by_name(self, name: str) -> User: ... + def find_all_by_status_in(self, status: list[str]) -> list[User]: ... + def find_all_by_id_in_and_name(self, id: list[int], name: str) -> list[User]: ... + def find_all_by_status_in_or_category_in(self, status: list[str], category: list[str]) -> list[User]: ... @Query("SELECT * FROM user WHERE name = {name}") def query_uery_by_name(self, name: str) -> User: ... @@ -53,17 +58,17 @@ def implementation_service(self) -> CrudRepositoryImplementationService: def test_query_single_annotation(self, implementation_service: CrudRepositoryImplementationService): parsed_query = _MetodQueryBuilder("find_by_name").parse_query() statement = implementation_service._get_sql_statement(User, parsed_query, {"name": "John Doe"}) - assert str(statement).replace("\n", "") == 'SELECT "user".id, "user".name, "user".email FROM "user" WHERE "user".name = :name_1' + assert str(statement).replace("\n", "") == 'SELECT "user".id, "user".name, "user".email, "user".status, "user".category FROM "user" WHERE "user".name = :name_1' def test_query_and_annotation(self, implementation_service: CrudRepositoryImplementationService): parsed_query = _MetodQueryBuilder("find_by_name_and_email").parse_query() statement = implementation_service._get_sql_statement(User, parsed_query, {"name": "John Doe", "email": "john@example.com"}) - assert str(statement).replace("\n", "") == 'SELECT "user".id, "user".name, "user".email FROM "user" WHERE "user".email = :email_1 AND "user".name = :name_1' + assert str(statement).replace("\n", "") == 'SELECT "user".id, "user".name, "user".email, "user".status, "user".category FROM "user" WHERE "user".email = :email_1 AND "user".name = :name_1' def test_query_or_annotation(self, implementation_service: CrudRepositoryImplementationService): parsed_query = _MetodQueryBuilder("find_by_name_or_email").parse_query() statement = implementation_service._get_sql_statement(User, parsed_query, {"name": "John Doe", "email": "john@example.com"}) - assert str(statement).replace("\n", "") == 'SELECT "user".id, "user".name, "user".email FROM "user" WHERE "user".email = :email_1 OR "user".name = :name_1' + assert str(statement).replace("\n", "") == 'SELECT "user".id, "user".name, "user".email, "user".status, "user".category FROM "user" WHERE "user".email = :email_1 OR "user".name = :name_1' def test_did_implement_query(self, user_repository: UserRepository, implementation_service: CrudRepositoryImplementationService): user = User(name="John Doe", email="john@example.com") @@ -103,9 +108,68 @@ def test_query_user_view_by_name_invalid_argument_type(self, user_repository: Us with pytest.raises(ValueError, match=".*"): user_repository.query_user_view_by_name(name=None) # `name` should not be None # type: ignore + def test_in_operator_single_field(self, implementation_service: CrudRepositoryImplementationService): + parsed_query = _MetodQueryBuilder("find_by_status_in").parse_query() + statement = implementation_service._get_sql_statement(User, parsed_query, {"status": ["active", "pending"]}) + assert "IN" in str(statement).upper() + assert "status" in str(statement).lower() + + def test_in_operator_with_and(self, implementation_service: CrudRepositoryImplementationService): + parsed_query = _MetodQueryBuilder("find_by_id_in_and_name").parse_query() + statement = implementation_service._get_sql_statement(User, parsed_query, {"id": [1, 2, 3], "name": "John"}) + assert "IN" in str(statement).upper() + assert "AND" in str(statement).upper() + + def test_in_operator_with_or(self, implementation_service: CrudRepositoryImplementationService): + parsed_query = _MetodQueryBuilder("find_by_status_in_or_category_in").parse_query() + statement = implementation_service._get_sql_statement(User, parsed_query, {"status": ["active"], "category": ["premium"]}) + assert "IN" in str(statement).upper() + assert "OR" in str(statement).upper() + + def test_in_operator_empty_list(self, implementation_service: CrudRepositoryImplementationService): + parsed_query = _MetodQueryBuilder("find_by_status_in").parse_query() + statement = implementation_service._get_sql_statement(User, parsed_query, {"status": []}) + # Empty list should result in a condition that's always false + assert "IS NULL" in str(statement) or "= NULL" in str(statement) + + def test_in_operator_invalid_type(self, implementation_service: CrudRepositoryImplementationService): + parsed_query = _MetodQueryBuilder("find_by_status_in").parse_query() + with pytest.raises(ValueError, match="Parameter for IN operation must be a collection"): + implementation_service._get_sql_statement(User, parsed_query, {"status": "not_a_list"}) + + def test_in_operator_implementation(self, user_repository: UserRepository, implementation_service: CrudRepositoryImplementationService): + # Create test users + user1 = User(name="John", email="john@example.com", status="active", category="premium") + user2 = User(name="Jane", email="jane@example.com", status="pending", category="premium") + user3 = User(name="Bob", email="bob@example.com", status="active", category="basic") + user_repository.save(user1) + user_repository.save(user2) + user_repository.save(user3) - - - + # Implement the query + implementation_service._implemenmt_query(user_repository.__class__) + + # Test IN operator + active_users = user_repository.find_all_by_status_in(status=["active"]) + assert len(active_users) == 2 + assert all(user.status == "active" for user in active_users) + + # Test IN with AND + premium_active_users = user_repository.find_all_by_id_in_and_name(id=[user1.id, user2.id], name="John") + assert len(premium_active_users) == 1 + assert premium_active_users[0].name == "John" + + # Test IN with OR + active_or_premium = user_repository.find_all_by_status_in_or_category_in(status=["active"], category=["premium"]) + assert len(active_or_premium) == 3 # All users are either active or premium + def test_in_operator_empty_list_returns_no_results(self, user_repository: UserRepository, implementation_service: CrudRepositoryImplementationService): + user = User(name="John", email="john@example.com", status="active") + user_repository.save(user) + + implementation_service._implemenmt_query(user_repository.__class__) + + # Empty list should return no results + results = user_repository.find_all_by_status_in(status=[]) + assert len(results) == 0 \ No newline at end of file diff --git a/tests/test_method_query_builder.py b/tests/test_method_query_builder.py index 27436f1..840340a 100644 --- a/tests/test_method_query_builder.py +++ b/tests/test_method_query_builder.py @@ -5,7 +5,7 @@ class TestMetodQueryBuilder: @pytest.mark.parametrize( - "method_name, expected_raw_query_list, expected_is_one_result, expected_required_fields, expected_notations", + "method_name, expected_raw_query_list, expected_is_one_result, expected_required_fields, expected_notations, expected_field_operations", [ ( "get_by_name_and_age", @@ -13,6 +13,7 @@ class TestMetodQueryBuilder: True, ["name", "age"], ["_and_"], + {}, ), ( "find_by_name_or_age", @@ -20,6 +21,7 @@ class TestMetodQueryBuilder: True, ["name", "age"], ["_or_"], + {}, ), ( "find_all_by_name_and_age", @@ -27,6 +29,7 @@ class TestMetodQueryBuilder: False, ["name", "age"], ["_and_"], + {}, ), ( "get_all_by_city_or_country", @@ -34,6 +37,39 @@ class TestMetodQueryBuilder: False, ["city", "country"], ["_or_"], + {}, + ), + ( + "find_by_status_in", + ["status_in"], + True, + ["status"], + [], + {"status": "in"}, + ), + ( + "find_all_by_id_in", + ["id_in"], + False, + ["id"], + [], + {"id": "in"}, + ), + ( + "find_by_status_in_and_name", + ["status_in", "_and_", "name"], + True, + ["status", "name"], + ["_and_"], + {"status": "in"}, + ), + ( + "find_by_status_in_or_category_in", + ["status_in", "_or_", "category_in"], + True, + ["status", "category"], + ["_or_"], + {"status": "in", "category": "in"}, ), ], ) @@ -44,6 +80,7 @@ def test_parse_query( expected_is_one_result, expected_required_fields, expected_notations, + expected_field_operations, ): builder = _MetodQueryBuilder(method_name) query = builder.parse_query() @@ -53,6 +90,7 @@ def test_parse_query( assert query.is_one_result == expected_is_one_result assert query.required_fields == expected_required_fields assert query.notations == expected_notations + assert query.field_operations == expected_field_operations def test_invalid_method_name(self): invalid_method_name = "invalid_method_name" From 3ba28d540d6a01901882572470d2c34867c4b5e5 Mon Sep 17 00:00:00 2001 From: William Chen Date: Fri, 18 Jul 2025 20:48:12 +0800 Subject: [PATCH 2/8] feat: Introduce comprehensive field operations support for dynamic queries - Added a new documentation file detailing supported field operations for dynamic query generation, including EQUALS, IN, GREATER_THAN, and others. - Enhanced `CrudRepositoryImplementationService` to handle various field operations, including validation and edge case management. - Updated `_Query` and `_MetodQueryBuilder` to incorporate field operation parsing and handling. - Implemented unit tests for field operations, ensuring correct functionality and validation for different query scenarios. - Improved method naming conventions and added examples for better clarity in usage. --- py_spring_model/docs/query_operators.md | 338 ++++++++++++++++++ .../crud_repository_implementation_service.py | 155 +++++--- .../method_query_builder.py | 94 ++++- tests/test_field_operations.py | 163 +++++++++ tests/test_method_query_builder.py | 50 ++- tests/test_query_modifying_operations.py | 11 +- 6 files changed, 754 insertions(+), 57 deletions(-) create mode 100644 py_spring_model/docs/query_operators.md create mode 100644 tests/test_field_operations.py diff --git a/py_spring_model/docs/query_operators.md b/py_spring_model/docs/query_operators.md new file mode 100644 index 0000000..1194e13 --- /dev/null +++ b/py_spring_model/docs/query_operators.md @@ -0,0 +1,338 @@ +# Field Operations Support in PySpringModel + +PySpringModel now supports multiple field operations for dynamic query generation, similar to Spring Data JPA. This allows you to find entities using various comparison operators and conditions. + +## Supported Field Operations + +| Operation | Suffix | Description | Example | +|-----------|--------|-------------|---------| +| `EQUALS` | (default) | Field equals value | `find_by_name` | +| `IN` | `_in` | Field in list of values | `find_by_status_in` | +| `GREATER_THAN` | `_gt` | Field greater than value | `find_by_age_gt` | +| `GREATER_EQUAL` | `_gte` | Field greater than or equal to value | `find_by_age_gte` | +| `LESS_THAN` | `_lt` | Field less than value | `find_by_age_lt` | +| `LESS_EQUAL` | `_lte` | Field less than or equal to value | `find_by_age_lte` | +| `LIKE` | `_like` | Field matches pattern | `find_by_name_like` | +| `NOT_EQUALS` | `_ne` | Field not equals value | `find_by_status_ne` | +| `NOT_IN` | `_not_in` | Field not in list of values | `find_by_category_not_in` | + +## Basic Usage + +### Method Naming Convention + +To use field operations, append the appropriate suffix to your field name in the method signature: + +```python +from py_spring_model import PySpringModel, Field, CrudRepository +from typing import List + +class User(PySpringModel, table=True): + id: int = Field(default=None, primary_key=True) + name: str + email: str + age: int + salary: float + status: str = Field(default="active") + category: str = Field(default="general") + +class UserRepository(CrudRepository[int, User]): + # IN operations + def find_all_by_status_in(self, status: List[str]) -> List[User]: ... + def find_all_by_id_in(self, id: List[int]) -> List[User]: ... + + # Comparison operations + def find_by_age_gt(self, age: int) -> User: ... + def find_all_by_age_gte(self, age: int) -> List[User]: ... + def find_by_age_lt(self, age: int) -> List[User]: ... + def find_by_age_lte(self, age: int) -> List[User]: ... + + # Pattern matching + def find_by_name_like(self, name: str) -> List[User]: ... + + # Negation operations + def find_by_status_ne(self, status: str) -> List[User]: ... + def find_by_category_not_in(self, category: List[str]) -> List[User]: ... +``` + +### Usage Examples + +```python +# Create repository instance +user_repo = UserRepository() + +# IN operations +active_or_pending_users = user_repo.find_all_by_status_in( + status=["active", "pending"] +) + +users_by_ids = user_repo.find_all_by_id_in(id=[1, 2, 3, 5]) + +# Comparison operations +adults = user_repo.find_all_by_age_gte(age=18) +young_users = user_repo.find_by_age_lt(age=25) +senior_users = user_repo.find_by_age_gte(age=65) + +# Pattern matching +johns = user_repo.find_by_name_like(name="%John%") + +# Negation operations +non_active_users = user_repo.find_by_status_ne(status="active") +non_employees = user_repo.find_by_category_not_in( + category=["employee", "intern"] +) +``` + +## Combining Operations with Logical Operators + +### AND Combinations + +```python +class UserRepository(CrudRepository[int, User]): + # Find users by age > 30 AND status IN list + def find_by_age_gt_and_status_in( + self, + age: int, + status: List[str] + ) -> List[User]: ... + + # Find users by salary >= amount AND category IN list + def find_by_salary_gte_and_category_in( + self, + salary: float, + category: List[str] + ) -> List[User]: ... + +# Usage +senior_active_users = user_repo.find_by_age_gt_and_status_in( + age=30, + status=["active", "pending"] +) + +high_paid_executives = user_repo.find_by_salary_gte_and_category_in( + salary=100000.0, + category=["executive", "director"] +) +``` + +### OR Combinations + +```python +class UserRepository(CrudRepository[int, User]): + # Find users by age >= min_age OR category IN list + def find_by_age_gte_or_category_in( + self, + age: int, + category: List[str] + ) -> List[User]: ... + + # Find users by status NE value OR salary >= amount + def find_by_status_ne_or_salary_gte( + self, + status: str, + salary: float + ) -> List[User]: ... + +# Usage +experienced_or_executives = user_repo.find_by_age_gte_or_category_in( + age=40, + category=["executive", "manager"] +) + +non_active_or_high_paid = user_repo.find_by_status_ne_or_salary_gte( + status="active", + salary=80000.0 +) +``` + +## Complex Combinations + +```python +class UserRepository(CrudRepository[int, User]): + # Multiple operations with AND/OR + def find_by_age_gt_and_status_in_or_category_in( + self, + age: int, + status: List[str], + category: List[str] + ) -> List[User]: ... + +# Usage +target_users = user_repo.find_by_age_gt_and_status_in_or_category_in( + age=25, + status=["active"], + category=["premium", "vip"] +) +``` + +## Edge Cases and Best Practices + +### Empty List Handling + +When you pass an empty list to IN/NOT IN operations: + +```python +# IN with empty list returns no results +users = user_repo.find_all_by_status_in(status=[]) +assert len(users) == 0 + +# NOT IN with empty list returns all results +users = user_repo.find_by_category_not_in(category=[]) +assert len(users) == total_user_count +``` + +### Type Validation + +Operations have specific type requirements: + +```python +# ✅ Valid - IN operations require collections +users = user_repo.find_all_by_status_in(status=["active", "pending"]) +users = user_repo.find_all_by_status_in(status=("active", "pending")) +users = user_repo.find_all_by_status_in(status={"active", "pending"}) + +# ❌ Invalid - will raise ValueError +users = user_repo.find_all_by_status_in(status="active") # TypeError + +# ✅ Valid - Comparison operations work with appropriate types +users = user_repo.find_by_age_gt(age=25) +users = user_repo.find_by_salary_gte(salary=50000.0) + +# ✅ Valid - LIKE operations work with strings +users = user_repo.find_by_name_like(name="%John%") +``` + +### Method Naming Rules + +- Use `find_by_` prefix for methods that return single objects +- Use `find_all_by_` prefix for methods that return lists +- Append operation suffix to field name +- Combine with `_and_` and `_or_` for complex queries +- Field names should match your model attributes exactly + +## Generated SQL Examples + +The operations generate SQL similar to: + +```sql +-- find_all_by_status_in(status=["active", "pending"]) +SELECT user.id, user.name, user.email, user.age, user.salary, user.status, user.category +FROM user +WHERE user.status IN ('active', 'pending') + +-- find_by_age_gt_and_status_in(age=30, status=["active"]) +SELECT user.id, user.name, user.email, user.age, user.salary, user.status, user.category +FROM user +WHERE user.age > 30 AND user.status IN ('active') + +-- find_by_name_like(name="%John%") +SELECT user.id, user.name, user.email, user.age, user.salary, user.status, user.category +FROM user +WHERE user.name LIKE '%John%' + +-- find_by_salary_gte_or_category_in(salary=80000.0, category=["executive"]) +SELECT user.id, user.name, user.email, user.age, user.salary, user.status, user.category +FROM user +WHERE user.salary >= 80000.0 OR user.category IN ('executive') +``` + +## Comparison with Spring Data JPA + +| Spring Data JPA | PySpringModel | +|-----------------|---------------| +| `findByAgeGreaterThan(int age)` | `find_by_age_gt(self, age: int)` | +| `findByAgeGreaterThanEqual(int age)` | `find_by_age_gte(self, age: int)` | +| `findByAgeLessThan(int age)` | `find_by_age_lt(self, age: int)` | +| `findByAgeLessThanEqual(int age)` | `find_by_age_lte(self, age: int)` | +| `findByNameLike(String name)` | `find_by_name_like(self, name: str)` | +| `findByStatusNot(String status)` | `find_by_status_ne(self, status: str)` | +| `findByStatusIn(List statuses)` | `find_by_status_in(self, status: List[str])` | +| `findByCategoryNotIn(List categories)` | `find_by_category_not_in(self, category: List[str])` | +| `findByAgeGreaterThanAndStatusIn(int age, List statuses)` | `find_by_age_gt_and_status_in(self, age: int, status: List[str])` | + +## Complete Example + +```python +from py_spring_model import PySpringModel, Field, CrudRepository +from typing import List, Optional +from sqlalchemy import create_engine +from sqlmodel import SQLModel + +# Define the model +class Product(PySpringModel, table=True): + id: int = Field(default=None, primary_key=True) + name: str + category: str + price: float + status: str = Field(default="active") + stock: int = Field(default=0) + +# Define the repository +class ProductRepository(CrudRepository[int, Product]): + # Single field operations + def find_by_price_gt(self, price: float) -> List[Product]: ... + def find_by_price_gte(self, price: float) -> List[Product]: ... + def find_by_stock_lt(self, stock: int) -> List[Product]: ... + def find_by_name_like(self, name: str) -> List[Product]: ... + def find_by_status_ne(self, status: str) -> List[Product]: ... + def find_by_category_in(self, category: List[str]) -> List[Product]: ... + def find_by_category_not_in(self, category: List[str]) -> List[Product]: ... + + # Combined operations + def find_by_price_gte_and_status_in( + self, + price: float, + status: List[str] + ) -> List[Product]: ... + def find_by_stock_lt_or_category_in( + self, + stock: int, + category: List[str] + ) -> List[Product]: ... + +# Setup database +engine = create_engine("sqlite:///:memory:") +PySpringModel._engine = engine +SQLModel.metadata.create_all(engine) + +# Create repository and add data +repo = ProductRepository() +products = [ + Product(name="Laptop", category="electronics", price=999.99, status="active", stock=10), + Product(name="Phone", category="electronics", price=699.99, status="active", stock=5), + Product(name="Book", category="books", price=19.99, status="inactive", stock=0), + Product(name="Tablet", category="electronics", price=399.99, status="pending", stock=2), +] + +for product in products: + repo.save(product) + +# Query examples +expensive_products = repo.find_by_price_gt(price=500.0) +print(f"Found {len(expensive_products)} expensive products") + +in_stock_products = repo.find_by_stock_gt(stock=0) +print(f"Found {len(in_stock_products)} products in stock") + +electronics_in_stock = repo.find_by_category_in_and_stock_gt( + category=["electronics"], + stock=0 +) +print(f"Found {len(electronics_in_stock)} electronics products in stock") + +# Pattern matching +laptops = repo.find_by_name_like(name="%Laptop%") +print(f"Found {len(laptops)} products with 'Laptop' in name") + +# Negation +non_active_products = repo.find_by_status_ne(status="active") +print(f"Found {len(non_active_products)} non-active products") + +# Complex combination +target_products = repo.find_by_price_gte_and_status_in( + price=300.0, + status=["active", "pending"] +) +print(f"Found {len(target_products)} active/pending products over $300") +``` + +This implementation provides comprehensive field operation support similar to Spring Data JPA, with proper handling of edge cases, type validation, and complex query combinations. \ No newline at end of file diff --git a/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py b/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py index df6af50..4b7af8a 100644 --- a/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py +++ b/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py @@ -12,7 +12,6 @@ from loguru import logger from py_spring_core import Component from pydantic import BaseModel -from enum import Enum from sqlalchemy import ColumnElement from sqlalchemy.sql import and_, or_ from sqlmodel import select @@ -24,15 +23,13 @@ from py_spring_model.py_spring_model_rest.service.curd_repository_implementation_service.method_query_builder import ( _MetodQueryBuilder, _Query, + FieldOperation, + ConditionNotation ) PySpringModelT = TypeVar("PySpringModelT", bound=PySpringModel) -class ConditionNotation(str,Enum): - AND = "_and_" - OR = "_or_" - class CrudRepositoryImplementationService(Component): """ The `CrudRepositoryImplementationService` class is responsible for implementing the query logic for the `CrudRepository` inheritors. @@ -133,47 +130,125 @@ def _get_sql_statement( parsed_query: _Query, params: dict[str, Any], ) -> SelectOfScalar[PySpringModelT]: - filter_condition_stack: list[ColumnElement[bool]] = [] + """Build SQL statement from parsed query and parameters.""" + filter_conditions = self._build_filter_conditions(model_type, parsed_query, params) + combined_condition = self._combine_conditions_with_notations(filter_conditions, [ConditionNotation(notation) for notation in parsed_query.notations]) + + query = select(model_type) + if combined_condition is not None: + query = query.where(combined_condition) + return query + + def _build_filter_conditions( + self, + model_type: Type[PySpringModelT], + parsed_query: _Query, + params: dict[str, Any], + ) -> list[ColumnElement[bool]]: + """Build individual filter conditions for each field.""" + filter_conditions = [] for field in parsed_query.required_fields: column = getattr(model_type, field) param_value = params[field] - - # Check if this field has a specific operation - if field in parsed_query.field_operations: - operation = parsed_query.field_operations[field] - if operation == "in": - # Handle IN operation - if not isinstance(param_value, (list, tuple, set)): - raise ValueError(f"Parameter for IN operation must be a collection (list, tuple, or set), got {type(param_value)}") - - # Handle empty list case - return no results - if len(param_value) == 0: - # Create a condition that's always false - filter_condition_stack.append(column == None) - continue - - filter_condition_stack.append(column.in_(param_value)) - else: - # Default to equality for unknown operations - filter_condition_stack.append(column == param_value) - else: - # Default equality operation - filter_condition_stack.append(column == param_value) + condition = self._create_field_condition(column, field, param_value, parsed_query.field_operations) + filter_conditions.append(condition) - for notation in parsed_query.notations: - right_condition = filter_condition_stack.pop(0) - left_condition = filter_condition_stack.pop(0) - match notation: - case ConditionNotation.AND: - filter_condition_stack.append(and_(left_condition, right_condition)) - case ConditionNotation.OR: - filter_condition_stack.append(or_(left_condition, right_condition)) + return filter_conditions - query = select(model_type) - if len(filter_condition_stack) > 0: - query = query.where(filter_condition_stack.pop()) - return query + def _create_field_condition( + self, + column: Any, + field: str, + param_value: Any, + field_operations: dict[str, FieldOperation], + ) -> ColumnElement[bool]: + """Create a condition for a single field based on its operation type.""" + if field not in field_operations: + return column == param_value + + operation = field_operations[field] + + match operation: + case FieldOperation.IN: + return self._create_in_condition(column, param_value) + case FieldOperation.GREATER_THAN: + return column > param_value + case FieldOperation.GREATER_EQUAL: + return column >= param_value + case FieldOperation.LESS_THAN: + return column < param_value + case FieldOperation.LESS_EQUAL: + return column <= param_value + case FieldOperation.LIKE: + return column.like(param_value) + case FieldOperation.NOT_EQUALS: + return column != param_value + case FieldOperation.NOT_IN: + return self._create_not_in_condition(column, param_value) + case _: + # Default to equality for unknown operations + return column == param_value + + def _create_in_condition(self, column: Any, param_value: Any) -> ColumnElement[bool]: + """Create an IN condition with proper validation and edge case handling.""" + if not isinstance(param_value, (list, tuple, set)): + raise ValueError( + f"Parameter for IN operation must be a collection (list, tuple, or set), got {type(param_value)}" + ) + + # Handle empty list case - return a condition that's always false + if len(param_value) == 0: + return column == None + + return column.in_(param_value) + + def _create_not_in_condition(self, column: Any, param_value: Any) -> ColumnElement[bool]: + """Create a NOT IN condition with proper validation and edge case handling.""" + if not isinstance(param_value, (list, tuple, set)): + raise ValueError( + f"Parameter for NOT IN operation must be a collection (list, tuple, or set), got {type(param_value)}" + ) + + # Handle empty list case - return a condition that's always true + if len(param_value) == 0: + return column != None + + return ~column.in_(param_value) + + def _combine_conditions_with_notations( + self, + filter_conditions: list[ColumnElement[bool]], + notations: list[ConditionNotation], + ) -> ColumnElement[bool] | None: + """Combine filter conditions using logical operators (AND/OR).""" + if not filter_conditions: + return None + + # Use stack-based approach to maintain original order + condition_stack = filter_conditions.copy() + + for notation in notations: + if len(condition_stack) >= 2: + right_condition = condition_stack.pop(0) + left_condition = condition_stack.pop(0) + combined = self._apply_logical_operator(left_condition, right_condition, notation) + condition_stack.append(combined) + + return condition_stack[0] if condition_stack else None + + def _apply_logical_operator( + self, + left_condition: ColumnElement[bool], + right_condition: ColumnElement[bool], + notation: ConditionNotation, + ) -> ColumnElement[bool]: + """Apply logical operator (AND/OR) to two conditions.""" + match notation: + case ConditionNotation.AND: + return and_(left_condition, right_condition) + case ConditionNotation.OR: + return or_(left_condition, right_condition) @Transactional def _session_execute(self, statement: SelectOfScalar, is_one_result: bool) -> Any: diff --git a/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/method_query_builder.py b/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/method_query_builder.py index 9db70f8..1bbf78d 100644 --- a/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/method_query_builder.py +++ b/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/method_query_builder.py @@ -1,9 +1,30 @@ import re from typing import Dict, Any +from enum import Enum from pydantic import BaseModel +class ConditionNotation(str,Enum): + AND = "_and_" + OR = "_or_" + +class FieldOperation(str, Enum): + """ + Enumeration of supported field operations for dynamic query generation. + These operations define how a field should be queried in the database. + """ + EQUALS = "equals" # Default operation: field = value + IN = "in" # field IN (value1, value2, ...) + GREATER_THAN = "gt" # field > value + GREATER_EQUAL = "gte" # field >= value + LESS_THAN = "lt" # field < value + LESS_EQUAL = "lte" # field <= value + LIKE = "like" # field LIKE pattern + NOT_EQUALS = "ne" # field != value + NOT_IN = "not_in" # field NOT IN (value1, value2, ...) + + class _Query(BaseModel): """ A data model representing a query with the following fields: @@ -15,9 +36,9 @@ class _Query(BaseModel): raw_query_list: list[str] is_one_result: bool - notations: list[str] + notations: list[ConditionNotation] required_fields: list[str] - field_operations: Dict[str, str] = {} + field_operations: Dict[str, FieldOperation] = {} class _MetodQueryBuilder: @@ -39,7 +60,8 @@ def parse_query(self) -> _Query: Example: - 'find_by_name_and_age' -> Query(raw_query_list=['name', '_and_', 'age'], is_one_result=True, required_fields=['name', 'age']) - 'find_all_by_name_or_age' -> Query(raw_query_list=['name', '_or_', 'age'], is_one_result=False, required_fields=['name', 'age']) - - 'find_by_status_in' -> Query(raw_query_list=['status'], is_one_result=True, required_fields=['status'], field_operations={'status': 'in'}) + - 'find_by_status_in' -> Query(raw_query_list=['status'], is_one_result=True, required_fields=['status'], field_operations={'status': FieldOperation.IN}) + - 'find_by_age_gt' -> Query(raw_query_list=['age'], is_one_result=True, required_fields=['age'], field_operations={'age': FieldOperation.GREATER_THAN}) """ is_one = False pattern = "" @@ -71,11 +93,12 @@ def parse_query(self) -> _Query: for field in raw_query_list: if field not in ["_and_", "_or_"]: - # Check for IN operation - if field.endswith("_in"): - base_field = field[:-3] # Remove "_in" suffix + # Check for field operations + operation = self._detect_field_operation(field) + if operation: + base_field = self._extract_base_field(field, operation) required_fields.append(base_field) - field_operations[base_field] = "in" + field_operations[base_field] = operation else: required_fields.append(field) @@ -84,7 +107,62 @@ def parse_query(self) -> _Query: is_one_result=is_one, required_fields=required_fields, notations=[ - notation for notation in raw_query_list if notation in ["_and_", "_or_"] + ConditionNotation(notation) for notation in raw_query_list if notation in ["_and_", "_or_"] ], field_operations=field_operations, ) + + def _detect_field_operation(self, field: str) -> FieldOperation | None: + """ + Detect field operation based on field suffix. + + Args: + field: The field name with potential operation suffix + + Returns: + FieldOperation if detected, None if no operation suffix found + """ + # Check for _not_in first (longer suffix) to avoid matching _in + if field.endswith("_not_in"): + return FieldOperation.NOT_IN + + operation_suffixes = { + "_in": FieldOperation.IN, + "_gt": FieldOperation.GREATER_THAN, + "_gte": FieldOperation.GREATER_EQUAL, + "_lt": FieldOperation.LESS_THAN, + "_lte": FieldOperation.LESS_EQUAL, + "_like": FieldOperation.LIKE, + "_ne": FieldOperation.NOT_EQUALS, + } + + for suffix, operation in operation_suffixes.items(): + if field.endswith(suffix): + return operation + + return None + + def _extract_base_field(self, field: str, operation: FieldOperation) -> str: + """ + Extract the base field name by removing the operation suffix. + + Args: + field: The field name with operation suffix + operation: The detected field operation + + Returns: + The base field name without operation suffix + """ + operation_suffixes = { + FieldOperation.IN: "_in", + FieldOperation.GREATER_THAN: "_gt", + FieldOperation.GREATER_EQUAL: "_gte", + FieldOperation.LESS_THAN: "_lt", + FieldOperation.LESS_EQUAL: "_lte", + FieldOperation.LIKE: "_like", + FieldOperation.NOT_EQUALS: "_ne", + FieldOperation.NOT_IN: "_not_in", + } + + suffix = operation_suffixes[operation] + return field[:-len(suffix)] diff --git a/tests/test_field_operations.py b/tests/test_field_operations.py new file mode 100644 index 0000000..036a648 --- /dev/null +++ b/tests/test_field_operations.py @@ -0,0 +1,163 @@ +import pytest +from sqlalchemy import create_engine +from sqlmodel import SQLModel + +from py_spring_model import PySpringModel, Field, CrudRepository +from py_spring_model.py_spring_model_rest.service.curd_repository_implementation_service.crud_repository_implementation_service import CrudRepositoryImplementationService +from py_spring_model.py_spring_model_rest.service.curd_repository_implementation_service.method_query_builder import FieldOperation + + +class TestUser(PySpringModel, table=True): + id: int = Field(default=None, primary_key=True) + name: str + email: str + age: int + salary: float = Field(default=0.0) + status: str = Field(default="active") + category: str = Field(default="general") + + +class TestUserRepository(CrudRepository[int, TestUser]): + # Test different field operations + def find_all_by_age_gt(self, age: int) -> list[TestUser]: ... + def find_all_by_age_gte(self, age: int) -> list[TestUser]: ... + def find_all_by_age_lt(self, age: int) -> list[TestUser]: ... + def find_all_by_age_lte(self, age: int) -> list[TestUser]: ... + def find_all_by_name_like(self, name: str) -> list[TestUser]: ... + def find_all_by_status_ne(self, status: str) -> list[TestUser]: ... + def find_all_by_status_in(self, status: list[str]) -> list[TestUser]: ... + def find_all_by_category_not_in(self, category: list[str]) -> list[TestUser]: ... + + # Test combinations + def find_all_by_age_gt_and_status_in(self, age: int, status: list[str]) -> list[TestUser]: ... + def find_all_by_salary_gte_or_category_in(self, salary: float, category: list[str]) -> list[TestUser]: ... + + +class TestFieldOperations: + def setup_method(self): + self.engine = create_engine("sqlite:///:memory:", echo=False) + PySpringModel._engine = self.engine + SQLModel.metadata.create_all(self.engine) + + self.repository = TestUserRepository() + self.implementation_service = CrudRepositoryImplementationService() + + # Create test data + self.test_users = [ + TestUser(name="John Doe", email="john@example.com", age=25, salary=50000.0, status="active", category="employee"), + TestUser(name="Jane Smith", email="jane@example.com", age=30, salary=60000.0, status="active", category="manager"), + TestUser(name="Bob Johnson", email="bob@example.com", age=35, salary=70000.0, status="inactive", category="employee"), + TestUser(name="Alice Brown", email="alice@example.com", age=40, salary=80000.0, status="pending", category="executive"), + TestUser(name="Charlie Wilson", email="charlie@example.com", age=45, salary=90000.0, status="active", category="executive"), + ] + + for user in self.test_users: + self.repository.save(user) + + # Implement the queries + self.implementation_service._implemenmt_query(self.repository.__class__) + + def test_greater_than_operation(self): + """Test greater than operation""" + users = self.repository.find_all_by_age_gte(age=30) + assert len(users) == 4 # 30, 35, 40, 45 + + # Verify all users are >= 30 + for user in users: + assert user.age >= 30 + + def test_less_than_operation(self): + """Test less than operation""" + users = self.repository.find_all_by_age_lt(age=35) + assert len(users) == 2 # 25, 30 + + # Verify all users are < 35 + for user in users: + assert user.age < 35 + + def test_less_equal_operation(self): + """Test less than or equal operation""" + users = self.repository.find_all_by_age_lte(age=30) + assert len(users) == 2 # 25, 30 + + # Verify all users are <= 30 + for user in users: + assert user.age <= 30 + + def test_like_operation(self): + """Test LIKE operation""" + users = self.repository.find_all_by_name_like(name="%ohn%") + assert len(users) == 2 # John Doe, Bob Johnson + + # Verify all users have "ohn" in their name + for user in users: + assert "ohn" in user.name.lower() + + def test_not_equals_operation(self): + """Test not equals operation""" + users = self.repository.find_all_by_status_ne(status="active") + assert len(users) == 2 # inactive, pending + + # Verify all users are not "active" + for user in users: + assert user.status != "active" + + def test_not_in_operation(self): + """Test NOT IN operation""" + users = self.repository.find_all_by_category_not_in(category=["employee", "manager"]) + assert len(users) == 2 # executive users + + # Verify all users are not in the excluded categories + for user in users: + assert user.category not in ["employee", "manager"] + + def test_combination_gt_and_in(self): + """Test combination of greater than and IN operations""" + users = self.repository.find_all_by_age_gt_and_status_in(age=30, status=["active", "pending"]) + assert len(users) == 2 # 40 (pending), 45 (active) - only users > 30 with active/pending status + + # Verify all users are > 30 and have active/pending status + for user in users: + assert user.age > 30 + assert user.status in ["active", "pending"] + + def test_combination_gte_or_in(self): + """Test combination of greater equal and IN operations with OR""" + users = self.repository.find_all_by_salary_gte_or_category_in(salary=70000.0, category=["executive"]) + assert len(users) == 3 # 70000+, 80000+, 90000+ - all users with salary >= 70000 + + # Verify all users either have salary >= 70000 OR are executives + for user in users: + assert user.salary >= 70000.0 or user.category == "executive" + + def test_empty_list_in_operation(self): + """Test IN operation with empty list returns no results""" + users = self.repository.find_all_by_status_in(status=[]) + assert len(users) == 0 + + def test_empty_list_not_in_operation(self): + """Test NOT IN operation with empty list returns all results""" + users = self.repository.find_all_by_category_not_in(category=[]) + assert len(users) == 5 # All users + + def test_invalid_type_for_in_operation(self): + """Test that IN operation requires collection type""" + with pytest.raises(ValueError, match=".*collection.*"): + self.repository.find_all_by_status_in(status="active") # type: ignore # Should be list + + def test_invalid_type_for_not_in_operation(self): + """Test that NOT IN operation requires collection type""" + with pytest.raises(ValueError, match=".*collection.*"): + self.repository.find_all_by_category_not_in(category="employee") # type: ignore # Should be list + + def test_field_operation_enum_values(self): + """Test that FieldOperation enum has correct values""" + assert FieldOperation.IN == "in" + assert FieldOperation.GREATER_THAN == "gt" + assert FieldOperation.GREATER_EQUAL == "gte" + assert FieldOperation.LESS_THAN == "lt" + assert FieldOperation.LESS_EQUAL == "lte" + assert FieldOperation.LIKE == "like" + assert FieldOperation.NOT_EQUALS == "ne" + assert FieldOperation.NOT_IN == "not_in" + assert FieldOperation.EQUALS == "equals" \ No newline at end of file diff --git a/tests/test_method_query_builder.py b/tests/test_method_query_builder.py index 840340a..309bc62 100644 --- a/tests/test_method_query_builder.py +++ b/tests/test_method_query_builder.py @@ -1,6 +1,6 @@ import pytest -from py_spring_model.py_spring_model_rest.service.curd_repository_implementation_service.method_query_builder import _MetodQueryBuilder, _Query +from py_spring_model.py_spring_model_rest.service.curd_repository_implementation_service.method_query_builder import _MetodQueryBuilder, _Query, FieldOperation class TestMetodQueryBuilder: @@ -45,7 +45,7 @@ class TestMetodQueryBuilder: True, ["status"], [], - {"status": "in"}, + {"status": FieldOperation.IN}, ), ( "find_all_by_id_in", @@ -53,7 +53,7 @@ class TestMetodQueryBuilder: False, ["id"], [], - {"id": "in"}, + {"id": FieldOperation.IN}, ), ( "find_by_status_in_and_name", @@ -61,7 +61,7 @@ class TestMetodQueryBuilder: True, ["status", "name"], ["_and_"], - {"status": "in"}, + {"status": FieldOperation.IN}, ), ( "find_by_status_in_or_category_in", @@ -69,7 +69,47 @@ class TestMetodQueryBuilder: True, ["status", "category"], ["_or_"], - {"status": "in", "category": "in"}, + {"status": FieldOperation.IN, "category": FieldOperation.IN}, + ), + ( + "find_by_age_gt", + ["age_gt"], + True, + ["age"], + [], + {"age": FieldOperation.GREATER_THAN}, + ), + ( + "find_all_by_price_gte", + ["price_gte"], + False, + ["price"], + [], + {"price": FieldOperation.GREATER_EQUAL}, + ), + ( + "find_by_name_like", + ["name_like"], + True, + ["name"], + [], + {"name": FieldOperation.LIKE}, + ), + ( + "find_by_status_ne", + ["status_ne"], + True, + ["status"], + [], + {"status": FieldOperation.NOT_EQUALS}, + ), + ( + "find_by_age_gt_and_status_in", + ["age_gt", "_and_", "status_in"], + True, + ["age", "status"], + ["_and_"], + {"age": FieldOperation.GREATER_THAN, "status": FieldOperation.IN}, ), ], ) diff --git a/tests/test_query_modifying_operations.py b/tests/test_query_modifying_operations.py index 40416c2..bbbf721 100644 --- a/tests/test_query_modifying_operations.py +++ b/tests/test_query_modifying_operations.py @@ -17,18 +17,21 @@ class TestUser(PySpringModel, table=True): name: str email: str age: int = Field(default=0) + salary: float = Field(default=0.0) + status: str = Field(default="active") + category: str = Field(default="general") class TestUserRepository(CrudRepository[int, TestUser]): """Test repository with Query decorators for modifying operations""" - @Query("INSERT INTO testuser (name, email, age) VALUES ({name}, {email}, {age}) RETURNING *", is_modifying=True) + @Query("INSERT INTO testuser (name, email, age, salary, status, category) VALUES ({name}, {email}, {age}, 0.0, 'active', 'general') RETURNING *", is_modifying=True) def insert_user_with_commit(self, name: str, email: str, age: int) -> TestUser: """INSERT operation that should commit changes""" ... - @Query("INSERT INTO testuser (name, email, age) VALUES ({name}, {email}, {age})", is_modifying=False) + @Query("INSERT INTO testuser (name, email, age, salary, status, category) VALUES ({name}, {email}, {age}, 0.0, 'active', 'general')", is_modifying=False) def insert_user_without_commit(self, name: str, email: str, age: int) -> TestUser: """INSERT operation that should NOT commit changes""" ... @@ -669,7 +672,7 @@ def test_real_database_batch_insert_with_commit_control(self): with PySpringModel.create_managed_session(should_commit=True) as session: for name, email, age in batch_data: session.execute(text( - f"INSERT INTO testuser (name, email, age) VALUES ('{name}', '{email}', {age})" + f"INSERT INTO testuser (name, email, age, salary, status, category) VALUES ('{name}', '{email}', {age}, 0.0, 'active', 'general')" )) # Verify all batch inserts persisted @@ -696,7 +699,7 @@ def test_real_database_batch_insert_with_commit_control(self): with PySpringModel.create_managed_session(should_commit=False) as session: for name, email, age in rollback_data: session.execute(text( - f"INSERT INTO testuser (name, email, age) VALUES ('{name}', '{email}', {age})" + f"INSERT INTO testuser (name, email, age, salary, status, category) VALUES ('{name}', '{email}', {age}, 0.0, 'active', 'general')" )) # Force rollback raise Exception("Intentional rollback") From 592291d8ca791d695ce1f94c4d35eb7aeb150d5e Mon Sep 17 00:00:00 2001 From: William Chen Date: Fri, 18 Jul 2025 21:34:03 +0800 Subject: [PATCH 3/8] refactor: Improve field operation detection in query builder - Refactored `_detect_field_operation` method in `_MetodQueryBuilder` to streamline the detection of field operations. - Ensured that the `_not_in` suffix is prioritized to prevent false positives when matching with `_in`. - Cleaned up unnecessary comments and improved code clarity for better maintainability. --- .../method_query_builder.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/method_query_builder.py b/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/method_query_builder.py index 1bbf78d..b442767 100644 --- a/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/method_query_builder.py +++ b/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/method_query_builder.py @@ -111,7 +111,6 @@ def parse_query(self) -> _Query: ], field_operations=field_operations, ) - def _detect_field_operation(self, field: str) -> FieldOperation | None: """ Detect field operation based on field suffix. @@ -122,11 +121,8 @@ def _detect_field_operation(self, field: str) -> FieldOperation | None: Returns: FieldOperation if detected, None if no operation suffix found """ - # Check for _not_in first (longer suffix) to avoid matching _in - if field.endswith("_not_in"): - return FieldOperation.NOT_IN - operation_suffixes = { + "_not_in": FieldOperation.NOT_IN, # _not_in should be check before _in for preventing false positive "_in": FieldOperation.IN, "_gt": FieldOperation.GREATER_THAN, "_gte": FieldOperation.GREATER_EQUAL, From dc27ebad86c7d65dbe6adcea454759e6639c7f51 Mon Sep 17 00:00:00 2001 From: William Chen Date: Fri, 18 Jul 2025 23:27:48 +0800 Subject: [PATCH 4/8] feat: Implement parameter to field mapping for improved API design - Added a new method `_create_parameter_field_mapping` to facilitate mapping between parameter names and field names, enhancing API readability. - Updated `create_implementation_wrapper` to utilize the new mapping method, ensuring correct handling of keyword arguments in queries. - Introduced comprehensive unit tests for the new mapping functionality, covering various scenarios including order independence, validation, and edge cases. --- .../crud_repository_implementation_service.py | 121 ++++++++++++++++-- ..._crud_repository_implementation_service.py | 64 ++++++++- 2 files changed, 171 insertions(+), 14 deletions(-) diff --git a/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py b/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py index 4b7af8a..3e94af7 100644 --- a/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py +++ b/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py @@ -93,33 +93,128 @@ def _implemenmt_query(self, repository_type: Type[CrudRepository]) -> None: if RETURN_KEY in copy_annotations: copy_annotations.pop(RETURN_KEY) - if len(copy_annotations) != len(query.required_fields) or set( - copy_annotations.keys() - ) != set(query.required_fields): + # Create parameter to field mapping for better API design + param_to_field_mapping = self._create_parameter_field_mapping( + list(copy_annotations.keys()), query.required_fields + ) + + if len(copy_annotations) != len(query.required_fields): raise ValueError( f"Invalid number of annotations. Expected {query.required_fields}, received {list(copy_annotations.keys())}." ) + # Create a wrapper for the current method and query - wrapped_method = self.create_implementation_wrapper(query, model_type, copy_annotations) + wrapped_method = self.create_implementation_wrapper(query, model_type, copy_annotations, param_to_field_mapping) logger.info( f"Binding method: {method} to {repository_type}, with query: {query}" ) setattr(repository_type, method, wrapped_method) - def create_implementation_wrapper(self, query: _Query, model_type: Type[PySpringModel], original_func_annotations: dict[str, Any]) -> Callable[..., Any]: + def _create_parameter_field_mapping(self, param_names: list[str], field_names: list[str]) -> dict[str, str]: + """ + Create a mapping between parameter names and field names. + This allows for more readable API design where parameter names can be plural + while still mapping to singular field names. + + The method validates that parameter names correspond to field names and provides + clear error messages for mismatches. + + Examples: + - param_names: ['names'], field_names: ['name'] -> {'names': 'name'} + - param_names: ['ages'], field_names: ['age'] -> {'ages': 'age'} + - param_names: ['name', 'age'], field_names: ['name', 'age'] -> {'name': 'name', 'age': 'age'} + """ + if len(param_names) != len(field_names): + raise ValueError( + f"Parameter count mismatch. Expected {len(field_names)} parameters for fields {field_names}, " + f"but got {len(param_names)} parameters: {param_names}" + ) + + mapping = {} + unmatched_params = [] + unmatched_fields = [] + + # Create a set of field names for efficient lookup + field_set = set(field_names) + + for param_name in param_names: + # Try exact match first + if param_name in field_set: + mapping[param_name] = param_name + continue + + # Try singular/plural variations + singular_match = None + plural_match = None + + # Check if param_name is plural and field_name is singular + if param_name.endswith('s') and len(param_name) > 1: + singular_candidate = param_name[:-1] + if singular_candidate in field_set: + singular_match = singular_candidate + + # Check if param_name is singular and field_name is plural + elif not param_name.endswith('s'): + plural_candidate = param_name + 's' + if plural_candidate in field_set: + plural_match = plural_candidate + + # Use the best match found + if singular_match: + mapping[param_name] = singular_match + elif plural_match: + mapping[param_name] = plural_match + else: + unmatched_params.append(param_name) + + # Check for unmatched fields + mapped_fields = set(mapping.values()) + for field_name in field_names: + if field_name not in mapped_fields: + unmatched_fields.append(field_name) + + # Report any mismatches + if unmatched_params or unmatched_fields: + error_msg = "Parameter to field mapping failed:\n" + if unmatched_params: + error_msg += f" Unmatched parameters: {unmatched_params}\n" + if unmatched_fields: + error_msg += f" Unmatched fields: {unmatched_fields}\n" + error_msg += f" Available fields: {field_names}\n" + error_msg += f" Provided parameters: {param_names}" + raise ValueError(error_msg) + + return mapping + + def create_implementation_wrapper(self, query: _Query, model_type: Type[PySpringModel], original_func_annotations: dict[str, Any], param_to_field_mapping: dict[str, str]) -> Callable[..., Any]: def wrapper(*args, **kwargs) -> Any: if len(query.required_fields) > 0: - # Check if all required fields are present in kwargs - if set(query.required_fields) != set(kwargs.keys()): + # Map parameter names to field names + field_kwargs = {} + for param_name, value in kwargs.items(): + if param_name in param_to_field_mapping: + field_name = param_to_field_mapping[param_name] + field_kwargs[field_name] = value + else: + # Fallback: use parameter name as field name + field_kwargs[param_name] = value + + # Check if all required fields are present + if set(query.required_fields) != set(field_kwargs.keys()): raise ValueError( - f"Invalid number of keyword arguments. Expected {query.required_fields}, received {kwargs}." + f"Invalid number of keyword arguments. Expected {query.required_fields}, received {list(kwargs.keys())}." ) - # Execute the query - sql_statement = self._get_sql_statement(model_type, query, kwargs) - result = self._session_execute(sql_statement, query.is_one_result) - logger.info(f"Executing query with params: {kwargs}") - return result + # Execute the query with mapped field names + sql_statement = self._get_sql_statement(model_type, query, field_kwargs) + result = self._session_execute(sql_statement, query.is_one_result) + logger.info(f"Executing query with params: {kwargs}") + return result + else: + # No required fields, execute without parameters + sql_statement = self._get_sql_statement(model_type, query, {}) + result = self._session_execute(sql_statement, query.is_one_result) + return result wrapper.__annotations__ = original_func_annotations return wrapper diff --git a/tests/test_crud_repository_implementation_service.py b/tests/test_crud_repository_implementation_service.py index c3aacbb..4297d75 100644 --- a/tests/test_crud_repository_implementation_service.py +++ b/tests/test_crud_repository_implementation_service.py @@ -172,4 +172,66 @@ def test_in_operator_empty_list_returns_no_results(self, user_repository: UserRe # Empty list should return no results results = user_repository.find_all_by_status_in(status=[]) - assert len(results) == 0 \ No newline at end of file + assert len(results) == 0 + + def test_parameter_field_mapping_order_independence(self, implementation_service: CrudRepositoryImplementationService): + """Test that parameter field mapping works regardless of parameter order""" + # Test case 1: Parameters in different order than fields + param_names = ['age', 'name'] # Different order + field_names = ['name', 'age'] # From method name + mapping = implementation_service._create_parameter_field_mapping(param_names, field_names) + assert mapping == {'age': 'age', 'name': 'name'} + + # Test case 2: Plural parameters mapping to singular fields + param_names = ['names', 'ages'] + field_names = ['name', 'age'] + mapping = implementation_service._create_parameter_field_mapping(param_names, field_names) + assert mapping == {'names': 'name', 'ages': 'age'} + + # Test case 3: Mixed singular and plural + param_names = ['name', 'ages'] + field_names = ['name', 'age'] + mapping = implementation_service._create_parameter_field_mapping(param_names, field_names) + assert mapping == {'name': 'name', 'ages': 'age'} + + def test_parameter_field_mapping_validation(self, implementation_service: CrudRepositoryImplementationService): + """Test that parameter field mapping properly validates and reports errors""" + # Test case 1: Parameter count mismatch + with pytest.raises(ValueError, match="Parameter count mismatch"): + implementation_service._create_parameter_field_mapping(['name'], ['name', 'age']) + + # Test case 2: Unmatched parameters + with pytest.raises(ValueError, match="Unmatched parameters"): + implementation_service._create_parameter_field_mapping(['invalid_param'], ['name']) + + # Test case 3: Unmatched fields (when we have more fields than parameters) + with pytest.raises(ValueError, match="Parameter count mismatch"): + implementation_service._create_parameter_field_mapping(['name'], ['name', 'age']) + + # Test case 4: Unmatched fields (when we have more parameters than fields) + with pytest.raises(ValueError, match="Parameter count mismatch"): + implementation_service._create_parameter_field_mapping(['name', 'age'], ['name']) + + # Test case 5: Ambiguous plural mapping (same count but no match) + with pytest.raises(ValueError, match="Unmatched parameters"): + implementation_service._create_parameter_field_mapping(['statuses'], ['status']) + + def test_parameter_field_mapping_edge_cases(self, implementation_service: CrudRepositoryImplementationService): + """Test edge cases in parameter field mapping""" + # Test case 1: Single character parameter ending with 's' + param_names = ['s'] # This should not be treated as plural + field_names = ['s'] + mapping = implementation_service._create_parameter_field_mapping(param_names, field_names) + assert mapping == {'s': 's'} + + # Test case 2: Parameter ending with 's' but not plural + param_names = ['status'] # 'status' is already singular + field_names = ['status'] + mapping = implementation_service._create_parameter_field_mapping(param_names, field_names) + assert mapping == {'status': 'status'} + + # Test case 3: Exact matches take precedence + param_names = ['names', 'name'] + field_names = ['name', 'names'] + mapping = implementation_service._create_parameter_field_mapping(param_names, field_names) + assert mapping == {'names': 'names', 'name': 'name'} \ No newline at end of file From 8f77245deef22fe15bbcfacab67ceb4c366f5cd7 Mon Sep 17 00:00:00 2001 From: William Chen Date: Fri, 18 Jul 2025 23:39:00 +0800 Subject: [PATCH 5/8] refactor: Enhance parameter field mapping logic for improved validation and support - Refined the `_create_parameter_field_mapping` method to support exact matching and plural-to-singular mapping, improving API design clarity. - Updated error handling to provide clearer messages for unmatched parameters and added rules to prevent ambiguity in parameter names. - Enhanced unit tests to cover various mapping scenarios, including order independence, validation, and edge cases, ensuring robust functionality. --- .../crud_repository_implementation_service.py | 93 ++++++++----------- ..._crud_repository_implementation_service.py | 69 +++++++------- 2 files changed, 74 insertions(+), 88 deletions(-) diff --git a/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py b/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py index 3e94af7..6a51980 100644 --- a/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py +++ b/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py @@ -113,26 +113,23 @@ def _implemenmt_query(self, repository_type: Type[CrudRepository]) -> None: def _create_parameter_field_mapping(self, param_names: list[str], field_names: list[str]) -> dict[str, str]: """ Create a mapping between parameter names and field names. - This allows for more readable API design where parameter names can be plural - while still mapping to singular field names. - The method validates that parameter names correspond to field names and provides - clear error messages for mismatches. + Supports exact matching and common plural-to-singular mapping: + - Exact match: 'name' -> 'name' + - Plural to singular: 'names' -> 'name', 'ages' -> 'age' + + Rules: + 1. Parameter names must match field names exactly, OR + 2. Parameter names can be plural forms of field names (add 's') + 3. No ambiguity allowed (e.g., can't have both 'name' and 'names' as fields) Examples: - - param_names: ['names'], field_names: ['name'] -> {'names': 'name'} - - param_names: ['ages'], field_names: ['age'] -> {'ages': 'age'} - param_names: ['name', 'age'], field_names: ['name', 'age'] -> {'name': 'name', 'age': 'age'} + - param_names: ['names', 'ages'], field_names: ['name', 'age'] -> {'names': 'name', 'ages': 'age'} + - param_names: ['name', 'ages'], field_names: ['name', 'age'] -> {'name': 'name', 'ages': 'age'} """ - if len(param_names) != len(field_names): - raise ValueError( - f"Parameter count mismatch. Expected {len(field_names)} parameters for fields {field_names}, " - f"but got {len(param_names)} parameters: {param_names}" - ) - mapping = {} unmatched_params = [] - unmatched_fields = [] # Create a set of field names for efficient lookup field_set = set(field_names) @@ -143,45 +140,36 @@ def _create_parameter_field_mapping(self, param_names: list[str], field_names: l mapping[param_name] = param_name continue - # Try singular/plural variations - singular_match = None - plural_match = None - - # Check if param_name is plural and field_name is singular + # Try plural-to-singular mapping (only if param ends with 's' and is longer than 1 char) if param_name.endswith('s') and len(param_name) > 1: - singular_candidate = param_name[:-1] + # Handle special cases for words ending with 's' + if param_name.endswith('ies'): + # words ending with 'ies' -> 'y' (e.g., 'categories' -> 'category') + singular_candidate = param_name[:-3] + 'y' + elif param_name.endswith('ses'): + # words ending with 'ses' -> 's' (e.g., 'statuses' -> 'status') + singular_candidate = param_name[:-2] + else: + # regular plural: remove 's' + singular_candidate = param_name[:-1] + if singular_candidate in field_set: - singular_match = singular_candidate - - # Check if param_name is singular and field_name is plural - elif not param_name.endswith('s'): - plural_candidate = param_name + 's' - if plural_candidate in field_set: - plural_match = plural_candidate + # Check for ambiguity: make sure we don't have both singular and plural forms as fields + plural_candidate = singular_candidate + 's' + if plural_candidate not in field_set: + mapping[param_name] = singular_candidate + continue - # Use the best match found - if singular_match: - mapping[param_name] = singular_match - elif plural_match: - mapping[param_name] = plural_match - else: - unmatched_params.append(param_name) + # No match found + unmatched_params.append(param_name) - # Check for unmatched fields - mapped_fields = set(mapping.values()) - for field_name in field_names: - if field_name not in mapped_fields: - unmatched_fields.append(field_name) - - # Report any mismatches - if unmatched_params or unmatched_fields: + # Check if all parameters were mapped + if unmatched_params: error_msg = "Parameter to field mapping failed:\n" - if unmatched_params: - error_msg += f" Unmatched parameters: {unmatched_params}\n" - if unmatched_fields: - error_msg += f" Unmatched fields: {unmatched_fields}\n" + error_msg += f" Unmatched parameters: {unmatched_params}\n" error_msg += f" Available fields: {field_names}\n" - error_msg += f" Provided parameters: {param_names}" + error_msg += f" Provided parameters: {param_names}\n" + error_msg += " Note: Parameters must exactly match field names or be plural forms (add 's')" raise ValueError(error_msg) return mapping @@ -189,23 +177,16 @@ def _create_parameter_field_mapping(self, param_names: list[str], field_names: l def create_implementation_wrapper(self, query: _Query, model_type: Type[PySpringModel], original_func_annotations: dict[str, Any], param_to_field_mapping: dict[str, str]) -> Callable[..., Any]: def wrapper(*args, **kwargs) -> Any: if len(query.required_fields) > 0: - # Map parameter names to field names + # Simple mapping: parameter names must match field names exactly field_kwargs = {} for param_name, value in kwargs.items(): if param_name in param_to_field_mapping: field_name = param_to_field_mapping[param_name] field_kwargs[field_name] = value else: - # Fallback: use parameter name as field name - field_kwargs[param_name] = value - - # Check if all required fields are present - if set(query.required_fields) != set(field_kwargs.keys()): - raise ValueError( - f"Invalid number of keyword arguments. Expected {query.required_fields}, received {list(kwargs.keys())}." - ) + raise ValueError(f"Unknown parameter '{param_name}'. Expected parameters: {list(param_to_field_mapping.keys())}") - # Execute the query with mapped field names + # Execute the query sql_statement = self._get_sql_statement(model_type, query, field_kwargs) result = self._session_execute(sql_statement, query.is_one_result) logger.info(f"Executing query with params: {kwargs}") diff --git a/tests/test_crud_repository_implementation_service.py b/tests/test_crud_repository_implementation_service.py index 4297d75..f096288 100644 --- a/tests/test_crud_repository_implementation_service.py +++ b/tests/test_crud_repository_implementation_service.py @@ -174,21 +174,27 @@ def test_in_operator_empty_list_returns_no_results(self, user_repository: UserRe results = user_repository.find_all_by_status_in(status=[]) assert len(results) == 0 - def test_parameter_field_mapping_order_independence(self, implementation_service: CrudRepositoryImplementationService): - """Test that parameter field mapping works regardless of parameter order""" - # Test case 1: Parameters in different order than fields - param_names = ['age', 'name'] # Different order - field_names = ['name', 'age'] # From method name + def test_parameter_field_mapping_simple(self, implementation_service: CrudRepositoryImplementationService): + """Test that parameter field mapping works with exact name matching and plural support""" + # Test case 1: Exact match + param_names = ['name', 'age'] + field_names = ['name', 'age'] + mapping = implementation_service._create_parameter_field_mapping(param_names, field_names) + assert mapping == {'name': 'name', 'age': 'age'} + + # Test case 2: Different order but same names + param_names = ['age', 'name'] + field_names = ['name', 'age'] mapping = implementation_service._create_parameter_field_mapping(param_names, field_names) assert mapping == {'age': 'age', 'name': 'name'} - # Test case 2: Plural parameters mapping to singular fields + # Test case 3: Plural parameters mapping to singular fields param_names = ['names', 'ages'] field_names = ['name', 'age'] mapping = implementation_service._create_parameter_field_mapping(param_names, field_names) assert mapping == {'names': 'name', 'ages': 'age'} - # Test case 3: Mixed singular and plural + # Test case 4: Mixed singular and plural param_names = ['name', 'ages'] field_names = ['name', 'age'] mapping = implementation_service._create_parameter_field_mapping(param_names, field_names) @@ -196,42 +202,41 @@ def test_parameter_field_mapping_order_independence(self, implementation_service def test_parameter_field_mapping_validation(self, implementation_service: CrudRepositoryImplementationService): """Test that parameter field mapping properly validates and reports errors""" - # Test case 1: Parameter count mismatch - with pytest.raises(ValueError, match="Parameter count mismatch"): - implementation_service._create_parameter_field_mapping(['name'], ['name', 'age']) - - # Test case 2: Unmatched parameters + # Test case 1: Unmatched parameters (no exact match or plural form) with pytest.raises(ValueError, match="Unmatched parameters"): - implementation_service._create_parameter_field_mapping(['invalid_param'], ['name']) + implementation_service._create_parameter_field_mapping(['username'], ['name']) - # Test case 3: Unmatched fields (when we have more fields than parameters) - with pytest.raises(ValueError, match="Parameter count mismatch"): - implementation_service._create_parameter_field_mapping(['name'], ['name', 'age']) + # Test case 2: Parameter that can't be mapped to plural (ambiguous case) + # This should work because 'statuses' exists as an exact match + mapping = implementation_service._create_parameter_field_mapping(['statuses'], ['status', 'statuses']) + assert mapping == {'statuses': 'statuses'} - # Test case 4: Unmatched fields (when we have more parameters than fields) - with pytest.raises(ValueError, match="Parameter count mismatch"): - implementation_service._create_parameter_field_mapping(['name', 'age'], ['name']) - - # Test case 5: Ambiguous plural mapping (same count but no match) + # Test case 3: Single character ending with 's' (should not be treated as plural) with pytest.raises(ValueError, match="Unmatched parameters"): - implementation_service._create_parameter_field_mapping(['statuses'], ['status']) + implementation_service._create_parameter_field_mapping(['s'], ['name']) def test_parameter_field_mapping_edge_cases(self, implementation_service: CrudRepositoryImplementationService): """Test edge cases in parameter field mapping""" - # Test case 1: Single character parameter ending with 's' - param_names = ['s'] # This should not be treated as plural - field_names = ['s'] + # Test case 1: Single parameter + param_names = ['name'] + field_names = ['name'] + mapping = implementation_service._create_parameter_field_mapping(param_names, field_names) + assert mapping == {'name': 'name'} + + # Test case 2: Empty lists + param_names = [] + field_names = [] mapping = implementation_service._create_parameter_field_mapping(param_names, field_names) - assert mapping == {'s': 's'} + assert mapping == {} - # Test case 2: Parameter ending with 's' but not plural - param_names = ['status'] # 'status' is already singular + # Test case 3: Parameter ending with 's' but not plural (like 'status') + param_names = ['status'] field_names = ['status'] mapping = implementation_service._create_parameter_field_mapping(param_names, field_names) assert mapping == {'status': 'status'} - # Test case 3: Exact matches take precedence - param_names = ['names', 'name'] - field_names = ['name', 'names'] + # Test case 4: Plural of word ending with 's' (like 'statuses') + param_names = ['statuses'] + field_names = ['status'] mapping = implementation_service._create_parameter_field_mapping(param_names, field_names) - assert mapping == {'names': 'names', 'name': 'name'} \ No newline at end of file + assert mapping == {'statuses': 'status'} \ No newline at end of file From 9c1137df3443b8688d718764e24737e5e75fbcd7 Mon Sep 17 00:00:00 2001 From: William Chen Date: Sat, 19 Jul 2025 00:12:20 +0800 Subject: [PATCH 6/8] refactor: Extract plural-to-singular mapping logic into a dedicated method - Moved the plural-to-singular conversion logic from `_create_parameter_field_mapping` to a new method `_cast_plural_to_singular`, improving code readability and maintainability. - This change enhances the clarity of the mapping process and prepares the code for potential future extensions. --- .../crud_repository_implementation_service.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py b/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py index 6a51980..1dbac5f 100644 --- a/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py +++ b/py_spring_model/py_spring_model_rest/service/curd_repository_implementation_service/crud_repository_implementation_service.py @@ -143,16 +143,7 @@ def _create_parameter_field_mapping(self, param_names: list[str], field_names: l # Try plural-to-singular mapping (only if param ends with 's' and is longer than 1 char) if param_name.endswith('s') and len(param_name) > 1: # Handle special cases for words ending with 's' - if param_name.endswith('ies'): - # words ending with 'ies' -> 'y' (e.g., 'categories' -> 'category') - singular_candidate = param_name[:-3] + 'y' - elif param_name.endswith('ses'): - # words ending with 'ses' -> 's' (e.g., 'statuses' -> 'status') - singular_candidate = param_name[:-2] - else: - # regular plural: remove 's' - singular_candidate = param_name[:-1] - + singular_candidate = self._cast_plural_to_singular(param_name) if singular_candidate in field_set: # Check for ambiguity: make sure we don't have both singular and plural forms as fields plural_candidate = singular_candidate + 's' @@ -173,6 +164,17 @@ def _create_parameter_field_mapping(self, param_names: list[str], field_names: l raise ValueError(error_msg) return mapping + + def _cast_plural_to_singular(self, word: str) -> str: + if word.endswith('ies'): + # words ending with 'ies' -> 'y' (e.g., 'categories' -> 'category') + return word[:-3] + 'y' + elif word.endswith('ses'): + # words ending with 'ses' -> 's' (e.g., 'statuses' -> 'status') + return word[:-2] + else: + # regular plural: remove 's' + return word[:-1] def create_implementation_wrapper(self, query: _Query, model_type: Type[PySpringModel], original_func_annotations: dict[str, Any], param_to_field_mapping: dict[str, str]) -> Callable[..., Any]: def wrapper(*args, **kwargs) -> Any: From 7cbfe2cbbb965844a451f3a2393e34ec0df566ef Mon Sep 17 00:00:00 2001 From: William Chen Date: Sat, 19 Jul 2025 00:14:03 +0800 Subject: [PATCH 7/8] feat: Add support for various field operations in dynamic queries - Introduced new field operations such as IN, GREATER_THAN, LESS_THAN, and LIKE to enhance dynamic query generation capabilities. - Updated the documentation to include detailed descriptions and examples of supported field operations. - Modified the `UserRepository` and `ProductRepository` classes to implement new methods for these operations, ensuring consistent usage across repositories. - Enhanced return types for query methods to use `Optional` for better handling of potential null results. --- README.md | 158 +++++++++++++++++++++++- py_spring_model/docs/query_operators.md | 40 +++--- 2 files changed, 175 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 3a4158a..981360d 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Features - Automatic CRUD Repository: PySpringModel automatically generates a CRUD repository for each of your SQLModel entities, providing common database operations such as Create, Read, Update, and Delete. - Managed Sessions: PySpringModel provides a context manager for database sessions, automatically handling session commit and rollback to ensure data consistency. - Dynamic Query Generation: PySpringModel can dynamically generate and execute SQL queries based on method names in your repositories. +- Field Operations Support: PySpringModel supports various field operations like IN, NOT IN, greater than, less than, LIKE, and more. - Custom SQL Queries: PySpringModel supports custom SQL queries using the `@Query` decorator for complex database operations. - RESTful API Integration: PySpringModel integrates with the PySpring framework to automatically generate basic table CRUD APIs for your SQLModel entities. @@ -50,6 +51,11 @@ class UserRepository(CrudRepository[int, User]): def find_all_by_status(self, status: str) -> List[User]: ... def find_all_by_age_and_status(self, age: int, status: str) -> List[User]: ... + # Field operations + def find_all_by_status_in(self, status: List[str]) -> List[User]: ... + def find_by_age_gt(self, age: int) -> Optional[User]: ... + def find_by_name_like(self, name: str) -> Optional[User]: ... + # Custom SQL queries using @Query decorator @Query("SELECT * FROM user WHERE age > {min_age}") def find_users_older_than(self, min_age: int) -> List[User]: ... @@ -123,6 +129,84 @@ def find_all_by_status_or_age(self, status: str, age: int) -> List[User]: ... def get_all_by_name_or_email(self, name: str, email: str) -> List[User]: ... ``` +### Field Operations + +PySpringModel supports various field operations for dynamic query generation: + +#### Supported Operations + +| Operation | Suffix | Description | Example | +|-----------|--------|-------------|---------| +| `EQUALS` | (default) | Field equals value | `find_by_name` | +| `IN` | `_in` | Field in list of values | `find_by_status_in` | +| `GREATER_THAN` | `_gt` | Field greater than value | `find_by_age_gt` | +| `GREATER_EQUAL` | `_gte` | Field greater than or equal to value | `find_by_age_gte` | +| `LESS_THAN` | `_lt` | Field less than value | `find_by_age_lt` | +| `LESS_EQUAL` | `_lte` | Field less than or equal to value | `find_by_age_lte` | +| `LIKE` | `_like` | Field matches pattern | `find_by_name_like` | +| `NOT_EQUALS` | `_ne` | Field not equals value | `find_by_status_ne` | +| `NOT_IN` | `_not_in` | Field not in list of values | `find_by_category_not_in` | + +#### Field Operation Examples + +```py +class UserRepository(CrudRepository[int, User]): + # IN operations + def find_all_by_status_in(self, status: List[str]) -> List[User]: ... + def find_all_by_id_in(self, id: List[int]) -> List[User]: ... + + # Comparison operations + def find_by_age_gt(self, age: int) -> Optional[User]: ... + def find_all_by_age_gte(self, age: int) -> List[User]: ... + def find_by_age_lt(self, age: int) -> Optional[User]: ... + def find_by_age_lte(self, age: int) -> Optional[User]: ... + + # Pattern matching + def find_by_name_like(self, name: str) -> Optional[User]: ... + + # Negation operations + def find_by_status_ne(self, status: str) -> Optional[User]: ... + def find_by_category_not_in(self, category: List[str]) -> List[User]: ... + + # Combined operations + def find_by_age_gt_and_status_in(self, age: int, status: List[str]) -> Optional[User]: ... + def find_by_salary_gte_or_category_in(self, salary: float, category: List[str]) -> Optional[User]: ... +``` + +#### Usage Examples + +```py +# Create repository instance +user_repo = UserRepository() + +# IN operations +active_or_pending_users = user_repo.find_all_by_status_in( + status=["active", "pending"] +) + +users_by_ids = user_repo.find_all_by_id_in(id=[1, 2, 3, 5]) + +# Comparison operations +adults = user_repo.find_all_by_age_gte(age=18) +young_users = user_repo.find_by_age_lt(age=25) +senior_users = user_repo.find_by_age_gte(age=65) + +# Pattern matching +johns = user_repo.find_by_name_like(name="%John%") + +# Negation operations +non_active_users = user_repo.find_by_status_ne(status="active") +non_employees = user_repo.find_by_category_not_in( + category=["employee", "intern"] +) + +# Complex combinations +target_users = user_repo.find_by_age_gt_and_status_in( + age=30, + status=["active", "pending"] +) +``` + ### Custom SQL Queries For complex queries that can't be expressed through method names, use the `@Query` decorator: @@ -208,6 +292,11 @@ class UserRepository(CrudRepository[int, User]): def find_all_by_status(self, status: str) -> List[User]: ... def find_all_by_age_and_status(self, age: int, status: str) -> List[User]: ... + # Field operations + def find_all_by_status_in(self, status: List[str]) -> List[User]: ... + def find_by_age_gt(self, age: int) -> Optional[User]: ... + def find_by_name_like(self, name: str) -> Optional[User]: ... + # Custom SQL queries @Query("SELECT * FROM user WHERE age > {min_age}") def find_users_older_than(self, min_age: int) -> List[User]: ... @@ -243,9 +332,72 @@ The dynamic query generation follows these naming conventions: - **Single field**: `find_by_name` → `WHERE name = ?` - **Multiple fields with AND**: `find_by_name_and_email` → `WHERE name = ? AND email = ?` - **Multiple fields with OR**: `find_by_name_or_email` → `WHERE name = ? OR email = ?` -- **Return types**: - - `find_by_*` and `get_by_*` return `Optional[Model]` - - `find_all_by_*` and `get_all_by_*` return `List[Model]` +- **Field operations**: `find_by_age_gt` → `WHERE age > ?`, `find_by_status_in` → `WHERE status IN (?)` + +### Parameter Field Mapping + +The system supports both exact matching and common plural-to-singular mapping: + +- **Exact Matching**: Parameter names match field names exactly +- **Plural Support**: Parameters can use plural forms (add 's') for better API design +- **Clear Rules**: No ambiguity - parameters must match exactly or be plural forms +- **Helpful Errors**: Clear error messages when mapping fails + +```python +class UserRepository(CrudRepository[int, User]): + # Method: find_by_name_and_age + # Fields extracted: ['name', 'age'] + + # ✅ Valid - Exact parameter names + def find_by_name_and_age(self, name: str, age: int) -> Optional[User]: ... + + # ✅ Valid - Different order but same names + def find_by_name_and_age(self, age: int, name: str) -> Optional[User]: ... + + # ✅ Valid - Plural parameters for better API design + def find_by_name_and_age(self, names: List[str], ages: List[int]) -> Optional[User]: ... + + # ✅ Valid - Mixed singular and plural + def find_by_name_and_age(self, name: str, ages: List[int]) -> Optional[User]: ... + + # ❌ Invalid - Wrong parameter names + def find_by_name_and_age(self, username: str, user_age: int) -> Optional[User]: ... # Should be 'name' and 'age' +``` + +**Return types**: +- `find_by_*` and `get_by_*` return `Optional[Model]` +- `find_all_by_*` and `get_all_by_*` return `List[Model]` + +### Quality-Focused Approach + +The system balances simplicity with API quality: + +**✅ Current Approach (Quality + Simplicity)** +```python +# Method: find_by_name_and_age +# Fields: ['name', 'age'] + +def find_by_name_and_age(self, name: str, age: int) -> Optional[User]: ... +# ✅ Works: exact parameter names + +def find_by_name_and_age(self, names: List[str], ages: List[int]) -> Optional[User]: ... +# ✅ Works: plural parameters for better API design + +def find_by_name_and_age(self, username: str, user_age: int) -> Optional[User]: ... +# ❌ Fails: clear error about wrong parameter names +``` + +**Plural-to-Singular Mapping Rules:** +- **Exact Match First**: If parameter name exists as a field, use it directly +- **Regular Plurals**: `names` → `name`, `ages` → `age` +- **Special Cases**: `statuses` → `status`, `categories` → `category` +- **No Ambiguity**: Won't map if both singular and plural forms exist as fields + +**Key Benefits:** +- **API Quality**: Plural parameters make APIs more intuitive +- **Predictable**: Clear rules about what works and what doesn't +- **Smart Handling**: Properly handles words ending with 's' like 'status' +- **Maintainable**: Simple logic that's easy to understand ### Query Decorator Features diff --git a/py_spring_model/docs/query_operators.md b/py_spring_model/docs/query_operators.md index 1194e13..ab98a0d 100644 --- a/py_spring_model/docs/query_operators.md +++ b/py_spring_model/docs/query_operators.md @@ -1,6 +1,6 @@ # Field Operations Support in PySpringModel -PySpringModel now supports multiple field operations for dynamic query generation, similar to Spring Data JPA. This allows you to find entities using various comparison operators and conditions. +PySpringModel supports multiple field operations for dynamic query generation, similar to Spring Data JPA. This allows you to find entities using various comparison operators and conditions. ## Supported Field Operations @@ -24,7 +24,7 @@ To use field operations, append the appropriate suffix to your field name in the ```python from py_spring_model import PySpringModel, Field, CrudRepository -from typing import List +from typing import List, Optional class User(PySpringModel, table=True): id: int = Field(default=None, primary_key=True) @@ -41,16 +41,16 @@ class UserRepository(CrudRepository[int, User]): def find_all_by_id_in(self, id: List[int]) -> List[User]: ... # Comparison operations - def find_by_age_gt(self, age: int) -> User: ... + def find_by_age_gt(self, age: int) -> Optional[User]: ... def find_all_by_age_gte(self, age: int) -> List[User]: ... - def find_by_age_lt(self, age: int) -> List[User]: ... - def find_by_age_lte(self, age: int) -> List[User]: ... + def find_by_age_lt(self, age: int) -> Optional[User]: ... + def find_by_age_lte(self, age: int) -> Optional[User]: ... # Pattern matching - def find_by_name_like(self, name: str) -> List[User]: ... + def find_by_name_like(self, name: str) -> Optional[User]: ... # Negation operations - def find_by_status_ne(self, status: str) -> List[User]: ... + def find_by_status_ne(self, status: str) -> Optional[User]: ... def find_by_category_not_in(self, category: List[str]) -> List[User]: ... ``` @@ -93,7 +93,7 @@ class UserRepository(CrudRepository[int, User]): self, age: int, status: List[str] - ) -> List[User]: ... + ) -> Optional[User]: ... # Find users by salary >= amount AND category IN list def find_by_salary_gte_and_category_in( @@ -123,14 +123,14 @@ class UserRepository(CrudRepository[int, User]): self, age: int, category: List[str] - ) -> List[User]: ... + ) -> Optional[User]: ... # Find users by status NE value OR salary >= amount def find_by_status_ne_or_salary_gte( self, status: str, salary: float - ) -> List[User]: ... + ) -> Optional[User]: ... # Usage experienced_or_executives = user_repo.find_by_age_gte_or_category_in( @@ -154,7 +154,7 @@ class UserRepository(CrudRepository[int, User]): age: int, status: List[str], category: List[str] - ) -> List[User]: ... + ) -> Optional[User]: ... # Usage target_users = user_repo.find_by_age_gt_and_status_in_or_category_in( @@ -269,25 +269,25 @@ class Product(PySpringModel, table=True): # Define the repository class ProductRepository(CrudRepository[int, Product]): # Single field operations - def find_by_price_gt(self, price: float) -> List[Product]: ... - def find_by_price_gte(self, price: float) -> List[Product]: ... - def find_by_stock_lt(self, stock: int) -> List[Product]: ... - def find_by_name_like(self, name: str) -> List[Product]: ... - def find_by_status_ne(self, status: str) -> List[Product]: ... - def find_by_category_in(self, category: List[str]) -> List[Product]: ... - def find_by_category_not_in(self, category: List[str]) -> List[Product]: ... + def find_by_price_gt(self, price: float) -> Optional[Product]: ... + def find_by_price_gte(self, price: float) -> Optional[Product]: ... + def find_by_stock_lt(self, stock: int) -> Optional[Product]: ... + def find_by_name_like(self, name: str) -> Optional[Product]: ... + def find_by_status_ne(self, status: str) -> Optional[Product]: ... + def find_by_category_in(self, category: List[str]) -> Optional[Product]: ... + def find_by_category_not_in(self, category: List[str]) -> Optional[Product]: ... # Combined operations def find_by_price_gte_and_status_in( self, price: float, status: List[str] - ) -> List[Product]: ... + ) -> Optional[Product]: ... def find_by_stock_lt_or_category_in( self, stock: int, category: List[str] - ) -> List[Product]: ... + ) -> Optional[Product]: ... # Setup database engine = create_engine("sqlite:///:memory:") From b74052e96b46a5e06e80a3a7b2a77f9ba020eceb Mon Sep 17 00:00:00 2001 From: William Chen Date: Sat, 19 Jul 2025 00:15:21 +0800 Subject: [PATCH 8/8] chore: Bump version to 0.2.0 --- py_spring_model/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/py_spring_model/__init__.py b/py_spring_model/__init__.py index 69bafae..668ed27 100644 --- a/py_spring_model/__init__.py +++ b/py_spring_model/__init__.py @@ -18,4 +18,4 @@ "Query", ] -__version__ = "0.1.1" \ No newline at end of file +__version__ = "0.2.0" \ No newline at end of file