diff --git a/python-sdk/README.md b/python-sdk/README.md new file mode 100644 index 0000000..d3190fd --- /dev/null +++ b/python-sdk/README.md @@ -0,0 +1,490 @@ +# GraphLite Python SDK + +High-level, ergonomic Python SDK for GraphLite - the fast, embedded graph database. + +## Overview + +The GraphLite Python SDK provides a developer-friendly API for working with GraphLite databases in Python applications. It wraps the low-level FFI bindings with a clean, intuitive interface following patterns from popular embedded databases like SQLite. + +## Features + +- **Simple API** - Clean, Pythonic interface following SQLite conventions +- **Session Management** - User context and permissions support +- **Transactions** - ACID guarantees with automatic rollback (context managers) +- **Query Builder** - Fluent API for constructing GQL queries +- **Typed Results** - Deserialize query results into Python dataclasses +- **Zero External Dependencies** - Fully embedded, no server required +- **Type Hints** - Full type annotation support for better IDE integration + +## Installation + +```bash +# Install from source (for now) +cd python-sdk +pip install -e . +``` + +## Quick Start + +Basic usage: + +```python +from graphlite_sdk import GraphLite + +# Open database +db = GraphLite.open("./mydb") + +# Create session +session = db.session("admin") + +# Execute query +result = session.query("MATCH (p:Person) RETURN p.name") + +for row in result.rows: + print(row) +``` + +## Core Concepts + +### Opening a Database + +GraphLite is an embedded database - no server required. Just open a directory: + +```python +from graphlite_sdk import GraphLite + +db = GraphLite.open("./mydb") +``` + +This creates or opens a database at the specified path. + +### Sessions + +Unlike SQLite, GraphLite uses sessions for user context and permissions: + +```python +session = db.session("username") +``` + +Sessions provide: +- User authentication and authorization +- Transaction isolation +- Audit logging + +### Executing Queries + +Simple query execution: + +```python +result = session.query("MATCH (n:Person) RETURN n") +``` + +Or for statements that don't return results: + +```python +session.execute("INSERT (p:Person {name: 'Alice'})") +``` + +### Transactions + +Transactions use Python context managers with automatic rollback: + +```python +# Transaction with explicit commit +with session.transaction() as tx: + tx.execute("INSERT (p:Person {name: 'Alice'})") + tx.execute("INSERT (p:Person {name: 'Bob'})") + tx.commit() # Persist changes + +# Transaction with automatic rollback +with session.transaction() as tx: + tx.execute("INSERT (p:Person {name: 'Charlie'})") + # tx exits without commit - changes are automatically rolled back +``` + +### Query Builder + +Build queries fluently: + +```python +result = (session.query_builder() + .match_pattern("(p:Person)") + .where_clause("p.age > 25") + .return_clause("p.name, p.age") + .order_by("p.age DESC") + .limit(10) + .execute()) +``` + +### Typed Results + +Deserialize results into Python dataclasses: + +```python +from dataclasses import dataclass +from graphlite_sdk import TypedResult + +@dataclass +class Person: + name: str + age: int + +result = session.query("MATCH (p:Person) RETURN p.name as name, p.age as age") +typed = TypedResult(result) +people = typed.deserialize_rows(Person) + +for person in people: + print(f"{person.name} is {person.age} years old") +``` + +## Examples + +### Basic CRUD Operations + +```python +from graphlite_sdk import GraphLite + +db = GraphLite.open("./mydb") +session = db.session("admin") + +# Create schema and graph +session.execute("CREATE SCHEMA IF NOT EXISTS /example") +session.execute("SESSION SET SCHEMA /example") +session.execute("CREATE GRAPH IF NOT EXISTS social") +session.execute("SESSION SET GRAPH social") + +# Create nodes +session.execute("INSERT (p:Person {name: 'Alice', age: 30})") +session.execute("INSERT (p:Person {name: 'Bob', age: 25})") + +# Create relationships +session.execute(""" + MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Bob'}) + INSERT (a)-[:KNOWS]->(b) +""") + +# Query +result = session.query(""" + MATCH (p:Person)-[:KNOWS]->(f:Person) + RETURN p.name as person, f.name as friend +""") + +for row in result.rows: + print(f"{row['person']} knows {row['friend']}") +``` + +### Transaction Example + +```python +from graphlite_sdk import GraphLite + +db = GraphLite.open("./mydb") +session = db.session("admin") + +with session.transaction() as tx: + # Delete old relationship + tx.execute("MATCH (a)-[r:FOLLOWS]->(b) WHERE a.name = 'Alice' DELETE r") + + # Create new relationship + tx.execute(""" + MATCH (a {name: 'Alice'}), (c {name: 'Charlie'}) + INSERT (a)-[:FOLLOWS]->(c) + """) + + tx.commit() +``` + +### Query Builder Example + +```python +from graphlite_sdk import GraphLite + +db = GraphLite.open("./mydb") +session = db.session("admin") + +result = (session.query_builder() + .match_pattern("(u:User)") + .where_clause("u.status = 'active'") + .where_clause("u.lastLogin > '2024-01-01'") + .return_clause("u.name, u.email") + .order_by("u.lastLogin DESC") + .limit(20) + .execute()) + +for row in result.rows: + print(f"{row['name']}: {row['email']}") +``` + +### Typed Deserialization Example + +```python +from dataclasses import dataclass +from graphlite_sdk import GraphLite, TypedResult + +@dataclass +class User: + name: str + email: str + age: int + +db = GraphLite.open("./mydb") +session = db.session("admin") + +result = session.query( + "MATCH (u:User) RETURN u.name as name, u.email as email, u.age as age" +) + +typed = TypedResult(result) +users = typed.deserialize_rows(User) + +for user in users: + print(f"{user.name} ({user.email}): {user.age}") +``` + +### Scalar and First Methods + +```python +from dataclasses import dataclass +from graphlite_sdk import TypedResult + +# Get a scalar value (single value from first row, first column) +result = session.query("MATCH (p:Person) RETURN count(p)") +typed = TypedResult(result) +count = typed.scalar() +print(f"Total persons: {count}") + +# Get first row as typed object +@dataclass +class Count: + count: int + +result = session.query("MATCH (p:Person) RETURN count(p) as count") +typed = TypedResult(result) +count_obj = typed.first(Count) +print(f"Total persons: {count_obj.count}") +``` + +## API Comparison with SQLite + +GraphLite SDK follows similar patterns to Python's sqlite3 but adapted for graph databases: + +| Operation | sqlite3 (SQLite) | graphlite-sdk (GraphLite) | +|-----------|------------------|---------------------------| +| Open DB | `sqlite3.connect()` | `GraphLite.open()` | +| Execute | `conn.execute()` | `session.execute()` | +| Query | `conn.execute()` | `session.query()` | +| Transaction | `with conn:` | `with session.transaction():` | +| Commit | `conn.commit()` | `tx.commit()` | +| Rollback | `conn.rollback()` | `tx.rollback()` or auto | + +**Key Differences:** +- GraphLite uses **sessions** for user context (SQLite doesn't have sessions) +- GraphLite uses **GQL** (Graph Query Language) instead of SQL +- GraphLite is optimized for **graph data** (nodes, edges, paths) + +## Architecture + +```text +Your Application + │ + ▼ +┌─────────────────────────────────────────┐ +│ GraphLite SDK (this package) │ +│ - GraphLite (main API) │ ← You are here +│ - Session (session management) │ +│ - Transaction (ACID support) │ +│ - QueryBuilder (fluent queries) │ +│ - TypedResult (deserialization) │ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ GraphLite FFI Bindings │ +│ (Low-level ctypes wrapper) │ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ GraphLite Core (Rust) │ +│ - QueryCoordinator │ +│ - Storage Engine │ +│ - Catalog Manager │ +└─────────────────────────────────────────┘ +``` + +## Language Bindings + +The GraphLite Python SDK is specifically for **Python applications**. For other languages: + +- **Rust** - Use `graphlite-sdk/` (native Rust SDK) +- **Java** - Use `bindings/java/` (via JNI) +- **JavaScript/Node.js** - Use `bindings/javascript/` (via FFI/WASM) +- **Kotlin** - Use `bindings/kotlin/` (via JNI) + +See the main [MULTI_LANGUAGE_BINDINGS_DESIGN.md](../MULTI_LANGUAGE_BINDINGS_DESIGN.md) for details. + +## Performance + +GraphLite Python SDK provides good performance via FFI: +- Direct ctypes FFI calls (minimal overhead) +- JSON serialization only for query results +- Python-native data structures + +Benchmark comparison: +- **Rust SDK**: ~100% of native performance +- **Python SDK** (via FFI): ~80-90% of native +- **JavaScript bindings** (via WASM): ~70-80% of native + +## Error Handling + +The SDK uses a comprehensive error hierarchy: + +```python +from graphlite_sdk import ( + GraphLiteError, # Base error class + ConnectionError, # Database connection errors + SessionError, # Session management errors + QueryError, # Query execution errors + TransactionError, # Transaction errors + SerializationError, # Deserialization errors +) + +try: + db = GraphLite.open("./mydb") + session = db.session("admin") + result = session.query("MATCH (n) RETURN n") +except ConnectionError as e: + print(f"Failed to connect: {e}") +except QueryError as e: + print(f"Query failed: {e}") +except GraphLiteError as e: + print(f"GraphLite error: {e}") +``` + +## Examples + +Run the examples: + +```bash +# Basic usage example +python3 examples/basic_usage.py + +# More examples coming soon +``` + +## Development + +To work on the SDK: + +```bash +# Install in development mode +cd python-sdk +pip install -e . + +# Run tests (coming soon) +pytest + +# Type checking +mypy src/ +``` + +## API Reference + +### GraphLite + +```python +class GraphLite: + @classmethod + def open(cls, path: str) -> GraphLite + + def session(self, username: str) -> Session + + def close(self) -> None +``` + +### Session + +```python +class Session: + def query(self, query: str) -> QueryResult + + def execute(self, statement: str) -> None + + def transaction(self) -> Transaction + + def query_builder(self) -> QueryBuilder +``` + +### Transaction + +```python +class Transaction: + def execute(self, statement: str) -> None + + def query(self, query: str) -> QueryResult + + def commit(self) -> None + + def rollback(self) -> None +``` + +### QueryBuilder + +```python +class QueryBuilder: + def match_pattern(self, pattern: str) -> QueryBuilder + + def where_clause(self, condition: str) -> QueryBuilder + + def with_clause(self, clause: str) -> QueryBuilder + + def return_clause(self, clause: str) -> QueryBuilder + + def order_by(self, clause: str) -> QueryBuilder + + def skip(self, n: int) -> QueryBuilder + + def limit(self, n: int) -> QueryBuilder + + def build(self) -> str + + def execute(self) -> QueryResult +``` + +### TypedResult + +```python +class TypedResult: + def row_count(self) -> int + + def column_names(self) -> List[str] + + def get_row(self, index: int) -> Optional[Dict[str, Any]] + + def deserialize_rows(self, target_type: Type[T]) -> List[T] + + def deserialize_row(self, row: Dict[str, Any], target_type: Type[T]) -> T + + def first(self, target_type: Type[T]) -> T + + def scalar(self) -> Any + + def is_empty(self) -> bool + + def rows(self) -> List[Dict[str, Any]] +``` + +## Contributing + +Contributions welcome! Areas where help is needed: + +- **ORM Features** - Decorators for mapping classes to graph nodes +- **Query Validation** - Runtime query validation +- **Async Support** - asyncio integration +- **Connection Pooling** - Multi-threaded access patterns +- **Graph Algorithms** - Built-in graph algorithms (shortest path, centrality, etc.) +- **Documentation** - More examples and tutorials + +## License + +Apache-2.0 - See [LICENSE](../LICENSE) for details. diff --git a/python-sdk/examples/basic_usage.py b/python-sdk/examples/basic_usage.py new file mode 100644 index 0000000..946fd92 --- /dev/null +++ b/python-sdk/examples/basic_usage.py @@ -0,0 +1,147 @@ +""" +GraphLite Python SDK - Basic Usage Example + +This example demonstrates the core features of the GraphLite Python SDK: +- Opening a database +- Creating sessions +- Executing queries +- Using transactions +- Query builder API +- Typed result deserialization + +Run with: python3 examples/basic_usage.py +""" + +from dataclasses import dataclass +from typing import Optional +import tempfile +import shutil +import sys +from pathlib import Path + +# Add parent directory to path so we can import src +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.connection import GraphLite +from src.error import GraphLiteError, SerializationError, QueryError, TransactionError +from src.result import TypedResult + +@dataclass +class Person: + """Person entity for typed deserialization""" + name: str + age: int + + +def main() -> int: + """Run the basic usage example""" + print("=== GraphLite SDK Basic Usage Example ===\n") + + # Use temporary directory for demo + db_path = tempfile.mkdtemp(prefix="graphlite_sdk_example_") + + try: + # 1. Open a database + print("1. Opening database...") + db = GraphLite.open(db_path) + print(f" ✓ Database opened at {db_path}\n") + + # 2. Create a session + print("2. Creating session...") + session = db.session("admin") + print(" ✓ Session created for user 'admin'\n") + + # 3. Execute DDL statements + print("3. Creating schema and graph...") + session.execute("CREATE SCHEMA IF NOT EXISTS /example") + session.execute("SESSION SET SCHEMA /example") + session.execute("CREATE GRAPH IF NOT EXISTS social") + session.execute("SESSION SET GRAPH social") + print(" ✓ Schema and graph created\n") + + # 4. Insert data using transactions + print("4. Inserting data with transaction...") + with session.transaction() as tx: + tx.execute("INSERT (p:Person {name: 'Alice', age: 30})") + tx.execute("INSERT (p:Person {name: 'Bob', age: 25})") + tx.execute("INSERT (p:Person {name: 'Charlie', age: 35})") + tx.execute("INSERT (p:Person {name: 'David', age: 28})") + tx.execute("INSERT (p:Person {name: 'Eve', age: 23})") + tx.execute("INSERT (p:Person {name: 'Frank', age: 40})") + tx.commit() + print(" ✓ Inserted 6 persons\n") + + # 5. Query data directly + print("5. Querying data...") + result = session.query("MATCH (p:Person) RETURN p.name as name, p.age as age") + print(f" Found {len(result.rows)} persons:") + for row in result.rows: + name = row.get("name") + age = row.get("age") + if name is not None and age is not None: + print(f" - Name: {name}, Age: {age}") + print() + + # 6. Use query builder + print("6. Using query builder...") + result = (session.query_builder() + .match_pattern("(p:Person)") + .where_clause("p.age > 25") + .return_clause("p.name as name, p.age as age") + .order_by("p.age DESC") + .execute()) + print(f" Found {len(result.rows)} persons over 25:") + for row in result.rows: + name = row.get("name") + age = row.get("age") + if name is not None and age is not None: + print(f" - Name: {name}, Age: {age}") + print() + + # 7. Typed deserialization + print("7. Using typed deserialization...") + result = session.query("MATCH (p:Person) RETURN p.name as name, p.age as age") + typed = TypedResult(result) + people = typed.deserialize_rows(Person) + print(f" Deserialized {len(people)} persons:") + for person in people: + print(f" - {person}") + print() + + # 8. Transaction with rollback + print("8. Demonstrating transaction rollback...") + try: + with session.transaction() as tx: + tx.execute("INSERT (p:Person {name: 'George', age: 50})") + print(" Inserted person 'George' in transaction") + # Transaction is NOT committed - will auto-rollback + # (by not calling tx.commit()) + except Exception: + pass # Expected - rollback on exception + print(" Transaction rolled back (George not persisted)\n") + + # 9. Verify rollback + result = session.query("MATCH (p:Person) RETURN count(p) as count") + if result.rows: + count = result.rows[0].get("count") + print(f" Person count after rollback: {count}\n") + print("=== Example completed successfully ===") + return 0 + + except GraphLiteError as e: + print(f"\n❌ GraphLite Error: {e}") + return 1 + + except Exception as e: + print(f"\n❌ Unexpected error: {e}") + import traceback + traceback.print_exc() + return 1 + + finally: + # Cleanup temporary directory + shutil.rmtree(db_path, ignore_errors=True) + + +if __name__ == "__main__": + exit(main()) diff --git a/python-sdk/src/__init__.py b/python-sdk/src/__init__.py new file mode 100644 index 0000000..e47eb70 --- /dev/null +++ b/python-sdk/src/__init__.py @@ -0,0 +1,76 @@ +""" +GraphLite SDK - High-level Python API for GraphLite + +This package provides a high-level, developer-friendly SDK on top of GraphLite's core API. +It offers ergonomic patterns, type safety, session management, query builders, and +transaction support - everything needed to build robust graph-based applications in Python. + +Quick Start +----------- + +```python +from graphlite_sdk import GraphLite + +# Open database +db = GraphLite.open("./mydb") + +# Create session +session = db.session("admin") + +# Execute query +result = session.query("MATCH (p:Person) RETURN p.name") + +# Use transactions +with session.transaction() as tx: + tx.execute("INSERT (p:Person {name: 'Alice'})") + tx.commit() +``` + +Architecture +----------- + +``` +Your Application + │ + ▼ +┌─────────────────────────────────────────┐ +│ GraphLite SDK (this package) │ +│ - GraphLite (main API) │ +│ - Session (session management) │ +│ - Transaction (ACID support) │ +│ - QueryBuilder (fluent queries) │ +│ - TypedResult (deserialization) │ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ GraphLite FFI Bindings │ +│ (Low-level ctypes wrapper) │ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ GraphLite Core (Rust) │ +│ - QueryCoordinator │ +│ - Storage Engine │ +│ - Catalog Manager │ +└─────────────────────────────────────────┘ +``` +""" + +from .error import GraphLiteError +from .connection import GraphLite, Session +from .transaction import Transaction +from .query import QueryBuilder +# from .result import TypedResult + +__version__ = "0.1.0" + +__all__ = [ + "GraphLite", + "Session", + "Transaction", + "QueryBuilder", + "TypedResult", + "GraphLiteError", +] diff --git a/python-sdk/src/connection.py b/python-sdk/src/connection.py new file mode 100644 index 0000000..e43afb6 --- /dev/null +++ b/python-sdk/src/connection.py @@ -0,0 +1,161 @@ +""" +Database connection and session management +This module provides the main entry points for working with GraphLite databases. +""" + +import sys +from pathlib import Path + +# Add bindings/python to path so we can import the low-level bindings +bindings_path = Path(__file__).parent.parent.parent / "bindings" / "python" +if str(bindings_path) not in sys.path: + sys.path.insert(0, str(bindings_path)) + +# Low-level GraphLite binding (like QueryCoordinator in Rust) +from graphlite import GraphLite as _GraphLiteBinding, QueryResult +from .error import ConnectionError, SessionError, QueryError + + +class GraphLite: + """ + GraphLite database connection + + High-level wrapper around the FFI bindings, similar to the Rust SDK. + """ + + def __init__(self, db: _GraphLiteBinding): + """ + Internal constructor - use GraphLite.open() instead + + Store instance of the low-level GraphLite binding + """ + self._db = db + + @classmethod + def open(cls, path: str): + """ + Open a GraphLite database at the given path + + Args: + path: Path to the database directory + + Returns: + GraphLite instance + + Raises: + ConnectionError: If database cannot be opened + """ + try: + db = _GraphLiteBinding(path) + return cls(db) + except Exception as e: + raise ConnectionError(f"Failed to open database: {e}") + + def session(self, username: str): + """ + Create a new session for the given user + + Args: + username: Username for the session + + Returns: + Session instance + + Raises: + SessionError: If session creation fails + """ + try: + session_id = self._db.create_session(username) + return Session(session_id, self._db, username) + except Exception as e: + raise SessionError(f"Failed to create session: {e}") + + def close(self): + """Close the database connection""" + if self._db: + self._db.close() + + +class Session: + """ + GraphLite database session + + Provides user context for executing queries. + """ + + def __init__(self, session_id: str, db: _GraphLiteBinding, username: str): + """Internal constructor - use db.session() instead""" + self._session_id = session_id + self._db = db + self._username = username + + def id(self) -> str: + """Get the session ID""" + return self._session_id + + def username(self) -> str: + """Get the username for this session""" + return self._username + + def query(self, query: str) -> QueryResult: + """ + Execute a GQL query + + Args: + query: GQL query string + + Returns: + QueryResult with rows and metadata + + Raises: + QueryError: If query execution fails + """ + try: + return self._db.query(self._session_id, query) + except Exception as e: + raise QueryError(f"Query failed: {e}") + + def execute(self, statement: str): + """ + Execute a statement without returning results + + Args: + statement: GQL statement to execute + + Raises: + QueryError: If execution fails + """ + try: + self._db.execute(self._session_id, statement) + except Exception as e: + raise QueryError(f"Execute failed: {e}") + + def transaction(self): + """ + Begin a new transaction + + Returns a Transaction object that should be used as a context manager. + The transaction will automatically roll back when the context exits + unless commit() is explicitly called. + + Returns: + Transaction instance + + Raises: + TransactionError: If transaction cannot be started + """ + from .transaction import Transaction + return Transaction(self) + + def query_builder(self): + """ + Create a new query builder for fluent query construction + + Returns: + QueryBuilder instance + """ + from .query import QueryBuilder + return QueryBuilder(self) + + +__all__ = ['GraphLite', 'Session', 'QueryResult'] diff --git a/python-sdk/src/error.py b/python-sdk/src/error.py new file mode 100644 index 0000000..1c66e92 --- /dev/null +++ b/python-sdk/src/error.py @@ -0,0 +1,103 @@ +""" +Error types for the GraphLite SDK +""" + +from typing import Union +import json + +class GraphLiteError(Exception): + """ + Base exception for all GraphLite SDK errors. + """ + def __init__(self, message: str): + self.message = message + super().__init__(message) + +class GraphLiteCoreError(GraphLiteError): + """Error from the core GraphLite library""" + def __init__(self, message: str): + super().__init__(f"GraphLite error: {message}") + +class SessionError(GraphLiteError): + """Session-related errors""" + def __init__(self, message: str): + super().__init__(f"Session error: {message}") + +class QueryError(GraphLiteError): + """Query execution errors""" + def __init__(self, message: str): + super().__init__(f"Query error: {message}") + +class TransactionError(GraphLiteError): + """Transaction errors""" + def __init__(self, message: str): + super().__init__(f"Transaction error: {message}") + +class SerializationError(GraphLiteError): + """Serialization/deserialization errors""" + def __init__(self, message: str): + super().__init__(f"Serialization error: {message}") + + @classmethod + def from_json_error(cls, error: json.JSONDecodeError) -> "SerializationError": + """Create SerializationError from json.JSONDecodeError""" + return cls(f"JSON error: {error.msg} at line {error.lineno}, column {error.colno}") + +class TypeConversionError(GraphLiteError): + """Type conversion errors""" + def __init__(self, message: str): + super().__init__(f"Type conversion error: {message}") + +class InvalidOperationError(GraphLiteError): + """Invalid operation errors""" + def __init__(self, message: str): + super().__init__(f"Invalid operation: {message}") + +class NotFoundError(GraphLiteError): + """Resource not found errors""" + def __init__(self, message: str): + super().__init__(f"Not found: {message}") + +class ConnectionError(GraphLiteError): + """Connection errors""" + def __init__(self, message: str): + super().__init__(f"Connection error: {message}") + +class IoError(GraphLiteError): + """I/O errors""" + def __init__(self, message: str): + super().__init__(f"I/O error: {message}") + + @classmethod + def from_io_error(cls, error: OSError) -> "IoError": + """Create IoError from Python OSError (IOError in Python 3)""" + filename = getattr(error, 'filename', None) or '' + strerror = getattr(error, 'strerror', None) or str(error) + return cls(f"{strerror}: {filename}" if filename else strerror) + + +# ============================================================================ +# Error Conversion Helpers +# ============================================================================ + +def from_string(message: Union[str, bytes]) -> GraphLiteCoreError: + """ + Convert a string to GraphLiteCoreError. + """ + if isinstance(message, bytes): + message = message.decode('utf-8', errors='replace') + return GraphLiteCoreError(message) + + +def from_json_error(error: json.JSONDecodeError) -> SerializationError: + """ + Convert json.JSONDecodeError to SerializationError. + """ + return SerializationError.from_json_error(error) + + +def from_io_error(error: OSError) -> IoError: + """ + Convert OSError to IoError. + """ + return IoError.from_io_error(error) diff --git a/python-sdk/src/query.py b/python-sdk/src/query.py new file mode 100644 index 0000000..43c8f88 --- /dev/null +++ b/python-sdk/src/query.py @@ -0,0 +1,156 @@ +''' +Query builder for fluent GQL query construction + +This module provides a builder API for constructing GQL queries in a +type-safe and ergonomic way. +''' + +from typing import Optional, List, TYPE_CHECKING +from .error import QueryError + +if TYPE_CHECKING: + from .connection import Session + +import sys +from pathlib import Path +bindings_path = Path(__file__).parent.parent.parent / "bindings" / "python" +if str(bindings_path) not in sys.path: + sys.path.insert(0, str(bindings_path)) + +class QueryBuilder: + ''' + Fluent API for building GQL queries + + QueryBuilder provides a convenient way to construct complex GQL queries + without manually concatenating strings. + ''' + + def __init__(self, session: 'Session'): + ''' + Initialize the query builder + ''' + self._session = session + self._match_patterns = [] + self._where_clauses = [] + self._with_clauses = [] + self._return_clause = None + self._order_by = None + self._skip = None + self._limit = None + + def match_pattern(self, pattern: str) -> 'QueryBuilder': + ''' + Add a MATCH pattern to the query + Can be called multiple times to add multiple MATCH patterns. + ''' + self._match_patterns.append(pattern) + return self + + def where_clause(self, condition: str) -> 'QueryBuilder': + ''' + Add a WHERE clause to the query + Can be called multiple times - conditions are AND'ed together. + ''' + self._where_clauses.append(condition) + return self + + def with_clause(self, clause: str) -> 'QueryBuilder': + ''' + Add a WITH clause to the query + WITH clauses are used for query chaining and intermediate results. + ''' + self._with_clauses.append(clause) + return self + + def return_clause(self, clause: str) -> 'QueryBuilder': + ''' + Set the RETURN clause to the query + Specifies what to return from the query. Required for MATCH queries. + ''' + self._return_clause = clause + return self + + def order_by(self, clause: str) -> 'QueryBuilder': + ''' + Set the ORDER BY clause to the query + ''' + self._order_by = clause + return self + + def skip(self, n: int) -> 'QueryBuilder': + ''' + Set the SKIP value to the query + Skips the first N results. + ''' + self._skip = n + return self + + def limit(self, n: int) -> 'QueryBuilder': + ''' + Set the LIMIT value to the query + Limits the number of results returned. + ''' + self._limit = n + return self + + def build(self) -> str: + ''' + Build the query string without executing + Returns the constructed GQL query as a string. + ''' + query = "" + + # MATCH clauses + for pattern in self._match_patterns: + if query: + query += " " + query += "MATCH " + query += pattern + + # WHERE clauses + if self._where_clauses: + query += " WHERE " + query += " AND ".join(self._where_clauses) + + # WITH clauses + for clause in self._with_clauses: + query += " WITH " + query += clause + + # RETURN clause + if self._return_clause: + query += " RETURN " + query += self._return_clause + + # ORDER BY clause + if self._order_by: + query += " ORDER BY " + query += self._order_by + + # SKIP clause + if self._skip is not None: + query += " SKIP " + query += str(self._skip) + + # LIMIT clause + if self._limit is not None: + query += " LIMIT " + query += str(self._limit) + + return query.strip() + + def execute(self): + ''' + Execute the query and return results + + Returns: + QueryResult with rows and metadata + + Raises: + QueryError: If query execution fails + ''' + query = self.build() + return self._session.query(query) + + +__all__ = ['QueryBuilder'] \ No newline at end of file diff --git a/python-sdk/src/result.py b/python-sdk/src/result.py new file mode 100644 index 0000000..6eca5d9 --- /dev/null +++ b/python-sdk/src/result.py @@ -0,0 +1,195 @@ +''' +Result handling and typed deserialization + +This module provides utilities for working with query results, including +type-safe deserialization into Rust structs. +''' + +from typing import Optional, List, Any, Dict, TypeVar, Type, TYPE_CHECKING +from .error import SerializationError + +if TYPE_CHECKING: + from .connection import Session + +import sys +from pathlib import Path + +# Add bindings/python to path so we can import the low-level bindings +bindings_path = Path(__file__).parent.parent.parent / "bindings" / "python" +if str(bindings_path) not in sys.path: + sys.path.insert(0, str(bindings_path)) + +from graphlite import QueryResult + +T = TypeVar('T') + +class TypedResult: + ''' + Wrapper around QueryResult with additional type-safe methods + + TypedResult provides convenient methods for deserializing query results + into Python types. + ''' + + def __init__(self, result: QueryResult): + ''' + Create a new TypedResult from a QueryResult + ''' + self.result = result + + def row_count(self) -> int: + ''' + Get the number of rows in the result + ''' + return len(self.result.rows) + + def column_names(self) -> List[str]: + ''' + Get the column names (variables) in the result + ''' + return self.result.variables + + def get_row(self, index: int) -> Optional[Dict[str, Any]]: + ''' + Get a specific row by index + ''' + if 0 <= index < len(self.result.rows): + return self.result.rows[index] + return None + + def deserialize_rows(self, target_type: Type[T]) -> List[T]: + ''' + Deserialize all rows into instances of the target type + + Args: + target_type: A class/type to deserialize into (e.g., a dataclass) + + Returns: + List of deserialized objects + + Raises: + SerializationError: If deserialization fails + + Examples: + >>> @dataclass + >>> class Person: + ... name: str + ... age: int + >>> + >>> result = session.query("MATCH (p:Person) RETURN p.name as name, p.age as age") + >>> typed = TypedResult(result) + >>> people = typed.deserialize_rows(Person) + ''' + try: + objects = [] + for row in self.result.rows: + obj = self.deserialize_row(row, target_type) + objects.append(obj) + return objects + except Exception as e: + raise SerializationError(f"Failed to deserialize rows: {e}") + + def deserialize_row(self, row: Dict[str, Any], target_type: Type[T]) -> T: + ''' + Deserialize a single row dict into an instance of the target type + + Args: + row: Row dictionary to deserialize + target_type: A class/type to deserialize into + + Returns: + Deserialized object + + Raises: + SerializationError: If deserialization fails + + Examples: + >>> row = result.rows[0] + >>> first_person = typed.deserialize_row(row, Person) + ''' + try: + return target_type(**row) + except Exception as e: + raise SerializationError(f"Failed to deserialize row: {e}") + + def first(self, target_type: Type[T]) -> T: + ''' + Get the first row as the given type + + Convenience method for queries that return a single row. + + Args: + target_type: A class/type to deserialize into + + Returns: + Deserialized object from the first row + + Raises: + SerializationError: If no rows exist or deserialization fails + + Examples: + >>> @dataclass + >>> class Count: + ... count: int + >>> + >>> result = session.query("MATCH (p:Person) RETURN count(p) as count") + >>> typed = TypedResult(result) + >>> count_obj = typed.first(Count) + ''' + if self.is_empty(): + raise SerializationError("No rows returned") + + return self.deserialize_row(self.result.rows[0], target_type) + + def scalar(self) -> Any: + ''' + Get a single value from the first row and first column + + Useful for queries that return a single scalar value. + + Returns: + The scalar value from the first row, first column + + Raises: + SerializationError: If no rows or columns exist + + Examples: + >>> result = session.query("MATCH (p:Person) RETURN count(p)") + >>> typed = TypedResult(result) + >>> count = typed.scalar() # Returns the count value directly + ''' + if self.is_empty(): + raise SerializationError("No rows returned") + + columns = self.result.variables + if not columns: + raise SerializationError("No columns returned") + + first_row = self.result.rows[0] + first_column = columns[0] + + if first_column not in first_row: + raise SerializationError(f"Column '{first_column}' not found in row") + + return first_row[first_column] + + def is_empty(self) -> bool: + ''' + Check if the result is empty (no rows) + + Returns: + True if result has no rows, False otherwise + ''' + return len(self.result.rows) == 0 + + def rows(self) -> List[Dict[str, Any]]: + ''' + Get all rows + + Returns: + List of row dictionaries + ''' + return self.result.rows + + +__all__ = ['TypedResult'] \ No newline at end of file diff --git a/python-sdk/src/transaction.py b/python-sdk/src/transaction.py new file mode 100644 index 0000000..bf630f7 --- /dev/null +++ b/python-sdk/src/transaction.py @@ -0,0 +1,205 @@ +""" +Transaction support with ACID guarantees + +This module provides transaction support following the rusqlite pattern: +- Transactions automatically roll back when context exits (unless committed) +- Explicit commit() required to persist changes +- Can be used as a context manager for automatic cleanup +""" + +from typing import TYPE_CHECKING +from .error import TransactionError + +if TYPE_CHECKING: + from .connection import Session + +import sys +from pathlib import Path +bindings_path = Path(__file__).parent.parent.parent / "bindings" / "python" +if str(bindings_path) not in sys.path: + sys.path.insert(0, str(bindings_path)) + +# Import QueryResult for type hints +from graphlite import QueryResult + + +class Transaction: + """ + Represents an active database transaction + + Transactions provide ACID guarantees for multi-statement operations. + Following the rusqlite pattern: + - Transactions automatically roll back when context exits + - Must explicitly call commit() to persist changes + - This prevents accidentally forgetting to commit or rollback + + Examples: + >>> db = GraphLite.open("./mydb") + >>> session = db.session("admin") + >>> + >>> # Using as context manager (recommended) + >>> with session.transaction() as tx: + ... tx.execute("INSERT (p:Person {name: 'Alice'})") + ... tx.execute("INSERT (p:Person {name: 'Bob'})") + ... tx.commit() # Changes are persisted + >>> + >>> # Transaction that rolls back (no commit) + >>> with session.transaction() as tx: + ... tx.execute("INSERT (p:Person {name: 'Charlie'})") + ... # Automatically rolled back on context exit + """ + + def __init__(self, session: 'Session'): + """ + Internal constructor - use session.transaction() instead + + Begin a new transaction + """ + self._session = session + self._committed = False + self._rolled_back = False + + # Execute BEGIN TRANSACTION + try: + self._session._db.execute(self._session._session_id, "BEGIN TRANSACTION") + except Exception as e: + raise TransactionError(f"Failed to begin transaction: {e}") + + def execute(self, statement: str) -> None: + """ + Execute a GQL statement within this transaction + + Args: + statement: GQL statement to execute + + Raises: + TransactionError: If transaction is already finished or execution fails + + Examples: + >>> with session.transaction() as tx: + ... tx.execute("INSERT (p:Person {name: 'Alice'})") + ... tx.execute("INSERT (p:Person {name: 'Bob'})") + ... tx.commit() + """ + if self._committed: + raise TransactionError("Transaction already committed") + if self._rolled_back: + raise TransactionError("Transaction already rolled back") + + try: + self._session._db.execute(self._session._session_id, statement) + except Exception as e: + raise TransactionError(f"Execute failed: {e}") + + def query(self, query: str) -> QueryResult: + """ + Execute a query within this transaction and return results + + Args: + query: GQL query to execute + + Returns: + QueryResult with rows and metadata + + Raises: + TransactionError: If transaction is already finished or query fails + + Examples: + >>> with session.transaction() as tx: + ... tx.execute("INSERT (p:Person {name: 'Alice', age: 30})") + ... result = tx.query("MATCH (p:Person) RETURN p") + ... tx.commit() + """ + if self._committed: + raise TransactionError("Transaction already committed") + if self._rolled_back: + raise TransactionError("Transaction already rolled back") + + try: + return self._session._db.query(self._session._session_id, query) + except Exception as e: + raise TransactionError(f"Query failed: {e}") + + def commit(self) -> None: + """ + Commit the transaction + + Persists all changes made within this transaction. After calling commit(), + the transaction cannot be used further. + + Raises: + TransactionError: If transaction is already finished or commit fails + + Examples: + >>> with session.transaction() as tx: + ... tx.execute("INSERT (p:Person {name: 'Alice'})") + ... tx.commit() # Changes are now persistent + """ + if self._committed: + raise TransactionError("Transaction already committed") + if self._rolled_back: + raise TransactionError("Transaction already rolled back") + + try: + self._session._db.execute(self._session._session_id, "COMMIT") + self._committed = True + except Exception as e: + raise TransactionError(f"Failed to commit: {e}") + + def rollback(self) -> None: + """ + Rollback the transaction + + Discards all changes made within this transaction. This is called + automatically when the transaction context exits, so explicit rollback + is rarely needed. + + Raises: + TransactionError: If rollback fails + + Examples: + >>> with session.transaction() as tx: + ... tx.execute("INSERT (p:Person {name: 'Alice'})") + ... tx.rollback() # Explicit rollback (optional, automatic on exit) + """ + if self._committed: + return # Already committed, nothing to rollback + if self._rolled_back: + return # Already rolled back + + try: + self._session._db.execute(self._session._session_id, "ROLLBACK") + self._rolled_back = True + except Exception as e: + raise TransactionError(f"Failed to rollback: {e}") + + def __enter__(self): + """Context manager entry""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """ + Context manager exit - automatically rollback if not committed + + If an exception occurred, always rollback. + If no exception, rollback unless commit() was called. + """ + if exc_type is not None: + # Exception occurred, always rollback + if not self._committed and not self._rolled_back: + try: + self.rollback() + except Exception: + pass # Ignore rollback errors during exception handling + else: + # No exception - rollback if not committed + if not self._committed and not self._rolled_back: + try: + self.rollback() + except Exception: + pass # Ignore rollback errors + + return False # Don't suppress exceptions + + +__all__ = ['Transaction']