From 2714424744c1f09e2c28ce39936e2c3ad4b05812 Mon Sep 17 00:00:00 2001 From: Artemiy Vereshchinskiy Date: Tue, 10 Jun 2025 19:37:28 +0700 Subject: [PATCH 1/2] Implements records result iterator --- README.md | 201 ++++++++++++++++++++++- pyproject.toml | 6 +- src/rushdb/__init__.py | 3 + src/rushdb/api/records.py | 48 ++++-- src/rushdb/client.py | 2 +- src/rushdb/models/__init__.py | 17 ++ src/rushdb/models/record.py | 138 +++++++++++++++- src/rushdb/models/result.py | 102 ++++++++++++ tests/test_base_setup.py | 2 +- tests/test_create_import.py | 67 +++++++- tests/test_search_query.py | 46 +++++- tests/test_search_result.py | 299 ++++++++++++++++++++++++++++++++++ 12 files changed, 896 insertions(+), 35 deletions(-) create mode 100644 src/rushdb/models/result.py create mode 100644 tests/test_search_result.py diff --git a/README.md b/README.md index 4e44a41..2086be4 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ user = db.records.create( ) # Find records -results = db.records.find({ +result = db.records.find({ "where": { "age": {"$gte": 18}, "name": {"$startsWith": "J"} @@ -48,6 +48,20 @@ results = db.records.find({ "limit": 10 }) +# Work with SearchResult +print(f"Found {len(result)} records out of {result.total} total") + +# Iterate over results +for record in result: + print(f"User: {record.get('name')} (Age: {record.get('age')})") + +# Check if there are more results +if result.has_more: + print("There are more records available") + +# Access specific records +first_user = result[0] if result else None + # Create relationships company = db.records.create( label="COMPANY", @@ -83,6 +97,166 @@ db.records.create_many("COMPANY", { }) ``` +## SearchResult API + +RushDB Python SDK uses a modern `SearchResult` container that follows Python SDK best practices similar to boto3, google-cloud libraries, and other popular SDKs. + +### SearchResult Features + +- **List-like access**: Index, slice, and iterate like a regular list +- **Search context**: Access total count, pagination info, and the original search query +- **Boolean conversion**: Use in if statements naturally +- **Pagination support**: Built-in pagination information and `has_more` property + +### Basic Usage + +```python +# Perform a search +result = db.records.find({ + "where": {"status": "active"}, + "limit": 10, + "skip": 20 +}) + +# Check if we have results +if result: + print(f"Found {len(result)} records") + +# Access search result information +print(f"Total matching records: {result.total}") +print(f"Current page size: {result.count}") +print(f"Records skipped: {result.skip}") +print(f"Has more results: {result.has_more}") +print(f"Search query: {result.search_query}") + +# Iterate over results +for record in result: + print(f"Record: {record.get('name')}") + +# List comprehensions work +names = [r.get('name') for r in result] + +# Indexing and slicing +first_record = result[0] if result else None +first_five = result[:5] +``` + +### SearchResult Properties + +| Property | Type | Description | +| -------------- | --------------- | ---------------------------------------- | +| `data` | `List[Record]` | The list of record results | +| `total` | `int` | Total number of matching records | +| `count` | `int` | Number of records in current result set | +| `limit` | `Optional[int]` | Limit that was applied to the search | +| `skip` | `int` | Number of records that were skipped | +| `has_more` | `bool` | Whether there are more records available | +| `search_query` | `SearchQuery` | The search query used to generate result | + +### Pagination Example + +```python +# Paginated search +page_size = 10 +current_page = 0 + +while True: + result = db.records.find({ + "where": {"category": "electronics"}, + "limit": page_size, + "skip": current_page * page_size, + "orderBy": {"created_at": "desc"} + }) + + if not result: + break + + print(f"Page {current_page + 1}: {len(result)} records") + + for record in result: + process_record(record) + + if not result.has_more: + break + + current_page += 1 +``` + +## Improved Record API + +The Record class has been enhanced with better data access patterns and utility methods. + +### Enhanced Data Access + +```python +# Create a record +user = db.records.create("User", { + "name": "John Doe", + "email": "john@example.com", + "age": 30, + "department": "Engineering" +}) + +# Safe field access with defaults +name = user.get("name") # "John Doe" +phone = user.get("phone", "Not provided") # "Not provided" + +# Get clean user data (excludes internal fields like __id, __label) +user_data = user.get_data() +# Returns: {"name": "John Doe", "email": "john@example.com", "age": 30, "department": "Engineering"} + +# Get all data including internal fields +full_data = user.get_data(exclude_internal=False) +# Includes: __id, __label, __proptypes, etc. + +# Convenient fields property +fields = user.fields # Same as user.get_data() + +# Dictionary conversion +user_dict = user.to_dict() # Clean user data +full_dict = user.to_dict(exclude_internal=False) # All data + +# Direct field access +user_name = user["name"] # Direct access +user_id = user["__id"] # Internal field access +``` + +### Record Existence Checking + +```python +# Safe existence checking (no exceptions) +if user.exists(): + print("Record is valid and accessible") + user.update({"status": "active"}) +else: + print("Record doesn't exist or is not accessible") + +# Perfect for validation workflows +def process_record_safely(record): + if not record.exists(): + return None + return record.get_data() + +# Conditional operations +records = db.records.find({"where": {"status": "pending"}}) +for record in records: + if record.exists(): + record.update({"processed_at": datetime.now()}) +``` + +### String Representations + +```python +user = db.records.create("User", {"name": "Alice Johnson"}) + +print(repr(user)) # Record(id='abc-123', label='User') +print(str(user)) # User: Alice Johnson + +# For records without names +product = db.records.create("Product", {"sku": "ABC123"}) +print(str(product)) # Product (product-id-here) +``` + ## Complete Documentation For comprehensive documentation, tutorials, and examples, please visit: @@ -206,18 +380,18 @@ def find( search_query: Optional[SearchQuery] = None, record_id: Optional[str] = None, transaction: Optional[Transaction] = None -) -> List[Record] +) -> RecordSearchResult ``` **Arguments:** -- `query` (Optional[SearchQuery]): Search query parameters +- `search_query` (Optional[SearchQuery]): Search query parameters - `record_id` (Optional[str]): Optional record ID to search from - `transaction` (Optional[Transaction]): Optional transaction object **Returns:** -- `List[Record]`: List of matching records +- `RecordSearchResult`: SearchResult container with matching records and metadata **Example:** @@ -235,7 +409,24 @@ query = { "limit": 10 } -records = db.records.find(query=query) +result = db.records.find(query=query) + +# Work with SearchResult +print(f"Found {len(result)} out of {result.total} total records") + +# Iterate over results +for record in result: + print(f"Employee: {record.get('name')} - {record.get('department')}") + +# Check pagination +if result.has_more: + print("More results available") + +# Access specific records +first_employee = result[0] if result else None + +# List operations +senior_employees = [r for r in result if r.get('age', 0) > 30] ``` ### delete() diff --git a/pyproject.toml b/pyproject.toml index 11da9a3..4a97d1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [tool.poetry] name = "rushdb" -version = "1.4.0" +version = "1.5.0" description = "RushDB Python SDK" authors = ["RushDB Team "] license = "Apache-2.0" readme = "README.md" -homepage = "https://github.com/rushdb/rushdb-python" -repository = "https://github.com/rushdb/rushdb-python" +homepage = "https://github.com/rush-db/rushdb-python" +repository = "https://github.com/rush-db/rushdb-python" documentation = "https://docs.rushdb.com" packages = [{ include = "rushdb", from = "src" }] keywords = [ diff --git a/src/rushdb/__init__.py b/src/rushdb/__init__.py index a146296..984137e 100644 --- a/src/rushdb/__init__.py +++ b/src/rushdb/__init__.py @@ -8,12 +8,15 @@ from .models.property import Property from .models.record import Record from .models.relationship import RelationshipDetachOptions, RelationshipOptions +from .models.result import RecordSearchResult, SearchResult from .models.transaction import Transaction __all__ = [ "RushDB", "RushDBError", "Record", + "RecordSearchResult", + "SearchResult", "Transaction", "Property", "RelationshipOptions", diff --git a/src/rushdb/api/records.py b/src/rushdb/api/records.py index 6f0e7ea..1763274 100644 --- a/src/rushdb/api/records.py +++ b/src/rushdb/api/records.py @@ -1,8 +1,9 @@ import typing -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Union from ..models.record import Record from ..models.relationship import RelationshipDetachOptions, RelationshipOptions +from ..models.result import RecordSearchResult from ..models.search_query import SearchQuery from ..models.transaction import Transaction from .base import BaseAPI @@ -427,7 +428,7 @@ def find( search_query: Optional[SearchQuery] = None, record_id: Optional[str] = None, transaction: Optional[Transaction] = None, - ) -> Tuple[List[Record], int]: + ) -> RecordSearchResult: """Search for and retrieve records matching the specified criteria. Searches the database for records that match the provided search query. @@ -443,13 +444,11 @@ def find( If provided, the operation will be part of the transaction. Defaults to None. Returns: - Tuple[List[Record], int]: A tuple containing: - - List[Record]: List of Record objects matching the search criteria - - int: Total count of matching records (may be larger than returned list if pagination applies) - - Note: - The method includes exception handling that returns an empty list if an error occurs. - In production code, you may want to handle specific exceptions differently. + RecordSearchResult: A result object containing: + - Iterable list of Record objects matching the search criteria + - Total count of matching records (may be larger than returned list if pagination applies) + - Additional metadata about the search operation + - Convenient properties like .has_more, .count, etc. Example: >>> from rushdb.models.search_query import SearchQuery @@ -457,11 +456,22 @@ def find( >>> >>> # Find all records with a specific label >>> query = SearchQuery(labels=["User"]) - >>> records, total = records_api.find(query) - >>> print(f"Found {len(records)} records out of {total} total") + >>> result = records_api.find(query) + >>> print(f"Found {result.count} records out of {result.total} total") + >>> + >>> # Iterate over results + >>> for record in result: + ... print(f"User: {record.get('name', 'Unknown')}") + >>> + >>> # Access specific records + >>> first_user = result[0] if result else None + >>> + >>> # Check if there are more results + >>> if result.has_more: + ... print("There are more records available") >>> >>> # Find records related to a specific record - >>> related_records, total = records_api.find(query, record_id="parent_123") + >>> related_result = records_api.find(query, record_id="parent_123") """ try: @@ -474,11 +484,17 @@ def find( data=typing.cast(typing.Dict[str, typing.Any], search_query or {}), headers=headers, ) - return [ - Record(self.client, record) for record in response.get("data") - ], response.get("total") or 0 + + records = [ + Record(self.client, record) for record in response.get("data", []) + ] + total = response.get("total", 0) + + return RecordSearchResult( + data=records, total=total, search_query=search_query + ) except Exception: - return [], 0 + return RecordSearchResult(data=[], total=0) def import_csv( self, diff --git a/src/rushdb/client.py b/src/rushdb/client.py index 9e015d8..7fdf3b4 100644 --- a/src/rushdb/client.py +++ b/src/rushdb/client.py @@ -240,7 +240,7 @@ def ping(self) -> bool: ... return client """ try: - self._make_request("GET", "/") + self._make_request("GET", "/settings") return True except RushDBError: return False diff --git a/src/rushdb/models/__init__.py b/src/rushdb/models/__init__.py index e69de29..c5e9342 100644 --- a/src/rushdb/models/__init__.py +++ b/src/rushdb/models/__init__.py @@ -0,0 +1,17 @@ +from .property import Property +from .record import Record +from .relationship import RelationshipDetachOptions, RelationshipOptions +from .result import RecordSearchResult, SearchResult +from .search_query import SearchQuery +from .transaction import Transaction + +__all__ = [ + "Property", + "Record", + "RelationshipDetachOptions", + "RelationshipOptions", + "RecordSearchResult", + "SearchResult", + "SearchQuery", + "Transaction", +] diff --git a/src/rushdb/models/record.py b/src/rushdb/models/record.py index ff1e7e1..35e2788 100644 --- a/src/rushdb/models/record.py +++ b/src/rushdb/models/record.py @@ -11,15 +11,12 @@ class Record: """Represents a record in RushDB with methods for manipulation.""" - def __init__(self, client: "RushDB", data: Union[Dict[str, Any], None] = None): + def __init__(self, client: "RushDB", data: Dict[str, Any] = {}): self._client = client - # Handle different data formats if isinstance(data, dict): self.data = data - elif isinstance(data, str): - # If just a string is passed, assume it's an ID - self.data = {} else: + self.data = {} raise ValueError(f"Invalid data format for Record: {type(data)}") @property @@ -107,10 +104,139 @@ def delete(self, transaction: Optional[Transaction] = None) -> Dict[str, str]: def __repr__(self) -> str: """String representation of record.""" - return f"Record(id='{self.id}')" + try: + return f"Record(id='{self.id}', label='{self.label}')" + except (ValueError, KeyError): + return f"Record(data_keys={list(self.data.keys())})" + + def __str__(self) -> str: + """Human-readable string representation.""" + try: + name = self.get("name", self.get("title", self.get("email", ""))) + if name: + return f"{self.label}: {name}" + return f"{self.label} ({self.id})" + except (ValueError, KeyError): + return f"Record with {len(self.data)} fields" + + def __eq__(self, other) -> bool: + """Check equality based on record ID.""" + if not isinstance(other, Record): + return False + try: + return self.id == other.id + except (ValueError, KeyError): + return False + + def __hash__(self) -> int: + """Hash based on record ID for use in sets and dicts.""" + try: + return hash(self.id) + except (ValueError, KeyError): + return hash(id(self)) + + def to_dict(self, exclude_internal: bool = True) -> Dict[str, Any]: + """ + Convert record to dictionary. + + Args: + exclude_internal: If True, excludes fields starting with '__' + + Returns: + Dictionary representation of the record + """ + return self.get_data(exclude_internal=exclude_internal) def __getitem__(self, key: str) -> Any: + """Get a field value by key, supporting both data fields and internal fields.""" return self.data[key] def get(self, key: str, default: Any = None) -> Any: + """ + Get a field value with optional default. + + This method provides convenient access to record data fields while + excluding internal RushDB fields (those starting with '__'). + + Args: + key: The field name to retrieve + default: Default value if field doesn't exist + + Returns: + The field value or default if not found + + Example: + >>> record = db.records.create("User", {"name": "John", "age": 30}) + >>> record.get("name") # "John" + >>> record.get("email", "no-email@example.com") # "no-email@example.com" + """ return self.data.get(key, default) + + def get_data(self, exclude_internal: bool = True) -> Dict[str, Any]: + """ + Get all record data, optionally excluding internal RushDB fields. + + Args: + exclude_internal: If True, excludes fields starting with '__' + + Returns: + Dictionary containing the record data + + Example: + >>> record = db.records.create("User", {"name": "John", "age": 30}) + >>> record.get_data() # {"name": "John", "age": 30} + >>> record.get_data(exclude_internal=False) # includes __id, __label, etc. + """ + if exclude_internal: + return {k: v for k, v in self.data.items() if not k.startswith("__")} + return self.data.copy() + + @property + def fields(self) -> Dict[str, Any]: + """ + Get user data fields (excluding internal RushDB fields). + + This is a convenient property for accessing just the user-defined + data without RushDB's internal metadata fields. + + Returns: + Dictionary containing only user-defined fields + + Example: + >>> record = db.records.create("User", {"name": "John", "age": 30}) + >>> record.fields # {"name": "John", "age": 30} + """ + return self.get_data(exclude_internal=True) + + def exists(self) -> bool: + """ + Check if the record exists in the database. + + This method safely checks if the record exists without throwing exceptions, + making it ideal for validation and conditional logic. + + Returns: + bool: True if record exists and is accessible, False otherwise + + Example: + >>> record = db.records.create("User", {"name": "John"}) + >>> record.exists() # True + >>> + >>> # After deletion + >>> record.delete() + >>> record.exists() # False + >>> + >>> # For invalid or incomplete records + >>> invalid_record = Record(client, {}) + >>> invalid_record.exists() # False + """ + try: + # Check if we have a valid ID first + record_id = self.data.get("__id") + if not record_id: + return False + return True + + except Exception: + # Any exception means the record doesn't exist or isn't accessible + return False diff --git a/src/rushdb/models/result.py b/src/rushdb/models/result.py new file mode 100644 index 0000000..112b138 --- /dev/null +++ b/src/rushdb/models/result.py @@ -0,0 +1,102 @@ +from typing import Generic, Iterator, List, Optional, TypeVar + +from .record import Record +from .search_query import SearchQuery + +# Generic type for result items +T = TypeVar("T") + + +class SearchResult(Generic[T]): + """ + Container for search results following Python SDK best practices. + + Provides both list-like access and iteration support, along with metadata + about the search operation (total count, pagination info, etc.). + + This class follows common Python SDK patterns used by libraries like: + - boto3 (AWS SDK) + - google-cloud libraries + - requests libraries + """ + + def __init__( + self, + data: List[T], + total: Optional[int] = None, + search_query: Optional[SearchQuery] = None, + ): + """ + Initialize search result. + + Args: + data: List of result items + total: Total number of matching records (may be larger than len(data)) + search_query: The search query used to generate this result + """ + self._data = data + self._total = total or len(data) + self._search_query = search_query or {} + + @property + def data(self) -> List[T]: + """Get the list of result items.""" + return self._data + + @property + def total(self) -> int: + """Get the total number of matching records.""" + return self._total + + @property + def count(self) -> int: + """Get the number of records in this result set (alias for len()).""" + return len(self._data) + + @property + def search_query(self) -> SearchQuery: + """Get the search query used to generate this result.""" + return self._search_query + + @property + def limit(self) -> Optional[int]: + """Get the limit that was applied to this search.""" + return self._search_query.get("limit") + + @property + def skip(self) -> int: + """Get the number of records that were skipped.""" + return ( + isinstance(self._search_query, dict) + and self.search_query.get("skip", 0) + or 0 + ) + + @property + def has_more(self) -> bool: + """Check if there are more records available beyond this result set.""" + return self._total > (self.skip + len(self._data)) + + def __len__(self) -> int: + """Get the number of records in this result set.""" + return len(self._data) + + def __iter__(self) -> Iterator[T]: + """Iterate over the result items.""" + return iter(self._data) + + def __getitem__(self, index) -> T: + """Get an item by index or slice.""" + return self._data[index] + + def __bool__(self) -> bool: + """Check if the result set contains any items.""" + return len(self._data) > 0 + + def __repr__(self) -> str: + """String representation of the search result.""" + return f"SearchResult(count={len(self._data)}, total={self._total})" + + +# Type alias for record search results +RecordSearchResult = SearchResult[Record] diff --git a/tests/test_base_setup.py b/tests/test_base_setup.py index 330b20f..c9b2f1b 100644 --- a/tests/test_base_setup.py +++ b/tests/test_base_setup.py @@ -33,7 +33,7 @@ def setUpClass(cls): # Get configuration from environment variables cls.token = os.getenv("RUSHDB_TOKEN") - cls.base_url = os.getenv("RUSHDB_URL", "http://localhost:8000") + cls.base_url = os.getenv("RUSHDB_URL") if not cls.token: raise ValueError( diff --git a/tests/test_create_import.py b/tests/test_create_import.py index 6184c6b..057536f 100644 --- a/tests/test_create_import.py +++ b/tests/test_create_import.py @@ -22,16 +22,26 @@ def test_create_with_data(self): record = self.client.records.create("COMPANY", data) print("\nDEBUG Record Data:") - print("Raw _data:", json.dumps(record.data, indent=2)) + print("Raw data:", json.dumps(record.data, indent=2)) print("Available keys:", list(record.data.keys())) print("Timestamp:", record.timestamp) print("Date:", record.date) + # Test new data access methods + print("Clean data:", record.get_data()) + print("Name (get method):", record.get("name", "Unknown")) + print("Fields property:", record.fields) + self.assertIsInstance(record, Record) self.assertEqual(record.data["__label"], "COMPANY") self.assertEqual(record.data["name"], "Google LLC") self.assertEqual(record.data["rating"], 4.9) + # Test new functionality + self.assertEqual(record.get("name"), "Google LLC") + self.assertEqual(record.get("nonexistent", "default"), "default") + self.assertTrue(record.exists()) + def test_record_methods(self): """Test Record class methods""" # Create a company record @@ -197,6 +207,61 @@ def test_import_csv(self): self.client.records.import_csv("EMPLOYEE", csv_data) + def test_search_result_integration(self): + """Test SearchResult integration with find operations""" + # Create some test data + for i in range(5): + self.client.records.create( + "TEST_COMPANY", + { + "name": f"Test Company {i}", + "industry": "Technology" if i % 2 == 0 else "Finance", + "employees": 100 + i * 50, + }, + ) + + # Test SearchResult API + result = self.client.records.find( + { + "where": {"industry": "Technology"}, + "orderBy": {"name": "asc"}, + "limit": 10, + } + ) + + # Test SearchResult properties + print("\nSearchResult Demo:") + print(f"Found {len(result)} records out of {result.total} total") + print(f"Has more: {result.has_more}") + print(f"Limit: {result.limit}, Skip: {result.skip}") + + # Test iteration + print("Technology companies:") + for i, company in enumerate(result, 1): + name = company.get("name", "Unknown") + employees = company.get("employees", 0) + print(f" {i}. {name} ({employees} employees)") + + # Test boolean conversion + if result: + print("✓ Search found results") + + # Test list operations + company_names = [c.get("name") for c in result] + print(f"Company names: {company_names}") + + # Test indexing if we have results + if len(result) > 0: + first_company = result[0] + print(f"First company: {first_company.get('name')}") + + # Validate SearchResult + from src.rushdb.models.result import RecordSearchResult + + self.assertIsInstance(result, RecordSearchResult) + self.assertGreaterEqual(len(result), 0) + self.assertIsInstance(result.total, int) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_search_query.py b/tests/test_search_query.py index dc859aa..5962185 100644 --- a/tests/test_search_query.py +++ b/tests/test_search_query.py @@ -10,7 +10,40 @@ def test_basic_equality_search(self): """Test basic equality search""" query = {"where": {"name": "John Doe"}} # Implicit equality result = self.client.records.find(query) - print(result) + + # Test that result is SearchResult + self.assertIsNotNone(result) + print(f"Search returned {len(result)} results out of {result.total} total") + + # Test iteration + for record in result: + print(f"Found record: {record.get('name', 'Unknown')}") + + # Test boolean check + if result: + print("Search found results") + else: + print("No results found") + + def test_empty_criteria_search(self): + """Test basic equality search""" + + result = self.client.records.find() + + # Test that result is SearchResult + self.assertIsNotNone(result) + print(f"Search returned {len(result)} results out of {result.total} total") + + # Test iteration + for record in result: + print(f"Found record: {record.get('name', 'Unknown')}") + print(f"ID: {record.id}") + + # Test boolean check + if result: + print("Search found results") + else: + print("No results found") def test_basic_comparison_operators(self): """Test basic comparison operators""" @@ -21,7 +54,16 @@ def test_basic_comparison_operators(self): "status": {"$ne": "inactive"}, } } - self.client.records.find(query) + result = self.client.records.find(query) + + # Test SearchResult properties + print(f"Comparison search: {len(result)} results, has_more: {result.has_more}") + + # Test data access for results + for record in result: + age = record.get("age") + if age: + self.assertGreater(age, 25) def test_string_operations(self): """Test string-specific operations""" diff --git a/tests/test_search_result.py b/tests/test_search_result.py new file mode 100644 index 0000000..26f30a1 --- /dev/null +++ b/tests/test_search_result.py @@ -0,0 +1,299 @@ +"""Test cases for SearchResult and improved Record functionality.""" + +import unittest +from unittest.mock import Mock + +from src.rushdb.models.record import Record +from src.rushdb.models.result import RecordSearchResult, SearchResult + +from .test_base_setup import TestBase + + +class TestSearchResult(unittest.TestCase): + """Test cases for SearchResult class functionality.""" + + def setUp(self): + """Set up test data.""" + self.test_data = [ + {"id": "1", "name": "John", "age": 30}, + {"id": "2", "name": "Jane", "age": 25}, + {"id": "3", "name": "Bob", "age": 35}, + ] + + def test_search_result_initialization(self): + """Test SearchResult initialization with various parameters.""" + # Basic initialization + result = SearchResult(self.test_data) + self.assertEqual(len(result), 3) + self.assertEqual(result.total, 3) + self.assertEqual(result.count, 3) + self.assertEqual(result.skip, 0) + self.assertIsNone(result.limit) + self.assertFalse(result.has_more) + + # With pagination parameters + search_query = {"limit": 2, "skip": 5} + result = SearchResult( + data=self.test_data[:2], total=10, search_query=search_query + ) + self.assertEqual(len(result), 2) + self.assertEqual(result.total, 10) + self.assertEqual(result.count, 2) + self.assertEqual(result.limit, 2) + self.assertEqual(result.skip, 5) + self.assertTrue(result.has_more) + + def test_search_result_properties(self): + """Test SearchResult properties.""" + search_query = {"limit": 10, "skip": 20, "where": {"name": "test"}} + result = SearchResult(data=self.test_data, total=100, search_query=search_query) + + self.assertEqual(result.data, self.test_data) + self.assertEqual(result.total, 100) + self.assertEqual(result.count, 3) + self.assertEqual(result.limit, 10) + self.assertEqual(result.skip, 20) + self.assertTrue(result.has_more) + self.assertEqual(result.search_query["where"]["name"], "test") + + def test_search_result_iteration(self): + """Test SearchResult iteration capabilities.""" + result = SearchResult(self.test_data) + + # Test iteration + items = [] + for item in result: + items.append(item) + self.assertEqual(items, self.test_data) + + # Test list comprehension + names = [item["name"] for item in result] + self.assertEqual(names, ["John", "Jane", "Bob"]) + + def test_search_result_indexing(self): + """Test SearchResult indexing and slicing.""" + result = SearchResult(self.test_data) + + # Test indexing + self.assertEqual(result[0], self.test_data[0]) + self.assertEqual(result[-1], self.test_data[-1]) + + # Test slicing + first_two = result[:2] + self.assertEqual(first_two, self.test_data[:2]) + + def test_search_result_boolean_conversion(self): + """Test SearchResult boolean conversion.""" + # Non-empty result + result = SearchResult(self.test_data) + self.assertTrue(bool(result)) + self.assertTrue(result) + + # Empty result + empty_result = SearchResult([]) + self.assertFalse(bool(empty_result)) + self.assertFalse(empty_result) + + def test_search_result_string_representation(self): + """Test SearchResult string representation.""" + result = SearchResult(self.test_data, total=100) + expected = "SearchResult(count=3, total=100)" + self.assertEqual(repr(result), expected) + + def test_record_search_result_type_alias(self): + """Test RecordSearchResult type alias.""" + # Mock client + mock_client = Mock() + + # Create Record objects + records = [ + Record(mock_client, {"__id": "1", "__label": "User", "name": "John"}), + Record(mock_client, {"__id": "2", "__label": "User", "name": "Jane"}), + ] + + result = RecordSearchResult(records, total=2) + self.assertIsInstance(result, SearchResult) + self.assertEqual(len(result), 2) + self.assertEqual(result.total, 2) + + +class TestRecordImprovements(TestBase): + """Test cases for improved Record functionality.""" + + def test_record_data_access_methods(self): + """Test improved Record data access methods.""" + # Create a test record + record = self.client.records.create( + "USER", + { + "name": "John Doe", + "email": "john@example.com", + "age": 30, + "department": "Engineering", + }, + ) + + # Test get method with default + self.assertEqual(record.get("name"), "John Doe") + self.assertEqual(record.get("phone", "N/A"), "N/A") + + # Test get_data method + user_data = record.get_data(exclude_internal=True) + self.assertIn("name", user_data) + self.assertNotIn("__id", user_data) + self.assertNotIn("__label", user_data) + + full_data = record.get_data(exclude_internal=False) + self.assertIn("__id", full_data) + self.assertIn("__label", full_data) + + # Test fields property + fields = record.fields + self.assertEqual(fields, user_data) + + # Test to_dict method + dict_data = record.to_dict() + self.assertEqual(dict_data, user_data) + + dict_with_internal = record.to_dict(exclude_internal=False) + self.assertEqual(dict_with_internal, full_data) + + def test_record_indexing_access(self): + """Test Record bracket notation access.""" + record = self.client.records.create( + "USER", {"name": "Jane Smith", "role": "Developer"} + ) + + # Test bracket notation + self.assertEqual(record["name"], "Jane Smith") + self.assertEqual(record["__label"], "USER") + + # Test KeyError for non-existent key + with self.assertRaises(KeyError): + _ = record["non_existent_key"] + + def test_record_string_representations(self): + """Test Record string representations.""" + record = self.client.records.create( + "USER", {"name": "Alice Johnson", "email": "alice@example.com"} + ) + + # Test __repr__ + repr_str = repr(record) + self.assertIn("Record(id=", repr_str) + self.assertIn("label='USER'", repr_str) + + # Test __str__ + str_repr = str(record) + self.assertIn("USER:", str_repr) + self.assertIn("Alice Johnson", str_repr) + + def test_record_equality_and_hashing(self): + """Test Record equality and hashing.""" + # Create two records + record1 = self.client.records.create("USER", {"name": "User 1"}) + record2 = self.client.records.create("USER", {"name": "User 2"}) + + # Test inequality + self.assertNotEqual(record1, record2) + self.assertNotEqual(hash(record1), hash(record2)) + + # Test equality with same record + self.assertEqual(record1, record1) + self.assertEqual(hash(record1), hash(record1)) + + # Test with non-Record object + self.assertNotEqual(record1, "not a record") + + def test_record_exists_method(self): + """Test Record exists() method.""" + # Create a valid record + record = self.client.records.create("USER", {"name": "Test User"}) + + # Test exists for valid record + self.assertTrue(record.exists()) + + # Create an invalid record (no ID) + invalid_record = Record(self.client, {}) + self.assertFalse(invalid_record.exists()) + + # Test exists after deletion + record.delete() + # Note: In real implementation, this might still return True + # until the record is actually removed from the database + + +class TestSearchResultIntegration(TestBase): + """Test SearchResult integration with actual RushDB operations.""" + + def test_find_returns_search_result(self): + """Test that find() returns SearchResult object.""" + # Create some test records + self.client.records.create( + "EMPLOYEE", {"name": "John Doe", "department": "Engineering", "age": 30} + ) + self.client.records.create( + "EMPLOYEE", {"name": "Jane Smith", "department": "Marketing", "age": 28} + ) + + # Search for records + query = {"where": {"department": "Engineering"}, "limit": 10} + result = self.client.records.find(query) + + # Test that result is SearchResult + self.assertIsInstance(result, SearchResult) + self.assertIsInstance(result, RecordSearchResult) + + # Test SearchResult properties + self.assertGreaterEqual(len(result), 1) + self.assertIsInstance(result.total, int) + self.assertIsInstance(result.count, int) + + # Test iteration + for record in result: + self.assertIsInstance(record, Record) + self.assertEqual(record.get("department"), "Engineering") + + # Test boolean conversion + if result: + print(f"Found {len(result)} engineering employees") + + # Test indexing if results exist + if len(result) > 0: + first_record = result[0] + self.assertIsInstance(first_record, Record) + + def test_empty_search_result(self): + """Test SearchResult with no results.""" + # Search for non-existent records + query = {"where": {"department": "NonExistentDepartment"}, "limit": 10} + result = self.client.records.find(query) + + self.assertIsInstance(result, SearchResult) + self.assertEqual(len(result), 0) + self.assertFalse(result) + self.assertFalse(result.has_more) + + def test_pagination_with_search_result(self): + """Test SearchResult pagination features.""" + # Create multiple records + for i in range(5): + self.client.records.create( + "PRODUCT", {"name": f"Product {i}", "price": 100 + i * 10} + ) + + # Search with pagination + query = {"where": {}, "labels": ["PRODUCT"], "limit": 2, "skip": 1} + result = self.client.records.find(query) + + self.assertIsInstance(result, SearchResult) + self.assertEqual(result.limit, 2) + self.assertEqual(result.skip, 1) + + # Check if has_more is correctly calculated + if result.total > (result.skip + result.count): + self.assertTrue(result.has_more) + + +if __name__ == "__main__": + unittest.main() From df2f6f60c5ec6939a09cdbaa93fefe93bf04c9de Mon Sep 17 00:00:00 2001 From: Artemiy Vereshchinskiy Date: Tue, 10 Jun 2025 19:46:54 +0700 Subject: [PATCH 2/2] Update readme --- README.md | 45 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2086be4..d32e277 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,10 @@ RushDB Python SDK uses a modern `SearchResult` container that follows Python SDK ### SearchResult Features +- **Generic type support**: Uses Python's typing generics (`SearchResult[T]`) with `RecordSearchResult` as a type alias for `SearchResult[Record]` - **List-like access**: Index, slice, and iterate like a regular list - **Search context**: Access total count, pagination info, and the original search query -- **Boolean conversion**: Use in if statements naturally +- **Boolean conversion**: Use in if statements naturally (returns `True` if the result contains any items) - **Pagination support**: Built-in pagination information and `has_more` property ### Basic Usage @@ -139,13 +140,35 @@ names = [r.get('name') for r in result] # Indexing and slicing first_record = result[0] if result else None first_five = result[:5] + +# String representation +print(repr(result)) # SearchResult(count=10, total=42) +``` + +### SearchResult Constructor + +```python +def __init__( + self, + data: List[T], + total: Optional[int] = None, + search_query: Optional[SearchQuery] = None, +): + """ + Initialize search result. + + Args: + data: List of result items + total: Total number of matching records (defaults to len(data) if not provided) + search_query: The search query used to generate this result (defaults to {}) + """ ``` ### SearchResult Properties | Property | Type | Description | | -------------- | --------------- | ---------------------------------------- | -| `data` | `List[Record]` | The list of record results | +| `data` | `List[T]` | The list of result items (generic type) | | `total` | `int` | Total number of matching records | | `count` | `int` | Number of records in current result set | | `limit` | `Optional[int]` | Limit that was applied to the search | @@ -153,6 +176,13 @@ first_five = result[:5] | `has_more` | `bool` | Whether there are more records available | | `search_query` | `SearchQuery` | The search query used to generate result | +> **Implementation Notes:** +> +> - If `search_query` is not provided during initialization, it defaults to an empty dictionary `{}` +> - The `skip` property checks if `search_query` is a dictionary and returns the "skip" value or 0 +> - The `has_more` property is calculated as `total > (skip + len(data))`, allowing for efficient pagination +> - The `__bool__` method returns `True` if the result contains any items (`len(data) > 0`) + ### Pagination Example ```python @@ -182,6 +212,17 @@ while True: current_page += 1 ``` +### RecordSearchResult Type + +The SDK provides a specialized type alias for search results containing Record objects: + +```python +# Type alias for record search results +RecordSearchResult = SearchResult[Record] +``` + +This type is what's returned by methods like `db.records.find()`, providing type safety and specialized handling for Record objects while leveraging all the functionality of the generic SearchResult class. + ## Improved Record API The Record class has been enhanced with better data access patterns and utility methods.