From 5d5fa5e3471c80ec861367b2725b1548857cf5eb Mon Sep 17 00:00:00 2001 From: lingyuzeng Date: Thu, 18 Sep 2025 15:43:33 +0800 Subject: [PATCH] add postgres examples --- README.md | 14 +++ docker/docker-compose.yml | 19 ++++ examples/07_postgres_minimal.py | 94 +++++++++++++++++ notebooks/07_postgres_minimal.ipynb | 156 ++++++++++++++++++++++++++++ 4 files changed, 283 insertions(+) create mode 100644 docker/docker-compose.yml create mode 100644 examples/07_postgres_minimal.py create mode 100644 notebooks/07_postgres_minimal.ipynb diff --git a/README.md b/README.md index 63f81d9..235b915 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ Reusable SQLModel + PostgreSQL kit with src layout, sync/async engines, and gene - Editable install: `uv pip install -e .` - Run tests: `uv pip install pytest && pytest -q` +## Local Postgres (Docker) +- Start the container: `docker compose -f docker/docker-compose.yml up -d` +- Stop when done: `docker compose -f docker/docker-compose.yml down` +- Default credentials match `DatabaseConfig`: user `appuser`, password `changeme`, database `appdb` +- Export `SQL_SSLMODE=disable` (container does not use TLS by default) + ## Usage - Configure environment (either `SQL_*` or Postgres `PG*` vars). Example for container ADDR: - `export SQL_HOST=192.168.64.8` @@ -142,6 +148,14 @@ with get_session() as s: ok = repo.delete(s, h.id) ``` +For a container-backed workflow, run `uv run python examples/07_postgres_minimal.py` (paired with `notebooks/07_postgres_minimal.ipynb`). That walkthrough now mirrors a REST-style request cycle: +- wipes existing demo rows so each run is deterministic +- upserts seed rows via `Repository.bulk_insert` +- paginates filtered results with `Repository.list(... where=..., order_by=..., page=..., size=...)` +- performs fuzzy search using `select(...).where(Model.name.ilike(...))` +- counts inventory with `session.exec(select(func.count(...))).scalar()` (compatible with SQLAlchemy 1.4/2.x) +- updates and deletes with the repository helpers you would call from PATCH/DELETE handlers + ### Bulk insert + filters/pagination ```bash diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..4642f09 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,19 @@ +services: + postgres: + image: postgres:15 + restart: unless-stopped + environment: + POSTGRES_DB: appdb + POSTGRES_USER: appuser + POSTGRES_PASSWORD: changeme + ports: + - "5432:5432" + volumes: + - pg_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 3s + timeout: 3s + retries: 5 +volumes: + pg_data: diff --git a/examples/07_postgres_minimal.py b/examples/07_postgres_minimal.py new file mode 100644 index 0000000..c4a4f24 --- /dev/null +++ b/examples/07_postgres_minimal.py @@ -0,0 +1,94 @@ +"""Minimal Postgres example using sqlmodel-pg-kit. + +Prerequisites: + 1. Start the dockerized Postgres instance: ``docker compose -f docker/docker-compose.yml up -d``. + 2. Export env vars so the kit can discover the database:: + + export SQL_HOST=localhost + export SQL_PORT=5432 + export SQL_USER=appuser + export SQL_PASSWORD=changeme + export SQL_DATABASE=appdb + export SQL_SSLMODE=disable + + 3. Install dependencies (``pip install sqlmodel-pg-kit[async]`` or ``uv pip install .``). + +Run: + ``python examples/07_postgres_minimal.py`` +""" + +from typing import Optional, List + +from sqlalchemy import func +from sqlmodel import Field, SQLModel, select + +from sqlmodel_pg_kit import Repository, create_all, get_session + + +class Widget(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + in_stock: bool = True + + +if __name__ == "__main__": + create_all() + + repo = Repository(Widget) + + with get_session() as session: + # In web apps it's common to start each request by ensuring you have a clean view of data. + session.execute(Widget.__table__.delete()) + session.commit() + + # Create: typical POST handler persisting a single payload. + widget = repo.create(session, {"name": "rocket"}) + print("Created:", widget) + + # Bulk insert: useful for admin imports or seeding catalogs. + repo.bulk_insert( + session, + [ + {"name": "satellite", "in_stock": True}, + {"name": "capsule", "in_stock": False}, + {"name": "probe", "in_stock": True}, + ], + ) + + # Read by id: GET /widgets/{id} + same = repo.get(session, widget.id) + print("Fetched by primary key:", same) + + # Query a paginated listing ordered by newest first, backing a GET /widgets endpoint. + page: List[Widget] = repo.list( + session, + where=Widget.in_stock.is_(True), + order_by=[Widget.id.desc()], + page=1, + size=2, + ) + print("First page of in-stock widgets:", [(w.id, w.name) for w in page]) + + # Free-form filtering with SQLModel/SQLAlchemy expressions for search endpoints. + search_term = "ro" + search_stmt = ( + select(Widget) + .where(Widget.name.ilike(f"%{search_term}%")) + .order_by(Widget.name.asc()) + ) + matches = session.exec(search_stmt).all() + print(f"Search '{search_term}' ->", [(w.id, w.name) for w in matches]) + + # Aggregate counts to power dashboards or pagination metadata. + count_stmt = select(func.count(Widget.id)).where(Widget.in_stock.is_(True)) + # scalar() keeps compatibility across SQLAlchemy versions + inventory_count = session.exec(count_stmt).scalar() or 0 + print("In-stock count:", inventory_count) + + # Update: PATCH /widgets/{id} + updated = repo.update(session, widget.id, in_stock=False) + print("Updated:", updated) + + # Delete: DELETE /widgets/{id} + removed = repo.delete(session, widget.id) + print("Deleted:", removed) diff --git a/notebooks/07_postgres_minimal.ipynb b/notebooks/07_postgres_minimal.ipynb new file mode 100644 index 0000000..298d33a --- /dev/null +++ b/notebooks/07_postgres_minimal.ipynb @@ -0,0 +1,156 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Minimal Postgres Demo\n", + "\n", + "This notebook connects to the dockerized Postgres instance defined in `docker/docker-compose.yml`.\n", + "\n", + "Steps before running the cells:\n", + "\n", + "1. Start Postgres: `docker compose -f docker/docker-compose.yml up -d`.\n", + "2. Export environment variables so `sqlmodel_pg_kit` can locate the database (host defaults to `localhost` when run from the host).\n", + " ```bash\n", + " export SQL_HOST=localhost\n", + " export SQL_PORT=5432\n", + " export SQL_USER=appuser\n", + " export SQL_PASSWORD=changeme\n", + " export SQL_DATABASE=appdb\n", + " export SQL_SSLMODE=disable\n", + " ```\n", + "3. `pip install -e .` or `uv pip install .` from the project root.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "44df8c21", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Optional, List\n", + "import os\n", + "os.environ[\"SQL_HOST\"] = \"127.0.0.1\"\n", + "os.environ[\"SQL_PORT\"] = \"5432\"\n", + "os.environ[\"SQL_USER\"] = \"appuser\"\n", + "os.environ[\"SQL_PASSWORD\"] = \"changeme\"\n", + "os.environ[\"SQL_DATABASE\"] = \"appdb\"\n", + "os.environ[\"SQL_SSLMODE\"] = \"disable\"\n", + "from sqlalchemy import func\n", + "from sqlmodel import Field, SQLModel, select\n", + "\n", + "from sqlmodel_pg_kit import Repository, create_all, get_session\n", + "\n", + "\n", + "class Widget(SQLModel, table=True):\n", + " id: Optional[int] = Field(default=None, primary_key=True)\n", + " name: str\n", + " in_stock: bool = True\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "23860bb5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Created: in_stock=True id=6 name='rocket'\n", + "Fetched by primary key: in_stock=True name='rocket' id=6\n", + "First page of in-stock widgets: [(9, 'probe'), (7, 'satellite')]\n", + "Search 'ro' -> [(9, 'probe'), (6, 'rocket')]\n", + "In-stock count: 3\n", + "Updated: in_stock=False id=6 name='rocket'\n", + "Deleted: True\n" + ] + } + ], + "source": [ + "create_all()\n", + "repo = Repository(Widget)\n", + "\n", + "with get_session() as session:\n", + " session.execute(Widget.__table__.delete())\n", + " session.commit()\n", + "\n", + " widget = repo.create(session, {\"name\": \"rocket\"})\n", + " print(\"Created:\", widget)\n", + "\n", + " repo.bulk_insert(\n", + " session,\n", + " [\n", + " {\"name\": \"satellite\", \"in_stock\": True},\n", + " {\"name\": \"capsule\", \"in_stock\": False},\n", + " {\"name\": \"probe\", \"in_stock\": True},\n", + " ],\n", + " )\n", + "\n", + " same = repo.get(session, widget.id)\n", + " print(\"Fetched by primary key:\", same)\n", + "\n", + " page = repo.list(\n", + " session,\n", + " where=Widget.in_stock.is_(True),\n", + " order_by=[Widget.id.desc()],\n", + " page=1,\n", + " size=2,\n", + " )\n", + " print(\"First page of in-stock widgets:\", [(w.id, w.name) for w in page])\n", + "\n", + " search_term = \"ro\"\n", + " search_stmt = (\n", + " select(Widget)\n", + " .where(Widget.name.ilike(f\"%{search_term}%\"))\n", + " .order_by(Widget.name.asc())\n", + " )\n", + " matches = session.exec(search_stmt).all()\n", + " print(f\"Search '{search_term}' ->\", [(w.id, w.name) for w in matches])\n", + "\n", + " count_stmt = select(func.count(Widget.id)).where(Widget.in_stock.is_(True))\n", + " # scalar() keeps compatibility across SQLAlchemy versions\n", + " inventory_count = session.exec(count_stmt).scalar() or 0\n", + " print(\"In-stock count:\", inventory_count)\n", + "\n", + " updated = repo.update(session, widget.id, in_stock=False)\n", + " print(\"Updated:\", updated)\n", + "\n", + " removed = repo.delete(session, widget.id)\n", + " print(\"Deleted:\", removed)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7637a17d", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "sqlmodel", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file