diff --git a/.github/workflows/test-pets-rollback.yml b/.github/workflows/test-pets-rollback.yml new file mode 100644 index 0000000..57d8744 --- /dev/null +++ b/.github/workflows/test-pets-rollback.yml @@ -0,0 +1,63 @@ +name: Pets Rollback Example + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-pets-rollback + cancel-in-progress: true + +jobs: + test-rollback: + name: Test per-test rollback with pets example + runs-on: ubuntu-latest + + env: + PGHOST: localhost + PGPORT: 5432 + PGUSER: postgres + PGPASSWORD: password + + services: + pg_db: + image: ghcr.io/constructive-io/docker/postgres-plus:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: latest + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Install dependencies + run: poetry install + + - name: Run pets rollback example tests + run: poetry run pytest tests/test_pets_rollback.py -v diff --git a/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 0000000..156e116 --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,189 @@ +# Publishing to PyPI + +This guide explains how to publish `pgsql-test` to PyPI using Poetry. + +## Prerequisites + +1. **PyPI Account**: Create an account at https://pypi.org/account/register/ +2. **API Token**: Generate an API token at https://pypi.org/manage/account/token/ +3. **Poetry**: Already installed if you've been developing locally + +## One-Time Setup + +Configure Poetry with your PyPI token: + +```bash +poetry config pypi-token.pypi +``` + +Alternatively, you can use environment variables: + +```bash +export POETRY_PYPI_TOKEN_PYPI= +``` + +## Publishing Steps + +### 1. Update Version + +Update the version in `pyproject.toml`: + +```toml +[tool.poetry] +name = "pgsql-test" +version = "0.1.1" # Bump this +``` + +Or use Poetry's version command: + +```bash +# Bump patch version (0.1.0 -> 0.1.1) +poetry version patch + +# Bump minor version (0.1.0 -> 0.2.0) +poetry version minor + +# Bump major version (0.1.0 -> 1.0.0) +poetry version major + +# Set specific version +poetry version 1.0.0 +``` + +### 2. Run Tests + +Ensure all tests pass before publishing: + +```bash +poetry run pytest -v +poetry run ruff check . +poetry run mypy src --ignore-missing-imports +``` + +### 3. Build the Package + +```bash +poetry build +``` + +This creates distribution files in the `dist/` directory: +- `pgsql_test-0.1.1.tar.gz` (source distribution) +- `pgsql_test-0.1.1-py3-none-any.whl` (wheel) + +### 4. Publish to PyPI + +```bash +poetry publish +``` + +Or build and publish in one command: + +```bash +poetry publish --build +``` + +## Testing with TestPyPI + +Before publishing to the real PyPI, you can test with TestPyPI: + +### 1. Create TestPyPI Account + +Register at https://test.pypi.org/account/register/ + +### 2. Configure TestPyPI + +```bash +poetry config repositories.testpypi https://test.pypi.org/legacy/ +poetry config pypi-token.testpypi +``` + +### 3. Publish to TestPyPI + +```bash +poetry publish --build -r testpypi +``` + +### 4. Test Installation + +```bash +pip install --index-url https://test.pypi.org/simple/ pgsql-test +``` + +## GitHub Actions Automation + +To automate publishing on releases, add this workflow to `.github/workflows/publish.yml`: + +```yaml +name: Publish to PyPI + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry + uses: snok/install-poetry@v1 + + - name: Build and publish + env: + POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }} + run: poetry publish --build +``` + +Then add your PyPI token as a repository secret named `PYPI_TOKEN`. + +## Versioning Guidelines + +Follow [Semantic Versioning](https://semver.org/): + +- **MAJOR** (1.0.0): Breaking changes to the public API +- **MINOR** (0.1.0): New features, backwards compatible +- **PATCH** (0.0.1): Bug fixes, backwards compatible + +For pre-release versions: +- `0.1.0a1` - Alpha release +- `0.1.0b1` - Beta release +- `0.1.0rc1` - Release candidate + +## Checklist Before Publishing + +- [ ] All tests pass locally +- [ ] Version number updated in `pyproject.toml` +- [ ] CHANGELOG updated (if you have one) +- [ ] README is up to date +- [ ] Commit and push all changes +- [ ] Create a git tag for the release + +```bash +git tag v0.1.1 +git push origin v0.1.1 +``` + +## Troubleshooting + +### "File already exists" Error + +PyPI doesn't allow overwriting existing versions. You must bump the version number. + +### Authentication Failed + +Verify your token is correct: + +```bash +poetry config pypi-token.pypi --unset +poetry config pypi-token.pypi +``` + +### Package Name Conflict + +If `pgsql-test` is taken, you may need to use a different name like `pgsql-test-py` or `constructive-pgsql-test`. diff --git a/README.md b/README.md index 6f5c709..311f018 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,47 @@ -# pysql-test - -PostgreSQL testing framework for Python - instant, isolated databases with automatic transaction rollback. +# pgsql-test + +

+ +

+ +

+ + + + + + + + + +

+ +The Python counterpart to [`pgsql-test`](https://www.npmjs.com/package/pgsql-test) on npm. Instant, isolated PostgreSQL databases for each test — with automatic transaction rollbacks, context switching, and clean seeding. ## Features -- **Instant isolated databases**: Each test gets a fresh database with a unique UUID name -- **Transaction rollback**: Changes are automatically rolled back after each test -- **Composable seeding**: Seed your database with SQL files, custom functions, or combine multiple strategies -- **RLS testing support**: Easy context switching for testing Row Level Security policies -- **Clean API**: Simple, intuitive interface inspired by the Node.js pgsql-test library +* **Instant test DBs** — each one seeded, isolated, and UUID-named +* **Per-test rollback** — every test runs in its own transaction with savepoint-based rollback via `before_each()`/`after_each()` +* **RLS-friendly** — test with role-based auth via `set_context()` +* **pgpm integration** — run database migrations using [pgpm](https://github.com/pgpm-io/pgpm) (PostgreSQL Package Manager) +* **Flexible seeding** — run `.sql` files, programmatic seeds, pgpm modules, or combine multiple strategies +* **Auto teardown** — no residue, no reboots, just clean exits ## Installation ```bash # Using Poetry (recommended) -poetry add pysql-test +poetry add pgsql-test # Using pip -pip install pysql-test +pip install pgsql-test ``` ## Quick Start ```python import pytest -from pysql_test import get_connections, seed +from pgsql_test import get_connections, seed # Basic usage def test_basic_query(): @@ -43,8 +60,105 @@ def db(): def test_with_fixture(db): result = db.query('SELECT 1 as value') assert result.rows[0]['value'] == 1 +``` + +## pgpm Integration + +The primary use case for pgsql-test is testing PostgreSQL modules managed by [pgpm](https://github.com/pgpm-io/pgpm). The `seed.pgpm()` adapter runs `pgpm deploy` to apply your migrations to an isolated test database. + +### Prerequisites + +Install pgpm globally: + +```bash +npm install -g pgpm +``` + +### Basic pgpm Usage + +```python +import pytest +from pgsql_test import get_connections, seed -# With SQL file seeding +@pytest.fixture +def db(): + conn = get_connections( + seed_adapters=[ + seed.pgpm( + module_path="./packages/my-module", + package="my-module" + ) + ] + ) + db = conn.db + db.before_each() + yield db + db.after_each() + conn.teardown() + +def test_my_function(db): + # Your pgpm module's functions are now available + result = db.one("SELECT my_schema.my_function() as result") + assert result['result'] == expected_value +``` + +### pgpm with Dependencies + +If your module depends on other pgpm packages (like `@pgpm/faker`), install them first: + +```bash +cd packages/my-module +pgpm install @pgpm/faker +``` + +Then test: + +```python +def test_faker_integration(db): + # @pgpm/faker functions are available after pgpm deploy + result = db.one("SELECT faker.city('MI') as city") + assert result['city'] is not None +``` + +### pgpm Workspace Structure + +A typical pgpm workspace for testing looks like: + +``` +my-workspace/ + pgpm.json # Workspace config + packages/ + my-module/ + package.json # Module metadata + my-module.control # PostgreSQL extension control + pgpm.plan # Migration plan + deploy/ + schemas/ + my_schema.sql # CREATE SCHEMA my_schema; + functions/ + my_function.sql # CREATE FUNCTION ... + revert/ + schemas/ + my_schema.sql # DROP SCHEMA my_schema; + verify/ + schemas/ + my_schema.sql # SELECT 1 FROM ... +``` + +### seed.pgpm() Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `module_path` | `str` | Path to the pgpm module directory | +| `package` | `str` | Package name to deploy (required to avoid interactive prompts) | +| `deploy_args` | `list[str]` | Additional arguments to pass to `pgpm deploy` | +| `cache` | `bool` | Enable caching (not yet implemented) | + +## SQL File Seeding + +For simpler use cases without pgpm, seed directly from SQL files: + +```python @pytest.fixture def seeded_db(): conn = get_connections( @@ -58,9 +172,18 @@ def test_with_seeding(seeded_db): assert len(users) > 0 ``` -## Transaction Isolation +## Per-Test Rollback -Use `before_each()` and `after_each()` for per-test isolation: +The `before_each()` and `after_each()` methods provide automatic transaction rollback for each test. This ensures complete isolation between tests - any changes made during a test are automatically rolled back, so each test starts with a clean slate. + +### How It Works + +1. `before_each()` begins a transaction and creates a savepoint +2. Your test runs and makes changes to the database +3. `after_each()` rolls back to the savepoint, undoing all changes +4. The next test starts fresh with only the seeded data + +### Basic Pattern ```python @pytest.fixture @@ -86,6 +209,15 @@ def test_user_count(db): assert result['count'] == 0 # Only seeded data ``` +### Why This Matters + +Without per-test rollback, tests can interfere with each other: +- Test A inserts a user +- Test B expects 0 users but finds 1 +- Tests become order-dependent and flaky + +With `before_each()`/`after_each()`, each test is completely isolated, making your test suite reliable and deterministic. + ## RLS Testing Test Row Level Security policies by switching contexts: @@ -105,6 +237,12 @@ def test_rls_policy(db): ## Seeding Strategies +### pgpm Modules + +```python +seed.pgpm(module_path="./packages/my-module", package="my-module") +``` + ### SQL Files ```python @@ -123,7 +261,8 @@ seed.fn(lambda ctx: ctx['pg'].execute( ```python seed.compose([ - seed.sqlfile(['schema.sql']), + seed.pgpm(module_path="./packages/my-module", package="my-module"), + seed.sqlfile(['fixtures.sql']), seed.fn(lambda ctx: ctx['pg'].execute("INSERT INTO ...")), ]) ``` @@ -177,6 +316,68 @@ Returns a `ConnectionResult` with: - `after_each()`: End test isolation (rollback) - `set_context(dict)`: Set session variables for RLS testing +## GitHub Actions Example + +Here's a complete CI workflow for testing pgpm modules: + +```yaml +name: Test + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + env: + PGHOST: localhost + PGPORT: 5432 + PGUSER: postgres + PGPASSWORD: password + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install pgpm + run: npm install -g pgpm + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Poetry + uses: snok/install-poetry@v1 + + - name: Install dependencies + run: poetry install + + - name: Bootstrap pgpm roles + run: | + pgpm admin-users bootstrap --yes + pgpm admin-users add --test --yes + + - name: Run tests + run: poetry run pytest -v +``` + ## Development ```bash @@ -193,6 +394,11 @@ poetry run ruff check . poetry run mypy src ``` +## Related Projects + +- [pgsql-test](https://github.com/launchql/pgsql-test) - The original TypeScript/Node.js version +- [pgpm](https://github.com/pgpm-io/pgpm) - PostgreSQL Package Manager + ## License MIT diff --git a/pyproject.toml b/pyproject.toml index fc352eb..021c0a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [tool.poetry] -name = "pysql-test" +name = "pgsql-test" version = "0.1.0" description = "PostgreSQL testing framework for Python - instant, isolated databases with automatic transaction rollback" authors = ["Constructive "] license = "MIT" readme = "README.md" -homepage = "https://github.com/constructive-io/pysql" -repository = "https://github.com/constructive-io/pysql" +homepage = "https://github.com/constructive-io/pgsql-test-python" +repository = "https://github.com/constructive-io/pgsql-test-python" keywords = ["postgres", "postgresql", "testing", "pytest", "database", "integration-tests"] classifiers = [ "Development Status :: 4 - Beta", diff --git a/tests/test_pets_rollback.py b/tests/test_pets_rollback.py new file mode 100644 index 0000000..a2f1d06 --- /dev/null +++ b/tests/test_pets_rollback.py @@ -0,0 +1,170 @@ +""" +Pets example demonstrating per-test rollback with before_each/after_each. + +This example shows how pgsql-test provides complete test isolation through +automatic transaction rollback. Each test starts with a clean slate, +regardless of what previous tests inserted. + +Key concept: before_each() creates a savepoint, after_each() rolls back to it. +""" + +import pytest + +from pysql_test import get_connections, seed + + +@pytest.fixture +def pets_db(): + """ + Create an isolated test database with a simple pets schema. + + The before_each()/after_each() pattern ensures each test: + 1. Starts with only the seeded data (empty pets table) + 2. Can insert/modify data freely during the test + 3. Has all changes rolled back automatically after the test + """ + conn = get_connections( + seed_adapters=[ + seed.fn(lambda ctx: ctx["pg"].query(""" + CREATE TABLE pets ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + species TEXT NOT NULL, + age INTEGER + ) + """)) + ] + ) + db = conn.db + db.before_each() # Begin transaction + create savepoint + yield db + db.after_each() # Rollback to savepoint (undo all changes) + conn.teardown() + + +# ============================================================================= +# Test 1: Insert a pet and verify it exists +# ============================================================================= +def test_insert_pet(pets_db): + """Insert a pet and verify it exists in the database.""" + pets_db.execute( + "INSERT INTO pets (name, species, age) VALUES (%s, %s, %s)", + ("Buddy", "dog", 3), + ) + + pet = pets_db.one("SELECT * FROM pets WHERE name = %s", ("Buddy",)) + + assert pet["name"] == "Buddy" + assert pet["species"] == "dog" + assert pet["age"] == 3 + + +# ============================================================================= +# Test 2: Verify the table is empty (previous insert was rolled back!) +# ============================================================================= +def test_table_empty_after_rollback(pets_db): + """ + Verify that the previous test's insert was rolled back. + + Even though test_insert_pet inserted 'Buddy', that change was + automatically rolled back by after_each(). This test starts fresh. + """ + count = pets_db.one("SELECT COUNT(*) as count FROM pets") + + # Table should be empty - Buddy was rolled back! + assert count["count"] == 0 + + +# ============================================================================= +# Test 3: Insert multiple pets +# ============================================================================= +def test_insert_multiple_pets(pets_db): + """Insert multiple pets and verify the count.""" + pets_db.execute( + "INSERT INTO pets (name, species, age) VALUES (%s, %s, %s)", + ("Whiskers", "cat", 5), + ) + pets_db.execute( + "INSERT INTO pets (name, species, age) VALUES (%s, %s, %s)", + ("Goldie", "fish", 1), + ) + pets_db.execute( + "INSERT INTO pets (name, species, age) VALUES (%s, %s, %s)", + ("Rex", "dog", 7), + ) + + count = pets_db.one("SELECT COUNT(*) as count FROM pets") + assert count["count"] == 3 + + # Verify we can query specific pets + cats = pets_db.many("SELECT * FROM pets WHERE species = %s", ("cat",)) + assert len(cats) == 1 + assert cats[0]["name"] == "Whiskers" + + +# ============================================================================= +# Test 4: Verify table is empty again (all 3 pets were rolled back!) +# ============================================================================= +def test_table_empty_again(pets_db): + """ + Verify that ALL previous inserts were rolled back. + + The 3 pets from test_insert_multiple_pets are gone. + Each test truly starts with a clean slate. + """ + count = pets_db.one("SELECT COUNT(*) as count FROM pets") + + # Table should be empty - all pets were rolled back! + assert count["count"] == 0 + + +# ============================================================================= +# Test 5: Demonstrate update rollback +# ============================================================================= +def test_update_rollback(pets_db): + """ + Demonstrate that updates are also rolled back. + + Insert a pet, update it, verify the update - all rolled back after. + """ + # Insert + pets_db.execute( + "INSERT INTO pets (name, species, age) VALUES (%s, %s, %s)", + ("Max", "dog", 2), + ) + + # Update + pets_db.execute( + "UPDATE pets SET age = %s WHERE name = %s", + (3, "Max"), + ) + + # Verify update worked within this test + pet = pets_db.one("SELECT * FROM pets WHERE name = %s", ("Max",)) + assert pet["age"] == 3 # Updated age + + +# ============================================================================= +# Test 6: Final verification - still empty! +# ============================================================================= +def test_final_empty_check(pets_db): + """ + Final check: table is still empty after all previous tests. + + This proves that before_each()/after_each() provides complete + isolation for every single test, no matter what operations were performed. + """ + count = pets_db.one("SELECT COUNT(*) as count FROM pets") + assert count["count"] == 0 + + # We can safely insert knowing it won't affect other tests + pets_db.execute( + "INSERT INTO pets (name, species, age) VALUES (%s, %s, %s)", + ("Luna", "cat", 4), + ) + + # Verify our insert worked + pet = pets_db.one("SELECT * FROM pets WHERE name = %s", ("Luna",)) + assert pet["name"] == "Luna" + + # Luna will be rolled back after this test completes