-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(storage): implement storage layer with tests
- Add AbstractStorage base class defining storage interface - Implement JsonStorage for file-based persistence - Add InMemoryStorage for testing purposes - Implement comprehensive test suite - Basic CRUD operations - Search functionality - Error handling - Performance tests - Edge cases - Ensure thread-safety for file operations - Add proper type hints and documentation
- Loading branch information
Showing
4 changed files
with
505 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
# src/storage/abstract.py | ||
from abc import ABC, abstractmethod | ||
|
||
from src.models.book import Book | ||
|
||
|
||
class AbstractStorage(ABC): | ||
"""Abstract base class defining the storage interface for the book management | ||
system.""" | ||
|
||
@abstractmethod | ||
def add(self, book: Book) -> None: | ||
""" | ||
Add a new book to the storage. | ||
Args: | ||
book: Book instance to store | ||
Raises: | ||
ValueError: If book with the same ID already exists | ||
StorageError: If storage operation fails | ||
""" | ||
|
||
@abstractmethod | ||
def get(self, book_id: str) -> Book | None: | ||
""" | ||
Retrieve a book by its ID. | ||
Args: | ||
book_id: Unique identifier of the book | ||
Returns: | ||
Book instance if found, None otherwise | ||
Raises: | ||
StorageError: If storage operation fails | ||
""" | ||
|
||
@abstractmethod | ||
def update(self, book: Book) -> None: | ||
""" | ||
Update an existing book in storage. | ||
Args: | ||
book: Book instance with updated data | ||
Raises: | ||
ValueError: If book doesn't exist | ||
StorageError: If storage operation fails | ||
""" | ||
|
||
@abstractmethod | ||
def delete(self, book_id: str) -> None: | ||
""" | ||
Delete a book from storage. | ||
Args: | ||
book_id: Unique identifier of the book to delete | ||
Raises: | ||
ValueError: If book doesn't exist | ||
StorageError: If storage operation fails | ||
""" | ||
|
||
@abstractmethod | ||
def list_all(self) -> list[Book]: | ||
""" | ||
Retrieve all books from storage. | ||
Returns: | ||
List of all Book instances | ||
Raises: | ||
StorageError: If storage operation fails | ||
""" | ||
|
||
@abstractmethod | ||
def search(self, query: str, field: str) -> list[Book]: | ||
""" | ||
Search for books by a specific field. | ||
Args: | ||
query: Search query string | ||
field: Field to search in ('title', 'author', or 'year') | ||
Returns: | ||
List of matching Book instances | ||
Raises: | ||
ValueError: If field is invalid | ||
StorageError: If storage operation fails | ||
""" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,158 @@ | ||
# src/storage/json_storage.py | ||
import json | ||
from pathlib import Path | ||
from threading import Lock | ||
from typing import Any # Add this import at the top with other imports | ||
|
||
from src.models.book import Book | ||
|
||
from .abstract import AbstractStorage | ||
|
||
|
||
class StorageError(Exception): | ||
"""Custom exception for storage-related errors.""" | ||
|
||
|
||
class JsonStorage(AbstractStorage): | ||
"""JSON file-based implementation of book storage.""" | ||
|
||
def __init__(self, file_path: str | Path) -> None: | ||
""" | ||
Initialize JSON storage. | ||
Args: | ||
file_path: Path to the JSON storage file | ||
""" | ||
self.file_path = Path(file_path) | ||
self._lock = Lock() # For thread-safe file operations | ||
self._ensure_storage_exists() | ||
|
||
def _ensure_storage_exists(self) -> None: | ||
"""Ensure storage file exists and is valid JSON.""" | ||
if not self.file_path.exists(): | ||
self.file_path.parent.mkdir(parents=True, exist_ok=True) | ||
try: | ||
self._save_data({}) | ||
except Exception as e: | ||
raise StorageError(f"Failed to create storage file: {e}") from e | ||
else: | ||
try: | ||
self._load_data() | ||
except json.JSONDecodeError as e: | ||
raise StorageError(f"Storage file contains invalid JSON: {e}") from e | ||
|
||
def _load_data(self) -> dict[str, dict[str, Any]]: | ||
"""Load data from JSON file.""" | ||
try: | ||
with self._lock, open(self.file_path, encoding="utf-8") as f: | ||
return json.load(f) # type: ignore | ||
except Exception as e: | ||
raise StorageError(f"Failed to load storage: {e}") from e | ||
|
||
def _save_data(self, data: dict) -> None: | ||
"""Save data to JSON file.""" | ||
try: | ||
with self._lock, open(self.file_path, "w", encoding="utf-8") as f: | ||
json.dump(data, f, indent=2, ensure_ascii=False) | ||
except Exception as e: | ||
raise StorageError(f"Failed to save to storage: {e}") from e | ||
|
||
def add(self, book: Book) -> None: | ||
data = self._load_data() | ||
if book.id.value in data: | ||
raise ValueError(f"Book with ID {book.id.value} already exists") | ||
|
||
data[book.id.value] = book.to_dict() | ||
self._save_data(data) | ||
|
||
def get(self, book_id: str) -> Book | None: | ||
data = self._load_data() | ||
book_data = data.get(book_id) | ||
return Book.from_dict(book_data) if book_data else None | ||
|
||
def update(self, book: Book) -> None: | ||
data = self._load_data() | ||
if book.id.value not in data: | ||
raise ValueError(f"Book with ID {book.id.value} not found") | ||
|
||
data[book.id.value] = book.to_dict() | ||
self._save_data(data) | ||
|
||
def delete(self, book_id: str) -> None: | ||
data = self._load_data() | ||
if book_id not in data: | ||
raise ValueError(f"Book with ID {book_id} not found") | ||
|
||
del data[book_id] | ||
self._save_data(data) | ||
|
||
def list_all(self) -> list[Book]: | ||
data = self._load_data() | ||
return [Book.from_dict(book_data) for book_data in data.values()] | ||
|
||
def search(self, query: str, field: str) -> list[Book]: | ||
if field not in {"title", "author", "year"}: | ||
raise ValueError(f"Invalid search field: {field}") | ||
|
||
data = self._load_data() | ||
results = [] | ||
|
||
for book_data in data.values(): | ||
if field == "year": | ||
# For year, convert query to int and do exact match | ||
try: | ||
if int(query) == book_data["year"]: | ||
results.append(Book.from_dict(book_data)) | ||
except ValueError: | ||
continue | ||
# For strings, do case-insensitive partial match | ||
elif str(query).lower() in str(book_data[field]).lower(): | ||
results.append(Book.from_dict(book_data)) | ||
|
||
return results | ||
|
||
|
||
class InMemoryStorage(AbstractStorage): | ||
"""In-memory implementation of book storage for testing.""" | ||
|
||
def __init__(self) -> None: | ||
"""Initialize in-memory storage.""" | ||
self._storage: dict[str, dict] = {} | ||
|
||
def add(self, book: Book) -> None: | ||
if book.id.value in self._storage: | ||
raise ValueError(f"Book with ID {book.id.value} already exists") | ||
self._storage[book.id.value] = book.to_dict() | ||
|
||
def get(self, book_id: str) -> Book | None: | ||
book_data = self._storage.get(book_id) | ||
return Book.from_dict(book_data) if book_data else None | ||
|
||
def update(self, book: Book) -> None: | ||
if book.id.value not in self._storage: | ||
raise ValueError(f"Book with ID {book.id.value} not found") | ||
self._storage[book.id.value] = book.to_dict() | ||
|
||
def delete(self, book_id: str) -> None: | ||
if book_id not in self._storage: | ||
raise ValueError(f"Book with ID {book_id} not found") | ||
del self._storage[book_id] | ||
|
||
def list_all(self) -> list[Book]: | ||
return [Book.from_dict(book_data) for book_data in self._storage.values()] | ||
|
||
def search(self, query: str, field: str) -> list[Book]: | ||
if field not in {"title", "author", "year"}: | ||
raise ValueError(f"Invalid search field: {field}") | ||
|
||
results = [] | ||
for book_data in self._storage.values(): | ||
if field == "year": | ||
try: | ||
if int(query) == book_data["year"]: | ||
results.append(Book.from_dict(book_data)) | ||
except ValueError: | ||
continue | ||
elif str(query).lower() in str(book_data[field]).lower(): | ||
results.append(Book.from_dict(book_data)) | ||
return results |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.