├── .gitignore ├── LICENSE ├── README.md ├── docker-compose.yml └── project ├── .dockerignore ├── Dockerfile ├── alembic.ini ├── app ├── __init__.py ├── db.py ├── main.py └── models.py ├── migrations ├── README ├── env.py ├── script.py.mako └── versions │ ├── 53754b2c08a4_add_year.py │ ├── 842abcd80d3e_init.py │ ├── f68b489cdeb0_add_year.py │ └── f9c634db477d_init.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | env 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 TestDriven.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI + SQLModel + Alembic 2 | 3 | Sample FastAPI project that uses async SQLAlchemy, SQLModel, Postgres, Alembic, and Docker. 4 | 5 | ## Want to learn how to build this? 6 | 7 | Check out the [post](https://testdriven.io/blog/fastapi-sqlmodel/). 8 | 9 | ## Want to use this project? 10 | 11 | ```sh 12 | $ docker-compose up -d --build 13 | $ docker-compose exec web alembic upgrade head 14 | ``` 15 | 16 | Sanity check: [http://localhost:8004/ping](http://localhost:8004/ping) 17 | 18 | Add a song: 19 | 20 | ```sh 21 | $ curl -d '{"name":"Midnight Fit", "artist":"Mogwai", "year":"2021"}' -H "Content-Type: application/json" -X POST http://localhost:8004/songs 22 | ``` 23 | 24 | Get all songs: [http://localhost:8004/songs](http://localhost:8004/songs) -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | 5 | web: 6 | build: ./project 7 | command: uvicorn app.main:app --reload --workers 1 --host 0.0.0.0 --port 8000 8 | volumes: 9 | - ./project:/usr/src/app 10 | ports: 11 | - 8004:8000 12 | environment: 13 | - DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/foo 14 | depends_on: 15 | - db 16 | 17 | db: 18 | image: postgres:15.3 19 | expose: 20 | - 5432 21 | environment: 22 | - POSTGRES_USER=postgres 23 | - POSTGRES_PASSWORD=postgres 24 | - POSTGRES_DB=foo 25 | -------------------------------------------------------------------------------- /project/.dockerignore: -------------------------------------------------------------------------------- 1 | .dockerignore 2 | Dockerfile 3 | -------------------------------------------------------------------------------- /project/Dockerfile: -------------------------------------------------------------------------------- 1 | # pull official base image 2 | FROM python:3.11-slim-buster 3 | 4 | # set working directory 5 | WORKDIR /usr/src/app 6 | 7 | # set environment variables 8 | ENV PYTHONDONTWRITEBYTECODE 1 9 | ENV PYTHONUNBUFFERED 1 10 | 11 | # install system dependencies 12 | RUN apt-get update \ 13 | && apt-get -y install netcat gcc postgresql \ 14 | && apt-get clean 15 | 16 | # install python dependencies 17 | RUN pip install --upgrade pip 18 | COPY ./requirements.txt . 19 | RUN pip install -r requirements.txt 20 | 21 | # add app 22 | COPY . . 23 | -------------------------------------------------------------------------------- /project/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 10 | 11 | # sys.path path, will be prepended to sys.path if present. 12 | # defaults to the current working directory. 13 | prepend_sys_path = . 14 | 15 | # timezone to use when rendering the date within the migration file 16 | # as well as the filename. 17 | # If specified, requires the python-dateutil library that can be 18 | # installed by adding `alembic[tz]` to the pip requirements 19 | # string value is passed to dateutil.tz.gettz() 20 | # leave blank for localtime 21 | # timezone = 22 | 23 | # max length of characters to apply to the 24 | # "slug" field 25 | # truncate_slug_length = 40 26 | 27 | # set to 'true' to run the environment during 28 | # the 'revision' command, regardless of autogenerate 29 | # revision_environment = false 30 | 31 | # set to 'true' to allow .pyc and .pyo files without 32 | # a source .py file to be detected as revisions in the 33 | # versions/ directory 34 | # sourceless = false 35 | 36 | # version location specification; This defaults 37 | # to migrations/versions. When using multiple version 38 | # directories, initial revisions must be specified with --version-path. 39 | # The path separator used here should be the separator specified by "version_path_separator" below. 40 | # version_locations = %(here)s/bar:%(here)s/bat:migrations/versions 41 | 42 | # version path separator; As mentioned above, this is the character used to split 43 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 44 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 45 | # Valid values for version_path_separator are: 46 | # 47 | # version_path_separator = : 48 | # version_path_separator = ; 49 | # version_path_separator = space 50 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 51 | 52 | # set to 'true' to search source files recursively 53 | # in each "version_locations" directory 54 | # new in Alembic version 1.10 55 | # recursive_version_locations = false 56 | 57 | # the output encoding used when revision files 58 | # are written from script.py.mako 59 | # output_encoding = utf-8 60 | 61 | sqlalchemy.url = postgresql+asyncpg://postgres:postgres@db:5432/foo 62 | 63 | 64 | [post_write_hooks] 65 | # post_write_hooks defines scripts or Python functions that are run 66 | # on newly generated revision scripts. See the documentation for further 67 | # detail and examples 68 | 69 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 70 | # hooks = black 71 | # black.type = console_scripts 72 | # black.entrypoint = black 73 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 74 | 75 | # Logging configuration 76 | [loggers] 77 | keys = root,sqlalchemy,alembic 78 | 79 | [handlers] 80 | keys = console 81 | 82 | [formatters] 83 | keys = generic 84 | 85 | [logger_root] 86 | level = WARN 87 | handlers = console 88 | qualname = 89 | 90 | [logger_sqlalchemy] 91 | level = WARN 92 | handlers = 93 | qualname = sqlalchemy.engine 94 | 95 | [logger_alembic] 96 | level = INFO 97 | handlers = 98 | qualname = alembic 99 | 100 | [handler_console] 101 | class = StreamHandler 102 | args = (sys.stderr,) 103 | level = NOTSET 104 | formatter = generic 105 | 106 | [formatter_generic] 107 | format = %(levelname)-5.5s [%(name)s] %(message)s 108 | datefmt = %H:%M:%S 109 | -------------------------------------------------------------------------------- /project/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testdrivenio/fastapi-sqlmodel-alembic/b9f78641513d78ec89954b140efc7ba16a50d1de/project/app/__init__.py -------------------------------------------------------------------------------- /project/app/db.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from sqlmodel import SQLModel, create_engine 4 | from sqlmodel.ext.asyncio.session import AsyncSession, AsyncEngine 5 | 6 | from sqlalchemy.orm import sessionmaker 7 | 8 | 9 | DATABASE_URL = os.environ.get("DATABASE_URL") 10 | 11 | engine = AsyncEngine(create_engine(DATABASE_URL, echo=True, future=True)) 12 | 13 | async def init_db(): 14 | async with engine.begin() as conn: 15 | # await conn.run_sync(SQLModel.metadata.drop_all) 16 | await conn.run_sync(SQLModel.metadata.create_all) 17 | 18 | 19 | async def get_session() -> AsyncSession: 20 | async_session = sessionmaker( 21 | engine, class_=AsyncSession, expire_on_commit=False 22 | ) 23 | async with async_session() as session: 24 | yield session -------------------------------------------------------------------------------- /project/app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends, FastAPI 2 | from sqlmodel import select 3 | from sqlmodel.ext.asyncio.session import AsyncSession 4 | 5 | from app.db import get_session, init_db 6 | from app.models import Song, SongCreate 7 | 8 | app = FastAPI() 9 | 10 | 11 | @app.get("/ping") 12 | async def pong(): 13 | return {"ping": "pong!"} 14 | 15 | 16 | @app.get("/songs", response_model=list[Song]) 17 | async def get_songs(session: AsyncSession = Depends(get_session)): 18 | result = await session.execute(select(Song)) 19 | songs = result.scalars().all() 20 | return [Song(name=song.name, artist=song.artist, year=song.year, id=song.id) for song in songs] 21 | 22 | 23 | @app.post("/songs") 24 | async def add_song(song: SongCreate, session: AsyncSession = Depends(get_session)): 25 | song = Song(name=song.name, artist=song.artist, year=song.year) 26 | session.add(song) 27 | await session.commit() 28 | await session.refresh(song) 29 | return song -------------------------------------------------------------------------------- /project/app/models.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import SQLModel, Field 2 | from typing import Optional 3 | 4 | 5 | class SongBase(SQLModel): 6 | name: str 7 | artist: str 8 | year: Optional[int] = None 9 | 10 | 11 | class Song(SongBase, table=True): 12 | id: int = Field(default=None, nullable=False, primary_key=True) 13 | 14 | 15 | class SongCreate(SongBase): 16 | pass -------------------------------------------------------------------------------- /project/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /project/migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | 4 | from sqlalchemy import pool 5 | from sqlalchemy.engine import Connection 6 | from sqlalchemy.ext.asyncio import async_engine_from_config 7 | from sqlmodel import SQLModel # NEW 8 | 9 | 10 | from alembic import context 11 | 12 | from app.models import Song # NEW 13 | 14 | # this is the Alembic Config object, which provides 15 | # access to the values within the .ini file in use. 16 | config = context.config 17 | 18 | # Interpret the config file for Python logging. 19 | # This line sets up loggers basically. 20 | if config.config_file_name is not None: 21 | fileConfig(config.config_file_name) 22 | 23 | # add your model's MetaData object here 24 | # for 'autogenerate' support 25 | # from myapp import mymodel 26 | # target_metadata = mymodel.Base.metadata 27 | target_metadata = SQLModel.metadata # UPDATED 28 | 29 | # other values from the config, defined by the needs of env.py, 30 | # can be acquired: 31 | # my_important_option = config.get_main_option("my_important_option") 32 | # ... etc. 33 | 34 | 35 | def run_migrations_offline() -> None: 36 | """Run migrations in 'offline' mode. 37 | 38 | This configures the context with just a URL 39 | and not an Engine, though an Engine is acceptable 40 | here as well. By skipping the Engine creation 41 | we don't even need a DBAPI to be available. 42 | 43 | Calls to context.execute() here emit the given string to the 44 | script output. 45 | 46 | """ 47 | url = config.get_main_option("sqlalchemy.url") 48 | context.configure( 49 | url=url, 50 | target_metadata=target_metadata, 51 | literal_binds=True, 52 | dialect_opts={"paramstyle": "named"}, 53 | ) 54 | 55 | with context.begin_transaction(): 56 | context.run_migrations() 57 | 58 | 59 | def do_run_migrations(connection: Connection) -> None: 60 | context.configure(connection=connection, target_metadata=target_metadata) 61 | 62 | with context.begin_transaction(): 63 | context.run_migrations() 64 | 65 | 66 | async def run_async_migrations() -> None: 67 | """In this scenario we need to create an Engine 68 | and associate a connection with the context. 69 | 70 | """ 71 | 72 | connectable = async_engine_from_config( 73 | config.get_section(config.config_ini_section, {}), 74 | prefix="sqlalchemy.", 75 | poolclass=pool.NullPool, 76 | ) 77 | 78 | async with connectable.connect() as connection: 79 | await connection.run_sync(do_run_migrations) 80 | 81 | await connectable.dispose() 82 | 83 | 84 | def run_migrations_online() -> None: 85 | """Run migrations in 'online' mode.""" 86 | 87 | asyncio.run(run_async_migrations()) 88 | 89 | 90 | if context.is_offline_mode(): 91 | run_migrations_offline() 92 | else: 93 | run_migrations_online() 94 | -------------------------------------------------------------------------------- /project/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import sqlmodel # NEW 11 | ${imports if imports else ""} 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = ${repr(up_revision)} 15 | down_revision = ${repr(down_revision)} 16 | branch_labels = ${repr(branch_labels)} 17 | depends_on = ${repr(depends_on)} 18 | 19 | 20 | def upgrade() -> None: 21 | ${upgrades if upgrades else "pass"} 22 | 23 | 24 | def downgrade() -> None: 25 | ${downgrades if downgrades else "pass"} 26 | -------------------------------------------------------------------------------- /project/migrations/versions/53754b2c08a4_add_year.py: -------------------------------------------------------------------------------- 1 | """add year 2 | 3 | Revision ID: 53754b2c08a4 4 | Revises: f9c634db477d 5 | Create Date: 2021-09-10 00:52:38.668620 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import sqlmodel 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = '53754b2c08a4' 15 | down_revision = 'f9c634db477d' 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.add_column('song', sa.Column('year', sa.Integer(), nullable=True)) 23 | op.create_index(op.f('ix_song_year'), 'song', ['year'], unique=False) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_index(op.f('ix_song_year'), table_name='song') 30 | op.drop_column('song', 'year') 31 | # ### end Alembic commands ### 32 | -------------------------------------------------------------------------------- /project/migrations/versions/842abcd80d3e_init.py: -------------------------------------------------------------------------------- 1 | """init 2 | 3 | Revision ID: 842abcd80d3e 4 | Revises: 5 | Create Date: 2023-07-10 17:10:45.380832 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import sqlmodel # NEW 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = '842abcd80d3e' 15 | down_revision = None 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade() -> None: 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table('song', 23 | sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), 24 | sa.Column('artist', sqlmodel.sql.sqltypes.AutoString(), nullable=False), 25 | sa.Column('id', sa.Integer(), nullable=False), 26 | sa.PrimaryKeyConstraint('id') 27 | ) 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade() -> None: 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | op.drop_table('song') 34 | # ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /project/migrations/versions/f68b489cdeb0_add_year.py: -------------------------------------------------------------------------------- 1 | """add year 2 | 3 | Revision ID: f68b489cdeb0 4 | Revises: 842abcd80d3e 5 | Create Date: 2023-07-10 17:12:35.936572 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import sqlmodel # NEW 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = 'f68b489cdeb0' 15 | down_revision = '842abcd80d3e' 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.add_column('song', sa.Column('year', sa.Integer(), nullable=True)) 23 | op.create_index(op.f('ix_song_year'), 'song', ['year'], unique=False) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_index(op.f('ix_song_year'), table_name='song') 30 | op.drop_column('song', 'year') 31 | # ### end Alembic commands ### 32 | -------------------------------------------------------------------------------- /project/migrations/versions/f9c634db477d_init.py: -------------------------------------------------------------------------------- 1 | """init 2 | 3 | Revision ID: f9c634db477d 4 | Revises: 5 | Create Date: 2021-09-10 00:24:32.718895 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import sqlmodel 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = 'f9c634db477d' 15 | down_revision = None 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table('song', 23 | sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), 24 | sa.Column('artist', sqlmodel.sql.sqltypes.AutoString(), nullable=False), 25 | sa.Column('id', sa.Integer(), nullable=True), 26 | sa.PrimaryKeyConstraint('id') 27 | ) 28 | op.create_index(op.f('ix_song_artist'), 'song', ['artist'], unique=False) 29 | op.create_index(op.f('ix_song_id'), 'song', ['id'], unique=False) 30 | op.create_index(op.f('ix_song_name'), 'song', ['name'], unique=False) 31 | # ### end Alembic commands ### 32 | 33 | 34 | def downgrade(): 35 | # ### commands auto generated by Alembic - please adjust! ### 36 | op.drop_index(op.f('ix_song_name'), table_name='song') 37 | op.drop_index(op.f('ix_song_id'), table_name='song') 38 | op.drop_index(op.f('ix_song_artist'), table_name='song') 39 | op.drop_table('song') 40 | # ### end Alembic commands ### 41 | -------------------------------------------------------------------------------- /project/requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.11.1 2 | asyncpg==0.28.0 3 | fastapi==0.100.0 4 | sqlmodel==0.0.8 5 | uvicorn==0.22.0 6 | --------------------------------------------------------------------------------