442 lines
14 KiB
Python
442 lines
14 KiB
Python
"""
|
|
Database Service for Appwrite database operations.
|
|
|
|
This service wraps the Appwrite Databases SDK to provide a clean interface
|
|
for CRUD operations on collections. It handles JSON serialization, error handling,
|
|
and provides structured logging.
|
|
"""
|
|
|
|
import os
|
|
from typing import Dict, Any, List, Optional
|
|
from datetime import datetime, timezone
|
|
from dataclasses import dataclass
|
|
|
|
from appwrite.client import Client
|
|
from appwrite.services.tables_db import TablesDB
|
|
from appwrite.exception import AppwriteException
|
|
from appwrite.id import ID
|
|
|
|
from app.utils.logging import get_logger
|
|
|
|
logger = get_logger(__file__)
|
|
|
|
|
|
@dataclass
|
|
class DatabaseRow:
|
|
"""
|
|
Represents a row in an Appwrite table.
|
|
|
|
Attributes:
|
|
id: Row ID
|
|
table_id: Table ID
|
|
data: Row data (parsed from JSON)
|
|
created_at: Creation timestamp
|
|
updated_at: Last update timestamp
|
|
"""
|
|
id: str
|
|
table_id: str
|
|
data: Dict[str, Any]
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convert row to dictionary."""
|
|
return {
|
|
"id": self.id,
|
|
"table_id": self.table_id,
|
|
"data": self.data,
|
|
"created_at": self.created_at.isoformat() if isinstance(self.created_at, datetime) else self.created_at,
|
|
"updated_at": self.updated_at.isoformat() if isinstance(self.updated_at, datetime) else self.updated_at,
|
|
}
|
|
|
|
|
|
class DatabaseService:
|
|
"""
|
|
Service for interacting with Appwrite database tables.
|
|
|
|
This service provides methods for:
|
|
- Creating rows
|
|
- Reading rows by ID or query
|
|
- Updating rows
|
|
- Deleting rows
|
|
- Querying with filters
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""
|
|
Initialize the database service.
|
|
|
|
Reads configuration from environment variables:
|
|
- APPWRITE_ENDPOINT: Appwrite API endpoint
|
|
- APPWRITE_PROJECT_ID: Appwrite project ID
|
|
- APPWRITE_API_KEY: Appwrite API key
|
|
- APPWRITE_DATABASE_ID: Appwrite database ID
|
|
"""
|
|
self.endpoint = os.getenv('APPWRITE_ENDPOINT')
|
|
self.project_id = os.getenv('APPWRITE_PROJECT_ID')
|
|
self.api_key = os.getenv('APPWRITE_API_KEY')
|
|
self.database_id = os.getenv('APPWRITE_DATABASE_ID', 'main')
|
|
|
|
if not all([self.endpoint, self.project_id, self.api_key]):
|
|
logger.error("Missing Appwrite configuration in environment variables")
|
|
raise ValueError("Appwrite configuration incomplete. Check APPWRITE_* environment variables.")
|
|
|
|
# Initialize Appwrite client
|
|
self.client = Client()
|
|
self.client.set_endpoint(self.endpoint)
|
|
self.client.set_project(self.project_id)
|
|
self.client.set_key(self.api_key)
|
|
|
|
# Initialize TablesDB service
|
|
self.tables_db = TablesDB(self.client)
|
|
|
|
logger.info("DatabaseService initialized", database_id=self.database_id)
|
|
|
|
def create_row(
|
|
self,
|
|
table_id: str,
|
|
data: Dict[str, Any],
|
|
row_id: Optional[str] = None,
|
|
permissions: Optional[List[str]] = None
|
|
) -> DatabaseRow:
|
|
"""
|
|
Create a new row in a table.
|
|
|
|
Args:
|
|
table_id: Table ID (e.g., "characters")
|
|
data: Row data (will be JSON-serialized if needed)
|
|
row_id: Optional custom row ID (auto-generated if None)
|
|
permissions: Optional permissions array
|
|
|
|
Returns:
|
|
DatabaseRow with created row
|
|
|
|
Raises:
|
|
AppwriteException: If creation fails
|
|
"""
|
|
try:
|
|
logger.info("Creating row", table_id=table_id, has_custom_id=bool(row_id))
|
|
|
|
# Generate ID if not provided
|
|
if row_id is None:
|
|
row_id = ID.unique()
|
|
|
|
# Create row (Appwrite manages timestamps automatically via $createdAt/$updatedAt)
|
|
result = self.tables_db.create_row(
|
|
database_id=self.database_id,
|
|
table_id=table_id,
|
|
row_id=row_id,
|
|
data=data,
|
|
permissions=permissions or []
|
|
)
|
|
|
|
logger.info("Row created successfully",
|
|
table_id=table_id,
|
|
row_id=result['$id'])
|
|
|
|
return self._parse_row(result, table_id)
|
|
|
|
except AppwriteException as e:
|
|
logger.error("Failed to create row",
|
|
table_id=table_id,
|
|
error=str(e),
|
|
code=e.code)
|
|
raise
|
|
|
|
def get_row(self, table_id: str, row_id: str) -> Optional[DatabaseRow]:
|
|
"""
|
|
Get a row by ID.
|
|
|
|
Args:
|
|
table_id: Table ID
|
|
row_id: Row ID
|
|
|
|
Returns:
|
|
DatabaseRow or None if not found
|
|
|
|
Raises:
|
|
AppwriteException: If retrieval fails (except 404)
|
|
"""
|
|
try:
|
|
logger.debug("Fetching row", table_id=table_id, row_id=row_id)
|
|
|
|
result = self.tables_db.get_row(
|
|
database_id=self.database_id,
|
|
table_id=table_id,
|
|
row_id=row_id
|
|
)
|
|
|
|
return self._parse_row(result, table_id)
|
|
|
|
except AppwriteException as e:
|
|
if e.code == 404:
|
|
logger.warning("Row not found",
|
|
table_id=table_id,
|
|
row_id=row_id)
|
|
return None
|
|
logger.error("Failed to fetch row",
|
|
table_id=table_id,
|
|
row_id=row_id,
|
|
error=str(e),
|
|
code=e.code)
|
|
raise
|
|
|
|
def update_row(
|
|
self,
|
|
table_id: str,
|
|
row_id: str,
|
|
data: Dict[str, Any],
|
|
permissions: Optional[List[str]] = None
|
|
) -> DatabaseRow:
|
|
"""
|
|
Update an existing row.
|
|
|
|
Args:
|
|
table_id: Table ID
|
|
row_id: Row ID
|
|
data: New row data (partial updates supported)
|
|
permissions: Optional permissions array
|
|
|
|
Returns:
|
|
DatabaseRow with updated row
|
|
|
|
Raises:
|
|
AppwriteException: If update fails
|
|
"""
|
|
try:
|
|
logger.info("Updating row", table_id=table_id, row_id=row_id)
|
|
|
|
# Update row (Appwrite manages timestamps automatically via $updatedAt)
|
|
result = self.tables_db.update_row(
|
|
database_id=self.database_id,
|
|
table_id=table_id,
|
|
row_id=row_id,
|
|
data=data,
|
|
permissions=permissions
|
|
)
|
|
|
|
logger.info("Row updated successfully",
|
|
table_id=table_id,
|
|
row_id=row_id)
|
|
|
|
return self._parse_row(result, table_id)
|
|
|
|
except AppwriteException as e:
|
|
logger.error("Failed to update row",
|
|
table_id=table_id,
|
|
row_id=row_id,
|
|
error=str(e),
|
|
code=e.code)
|
|
raise
|
|
|
|
def delete_row(self, table_id: str, row_id: str) -> bool:
|
|
"""
|
|
Delete a row.
|
|
|
|
Args:
|
|
table_id: Table ID
|
|
row_id: Row ID
|
|
|
|
Returns:
|
|
True if deletion successful
|
|
|
|
Raises:
|
|
AppwriteException: If deletion fails
|
|
"""
|
|
try:
|
|
logger.info("Deleting row", table_id=table_id, row_id=row_id)
|
|
|
|
self.tables_db.delete_row(
|
|
database_id=self.database_id,
|
|
table_id=table_id,
|
|
row_id=row_id
|
|
)
|
|
|
|
logger.info("Row deleted successfully",
|
|
table_id=table_id,
|
|
row_id=row_id)
|
|
return True
|
|
|
|
except AppwriteException as e:
|
|
logger.error("Failed to delete row",
|
|
table_id=table_id,
|
|
row_id=row_id,
|
|
error=str(e),
|
|
code=e.code)
|
|
raise
|
|
|
|
def list_rows(
|
|
self,
|
|
table_id: str,
|
|
queries: Optional[List[str]] = None,
|
|
limit: int = 25,
|
|
offset: int = 0
|
|
) -> List[DatabaseRow]:
|
|
"""
|
|
List rows in a table with optional filtering.
|
|
|
|
Args:
|
|
table_id: Table ID
|
|
queries: Optional Appwrite query filters
|
|
limit: Maximum rows to return (default 25, max 100)
|
|
offset: Number of rows to skip
|
|
|
|
Returns:
|
|
List of DatabaseRow instances
|
|
|
|
Raises:
|
|
AppwriteException: If query fails
|
|
"""
|
|
try:
|
|
logger.debug("Listing rows",
|
|
table_id=table_id,
|
|
has_queries=bool(queries),
|
|
limit=limit,
|
|
offset=offset)
|
|
|
|
result = self.tables_db.list_rows(
|
|
database_id=self.database_id,
|
|
table_id=table_id,
|
|
queries=queries or []
|
|
)
|
|
|
|
rows = [self._parse_row(row, table_id) for row in result['rows']]
|
|
|
|
logger.debug("Rows listed successfully",
|
|
table_id=table_id,
|
|
count=len(rows),
|
|
total=result.get('total', len(rows)))
|
|
|
|
return rows
|
|
|
|
except AppwriteException as e:
|
|
logger.error("Failed to list rows",
|
|
table_id=table_id,
|
|
error=str(e),
|
|
code=e.code)
|
|
raise
|
|
|
|
def count_rows(self, table_id: str, queries: Optional[List[str]] = None) -> int:
|
|
"""
|
|
Count rows in a table with optional filtering.
|
|
|
|
Args:
|
|
table_id: Table ID
|
|
queries: Optional Appwrite query filters
|
|
|
|
Returns:
|
|
Row count
|
|
|
|
Raises:
|
|
AppwriteException: If query fails
|
|
"""
|
|
try:
|
|
logger.debug("Counting rows", table_id=table_id, has_queries=bool(queries))
|
|
|
|
result = self.tables_db.list_rows(
|
|
database_id=self.database_id,
|
|
table_id=table_id,
|
|
queries=queries or []
|
|
)
|
|
|
|
count = result.get('total', len(result.get('rows', [])))
|
|
logger.debug("Rows counted", table_id=table_id, count=count)
|
|
return count
|
|
|
|
except AppwriteException as e:
|
|
logger.error("Failed to count rows",
|
|
table_id=table_id,
|
|
error=str(e),
|
|
code=e.code)
|
|
raise
|
|
|
|
def _parse_row(self, row: Dict[str, Any], table_id: str) -> DatabaseRow:
|
|
"""
|
|
Parse Appwrite row into DatabaseRow.
|
|
|
|
Args:
|
|
row: Appwrite row dictionary
|
|
table_id: Table ID
|
|
|
|
Returns:
|
|
DatabaseRow instance
|
|
"""
|
|
# Extract metadata
|
|
row_id = row['$id']
|
|
created_at = row.get('$createdAt', datetime.now(timezone.utc).isoformat())
|
|
updated_at = row.get('$updatedAt', datetime.now(timezone.utc).isoformat())
|
|
|
|
# Parse timestamps
|
|
if isinstance(created_at, str):
|
|
created_at = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
|
|
if isinstance(updated_at, str):
|
|
updated_at = datetime.fromisoformat(updated_at.replace('Z', '+00:00'))
|
|
|
|
# Remove Appwrite metadata from data
|
|
data = {k: v for k, v in row.items() if not k.startswith('$')}
|
|
|
|
return DatabaseRow(
|
|
id=row_id,
|
|
table_id=table_id,
|
|
data=data,
|
|
created_at=created_at,
|
|
updated_at=updated_at
|
|
)
|
|
|
|
# Backward compatibility aliases (deprecated, use new methods)
|
|
def create_document(self, collection_id: str, data: Dict[str, Any],
|
|
document_id: Optional[str] = None,
|
|
permissions: Optional[List[str]] = None) -> DatabaseRow:
|
|
"""Deprecated: Use create_row() instead."""
|
|
logger.warning("create_document() is deprecated, use create_row() instead")
|
|
return self.create_row(collection_id, data, document_id, permissions)
|
|
|
|
def get_document(self, collection_id: str, document_id: str) -> Optional[DatabaseRow]:
|
|
"""Deprecated: Use get_row() instead."""
|
|
logger.warning("get_document() is deprecated, use get_row() instead")
|
|
return self.get_row(collection_id, document_id)
|
|
|
|
def update_document(self, collection_id: str, document_id: str,
|
|
data: Dict[str, Any],
|
|
permissions: Optional[List[str]] = None) -> DatabaseRow:
|
|
"""Deprecated: Use update_row() instead."""
|
|
logger.warning("update_document() is deprecated, use update_row() instead")
|
|
return self.update_row(collection_id, document_id, data, permissions)
|
|
|
|
def delete_document(self, collection_id: str, document_id: str) -> bool:
|
|
"""Deprecated: Use delete_row() instead."""
|
|
logger.warning("delete_document() is deprecated, use delete_row() instead")
|
|
return self.delete_row(collection_id, document_id)
|
|
|
|
def list_documents(self, collection_id: str, queries: Optional[List[str]] = None,
|
|
limit: int = 25, offset: int = 0) -> List[DatabaseRow]:
|
|
"""Deprecated: Use list_rows() instead."""
|
|
logger.warning("list_documents() is deprecated, use list_rows() instead")
|
|
return self.list_rows(collection_id, queries, limit, offset)
|
|
|
|
def count_documents(self, collection_id: str, queries: Optional[List[str]] = None) -> int:
|
|
"""Deprecated: Use count_rows() instead."""
|
|
logger.warning("count_documents() is deprecated, use count_rows() instead")
|
|
return self.count_rows(collection_id, queries)
|
|
|
|
|
|
# Backward compatibility alias
|
|
DatabaseDocument = DatabaseRow
|
|
|
|
|
|
# Global instance for convenience
|
|
_service_instance: Optional[DatabaseService] = None
|
|
|
|
|
|
def get_database_service() -> DatabaseService:
|
|
"""
|
|
Get the global DatabaseService instance.
|
|
|
|
Returns:
|
|
Singleton DatabaseService instance
|
|
"""
|
|
global _service_instance
|
|
if _service_instance is None:
|
|
_service_instance = DatabaseService()
|
|
return _service_instance
|