Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to

## [Unreleased]

### Added

- A new operation to handle the first step of renaming a model:
`SaferRenameModelPart1`.
- A new operation to handle the second (and last) step of renaming a model:
`SaferRenameModelPart2`.

## [0.1.17] - 2025-01-14

### Added
Expand Down
180 changes: 180 additions & 0 deletions docs/usage/operations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1009,3 +1009,183 @@ Class Definitions
),
),
]


.. py:class:: SaferRenameModelPart1(old_name: str, new_name: str)

First step on a routine that provides a safer RenameModel alternative.

:param old_name: Old model name as it was once defined e.g Foo.
:type old_name: str
:param new_name: New model name as it is now defined e.g NewFoo.
:type new_name: str

**Why use this SaferRenameModelPart1 operation?**
-------------------------------------------------

When using Django's default ``RenameModel`` operation, the SQL created has
the following form:

.. code-block:: sql

BEGIN;
--
-- Rename model Foo to NewFoo
--
ALTER TABLE "myapp_foo" RENAME TO "myapp_newfoo";
COMMIT;

In modern applications that use gradual deployments like blue/green
deploys, renaming a table in-flight might cause issues when the traffic
hasn't fully been moved from the old servers (blue) to the new ones
(green).

For example, in a Django app the old servers would see the app state as if
the recently renamed table was still using the old name, and will therefore
crash when the model is used on those servers.

Additionally if the model has foreign keys, the relating models will have
their foreign key constraints created from scratch which might take a long
time. Column names for M2M tables are also updated. For example:

.. code-block:: py

class NewFoo(models.Model):
pass


class Bar(models.Model):
foo = models.ForeignKey(NewFoo, on_delete=models.CASCADE, null=True)


class Buzz(models.Model):
foos = models.ManyToManyField(NewFoo)

Culminates on the following statements:

.. code-block:: sql

BEGIN;
--
-- Rename model Foo to NewFoo
--
ALTER TABLE "myapp_foo" RENAME TO "myapp_newfoo";

SET CONSTRAINTS "myapp_bar_foo_id_f5927bae_fk_myapp_newfoo_id" IMMEDIATE;

ALTER TABLE "myapp_bar" DROP CONSTRAINT "myapp_bar_foo_id_f5927bae_fk_myapp_newfoo_id";

Comment on lines +1074 to +1077
Copy link
Contributor

Choose a reason for hiding this comment

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

Why would the constraint name myapp_bar_foo_id_f5927bae_fk_myapp_newfoo_id include "newfoo" at this point? (I suspect that this example has been put together by hand.)

From here:

I think that the problem here is that Django actually does more than it should.
Namely, dropping constraints and recreating them from scratch so that they can
have a fresh name, that is costly for FKs. In this case, that's explained in
the commit that adds the docs here which includes the output of the DDLs
performed by Django. Hope that clarifies?

I would expect to see a different constraint name after Django drops the old one and makes a new one. So no, the example here doesn't currently clarify :(

ALTER TABLE "myapp_bar" ADD CONSTRAINT "myapp_bar_foo_id_f5927bae_fk_myapp_newfoo_id"
FOREIGN KEY ("foo_id") REFERENCES "myapp_newfoo" ("id") DEFERRABLE INITIALLY DEFERRED;

ALTER TABLE "myapp_buzz_foos" RENAME COLUMN "foo_id" TO "newfoo_id";
COMMIT;

Instead, this operation avoids the inevitable crash on old servers by
creating a view from the schema of the renamed table, and it also avoids
the recreation of related objects (like FK constraints) unnecessarily.
Effectively, this view is an alias to the underlying table:

.. code-block:: sql

BEGIN;

ALTER TABLE myapp_foo RENAME TO myapp_newfoo;

CREATE VIEW myapp_foo AS SELECT * FROM myapp_newfoo;

COMMIT;

**NOTE**: Additional queries that are triggered by this operation to
guarantee idempotency have been omitted from the snippet above. The key
take away is that if this migration fails, it can be reattempted and it
will pick up from where it has left off (reentrancy).

**NOTE**: The name of database objects that Django created in relation to
the old name of the table (e.g., constraint names), *are not renamed* as
part of this operation. However, their definitions are automatically
updated by Postgres to point to the new table as part of the ``ALTER TABLE
... RENAME`` instruction. Renaming those objects is left to the user,
though it is not strictly necessary as long as you are happy with the old
names.

How to use
----------

1. Rename your model

.. code-block:: diff

- class Foo(models.Model):
+ class NewFoo(models.Model):


2. Make the new migration:

.. code-block:: bash

./manage.py makemigrations

3. The only change you need to perform is:

1. Swap Django's ``RenameModel`` for this package's
``SaferRenameModelPart1`` operation.

.. code-block:: diff

+ from django_pg_migration_tools import operations
from django.db import migrations


class Migration(migrations.Migration):
dependencies = [("myapp", "0042_dependency")]

operations = [
- migrations.RenameModel(
+ operations.SaferRenameModelPart1(
old_name="Foo",
new_name="NewFoo",
),
]

.. py:class:: SaferRenameModelPart2(new_name: str, old_table_name: str)

Second and final step on a routine that provides a safer RenameModel
alternative.

:param new_name: New model name as it is now defined e.g NewFoo.
:type new_name: str
:param old_table_name: The name of the old table before the renaming took place.
:type old_table_name: str

This operation is essentially the complement of ``SaferRenameModelPart1``
and is used drop the view created to satisfy old servers running the old
code (and Django state). After all servers are verified to be in the new
version, i.e., using the new table, this operation can be triggered.

How to use
----------

1. Create a new empty migration

.. code-block:: sh

./manage.py makemigrations --empty myapp

2. Insert the operation

.. code-block:: diff

+ from django_pg_migration_tools import operations
from django.db import migrations


class Migration(migrations.Migration):
dependencies = [("myapp", "0043_part_1_migration")]

operations = [
+ operations.SaferRenameModelPart2(
+ new_name="NewFoo",
+ old_table_name="myapp_foo"
),
]
Loading
Loading