This commit is contained in:
2025-05-22 20:25:38 +02:00
parent 09f6750c2b
commit ce03fbf12f
529 changed files with 3353 additions and 3312 deletions

View File

@@ -4,43 +4,23 @@ import typing as t
from .extension import SQLAlchemy
__version__ = "3.0.5"
__all__ = [
"SQLAlchemy",
]
_deprecated_map = {
"Model": ".model.Model",
"DefaultMeta": ".model.DefaultMeta",
"Pagination": ".pagination.Pagination",
"BaseQuery": ".query.Query",
"get_debug_queries": ".record_queries.get_recorded_queries",
"SignallingSession": ".session.Session",
"before_models_committed": ".track_modifications.before_models_committed",
"models_committed": ".track_modifications.models_committed",
}
def __getattr__(name: str) -> t.Any:
import importlib
import warnings
if name in _deprecated_map:
path = _deprecated_map[name]
import_path, _, new_name = path.rpartition(".")
action = "moved and renamed"
if new_name == name:
action = "moved"
if name == "__version__":
import importlib.metadata
import warnings
warnings.warn(
f"'{name}' has been {action} to '{path[1:]}'. The top-level import is"
" deprecated and will be removed in Flask-SQLAlchemy 3.1.",
"The '__version__' attribute is deprecated and will be removed in"
" Flask-SQLAlchemy 3.2. Use feature detection or"
" 'importlib.metadata.version(\"flask-sqlalchemy\")' instead.",
DeprecationWarning,
stacklevel=2,
)
mod = importlib.import_module(import_path, __name__)
return getattr(mod, new_name)
return importlib.metadata.version("flask-sqlalchemy")
raise AttributeError(name)

View File

@@ -1,7 +1,9 @@
from __future__ import annotations
import os
import types
import typing as t
import warnings
from weakref import WeakKeyDictionary
import sqlalchemy as sa
@@ -14,8 +16,11 @@ from flask import Flask
from flask import has_app_context
from .model import _QueryProperty
from .model import BindMixin
from .model import DefaultMeta
from .model import DefaultMetaNoName
from .model import Model
from .model import NameMixin
from .pagination import Pagination
from .pagination import SelectPagination
from .query import Query
@@ -26,6 +31,33 @@ from .table import _Table
_O = t.TypeVar("_O", bound=object) # Based on sqlalchemy.orm._typing.py
# Type accepted for model_class argument
_FSA_MCT = t.TypeVar(
"_FSA_MCT",
bound=t.Union[
t.Type[Model],
sa_orm.DeclarativeMeta,
t.Type[sa_orm.DeclarativeBase],
t.Type[sa_orm.DeclarativeBaseNoMeta],
],
)
# Type returned by make_declarative_base
class _FSAModel(Model):
metadata: sa.MetaData
def _get_2x_declarative_bases(
model_class: _FSA_MCT,
) -> list[t.Type[t.Union[sa_orm.DeclarativeBase, sa_orm.DeclarativeBaseNoMeta]]]:
return [
b
for b in model_class.__bases__
if issubclass(b, (sa_orm.DeclarativeBase, sa_orm.DeclarativeBaseNoMeta))
]
class SQLAlchemy:
"""Integrates SQLAlchemy with Flask. This handles setting up one or more engines,
associating tables and models with specific engines, and cleaning up connections and
@@ -66,6 +98,18 @@ class SQLAlchemy:
:param add_models_to_shell: Add the ``db`` instance and all model classes to
``flask shell``.
.. versionchanged:: 3.1.0
The ``metadata`` parameter can still be used with SQLAlchemy 1.x classes,
but is ignored when using SQLAlchemy 2.x style of declarative classes.
Instead, specify metadata on your Base class.
.. versionchanged:: 3.1.0
Added the ``disable_autonaming`` parameter.
.. versionchanged:: 3.1.0
Changed ``model_class`` parameter to accepta SQLAlchemy 2.x
declarative base subclass.
.. versionchanged:: 3.0
An active Flask application context is always required to access ``session`` and
``engine``.
@@ -100,11 +144,6 @@ class SQLAlchemy:
.. versionchanged:: 3.0
Removed the ``use_native_unicode`` parameter and config.
.. versionchanged:: 3.0
The ``COMMIT_ON_TEARDOWN`` configuration is deprecated and will
be removed in Flask-SQLAlchemy 3.1. Call ``db.session.commit()``
directly instead.
.. versionchanged:: 2.4
Added the ``engine_options`` parameter.
@@ -129,9 +168,10 @@ class SQLAlchemy:
metadata: sa.MetaData | None = None,
session_options: dict[str, t.Any] | None = None,
query_class: type[Query] = Query,
model_class: type[Model] | sa_orm.DeclarativeMeta = Model,
model_class: _FSA_MCT = Model, # type: ignore[assignment]
engine_options: dict[str, t.Any] | None = None,
add_models_to_shell: bool = True,
disable_autonaming: bool = False,
):
if session_options is None:
session_options = {}
@@ -173,8 +213,17 @@ class SQLAlchemy:
"""
if metadata is not None:
metadata.info["bind_key"] = None
self.metadatas[None] = metadata
if len(_get_2x_declarative_bases(model_class)) > 0:
warnings.warn(
"When using SQLAlchemy 2.x style of declarative classes,"
" the `metadata` should be an attribute of the base class."
"The metadata passed into SQLAlchemy() is ignored.",
DeprecationWarning,
stacklevel=2,
)
else:
metadata.info["bind_key"] = None
self.metadatas[None] = metadata
self.Table = self._make_table_class()
"""A :class:`sqlalchemy.schema.Table` class that chooses a metadata
@@ -192,7 +241,9 @@ class SQLAlchemy:
This is a subclass of SQLAlchemy's ``Table`` rather than a function.
"""
self.Model = self._make_declarative_base(model_class)
self.Model = self._make_declarative_base(
model_class, disable_autonaming=disable_autonaming
)
"""A SQLAlchemy declarative model class. Subclass this to define database
models.
@@ -204,9 +255,15 @@ class SQLAlchemy:
database engine. Otherwise, it will use the default :attr:`metadata` and
:attr:`engine`. This is ignored if the model sets ``metadata`` or ``__table__``.
Customize this by subclassing :class:`.Model` and passing the ``model_class``
parameter to the extension. A fully created declarative model class can be
For code using the SQLAlchemy 1.x API, customize this model by subclassing
:class:`.Model` and passing the ``model_class`` parameter to the extension.
A fully created declarative model class can be
passed as well, to use a custom metaclass.
For code using the SQLAlchemy 2.x API, customize this model by subclassing
:class:`sqlalchemy.orm.DeclarativeBase` or
:class:`sqlalchemy.orm.DeclarativeBaseNoMeta`
and passing the ``model_class`` parameter to the extension.
"""
if engine_options is None:
@@ -258,25 +315,13 @@ class SQLAlchemy:
)
app.extensions["sqlalchemy"] = self
app.teardown_appcontext(self._teardown_session)
if self._add_models_to_shell:
from .cli import add_models_to_shell
app.shell_context_processor(add_models_to_shell)
if app.config.get("SQLALCHEMY_COMMIT_ON_TEARDOWN", False):
import warnings
warnings.warn(
"'SQLALCHEMY_COMMIT_ON_TEARDOWN' is deprecated and will be removed in"
" Flask-SQAlchemy 3.1. Call 'db.session.commit()'` directly instead.",
DeprecationWarning,
stacklevel=2,
)
app.teardown_appcontext(self._teardown_commit)
else:
app.teardown_appcontext(self._teardown_session)
basic_uri: str | sa.engine.URL | None = app.config.setdefault(
"SQLALCHEMY_DATABASE_URI", None
)
@@ -393,20 +438,6 @@ class SQLAlchemy:
options.setdefault("query_cls", self.Query)
return sa_orm.sessionmaker(db=self, **options)
def _teardown_commit(self, exc: BaseException | None) -> None:
"""Commit the session at the end of the request if there was not an unhandled
exception during the request.
:meta private:
.. deprecated:: 3.0
Will be removed in 3.1. Use ``db.session.commit()`` directly instead.
"""
if exc is None:
self.session.commit()
self.session.remove()
def _teardown_session(self, exc: BaseException | None) -> None:
"""Remove the current session at the end of the request.
@@ -464,29 +495,16 @@ class SQLAlchemy:
if not args or (len(args) >= 2 and isinstance(args[1], sa.MetaData)):
return super().__new__(cls, *args, **kwargs)
if (
bind_key is None
and "info" in kwargs
and "bind_key" in kwargs["info"]
):
import warnings
warnings.warn(
"'table.info['bind_key'] is deprecated and will not be used in"
" Flask-SQLAlchemy 3.1. Pass the 'bind_key' parameter instead.",
DeprecationWarning,
stacklevel=2,
)
bind_key = kwargs["info"].get("bind_key")
metadata = self._make_metadata(bind_key)
return super().__new__(cls, *[args[0], metadata, *args[1:]], **kwargs)
return Table
def _make_declarative_base(
self, model: type[Model] | sa_orm.DeclarativeMeta
) -> type[t.Any]:
self,
model_class: _FSA_MCT,
disable_autonaming: bool = False,
) -> t.Type[_FSAModel]:
"""Create a SQLAlchemy declarative model class. The result is available as
:attr:`Model`.
@@ -498,7 +516,14 @@ class SQLAlchemy:
:meta private:
:param model: A model base class, or an already created declarative model class.
:param model_class: A model base class, or an already created declarative model
class.
:param disable_autonaming: Turns off automatic tablename generation in models.
.. versionchanged:: 3.1.0
Added support for passing SQLAlchemy 2.x base class as model class.
Added optional ``disable_autonaming`` parameter.
.. versionchanged:: 3.0
Renamed with a leading underscore, this method is internal.
@@ -506,22 +531,45 @@ class SQLAlchemy:
.. versionchanged:: 2.3
``model`` can be an already created declarative model class.
"""
if not isinstance(model, sa_orm.DeclarativeMeta):
metadata = self._make_metadata(None)
model = sa_orm.declarative_base(
metadata=metadata, cls=model, name="Model", metaclass=DefaultMeta
model: t.Type[_FSAModel]
declarative_bases = _get_2x_declarative_bases(model_class)
if len(declarative_bases) > 1:
# raise error if more than one declarative base is found
raise ValueError(
"Only one declarative base can be passed to SQLAlchemy."
" Got: {}".format(model_class.__bases__)
)
elif len(declarative_bases) == 1:
body = dict(model_class.__dict__)
body["__fsa__"] = self
mixin_classes = [BindMixin, NameMixin, Model]
if disable_autonaming:
mixin_classes.remove(NameMixin)
model = types.new_class(
"FlaskSQLAlchemyBase",
(*mixin_classes, *model_class.__bases__),
{"metaclass": type(declarative_bases[0])},
lambda ns: ns.update(body),
)
elif not isinstance(model_class, sa_orm.DeclarativeMeta):
metadata = self._make_metadata(None)
metaclass = DefaultMetaNoName if disable_autonaming else DefaultMeta
model = sa_orm.declarative_base(
metadata=metadata, cls=model_class, name="Model", metaclass=metaclass
)
else:
model = model_class # type: ignore[assignment]
if None not in self.metadatas:
# Use the model's metadata as the default metadata.
model.metadata.info["bind_key"] = None # type: ignore[union-attr]
self.metadatas[None] = model.metadata # type: ignore[union-attr]
model.metadata.info["bind_key"] = None
self.metadatas[None] = model.metadata
else:
# Use the passed in default metadata as the model's metadata.
model.metadata = self.metadatas[None] # type: ignore[union-attr]
model.metadata = self.metadatas[None]
model.query_class = self.Query
model.query = _QueryProperty()
model.query = _QueryProperty() # type: ignore[assignment]
model.__fsa__ = self
return model
@@ -660,80 +708,41 @@ class SQLAlchemy:
"""
return self.engines[None]
def get_engine(self, bind_key: str | None = None) -> sa.engine.Engine:
def get_engine(
self, bind_key: str | None = None, **kwargs: t.Any
) -> sa.engine.Engine:
"""Get the engine for the given bind key for the current application.
This requires that a Flask application context is active.
:param bind_key: The name of the engine.
.. deprecated:: 3.0
Will be removed in Flask-SQLAlchemy 3.1. Use ``engines[key]`` instead.
Will be removed in Flask-SQLAlchemy 3.2. Use ``engines[key]`` instead.
.. versionchanged:: 3.0
Renamed the ``bind`` parameter to ``bind_key``. Removed the ``app``
parameter.
"""
import warnings
warnings.warn(
"'get_engine' is deprecated and will be removed in Flask-SQLAlchemy 3.1."
" Use 'engine' or 'engines[key]' instead.",
"'get_engine' is deprecated and will be removed in Flask-SQLAlchemy"
" 3.2. Use 'engine' or 'engines[key]' instead. If you're using"
" Flask-Migrate or Alembic, you'll need to update your 'env.py' file.",
DeprecationWarning,
stacklevel=2,
)
if "bind" in kwargs:
bind_key = kwargs.pop("bind")
return self.engines[bind_key]
def get_tables_for_bind(self, bind_key: str | None = None) -> list[sa.Table]:
"""Get all tables in the metadata for the given bind key.
:param bind_key: The bind key to get.
.. deprecated:: 3.0
Will be removed in Flask-SQLAlchemy 3.1. Use ``metadata.tables`` instead.
.. versionchanged:: 3.0
Renamed the ``bind`` parameter to ``bind_key``.
"""
import warnings
warnings.warn(
"'get_tables_for_bind' is deprecated and will be removed in"
" Flask-SQLAlchemy 3.1. Use 'metadata.tables' instead.",
DeprecationWarning,
stacklevel=2,
)
return list(self.metadatas[bind_key].tables.values())
def get_binds(self) -> dict[sa.Table, sa.engine.Engine]:
"""Map all tables to their engine based on their bind key, which can be used to
create a session with ``Session(binds=db.get_binds(app))``.
This requires that a Flask application context is active.
.. deprecated:: 3.0
Will be removed in Flask-SQLAlchemy 3.1. ``db.session`` supports multiple
binds directly.
.. versionchanged:: 3.0
Removed the ``app`` parameter.
"""
import warnings
warnings.warn(
"'get_binds' is deprecated and will be removed in Flask-SQLAlchemy 3.1."
" 'db.session' supports multiple binds directly.",
DeprecationWarning,
stacklevel=2,
)
return {
table: engine
for bind_key, engine in self.engines.items()
for table in self.metadatas[bind_key].tables.values()
}
def get_or_404(
self, entity: type[_O], ident: t.Any, *, description: str | None = None
self,
entity: type[_O],
ident: t.Any,
*,
description: str | None = None,
**kwargs: t.Any,
) -> _O:
"""Like :meth:`session.get() <sqlalchemy.orm.Session.get>` but aborts with a
``404 Not Found`` error instead of returning ``None``.
@@ -741,10 +750,14 @@ class SQLAlchemy:
:param entity: The model class to query.
:param ident: The primary key to query.
:param description: A custom message to show on the error page.
:param kwargs: Extra arguments passed to ``session.get()``.
.. versionchanged:: 3.1
Pass extra keyword arguments to ``session.get()``.
.. versionadded:: 3.0
"""
value = self.session.get(entity, ident)
value = self.session.get(entity, ident, **kwargs)
if value is None:
abort(404, description=description)
@@ -974,24 +987,11 @@ class SQLAlchemy:
.. versionchanged:: 3.0
The :attr:`Query` class is set on ``backref``.
"""
# Deprecated, removed in SQLAlchemy 2.0. Accessed through ``__getattr__``.
self._set_rel_query(kwargs)
f = sa_orm.relationship
return f(*args, **kwargs)
def __getattr__(self, name: str) -> t.Any:
if name == "db":
import warnings
warnings.warn(
"The 'db' attribute is deprecated and will be removed in"
" Flask-SQLAlchemy 3.1. The extension is registered directly as"
" 'app.extensions[\"sqlalchemy\"]'.",
DeprecationWarning,
stacklevel=2,
)
return self
if name == "relation":
return self._relation

View File

@@ -18,14 +18,6 @@ class _QueryProperty:
:meta private:
"""
@t.overload
def __get__(self, obj: None, cls: type[Model]) -> Query:
...
@t.overload
def __get__(self, obj: Model, cls: type[Model]) -> Query:
...
def __get__(self, obj: Model | None, cls: type[Model]) -> Query:
return cls.query_class(
cls, session=cls.__fsa__.session() # type: ignore[arg-type]
@@ -100,6 +92,38 @@ class BindMetaMixin(type):
super().__init__(name, bases, d, **kwargs)
class BindMixin:
"""DeclarativeBase mixin to set a model's ``metadata`` based on ``__bind_key__``.
If no ``__bind_key__`` is specified, the model will use the default metadata
provided by ``DeclarativeBase`` or ``DeclarativeBaseNoMeta``.
If the model doesn't set ``metadata`` or ``__table__`` directly
and does set ``__bind_key__``, the model will use the metadata
for the specified bind key.
If the ``metadata`` is the same as the parent model, it will not be set
directly on the child model.
.. versionchanged:: 3.1.0
"""
__fsa__: SQLAlchemy
metadata: sa.MetaData
@classmethod
def __init_subclass__(cls: t.Type[BindMixin], **kwargs: t.Dict[str, t.Any]) -> None:
if not ("metadata" in cls.__dict__ or "__table__" in cls.__dict__) and hasattr(
cls, "__bind_key__"
):
bind_key = getattr(cls, "__bind_key__", None)
parent_metadata = getattr(cls, "metadata", None)
metadata = cls.__fsa__._make_metadata(bind_key)
if metadata is not parent_metadata:
cls.metadata = metadata
super().__init_subclass__(**kwargs)
class NameMetaMixin(type):
"""Metaclass mixin that sets a model's ``__tablename__`` by converting the
``CamelCase`` class name to ``snake_case``. A name is set for non-abstract models
@@ -169,6 +193,77 @@ class NameMetaMixin(type):
return None
class NameMixin:
"""DeclarativeBase mixin that sets a model's ``__tablename__`` by converting the
``CamelCase`` class name to ``snake_case``. A name is set for non-abstract models
that do not otherwise define ``__tablename__``. If a model does not define a primary
key, it will not generate a name or ``__table__``, for single-table inheritance.
.. versionchanged:: 3.1.0
"""
metadata: sa.MetaData
__tablename__: str
__table__: sa.Table
@classmethod
def __init_subclass__(cls: t.Type[NameMixin], **kwargs: t.Dict[str, t.Any]) -> None:
if should_set_tablename(cls):
cls.__tablename__ = camel_to_snake_case(cls.__name__)
super().__init_subclass__(**kwargs)
# __table_cls__ has run. If no table was created, use the parent table.
if (
"__tablename__" not in cls.__dict__
and "__table__" in cls.__dict__
and cls.__dict__["__table__"] is None
):
del cls.__table__
@classmethod
def __table_cls__(cls, *args: t.Any, **kwargs: t.Any) -> sa.Table | None:
"""This is called by SQLAlchemy during mapper setup. It determines the final
table object that the model will use.
If no primary key is found, that indicates single-table inheritance, so no table
will be created and ``__tablename__`` will be unset.
"""
schema = kwargs.get("schema")
if schema is None:
key = args[0]
else:
key = f"{schema}.{args[0]}"
# Check if a table with this name already exists. Allows reflected tables to be
# applied to models by name.
if key in cls.metadata.tables:
return sa.Table(*args, **kwargs)
# If a primary key is found, create a table for joined-table inheritance.
for arg in args:
if (isinstance(arg, sa.Column) and arg.primary_key) or isinstance(
arg, sa.PrimaryKeyConstraint
):
return sa.Table(*args, **kwargs)
# If no base classes define a table, return one that's missing a primary key
# so SQLAlchemy shows the correct error.
for base in cls.__mro__[1:-1]:
if "__table__" in base.__dict__:
break
else:
return sa.Table(*args, **kwargs)
# Single-table inheritance, use the parent table name. __init__ will unset
# __table__ based on this.
if "__tablename__" in cls.__dict__:
del cls.__tablename__
return None
def should_set_tablename(cls: type) -> bool:
"""Determine whether ``__tablename__`` should be generated for a model.
@@ -181,8 +276,16 @@ def should_set_tablename(cls: type) -> bool:
Later, ``__table_cls__`` will determine if the model looks like single or
joined-table inheritance. If no primary key is found, the name will be unset.
"""
if cls.__dict__.get("__abstract__", False) or not any(
isinstance(b, sa_orm.DeclarativeMeta) for b in cls.__mro__[1:]
if (
cls.__dict__.get("__abstract__", False)
or (
not issubclass(cls, (sa_orm.DeclarativeBase, sa_orm.DeclarativeBaseNoMeta))
and not any(isinstance(b, sa_orm.DeclarativeMeta) for b in cls.__mro__[1:])
)
or any(
(b is sa_orm.DeclarativeBase or b is sa_orm.DeclarativeBaseNoMeta)
for b in cls.__bases__
)
):
return False
@@ -196,7 +299,14 @@ def should_set_tablename(cls: type) -> bool:
return not (
base is cls
or base.__dict__.get("__abstract__", False)
or not isinstance(base, sa_orm.DeclarativeMeta)
or not (
# SQLAlchemy 1.x
isinstance(base, sa_orm.DeclarativeMeta)
# 2.x: DeclarativeBas uses this as metaclass
or isinstance(base, sa_orm.decl_api.DeclarativeAttributeIntercept)
# 2.x: DeclarativeBaseNoMeta doesn't use a metaclass
or issubclass(base, sa_orm.DeclarativeBaseNoMeta)
)
)
return True
@@ -212,3 +322,9 @@ class DefaultMeta(BindMetaMixin, NameMetaMixin, sa_orm.DeclarativeMeta):
"""SQLAlchemy declarative metaclass that provides ``__bind_key__`` and
``__tablename__`` support.
"""
class DefaultMetaNoName(BindMetaMixin, sa_orm.DeclarativeMeta):
"""SQLAlchemy declarative metaclass that provides ``__bind_key__`` and
``__tablename__`` support.
"""

View File

@@ -70,30 +70,6 @@ class _QueryInfo:
def duration(self) -> float:
return self.end_time - self.start_time
@property
def context(self) -> str:
import warnings
warnings.warn(
"'context' is renamed to 'location'. The old name is deprecated and will be"
" removed in Flask-SQLAlchemy 3.1.",
DeprecationWarning,
stacklevel=2,
)
return self.location
def __getitem__(self, key: int) -> object:
import warnings
name = ("statement", "parameters", "start_time", "end_time", "location")[key]
warnings.warn(
"Query info is a dataclass, not a tuple. Lookup by index is deprecated and"
f" will be removed in Flask-SQLAlchemy 3.1. Use 'info.{name}' instead.",
DeprecationWarning,
stacklevel=2,
)
return getattr(self, name)
def _listen(engine: sa.engine.Engine) -> None:
sa_event.listen(engine, "before_cursor_execute", _record_start, named=True)

View File

@@ -79,13 +79,22 @@ class Session(sa_orm.Session):
def _clause_to_engine(
clause: t.Any | None, engines: t.Mapping[str | None, sa.engine.Engine]
clause: sa.ClauseElement | None,
engines: t.Mapping[str | None, sa.engine.Engine],
) -> sa.engine.Engine | None:
"""If the clause is a table, return the engine associated with the table's
metadata's bind key.
"""
if isinstance(clause, sa.Table) and "bind_key" in clause.metadata.info:
key = clause.metadata.info["bind_key"]
table = None
if clause is not None:
if isinstance(clause, sa.Table):
table = clause
elif isinstance(clause, sa.UpdateBase) and isinstance(clause.table, sa.Table):
table = clause.table
if table is not None and "bind_key" in table.metadata.info:
key = table.metadata.info["bind_key"]
if key not in engines:
raise sa_exc.UnboundExecutionError(