Skip to content

Commit

Permalink
feat(storage): implement storage layer with tests
Browse files Browse the repository at this point in the history
- 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
TheFoxKD committed Nov 23, 2024
1 parent 80590b3 commit 2d490d6
Show file tree
Hide file tree
Showing 4 changed files with 505 additions and 10 deletions.
92 changes: 92 additions & 0 deletions src/storage/abstract.py
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
"""
158 changes: 158 additions & 0 deletions src/storage/json_storage.py
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
40 changes: 30 additions & 10 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# tests/conftest.py
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any

import pytest

from src.models.book import Book, BookStatus
from src.storage.abstract import AbstractStorage
from src.storage.json_storage import InMemoryStorage, JsonStorage


@pytest.fixture
Expand All @@ -19,18 +22,15 @@ def mock_current_time(monkeypatch) -> datetime:
Fixed datetime object
"""
initial_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC)
next_time = datetime(2024, 1, 1, 12, 1, 0, tzinfo=UTC) # 1 minute later
times = [initial_time, next_time]
current_index = 0
time_increment = 1 # increment in minutes

class MockDatetime:
_current_time = initial_time

@classmethod
def now(cls, tz=None): # Add tz parameter
nonlocal current_index
time = times[current_index]
if current_index < len(times) - 1:
current_index += 1
return time
def now(cls, tz=None):
cls._current_time += timedelta(minutes=time_increment)
return cls._current_time

@classmethod
def fromisoformat(cls, date_string):
Expand Down Expand Up @@ -133,3 +133,23 @@ def invalid_book_data() -> dict[str, dict[str, Any]]:
"long_title": {"title": "x" * 201, "author": "Valid Author", "year": 2020},
"long_author": {"title": "Valid Title", "author": "x" * 101, "year": 2020},
}


@pytest.fixture
def storage_file(tmp_path) -> Path:
"""Create a temporary storage file."""
return tmp_path / "test_storage.json"


@pytest.fixture(params=[JsonStorage, InMemoryStorage])
def storage(request, storage_file) -> AbstractStorage:
"""
Parametrized fixture providing both storage implementations.
For JsonStorage, uses a temporary file.
For InMemoryStorage, the file parameter is ignored.
"""
storage_class: type[AbstractStorage] = request.param
if storage_class == JsonStorage:
return JsonStorage(storage_file)
return InMemoryStorage()
Loading

0 comments on commit 2d490d6

Please sign in to comment.