diff --git a/.github/workflows/sqlguard-ci.yml b/.github/workflows/sqlguard-ci.yml index 31a8d46..4861230 100644 --- a/.github/workflows/sqlguard-ci.yml +++ b/.github/workflows/sqlguard-ci.yml @@ -23,7 +23,7 @@ jobs: run: | export UV_LINK_MODE=copy uv sync --all-extras - uv run pytest + uv run pytest tests test-tox-sqlguard: runs-on: ubuntu-latest @@ -37,7 +37,7 @@ jobs: uv tool install tox --with tox-uv echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Run Tox suite - run: tox run + run: tox run -- tests build-sqlguard: needs: [test-sqlguard, test-tox-sqlguard] diff --git a/examples/books_cli/README.md b/examples/books_cli/README.md new file mode 100644 index 0000000..eb6d7e2 --- /dev/null +++ b/examples/books_cli/README.md @@ -0,0 +1,114 @@ +# ๐Ÿ“š Books CLI โ€” Demo Project for pytest-sqlguard + +This sample project showcases how to use [`pytest-sqlguard`](https://pypi.org/project/pytest-sqlguard/) in a realistic application. It includes a minimal CLI interface and a FastAPI web application for managing a books and authors database. + +The main goal of this project is to demonstrate how `pytest-sqlguard` can help you detect and manage SQL changes in your codebase using snapshot testing. + +--- + +## ๐Ÿš€ Quick Start + +This project uses [`uv`](https://docs.astral.sh/uv/) as the environment and task manager. + +#### 1. Initialize the environment + +```bash +uv init +``` + +--- + +## ๐Ÿ–ฅ๏ธ Command-Line Interface + +The CLI is defined in the script [`books.py`](./books.py). + +๐Ÿ“Œ To view CLI options: + +```bash +uv run books.py --help +``` + +### CLI Commands + +- ๐Ÿ—๏ธ Create the SQLite database (Run this at least once): + + ```bash + uv run books.py init-db + ``` + +- ๐Ÿงน Drop the database: + + ```bash + uv run books.py drop-db + ``` + +- โœ๏ธ Manage authors (subcommand): + + ```bash + uv run books.py authors + ``` + +--- + +## ๐ŸŒ Web API (FastAPI) + +You can run the FastAPI app located in [`books_cli/main.py`](./books_cli/main.py): + +```bash +uv run fastapi dev books_cli/main.py +``` + +- API documentation is available at: + ๐Ÿ‘‰ [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs) + +--- + +## ๐Ÿงช Running Tests with pytest-sqlguard + +The test suite illustrates how `pytest-sqlguard` detects unexpected SQL changes via snapshot testing. + +Run the tests: + +```bash +uv run pytest +``` + +Want to see `pytest-sqlguard` in action? + +1. Open [`books_cli/operations/search.py`](./books_cli/operations/search.py) +2. Locate the function `list_books_by_author_name` +3. Uncomment the version that uses a SQL JOIN +4. Run tests again: + + ```bash + uv run pytest + ``` + +You should see SQL snapshot mismatches for both: + +- [`tests/test_author.py`](./tests/test_author.py) +- [`tests/test_author_api.py`](./tests/test_author_api.py) + +To resolve and update the snapshots to the new queries: + +```bash +uv run pytest --sqlguard-overwrite +``` + +This will overwrite the following snapshot files: + +- [`tests/test_author.queries.yaml`](./tests/test_author.queries.yaml) +- [`tests/test_author_api.queries.yaml`](./tests/test_author_api.queries.yaml) + +--- + +## ๐Ÿงฐ Tech Stack + +This project leverages: + +- โšก๏ธ [FastAPI](https://fastapi.tiangolo.com/) โ€“ Web framework +- ๐Ÿ [Typer](https://typer.tiangolo.com/) โ€“ CLI framework +- ๐Ÿ˜ [SQLAlchemy](https://www.sqlalchemy.org/) โ€“ ORM +- ๐Ÿ”Ž [pytest](https://docs.pytest.org/en/stable/) โ€“ Test runner +- ๐Ÿ” [pytest-sqlguard](https://pypi.org/project/pytest-sqlguard/) โ€“ SQL snapshot testing +- ๐Ÿš€ [uv](https://docs.astral.sh/uv/) โ€“ Python dependency and virtualenv manager diff --git a/examples/books_cli/books.py b/examples/books_cli/books.py new file mode 100644 index 0000000..72472b5 --- /dev/null +++ b/examples/books_cli/books.py @@ -0,0 +1,59 @@ +from contextlib import contextmanager +from decimal import Decimal + +import rich +import typer +from books_cli.model_base import SQLABase +from books_cli.operations.author import create_author, list_authors +from books_cli.operations.books import create_book +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +app = typer.Typer(no_args_is_help=True) + +engine = create_engine("sqlite:///books.db", echo=True) + + +@contextmanager +def session() -> Session: + with Session(autoflush=True, bind=engine) as session: + yield session + session.commit() + + +@app.command() +def init_db(): + SQLABase.metadata.create_all(bind=engine) + with session() as _session: + tolkien = create_author(name="Tolkien", age=99, session=_session) + rothfuss = create_author(name="Rothfuss", age=55, session=_session) + create_book(title="Lord of the Rings", price=Decimal("50.4"), author_id=tolkien.id, session=_session) + create_book(title="Bilbo Baggins", price=Decimal("23.2"), author_id=tolkien.id, session=_session) + create_book(title="The Name of the Wind", price=Decimal("19.99"), author_id=rothfuss.id, session=_session) + + +@app.command() +def drop_db(): + SQLABase.metadata.drop_all(bind=engine) + + +authors = typer.Typer() +app.add_typer(authors, name="authors") + + +@authors.command(name="create") +def create_auth(name: str, age: int): + with session() as _session: + author = create_author(name=name, age=age, session=_session) + rich.print("Created author:", author) + + +@authors.command(name="list") +def list_auth(): + with session() as _session: + authors = list_authors(_session) + rich.print("Authors:", authors) + + +if __name__ == "__main__": + app() diff --git a/examples/books_cli/books_cli/api/__init__.py b/examples/books_cli/books_cli/api/__init__.py new file mode 100644 index 0000000..2792bc2 --- /dev/null +++ b/examples/books_cli/books_cli/api/__init__.py @@ -0,0 +1,2 @@ +from .author import router as author_router +from .search import router as search_router diff --git a/examples/books_cli/books_cli/api/author.py b/examples/books_cli/books_cli/api/author.py new file mode 100644 index 0000000..3966f97 --- /dev/null +++ b/examples/books_cli/books_cli/api/author.py @@ -0,0 +1,17 @@ +from books_cli.db import DB +from books_cli.operations.author import create_author, list_authors +from books_cli.schemas.author import AuthorCreateSchema, AuthorSchema +from fastapi import APIRouter + +router = APIRouter() + + +@router.post("") +def new_author(author_info: AuthorCreateSchema, session: DB) -> AuthorSchema: + author = create_author(author_info.name, author_info.age, session) + return author + + +@router.get("") +def list_all_authors(session: DB) -> list[AuthorSchema]: + return list_authors(session) diff --git a/examples/books_cli/books_cli/api/search.py b/examples/books_cli/books_cli/api/search.py new file mode 100644 index 0000000..11d43e9 --- /dev/null +++ b/examples/books_cli/books_cli/api/search.py @@ -0,0 +1,11 @@ +from books_cli.db import DB +from books_cli.operations.search import list_books_by_author_name +from books_cli.schemas.book import BookSchema +from fastapi.routing import APIRouter + +router = APIRouter() + + +@router.get("/by-author-name/{name}") +def api_list_books_by_author_name(name: str, session: DB) -> list[BookSchema]: + return list_books_by_author_name(name, session) diff --git a/examples/books_cli/books_cli/db.py b/examples/books_cli/books_cli/db.py new file mode 100644 index 0000000..6811d11 --- /dev/null +++ b/examples/books_cli/books_cli/db.py @@ -0,0 +1,16 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +engine = create_engine("sqlite:///books.db", echo=True) + + +def get_session() -> Session: + with Session(bind=engine, autoflush=True) as session: + yield session + session.commit() # Autocommit for demonstration purposes + + +DB = Annotated[Session, Depends(get_session)] diff --git a/examples/books_cli/books_cli/main.py b/examples/books_cli/books_cli/main.py new file mode 100644 index 0000000..202eda8 --- /dev/null +++ b/examples/books_cli/books_cli/main.py @@ -0,0 +1,9 @@ +from books_cli.api import author_router, search_router +from fastapi import FastAPI + +app = FastAPI( + title="Books API", + version="0.9.9", +) +app.include_router(author_router, prefix="/author") +app.include_router(search_router, prefix="/search") diff --git a/examples/books_cli/books_cli/model_base.py b/examples/books_cli/books_cli/model_base.py new file mode 100644 index 0000000..e1743a6 --- /dev/null +++ b/examples/books_cli/books_cli/model_base.py @@ -0,0 +1,33 @@ +from datetime import datetime, timezone +from typing import Type + +from pydantic import BaseModel +from sqlalchemy import Column, DateTime, func +from sqlalchemy.orm import declarative_base +from sqlalchemy_utils import UUIDType +from ulid import ULID + +Base = declarative_base() + + +class SQLABase(Base): + __abstract__ = True + + def to_schema(self, schema: Type[BaseModel]): + return schema.model_validate(self, from_attributes=True) + + +class DBModelBase: + id = Column( + UUIDType, + primary_key=True, + default=lambda: ULID().to_uuid(), + nullable=False, + ) + + created_at = Column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + server_default=func.now(), + ) diff --git a/examples/books_cli/books_cli/models/__init__.py b/examples/books_cli/books_cli/models/__init__.py new file mode 100644 index 0000000..3988510 --- /dev/null +++ b/examples/books_cli/books_cli/models/__init__.py @@ -0,0 +1,2 @@ +from .author import Author +from .book import Book diff --git a/examples/books_cli/books_cli/models/author.py b/examples/books_cli/books_cli/models/author.py new file mode 100644 index 0000000..7926497 --- /dev/null +++ b/examples/books_cli/books_cli/models/author.py @@ -0,0 +1,12 @@ +from books_cli.model_base import DBModelBase, SQLABase +from sqlalchemy import Column, Integer, String +from sqlalchemy.orm import Mapped, relationship + + +class Author(SQLABase, DBModelBase): + __tablename__ = "authors" + + name = Column(String, nullable=False) + age = Column(Integer, nullable=False, default=20) + + books: Mapped[list["Book"]] = relationship(back_populates="author") # noqa diff --git a/examples/books_cli/books_cli/models/book.py b/examples/books_cli/books_cli/models/book.py new file mode 100644 index 0000000..1cfc197 --- /dev/null +++ b/examples/books_cli/books_cli/models/book.py @@ -0,0 +1,17 @@ +from decimal import Decimal + +from books_cli.model_base import DBModelBase, SQLABase +from sqlalchemy import Column, ForeignKey, String +from sqlalchemy.orm import Mapped, relationship +from sqlalchemy.sql.sqltypes import Numeric +from sqlalchemy_utils.types.uuid import UUIDType + + +class Book(SQLABase, DBModelBase): + __tablename__ = "books" + + title = Column(String, nullable=False) + price = Column(Numeric(10, 2), nullable=False, default=Decimal("20.0")) + author_id = Column(UUIDType, ForeignKey("authors.id"), nullable=False) + + author: Mapped["Author"] = relationship(back_populates="books") # noqa diff --git a/examples/books_cli/books_cli/operations/__init__.py b/examples/books_cli/books_cli/operations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/books_cli/books_cli/operations/author.py b/examples/books_cli/books_cli/operations/author.py new file mode 100644 index 0000000..e6e1ebd --- /dev/null +++ b/examples/books_cli/books_cli/operations/author.py @@ -0,0 +1,15 @@ +from books_cli.models.author import Author +from books_cli.schemas.author import AuthorSchema +from sqlalchemy.orm.session import Session + + +def create_author(name: str, age: int, session: Session) -> AuthorSchema: + author = Author(name=name, age=age) + session.add(author) + session.flush() + return author.to_schema(AuthorSchema) + + +def list_authors(session: Session) -> list[AuthorSchema]: + authors = session.query(Author).all() + return [a.to_schema(AuthorSchema) for a in authors] diff --git a/examples/books_cli/books_cli/operations/books.py b/examples/books_cli/books_cli/operations/books.py new file mode 100644 index 0000000..3e62bfa --- /dev/null +++ b/examples/books_cli/books_cli/operations/books.py @@ -0,0 +1,13 @@ +from decimal import Decimal +from uuid import UUID + +from books_cli.models import Book +from books_cli.schemas.book import BookSchema +from sqlalchemy.orm.session import Session + + +def create_book(title: str, price: Decimal, author_id: UUID, session: Session) -> BookSchema: + book = Book(title=title, price=price, author_id=author_id) + session.add(book) + session.flush() + return book.to_schema(BookSchema) diff --git a/examples/books_cli/books_cli/operations/search.py b/examples/books_cli/books_cli/operations/search.py new file mode 100644 index 0000000..898c9f7 --- /dev/null +++ b/examples/books_cli/books_cli/operations/search.py @@ -0,0 +1,23 @@ +from books_cli.models.author import Author +from books_cli.schemas.book import BookSchema +from sqlalchemy.orm import Session + + +def list_books_by_author_name(author_name: str, session: Session) -> list[BookSchema]: + author = session.query(Author).where(Author.name == author_name).first() + if author: + books = author.books + return [b.to_schema(BookSchema) for b in books] + + # Instead of doing two queries like the above, + # the same result can be obtained by doing: + # ==== Uncomment ===== + # from books_cli.models.book import Book + + # books = session.query(Book).join(Author, Author.id == Book.author_id).where(Author.name == author_name).all() + # return [b.to_schema(BookSchema) for b in books] + # === Enf of Uncomment == + # However if you do that, you need to update the guarded SQL present in + # test_author.queries.yaml["test_search_author"] + # You can do this with the command: pytest tests/test_author.py::test_search_author --sqlguard-overwrite + return [] diff --git a/examples/books_cli/books_cli/schemas/__init__.py b/examples/books_cli/books_cli/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/books_cli/books_cli/schemas/author.py b/examples/books_cli/books_cli/schemas/author.py new file mode 100644 index 0000000..2d10244 --- /dev/null +++ b/examples/books_cli/books_cli/schemas/author.py @@ -0,0 +1,16 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + + +class AuthorCreateSchema(BaseModel): + name: str + age: int = 20 + + +class AuthorSchema(BaseModel): + id: UUID + created_at: datetime + name: str + age: int diff --git a/examples/books_cli/books_cli/schemas/book.py b/examples/books_cli/books_cli/schemas/book.py new file mode 100644 index 0000000..1ada2da --- /dev/null +++ b/examples/books_cli/books_cli/schemas/book.py @@ -0,0 +1,13 @@ +from datetime import datetime +from decimal import Decimal +from uuid import UUID + +from pydantic.main import BaseModel + + +class BookSchema(BaseModel): + id: UUID + created_at: datetime + title: str + price: Decimal + author_id: UUID diff --git a/examples/books_cli/cli/__init__.py b/examples/books_cli/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/books_cli/cli/db.py b/examples/books_cli/cli/db.py new file mode 100644 index 0000000..2d80436 --- /dev/null +++ b/examples/books_cli/cli/db.py @@ -0,0 +1,36 @@ +from decimal import Decimal + +import typer +from books_cli.db import get_session +from books_cli.operations.author import create_author +from books_cli.operations.books import create_book # noqa + +app = typer.Typer() + + +@app.command() +def create(init: bool | None = None): + from books_cli.db import engine + from books_cli.model_base import SQLABase + + SQLABase.metadata.create_all(bind=engine) + if init: + session = next(get_session()) + tolkien = create_author(name="Tolkien", age=99, session=session) + rothfuss = create_author(name="Rothfuss", age=55, session=session) + create_book(title="Lord of the Rings", price=Decimal("50.4"), author_id=tolkien.id, session=session) + create_book(title="Bilbo Baggins", price=Decimal("23.2"), author_id=tolkien.id, session=session) + create_book(title="The Name of the Wind", price=Decimal("19.99"), author_id=rothfuss.id, session=session) + session.commit() + + +@app.command() +def drop(): + from books_cli.db import engine + from books_cli.model_base import SQLABase + + SQLABase.metadata.drop_all(bind=engine) + + +if __name__ == "__main__": + app() diff --git a/examples/books_cli/cli/mgmt.py b/examples/books_cli/cli/mgmt.py new file mode 100644 index 0000000..15a902c --- /dev/null +++ b/examples/books_cli/cli/mgmt.py @@ -0,0 +1,25 @@ +import rich +import typer +from books_cli.db import get_session +from books_cli.operations.author import create_author, list_authors # noqa + +app = typer.Typer() + + +@app.command() +def create(name: str, age: int): + session = next(get_session()) + author = create_author(name=name, age=age, session=session) + session.commit() + rich.print("Created author:", author) + + +@app.command() +def list(): + session = next(get_session()) + authors = list_authors(session) + rich.print("Authors:", authors) + + +if __name__ == "__main__": + app() diff --git a/examples/books_cli/pyproject.toml b/examples/books_cli/pyproject.toml new file mode 100644 index 0000000..54d233b --- /dev/null +++ b/examples/books_cli/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "books-api" +version = "0.1.0" +description = "Small book api for illustrating pytest-sqlguard" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "fastapi[standard]>=0.115.12", + "pydantic>=2.11.3", + "pytest>=8.3.5", + "pytest-sqlguard>=2025.311.0", + "python-ulid>=3.0.0", + "sqlalchemy>=2.0.40", + "sqlalchemy-utils>=0.41.2", +] diff --git a/examples/books_cli/tests/conftest.py b/examples/books_cli/tests/conftest.py new file mode 100644 index 0000000..14f78f2 --- /dev/null +++ b/examples/books_cli/tests/conftest.py @@ -0,0 +1,68 @@ +import pytest +from books_cli.db import get_session +from books_cli.main import app +from books_cli.model_base import SQLABase +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from starlette.testclient import TestClient + + +@pytest.fixture(scope="session", autouse=True) +def engine(): + engine = create_engine("sqlite:///books-test.db", echo=True) + SQLABase.metadata.drop_all(bind=engine) + SQLABase.metadata.create_all(bind=engine) + return engine + + +@pytest.fixture(scope="function") +def session(engine): + session = Session(bind=engine, autoflush=True) + + def override_get_session(): + return session + + app.dependency_overrides[get_session] = override_get_session + yield session + session.commit() + del app.dependency_overrides[get_session] + + +def pytest_addoption(parser): + sqlguard_group = parser.getgroup("sqlguard", "Options for SQLGuard") + sqlguard_group.addoption( + "--sqlguard-fail-missing", + dest="sqlguard_fail_missing", + default=False, + action="store_true", + help="SQLGuard: Fail if queries are missing in the stored reference file.", + ) + sqlguard_group.addoption( + "--sqlguard-overwrite", + dest="sqlguard_overwrite", + default=False, + action="store_true", + help="SQLGuard: Overwrite the stored reference file.", + ) + + +@pytest.fixture(scope="function") +def sqlguard(request, tmp_path_factory, session): + # We use the previously defined pytest cli arguments + fail_on_missing_reference_data = request.config.option.sqlguard_fail_missing + overwrite_reference_data = request.config.option.sqlguard_overwrite + + from pytest_sqlguard.sqlguard import sqlguard as sqlguard_function + + yield sqlguard_function( + request, + tmp_path_factory, + fail_on_missing_reference_data, + overwrite_reference_data, + ) + + +@pytest.fixture(scope="function") +def test_client(session: Session): + with TestClient(app) as test_client: + yield test_client diff --git a/examples/books_cli/tests/test_author.py b/examples/books_cli/tests/test_author.py new file mode 100644 index 0000000..4b7f122 --- /dev/null +++ b/examples/books_cli/tests/test_author.py @@ -0,0 +1,33 @@ +from decimal import Decimal + +from books_cli.models import Author +from books_cli.operations.author import create_author +from books_cli.operations.books import create_book +from books_cli.operations.search import list_books_by_author_name + + +def test_create_author(session): + author = create_author(name="First", age=90, session=session) + assert session.query(Author).first().name == "First" + + +def test_search_author(session, sqlguard): + author = create_author(name="Second", age=90, session=session) + book = create_book( + title="Some Title", + price=Decimal("98"), + author_id=author.id, + session=session, + ) + # With SQLite, we need to commit to the DB in order to be able to use sqlguard + # If you are using another DBMS, you won't have this issue, but with SQLite the + # DB is locked when we write and the lock is released only on commit. + session.commit() + with sqlguard(session): + # If you check closely the function list_books_by_author_name + # you'll see that there is a more optimized way of getting the books + # written and commented inside the function. + # If you were to use it, you'll need to update the guarded SQL of this + # test that is in the file test_author.queries.yaml. + # This can be done by running: pytest tests/test_author.py::test_search_author --sqlguard-overwrite + assert list_books_by_author_name("Second", session=session)[0].id == book.id diff --git a/examples/books_cli/tests/test_author.queries.yaml b/examples/books_cli/tests/test_author.queries.yaml new file mode 100644 index 0000000..c19d19c --- /dev/null +++ b/examples/books_cli/tests/test_author.queries.yaml @@ -0,0 +1,12 @@ +test_search_author: + queries: + - statement: |- + SELECT ... + FROM authors + WHERE authors.name = ? + LIMIT ? + OFFSET ? + - statement: |- + SELECT ... + FROM books + WHERE ? = books.author_id diff --git a/examples/books_cli/tests/test_author_api.py b/examples/books_cli/tests/test_author_api.py new file mode 100644 index 0000000..6d6448d --- /dev/null +++ b/examples/books_cli/tests/test_author_api.py @@ -0,0 +1,27 @@ +from decimal import Decimal + +from books_cli.operations.author import create_author +from books_cli.operations.books import create_book + + +def test_author_search_api(session, sqlguard, test_client): + author = create_author(name="API Author", age=90, session=session) + book = create_book( + title="API Title", + price=Decimal("98"), + author_id=author.id, + session=session, + ) + session.commit() + with sqlguard(session): + # If you check closely the API route (defined in the function api_list_books_by_author_name) + # you'll see that it calls list_books_by_author_name and that + # there is a more optimized way of getting the books + # written and commented inside that function. + # If you were to use it, you'll need to update the guarded SQL of this + # test that is in the file test_author_api.queries.yaml. + # This can be done by running: pytest tests/test_author.py::test_search_author_api --sqlguard-overwrite + res = test_client.get("/search/by-author-name/API Author") + + assert res.status_code == 200 + assert res.json()[0]["id"] == str(book.id) diff --git a/examples/books_cli/tests/test_author_api.queries.yaml b/examples/books_cli/tests/test_author_api.queries.yaml new file mode 100644 index 0000000..4881aa0 --- /dev/null +++ b/examples/books_cli/tests/test_author_api.queries.yaml @@ -0,0 +1,12 @@ +test_author_search_api: + queries: + - statement: |- + SELECT ... + FROM authors + WHERE authors.name = ? + LIMIT ? + OFFSET ? + - statement: |- + SELECT ... + FROM books + WHERE ? = books.author_id