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. pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy