Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support the SQLAlchemy 2.0 DeclarativeBase models #1215

Merged
merged 27 commits into from
Aug 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0150152
Possible way to mixin with DeclarativeBase
pamelafox May 24, 2023
7d0f5e5
Remove pdm-python file
pamelafox May 24, 2023
d073fa6
Try different approach to mixins for 2.0
pamelafox May 25, 2023
5ff15bc
Support MappedAsDataClass
pamelafox May 25, 2023
8d78e37
Update should check
pamelafox May 25, 2023
41fdd5d
Parameterizing all the tests
pamelafox May 31, 2023
31e8e2c
Remove redundant test
pamelafox May 31, 2023
b536d28
Updates to docs
pamelafox May 31, 2023
af5096d
Getting types to work (mostly)
pamelafox Jun 1, 2023
a8da619
Update sqlalchemy version
pamelafox Jun 13, 2023
d3a30c5
Mark the type classes as internal with an underscore in front
pamelafox Jun 13, 2023
73e0206
Fix tests
pamelafox Jul 4, 2023
2c3650a
Addressing docs note
pamelafox Jul 5, 2023
7610ea7
Add constraint of les than 2.1
pamelafox Jul 5, 2023
8fdc47a
Remove version cap per Davids comment
pamelafox Jul 6, 2023
1bbd722
Add disable_autonaming
pamelafox Jul 11, 2023
b052dd6
Update versionchanged for new mixins
pamelafox Aug 20, 2023
d8c31ca
Document refactor to emphasize 2.x, fix for bind key and metadata for…
pamelafox Aug 22, 2023
a89485b
Parameterize more tests for commit
pamelafox Aug 22, 2023
6f4c180
Parameterize more tests to use 2.x
pamelafox Aug 22, 2023
caee46a
Update README
pamelafox Aug 22, 2023
6d7a7e3
Style and sphinx checks
pamelafox Aug 22, 2023
1d877e8
Documentation updates- remove new mixins from ref, add some comments
pamelafox Aug 22, 2023
a2db216
Update abstract models and mixins
pamelafox Aug 22, 2023
cf4170a
Add test for declared_attr
pamelafox Aug 25, 2023
fe9e4de
Make suggested typing fix
pamelafox Aug 26, 2023
fdeec1d
Adjusted to work in 3.8
pamelafox Aug 26, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ Version 3.1.0

Unreleased

- Add support for the SQLAlchemy 2.x API via ``model_class`` parameter. :issue:`1140`
- Bump minimum version of SQLAlchemy to 2.0.16.
- Remove previously deprecated code.
- Pass extra keyword arguments from ``get_or_404`` to ``session.get``. :issue:`1149`

Expand Down
11 changes: 8 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,19 @@ A Simple Example

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///example.sqlite"
db = SQLAlchemy(app)

class Base(DeclarativeBase):
pass

db = SQLAlchemy(app, model_class=Base)

class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String, unique=True, nullable=False)
id: Mapped[int] = mapped_column(db.Integer, primary_key=True)
username: Mapped[str] = mapped_column(db.String, unique=True, nullable=False)

with app.app_context():
db.create_all()
Expand Down
6 changes: 6 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ Model
If the ``__table__`` or ``__tablename__`` is set explicitly, that will be used
instead.

Metaclass mixins (SQLAlchemy 1.x)
---------------------------------

If your code uses the SQLAlchemy 1.x API (the default for code that doesn't specify a ``model_class``),
then these mixins are automatically applied to the ``Model`` class.

.. autoclass:: DefaultMeta

.. autoclass:: BindMetaMixin
Expand Down
25 changes: 0 additions & 25 deletions docs/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -151,31 +151,6 @@ only need to use :data:`SQLALCHEMY_DATABASE_URI` and :data:`SQLALCHEMY_ENGINE_OP
in that engine's options.


Using custom MetaData and naming conventions
--------------------------------------------

You can optionally construct the :class:`.SQLAlchemy` object with a custom
:class:`~sqlalchemy.schema.MetaData` object. This allows you to specify a custom
constraint `naming convention`_. This makes constraint names consistent and predictable,
useful when using migrations, as described by `Alembic`_.

.. code-block:: python

from sqlalchemy import MetaData
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy(metadata=MetaData(naming_convention={
"ix": 'ix_%(column_0_label)s',
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s"
}))

.. _naming convention: https://docs.sqlalchemy.org/core/constraints.html#constraint-naming-conventions
.. _Alembic: https://alembic.sqlalchemy.org/en/latest/naming.html


Timeouts
--------

Expand Down
130 changes: 39 additions & 91 deletions docs/customizing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,29 +21,26 @@ joined-table inheritance.

.. code-block:: python

from flask_sqlalchemy.model import Model
import sqlalchemy as sa
import sqlalchemy.orm
from sqlalchemy import Integer, String, ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, declared_attr

class IdModel(Model):
@sa.orm.declared_attr
class Base(DeclarativeBase):
@declared_attr.cascading
@classmethod
def id(cls):
for base in cls.__mro__[1:-1]:
if getattr(base, "__table__", None) is not None:
type = sa.ForeignKey(base.id)
break
else:
type = sa.Integer
return mapped_column(ForeignKey(base.id), primary_key=True)
else:
return mapped_column(Integer, primary_key=True)

return sa.Column(type, primary_key=True)

db = SQLAlchemy(model_class=IdModel)
db = SQLAlchemy(app, model_class=Base)

class User(db.Model):
name = db.Column(db.String)
name: Mapped[str] = mapped_column(String)

class Employee(User):
title = db.Column(db.String)
title: Mapped[str] = mapped_column(String)


Abstract Models and Mixins
Expand All @@ -56,28 +53,49 @@ they are created or updated.
.. code-block:: python

from datetime import datetime
from sqlalchemy import DateTime, Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, declared_attr

class TimestampModel(db.Model):
__abstract__ = True
created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
updated = db.Column(db.DateTime, onupdate=datetime.utcnow)
created: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
updated: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

class Author(db.Model):
...
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String, unique=True, nullable=False)

class Post(TimestampModel):
...
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String, nullable=False)

This can also be done with a mixin class, inheriting from ``db.Model`` separately.

.. code-block:: python

class TimestampMixin:
created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
updated = db.Column(db.DateTime, onupdate=datetime.utcnow)
created: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
updated: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

class Post(TimestampMixin, db.Model):
...
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String, nullable=False)


Disabling Table Name Generation
-------------------------------

Some projects prefer to set each model's ``__tablename__`` manually rather than relying
on Flask-SQLAlchemy's detection and generation. The simple way to achieve that is to
set each ``__tablename__`` and not modify the base class. However, the table name
generation can be disabled by setting `disable_autonaming=True` in the `SQLAlchemy` constructor.

.. code-block:: python

class Base(sa_orm.DeclarativeBase):
pass

db = SQLAlchemy(app, model_class=Base, disable_autonaming=True)


Session Class
Expand Down Expand Up @@ -158,73 +176,3 @@ To customize only ``session.query``, pass the ``query_cls`` key to the
.. code-block:: python

db = SQLAlchemy(session_options={"query_cls": GetOrQuery})


Model Metaclass
---------------
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps a radical approach, but I've deleted the Model Metaclass section of the customizing doc, since:

  1. its main purpose was to explain table name generation, and I've now made that possible via a constructor parameter for both old and new sqlalchemy models
  2. going forward, new users will hopefully be using sqlalchemy 2.x, so less need for something as esoteric as metaclass customization


.. warning::
Metaclasses are an advanced topic, and you probably don't need to customize them to
achieve what you want. It is mainly documented here to show how to disable table
name generation.

The model metaclass is responsible for setting up the SQLAlchemy internals when defining
model subclasses. Flask-SQLAlchemy adds some extra behaviors through mixins; its default
metaclass, :class:`~.DefaultMeta`, inherits them all.

- :class:`.BindMetaMixin`: ``__bind_key__`` sets the bind to use for the model.
- :class:`.NameMetaMixin`: If the model does not specify a ``__tablename__`` but does
specify a primary key, a name is automatically generated.

You can add your own behaviors by defining your own metaclass and creating the
declarative base yourself. Be sure to still inherit from the mixins you want (or just
inherit from the default metaclass).

Passing a declarative base class instead of a simple model base class to ``model_class``
will cause Flask-SQLAlchemy to use this base instead of constructing one with the
default metaclass.

.. code-block:: python

from sqlalchemy.orm import declarative_base
from flask_sqlalchemy import SQLAlchemy
from flask_sqlalchemy.model import DefaultMeta, Model

class CustomMeta(DefaultMeta):
def __init__(cls, name, bases, d):
# custom class setup could go here

# be sure to call super
super(CustomMeta, cls).__init__(name, bases, d)

# custom class-only methods could go here

CustomModel = declarative_base(cls=Model, metaclass=CustomMeta, name="Model")
db = SQLAlchemy(model_class=CustomModel)

You can also pass whatever other arguments you want to
:func:`~sqlalchemy.orm.declarative_base` to customize the base class.


Disabling Table Name Generation
```````````````````````````````

Some projects prefer to set each model's ``__tablename__`` manually rather than relying
on Flask-SQLAlchemy's detection and generation. The simple way to achieve that is to
set each ``__tablename__`` and not modify the base class. However, the table name
generation can be disabled by defining a custom metaclass with only the
``BindMetaMixin`` and not the ``NameMetaMixin``.

.. code-block:: python

from sqlalchemy.orm import DeclarativeMeta, declarative_base
from flask_sqlalchemy.model import BindMetaMixin, Model

class NoNameMeta(BindMetaMixin, DeclarativeMeta):
pass

CustomModel = declarative_base(cls=Model, metaclass=NoNameMeta, name="Model")
db = SQLAlchemy(model_class=CustomModel)

This creates a base that still supports the ``__bind_key__`` feature but does not
generate table names.
95 changes: 95 additions & 0 deletions docs/legacy-quickstart.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@

:orphan:

Legacy Quickstart
======================

.. warning::
This guide shows you how to initialize the extension and define models
when using the SQLAlchemy 1.x style of ORM model classes. We encourage you to
upgrade to `SQLAlchemy 2.x`_ to take advantage of the new typed model classes.

.. _SQLAlchemy 2.x: https://docs.sqlalchemy.org/en/20/orm/quickstart.html

Initialize the Extension
------------------------

First create the ``db`` object using the ``SQLAlchemy`` constructor.

When using the SQLAlchemy 1.x API, you do not need to pass any arguments to the ``SQLAlchemy`` constructor.
A declarative base class will be created behind the scenes for you.

.. code-block:: python

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase

db = SQLAlchemy()


Using custom MetaData and naming conventions
--------------------------------------------

You can optionally construct the :class:`.SQLAlchemy` object with a custom
:class:`~sqlalchemy.schema.MetaData` object. This allows you to specify a custom
constraint `naming convention`_. This makes constraint names consistent and predictable,
useful when using migrations, as described by `Alembic`_.

.. code-block:: python

from sqlalchemy import MetaData
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy(metadata=MetaData(naming_convention={
"ix": 'ix_%(column_0_label)s',
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s"
}))

.. _naming convention: https://docs.sqlalchemy.org/core/constraints.html#constraint-naming-conventions
.. _Alembic: https://alembic.sqlalchemy.org/en/latest/naming.html



Define Models
-------------

Subclass ``db.Model`` to define a model class. This is a SQLAlchemy declarative base
class, it will take ``Column`` attributes and create a table.

.. code-block:: python

class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String, unique=True, nullable=False)
email = db.Column(db.String)

For convenience, the extension object provides access to names in the ``sqlalchemy`` and
``sqlalchemy.orm`` modules. So you can use ``db.Column`` instead of importing and using
``sqlalchemy.Column``, although the two are equivalent.

Unlike plain SQLAlchemy, Flask-SQLAlchemy's model will automatically generate a table name
if ``__tablename__`` is not set and a primary key column is defined.
The table name ``"user"`` will automatically be assigned to the model's table.


Create the Tables
-----------------

Defining a model does not create it in the database. Use :meth:`~.SQLAlchemy.create_all`
to create the models and tables after defining them. If you define models in submodules,
you must import them so that SQLAlchemy knows about them before calling ``create_all``.

.. code-block:: python

with app.app_context():
db.create_all()

Querying the Data
-----------------

You can query the data the same way regardless of SQLAlchemy version.
See :doc:`queries` for more information about queries.
Loading