From 33f3356f2be348dd920f489d5334b715ead1f0a6 Mon Sep 17 00:00:00 2001 From: waclaw-claw Date: Thu, 9 Apr 2026 12:30:15 -0400 Subject: [PATCH] feat: add SearchRepository for hash-based search --- src/db/search.py | 52 ++++++++++++++++++ tests/conftest.py | 19 +++++++ tests/test_search.py | 128 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 src/db/search.py create mode 100644 tests/conftest.py create mode 100644 tests/test_search.py diff --git a/src/db/search.py b/src/db/search.py new file mode 100644 index 0000000..1d85ba9 --- /dev/null +++ b/src/db/search.py @@ -0,0 +1,52 @@ +"""Repository for search operations across blocks and transactions.""" + +from typing import Optional, Tuple + +from sqlmodel import select + +from db.clients import DbClient +from models.block import Block +from models.transactions.transaction import Transaction + + +class SearchRepository: + """Repository for search operations across blocks and transactions.""" + + def __init__(self, client: DbClient): + self.client = client + + async def search_by_hash(self, hash_value: str) -> Optional[Tuple[str, int]]: + """ + Search for a block or transaction by hash. + + Args: + hash_value: Hex string hash (with or without 0x prefix) + + Returns: + Tuple of (type, id) where type is "block" or "transaction", + or None if not found. + """ + # Normalize hash (handle 0x prefix) + if hash_value.startswith("0x"): + hash_bytes = bytes.fromhex(hash_value[2:]) + else: + hash_bytes = bytes.fromhex(hash_value) + + with self.client.session() as session: + # Try to find as block first + block_result = session.exec( + select(Block).where(Block.hash == hash_bytes) + ).first() + + if block_result: + return ("block", block_result.id) + + # Try to find as transaction + tx_result = session.exec( + select(Transaction).where(Transaction.hash == hash_bytes) + ).first() + + if tx_result: + return ("transaction", tx_result.id) + + return None diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e30b222 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +"""Pytest configuration and fixtures.""" + +import sys +from pathlib import Path + +import pytest + +# Add src directory to path for imports +src_path = Path(__file__).parent.parent / "src" +if str(src_path) not in sys.path: + sys.path.insert(0, str(src_path)) + + +@pytest.fixture(scope="session") +def event_loop_policy(): + """Use default event loop policy.""" + import asyncio + + return asyncio.DefaultEventLoopPolicy() diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..6db3f1f --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,128 @@ +"""Tests for SearchRepository.""" + +import tempfile +from pathlib import Path + +import pytest + +from db.clients.sqlite import SqliteClient +from db.search import SearchRepository +from models.block import Block + + +@pytest.mark.asyncio +async def test_search_by_block_hash(): + with tempfile.TemporaryDirectory() as tmpdir: + db_path = f"sqlite:///{Path(tmpdir) / 'test.db'}" + client = SqliteClient(db_path) + repo = SearchRepository(client) + + # Search for non-existent block should return None + result = await repo.search_by_hash("0x" + "00" * 32) + assert result is None + + +@pytest.mark.asyncio +async def test_search_by_transaction_hash(): + with tempfile.TemporaryDirectory() as tmpdir: + db_path = f"sqlite:///{Path(tmpdir) / 'test.db'}" + client = SqliteClient(db_path) + repo = SearchRepository(client) + + # Search for non-existent transaction should return None + result = await repo.search_by_hash("0x" + "11" * 32) + assert result is None + + +@pytest.mark.asyncio +async def test_search_finds_block(): + with tempfile.TemporaryDirectory() as tmpdir: + db_path = f"sqlite:///{Path(tmpdir) / 'test.db'}" + client = SqliteClient(db_path) + repo = SearchRepository(client) + + # Create a block + test_hash = bytes.fromhex("aa" * 32) + block = Block( + hash=test_hash, + parent_block=b"\x00" * 32, + slot=1, + height=0, + block_root=b"\x00" * 32, + proof_of_leadership=None, + ) + + with client.session() as session: + session.add(block) + session.flush() + block_id = block.id + session.commit() + + # Search for the block + result = await repo.search_by_hash("0x" + "aa" * 32) + assert result is not None + assert result[0] == "block" + assert result[1] == block_id + + +@pytest.mark.asyncio +async def test_search_without_0x_prefix(): + with tempfile.TemporaryDirectory() as tmpdir: + db_path = f"sqlite:///{Path(tmpdir) / 'test.db'}" + client = SqliteClient(db_path) + repo = SearchRepository(client) + + # Create a block + test_hash = bytes.fromhex("dd" * 32) + block = Block( + hash=test_hash, + parent_block=b"\x00" * 32, + slot=1, + height=0, + block_root=b"\x00" * 32, + proof_of_leadership=None, + ) + + with client.session() as session: + session.add(block) + session.flush() + session.commit() + + # Search without 0x prefix + result = await repo.search_by_hash("dd" * 32) + assert result is not None + assert result[0] == "block" + + +@pytest.mark.asyncio +async def test_search_returns_tuple_with_id(): + with tempfile.TemporaryDirectory() as tmpdir: + db_path = f"sqlite:///{Path(tmpdir) / 'test.db'}" + client = SqliteClient(db_path) + repo = SearchRepository(client) + + # Create a block + test_hash = bytes.fromhex("ee" * 32) + block = Block( + hash=test_hash, + parent_block=b"\x00" * 32, + slot=1, + height=0, + block_root=b"\x00" * 32, + proof_of_leadership=None, + ) + + with client.session() as session: + session.add(block) + session.flush() + block_id = block.id + session.commit() + + # Search should return tuple (type, id) + result = await repo.search_by_hash("0x" + "ee" * 32) + assert result is not None + assert isinstance(result, tuple) + assert len(result) == 2 + assert result[0] == "block" + assert isinstance(result[1], int) + assert result[1] > 0