From 163e43ac4c137e5128d5034153620bd9bc759402 Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 7 Jun 2024 11:50:26 -0700 Subject: [PATCH] write docs --- docs/_static/theme.css | 1 + docs/api.md | 12 +++ docs/changes.md | 4 + docs/conf.py | 55 +++++++++++ docs/engine.md | 113 +++++++++++++++++++++++ docs/index.md | 51 +++++++++++ docs/license.md | 5 + docs/session.md | 76 +++++++++++++++ docs/start.md | 203 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 520 insertions(+) create mode 100644 docs/_static/theme.css create mode 100644 docs/api.md create mode 100644 docs/changes.md create mode 100644 docs/conf.py create mode 100644 docs/engine.md create mode 100644 docs/index.md create mode 100644 docs/license.md create mode 100644 docs/session.md create mode 100644 docs/start.md diff --git a/docs/_static/theme.css b/docs/_static/theme.css new file mode 100644 index 0000000..35e705c --- /dev/null +++ b/docs/_static/theme.css @@ -0,0 +1 @@ +@import url('https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400;1,700&family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap'); diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..5a05c72 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,12 @@ +# API + +Anything documented here is part of the public API that Flask-SQLAlchemy +provides, unless otherwise indicated. Anything not documented here is considered +internal or private and may change at any time. + +```{eval-rst} +.. currentmodule:: flask_sqlalchemy_lite + +.. autoclass:: SQLAlchemy + :members: +``` diff --git a/docs/changes.md b/docs/changes.md new file mode 100644 index 0000000..5f522c2 --- /dev/null +++ b/docs/changes.md @@ -0,0 +1,4 @@ +# Changes + +```{include} ../CHANGES.md +``` diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..147894a --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,55 @@ +import importlib.metadata + +# Project -------------------------------------------------------------- + +project = "Flask-SQLAlchemy-Lite" +version = release = importlib.metadata.version("flask-sqlalchemy-lite").partition( + ".dev" +)[0] + +# General -------------------------------------------------------------- + +default_role = "code" +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.extlinks", + "sphinx.ext.intersphinx", + "myst_parser", +] +autodoc_member_order = "bysource" +autodoc_typehints = "description" +autodoc_preserve_defaults = True +extlinks = { + "issue": ("https://github.com/davidism/flask-sqlalchemy-lite/issues/%s", "#%s"), + "pr": ("https://github.com/davidism/flask-sqlalchemy-lite/pull/%s", "#%s"), +} +intersphinx_mapping = { + "python": ("https://docs.python.org/3/", None), + "flask": ("https://flask.palletsprojects.com", None), + "sqlalchemy": ("https://docs.sqlalchemy.org", None), +} +myst_enable_extensions = [ + "fieldlist", +] +myst_heading_anchors = 2 + +# HTML ----------------------------------------------------------------- + +html_theme = "furo" +html_static_path = ["_static"] +html_css_files = ["theme.css"] +html_copy_source = False +html_theme_options = { + "source_repository": "https://github.com/davidism/flask-sqlalchemy-lite/", + "source_branch": "main", + "source_directory": "docs/", + "light_css_variables": { + "font-stack": "'Atkinson Hyperlegible', sans-serif", + "font-stack--monospace": "'Source Code Pro', monospace", + }, +} +pygments_style = "default" +pygments_style_dark = "github-dark" +html_show_copyright = False +html_use_index = False +html_domain_indices = False diff --git a/docs/engine.md b/docs/engine.md new file mode 100644 index 0000000..c3cc399 --- /dev/null +++ b/docs/engine.md @@ -0,0 +1,113 @@ +# Engines + +One or more SQLAlchemy {class}`engines ` can be +configured through Flask's {attr}`app.config `. The engines +are created when {meth}`.SQLAlchemy.init_app` is called, changing config after +that will have no effect. Both sync and async engines can be configured. + + +## Flask Config + +```{currentmodule} flask_sqlalchemy_lite +``` + +```{data} SQLALCHEMY_ENGINES +A dictionary defining sync engine configurations. Each key is a name for an +engine, used to refer to them later. Each value is the engine configuration. + +If the value is a dict, it consists of keyword arguments to be passed to +{func}`sqlalchemy.create_engine`. The `'url'` key is required; it can be a +connection string (`dialect://user:pass@host:port/name?args`), a +{class}`sqlalchemy.engine.URL` instance, or a dict representing keyword +arguments to pass to {meth}`sqlalchemy.engine.URL.create`. + +As a shortcut, if you only need to specify the URL and no other arguments, the +value can be a connection string or `URL` instance. +``` + +```{data} SQLALCHEMY_ASYNC_ENGINES +The same as {data}`SQLALCHEMY_ENGINES`, but for async engine configurations. +``` + +### URL Examples + +The following configurations are all equivalent. + +```python +SQLALCHEMY_ENGINES = { + "default": "sqlite:///default.sqlite" +} +``` + +```python +from sqlalchemy import URL +SQLALCHEMY_ENGINES = { + "default": URL.create("sqlite", database="default.sqlite") +} +``` + +```python +SQLALCHEMY_ENGINES = { + "default": {"url": "sqlite:///default.sqlite"} +} +``` + +```python +from sqlalchemy import URL +SQLALCHEMY_ENGINES = { + "default": {"url": URL.create("sqlite", database="default.sqlite")} +} +``` + +```python +SQLALCHEMY_ENGINES = { + "default": {"url": {"drivername": "sqlite", "database": "default.sqlite"}} +} +``` + + +## Default Options + +Default engine options can be passed as the `engine_options` parameter when +creating the {class}`.SQLAlchemy` instance. The config for each engine will be +merged with these default options, overriding any shared keys. This applies to +both sync and async engines. You can use specific config if you need different +options for each. + + +### SQLite Defaults + +A relative database path will be relative to the app's +{attr}`~flask.Flask.instance_path` instead of the current directory. The +instance folder will be created if it does not exist. + +When using a memory database (no path, or `:memory:`), a static pool will be +used, and `check_same_thread=False` will be passed. This allows multiple workers +to share the database. + + +### MySQL Defaults + +When using a queue pool (default), `pool_recycle` is set to 7200 seconds +(2 hours), forcing SQLAlchemy to reconnect before MySQL would discard the idle +connection. + +The connection charset is set to `utf8mb4`. + + +## The Default Engine and Bind + +The `"default"` key is special, and will be used for {attr}`.SQLAlchemy.engine` +and as the default bind for {attr}`.SQLAlchemy.sessionmaker`. By default, it is +an error not to configure it for one of sync or async engines. + + +## Custom Engines + +You can ignore the Flask config altogether and create engines yourself. In that +case, you pass `require_default_engine=False` when creating the extension to +ignore the check for default config. Adding custom engines to the +{attr}`.SQLAlchemy.engines` map will make them accessible through the extension, +but that's not required either. You will want to call +`db.sessionmaker.configure(bind=..., binds=...)` to set up these custom engines +if you plan to use the provided session management though. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..5c7be20 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,51 @@ +# Flask-SQLAlchemy-Lite + +This [Flask]/[Quart] extension manages [SQLAlchemy] engines and sessions as part +of your web application. Engines can be configured through Flask config, and +sessions are manages and cleaned up as part of the app/request context. +SQLAlchemy's async capabilities are supported as well, and both sync and async +can be configured and used at the same time. + +[Flask]: https://flask.palletsprojects.com +[Quart]: https://quart.palletsprojects.com +[SQLAlchemy]: https://www.sqlalchemy.org + +Install it from PyPI using an installer such as pip: + +``` +$ pip install Flask-SQLAlchemy-Lite +``` + +This is intended to be a replacement for the [Flask-SQLAlchemy] extension. It +provides the same `db.engine` and `db.session` interface. However, this +extension avoids pretty much every other thing the former extension managed. It +does not create the base model, table class, or metadata itself. It does not +implement a custom bind system. It does not provide automatic table naming for +models. It does not provide query recording, pagination, query methods, etc. + +[Flask-SQLAlchemy]: https://flask-sqlalchemy.palletsprojects.com + +This extension tries to do as little as possible and as close to plain +SQLAlchemy as possible. You define your base model using whatever SQLAlchemy +pattern you want, old or modern. You use SQLAlchemy's `session.binds` API for +mapping different models to different engines. You import all names from +SQLAlchemy directly, rather than using `db.Mapped`, `db.select`, etc. Sessions +are tied directly to request lifetime, but can also be created and managed +directly, and do not use the `scoped_session` interface. + +These docs cover how the extension works, _not_ how to use SQLAlchemy. Read the +[SQLAlchemy docs], which include a comprehensive tutorial, to learn how to use +SQLAlchemy. + +[SQLAlchemy docs]: https://docs.sqlalchemy.org + +```{toctree} +:hidden: + +start +engine +session +api +changes +license +``` diff --git a/docs/license.md b/docs/license.md new file mode 100644 index 0000000..0f433a0 --- /dev/null +++ b/docs/license.md @@ -0,0 +1,5 @@ +# MIT License + +```{literalinclude} ../LICENSE.txt +:language: text +``` diff --git a/docs/session.md b/docs/session.md new file mode 100644 index 0000000..5f76677 --- /dev/null +++ b/docs/session.md @@ -0,0 +1,76 @@ +# Sessions + +A SQLAlchemy {class}`~sqlalchemy.orm.sessionmaker` is created when +{meth}`.SQLAlchemy.init_app` is called. Both sync and async sessionmakers +are created regardless of if any sync or async engines are defined. + + +## Default Options + +Default session options can be passed as the `session_options` parameter when +creating the {class}`.SQLAlchemy` instance. This applies to both sync and async +sessions. You can call each sessionmaker's `configure` method if you need +different options for each. + + +## Session Management + +Most use cases will use one session, and tie it to the lifetime of each request. +Use {attr}`db.session ` for this. It will return the same +session throughout a request, then close it when the request ends. SQLAlchemy +will rollback any uncomitted state in the session when it is closed. + +You can also create other sessions besides the default. Calling +{meth}`db.get_session(name)` will create separate sessions that are also closed +at the end of the request. + +The sessions are closed when the application context is torn down. This happens +for each request, but also at the end of CLI commands, and for manual +`with app.app_context()` blocks. + + +### Manual Sessions + +You can also use {attr}`db.sessionmaker ` directly to +create sessions. These will not be closed automatically at the end of requests, +so you'll need to manage them manually. An easy way to do that is using a `with` +block. + +```python +with db.sessionmaker() as session: + ... +``` + + +### Async + +SQLAlchemy warns that the async sessions it provides are _not_ safe to be used +across concurrent tasks. For example, the same session should not be passed to +multiple tasks when using `asyncio.gather`. Either use +{meth}`db.get_async_session(name) ` with a unique +name for each task, or use {attr}`db.async_sessionmaker` to manage sessions +and their lifetime manually. The latter is what SQLAlchemy recommends. + + +## Multiple Binds + +If the `"default"` engine key is defined when initializing the extension, it +will be set as the default bind for sessions. This is optional, but if you don't +configure it up front, you'll want to call `db.sessionmaker.configure(bind=...)` +later to set the default bind, or otherwise specify a bind for each query. + +SQLAlchemy supports using different engines when querying different tables or +models. This requires specifying a mapping from a model, base class, or table to +an engine object. When using the extension, you can set this up generically +in `session_options` by mapping to names instead of engine objects. During +initialization, the extension will substitute each name for the configured +engine. You can also call `db.sessionmaker.configure(binds=...)` after the fact +and pass the engines using {meth}`~.SQLAlchemy.get_engine` yourself. + +```python +db = SQLAlchemy(session_options={"binds": { + User: "auth", + Role: "auth", + ExternalBase: "external", +}}) +``` diff --git a/docs/start.md b/docs/start.md new file mode 100644 index 0000000..8d13274 --- /dev/null +++ b/docs/start.md @@ -0,0 +1,203 @@ +# Getting Started + +This page walks through the common use of the extenion. See the rest of the +documentation for more details about other features. + +These docs cover how the extension works, _not_ how to use SQLAlchemy. Read the +[SQLAlchemy docs], which include a comprehensive tutorial, to learn how to use +SQLAlchemy. + +[SQLAlchemy docs]: https://docs.sqlalchemy.org + + +## Setup + +Create an instance of {class}`.SQLAlchemy`. Define the +{data}`.SQLALCHEMY_ENGINES` config, a dict, with at least the `"default"` key +with a [connection string] value. When setting up the Flask app, call the +extension's {meth}`.SQLAlchemy.init_app` method. + +[connection string]: https://docs.sqlalchemy.org/core/engines.html#database-urls + +```python +from flask import Flask +from flask_sqlalchemy_lite import SQLAlchemy + +db = SQLAlchemy() + +def create_app(): + app = Flask(__name__) + app.config |= { + "SQLALCHEMY_ENGINES": { + "default": "sqlite:///default.sqlite", + }, + } + app.config.from_prefixed_env() + db.init_app(app) + return app +``` + +When not using the app factory pattern, you can pass the app directly when +creating the instance, and it will call `init_app` automatically. + +```python +app = Flask(__name__) +app.config |= { + "SQLALCHEMY_ENGINES": { + "default": "sqlite:///default.sqlite", + }, +} +app.config.from_prefixed_env() +db = SQLAlchemy(app) +``` + + +## Models + +The modern (SQLAlchemy 2) way to define models uses type annotations. Create a +base class first. Each model subclasses the base and defines at least a +`__tablename__` and a primary key column. + +```python +from __future__ import annotations +from datetime import datetime +from datetime import UTC +from sqlalchemy import ForeignKey +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + +class Model(DeclarativeBase): + pass + +class User(Model): + __tablename__ = "user" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] + posts: Mapped[list[Post]] = relationship(back_populates="author") + +class Post(Model): + __tablename__ = "post" + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] + body: Mapped[str] + author_id: Mapped[int] = mapped_column(ForeignKey(User.id)) + author: Mapped[User] = relationship(back_populates="posts") + created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC)) +``` + +There are other ways to define models, such as integrating with +{mod}`dataclasses`, the legacy metaclass base, or setting up mappings manually. +This extension can be used with any method. + + +### Creating Tables + +Typically, you'll want to use [Alembic] to generate and run migrations as you +create and modify your tables. The [Flask-Alembic] extension provides +integration between Flask, Flask-SQLAlchemy(-Lite), and Alembic. You can also +use Alembic directly, but it will require a little more setup. + +[Alembic]: https://alembic.sqlalchemy.org +[Flask-Alembic]: https://flask-alembic.readthedocs.io + +--- + +For basic uses, you can use the `metadata.create_all()` method. You can call +this for multiple metadatas with different engines. This will create any tables +that do not exist. It will _not_ update existing tables, such as adding new +columns. For that you need Alembic migrations. + +Engines and session can only be accessed inside a Flask application context. +When not inside a request or CLI command, such as during setup, push a context +using a `with` block. + +```python +with app.app_context(): + Model.metadata.create_all(db.engine) + OtherModel.metadata.create_all(db.get_engine("other")) +``` + + +### Populating the Flask Shell + +When using the `flask shell` command to start an interactive interpreter, +any model classes that have been registered with any SQLAlchemy base class will +be made available. The {class}`.SQLAlchemy` instance will be made available as +`db`. And the `sqlalchemy` namespace will be imported as `sa`. + +These three things make it easy to work with the database from the shell without +needing any manual imports. + +```pycon +>>> for user in db.session.scalars(sa.select(User)): +... user.active = False +... +>>> db.session.commit() +``` + + +## Executing Queries + +Queries are constructed and executed using standard SQLAlchemy. To add a model +instance to the session, use `db.session.add(obj)`. To modify a row, modify the +model's attributes. Then call `db.session.commit()` to save the changes to the +database. + +To query data from the database, use SQLAlchemy's `select()` constructor and +pass it to `db.session.scalars()` when selecting a model, or `.execute()` when +selecting a compound set of rows. There are also constructors for other +operations for less common use cases such as bulk inserts or updates. + +```python +from flask import request, abort, render_template +from sqlalchemy import select + +@app.route("/users") +def user_list(): + users = db.session.scalars(select(User).order_by(User.name)).all() + return render_template("users/list.html", users=users) + +@app.route("/users/create") +def user_create(): + name = request.form["name"] + + if db.session.scalar(select(User).where(User.name == name)) is not None: + abort(400) + + db.session.add(User(name=name)) + db.session.commit() + return app.redirect(app.url_for("user_list")) +``` + + +### Application Context + +Engines and sessions can only be accessed inside a Flask application context. +A context is active during each request, and during a CLI command. Therefore, +you can usually access `db.session` without any extra work. + +When not inside a request or CLI command, such as during setup or certain test +cases, push a context using a `with` block. + +```python +with app.app_context(): + # db.session and db.engine are accessible + ... +``` + + +## Async + +The extension also provides SQLAlchemy's async engines and sessions. Prefix any +engine or session access with `async_` to get the equivalent async objects. For +example, {attr}`db.async_session <.SQLAlchemy.async_session>`. You'll want to +review [SQLAlchemy's async docs][async docs], as there are some more things to +be aware of than with sync usage. + +[async docs]: https://docs.sqlalchemy.org/orm/extensions/asyncio.html + +In particular, SQLAlchemy warns that the async sessions it provides are _not_ +safe to be used across concurrent tasks. For example, the same session should +not be passed to multiple tasks when using `asyncio.gather`. Either use +{meth}`db.get_async_session(name) ` with a unique +name for each task, or use {attr}`db.async_sessionmaker` to manage sessions +and their lifetime manually. The latter is what SQLAlchemy recommends.