Skip to content

Commit 41df464

Browse files
authored
fix: no migration occurs when adding unique true to indexed field (#414)
* feat: alter unique for indexed column * chore: update docs and change some var names
1 parent c35282c commit 41df464

File tree

9 files changed

+130
-34
lines changed

9 files changed

+130
-34
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
#### Fixed
1212
- fix: aerich migrate raises tortoise.exceptions.FieldError when `index.INDEX_TYPE` is not empty. ([#415])
13+
- No migration occurs as expected when adding `unique=True` to indexed field. ([#404])
1314
- fix: inspectdb raise KeyError 'int2' for smallint. ([#401])
1415
- fix: inspectdb not match data type 'DOUBLE' and 'CHAR' for MySQL. ([#187])
1516

@@ -18,6 +19,7 @@
1819

1920
[#398]: https://github.com/tortoise/aerich/pull/398
2021
[#401]: https://github.com/tortoise/aerich/pull/401
22+
[#404]: https://github.com/tortoise/aerich/pull/404
2123
[#412]: https://github.com/tortoise/aerich/pull/412
2224
[#415]: https://github.com/tortoise/aerich/pull/415
2325
[#417]: https://github.com/tortoise/aerich/pull/417

aerich/ddl/__init__.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ def __init__(self, client: BaseDBAsyncClient) -> None:
4343
self.client = client
4444
self.schema_generator = self.schema_generator_cls(client)
4545

46+
@staticmethod
47+
def get_table_name(model: type[Model]) -> str:
48+
return model._meta.db_table
49+
4650
def create_table(self, model: type[Model]) -> str:
4751
schema = self.schema_generator._get_table_sql(model, True)["table_creation_string"]
4852
if tortoise.__version__ <= "0.23.0":
@@ -109,8 +113,6 @@ def _get_default(self, model: type[Model], field_describe: dict) -> Any:
109113
)
110114
except NotImplementedError:
111115
default = ""
112-
else:
113-
default = None
114116
return default
115117

116118
def add_column(self, model: type[Model], field_describe: dict, is_pk: bool = False) -> str:
@@ -276,3 +278,17 @@ def rename_table(self, model: type[Model], old_table_name: str, new_table_name:
276278
return self._RENAME_TABLE_TEMPLATE.format(
277279
table_name=db_table, old_table_name=old_table_name, new_table_name=new_table_name
278280
)
281+
282+
def alter_indexed_column_unique(
283+
self, model: type[Model], field_name: str, drop: bool = False
284+
) -> list[str]:
285+
"""Change unique constraint for indexed field, e.g.: Field(db_index=True) --> Field(unique=True)"""
286+
fields = [field_name]
287+
if drop:
288+
drop_unique = self.drop_index(model, fields, unique=True)
289+
add_normal_index = self.add_index(model, fields, unique=False)
290+
return [drop_unique, add_normal_index]
291+
else:
292+
drop_index = self.drop_index(model, fields, unique=False)
293+
add_unique_index = self.add_index(model, fields, unique=True)
294+
return [drop_index, add_unique_index]

aerich/ddl/mysql/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ class MysqlDDL(BaseDDL):
2525
)
2626
_ADD_INDEX_TEMPLATE = "ALTER TABLE `{table_name}` ADD {index_type}{unique}INDEX `{index_name}` ({column_names}){extra}"
2727
_DROP_INDEX_TEMPLATE = "ALTER TABLE `{table_name}` DROP INDEX `{index_name}`"
28+
_ADD_INDEXED_UNIQUE_TEMPLATE = (
29+
"ALTER TABLE `{table_name}` DROP INDEX `{index_name}`, ADD UNIQUE (`{column_name}`)"
30+
)
31+
_DROP_INDEXED_UNIQUE_TEMPLATE = (
32+
"ALTER TABLE `{table_name}` DROP INDEX `{column_name}`, ADD INDEX (`{index_name}`)"
33+
)
2834
_ADD_FK_TEMPLATE = "ALTER TABLE `{table_name}` ADD CONSTRAINT `{fk_name}` FOREIGN KEY (`{db_column}`) REFERENCES `{table}` (`{field}`) ON DELETE {on_delete}"
2935
_DROP_FK_TEMPLATE = "ALTER TABLE `{table_name}` DROP FOREIGN KEY `{fk_name}`"
3036
_M2M_TABLE_TEMPLATE = (
@@ -47,3 +53,13 @@ def _index_name(self, unique: bool | None, model: type[Model], field_names: list
4753
else:
4854
index_prefix = "idx"
4955
return self.schema_generator._generate_index_name(index_prefix, model, field_names)
56+
57+
def alter_indexed_column_unique(
58+
self, model: type[Model], field_name: str, drop: bool = False
59+
) -> list[str]:
60+
# if drop is false: Drop index and add unique
61+
# else: Drop unique index and add normal index
62+
template = self._DROP_INDEXED_UNIQUE_TEMPLATE if drop else self._ADD_INDEXED_UNIQUE_TEMPLATE
63+
table = self.get_table_name(model)
64+
index = self._index_name(unique=False, model=model, field_names=[field_name])
65+
return [template.format(table_name=table, index_name=index, column_name=field_name)]

aerich/migrate.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -632,7 +632,9 @@ def _handle_field_changes(
632632
# indexed include it
633633
continue
634634
# Change unique for indexed field, e.g.: `db_index=True, unique=False` --> `db_index=True, unique=True`
635-
# TODO
635+
drop_unique = old_new[0] is True and old_new[1] is False
636+
for sql in cls.ddl.alter_indexed_column_unique(model, field_name, drop_unique):
637+
cls._add_operator(sql, upgrade, True)
636638
elif option == "nullable":
637639
# change nullable
638640
cls._add_operator(cls._alter_null(model, new_data_field), upgrade)

tests/assets/sqlite_migrate/_tests.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ async def test_allow_duplicate() -> None:
1818
async def test_unique_is_true() -> None:
1919
with pytest.raises(IntegrityError):
2020
await Foo.create(name="foo")
21+
await Foo.create(name="foo")
2122

2223

2324
@pytest.mark.asyncio

tests/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ class Meta:
4949
class Email(Model):
5050
email_id = fields.IntField(primary_key=True)
5151
email = fields.CharField(max_length=200, db_index=True)
52+
company = fields.CharField(max_length=100, db_index=True, unique=True)
5253
is_primary = fields.BooleanField(default=False)
5354
address = fields.CharField(max_length=200)
5455
users: fields.ManyToManyRelation[User] = fields.ManyToManyField("models.User")

tests/old_models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class Meta:
4040

4141
class Email(Model):
4242
email = fields.CharField(max_length=200)
43+
company = fields.CharField(max_length=100, db_index=True)
4344
is_primary = fields.BooleanField(default=False)
4445
user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField(
4546
"models.User", db_constraint=False

tests/test_migrate.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,21 @@ def describe_index(idx: Index) -> Index | dict:
365365
"constraints": {"max_length": 200},
366366
"db_field_types": {"": "VARCHAR(200)"},
367367
},
368+
{
369+
"name": "company",
370+
"field_type": "CharField",
371+
"db_column": "company",
372+
"python_type": "str",
373+
"generated": False,
374+
"nullable": False,
375+
"unique": False,
376+
"indexed": True,
377+
"default": None,
378+
"description": None,
379+
"docstring": None,
380+
"constraints": {"max_length": 100},
381+
"db_field_types": {"": "VARCHAR(100)"},
382+
},
368383
{
369384
"name": "is_primary",
370385
"field_type": "BooleanField",
@@ -929,6 +944,7 @@ def test_migrate(mocker: MockerFixture):
929944
- drop fk field: Email.user
930945
- drop field: User.avatar
931946
- add index: Email.email
947+
- add unique to indexed field: Email.company
932948
- change index type for indexed field: Email.slug
933949
- add many to many: Email.users
934950
- add one to one: Email.config
@@ -977,6 +993,7 @@ def test_migrate(mocker: MockerFixture):
977993
"ALTER TABLE `email` ADD `address` VARCHAR(200) NOT NULL",
978994
"ALTER TABLE `email` ADD CONSTRAINT `fk_email_config_76a9dc71` FOREIGN KEY (`config_id`) REFERENCES `config` (`id`) ON DELETE CASCADE",
979995
"ALTER TABLE `email` ADD `config_id` INT NOT NULL UNIQUE",
996+
"ALTER TABLE `email` DROP INDEX `idx_email_company_1c9234`, ADD UNIQUE (`company`)",
980997
"ALTER TABLE `configs` RENAME TO `config`",
981998
"ALTER TABLE `product` DROP COLUMN `uuid`",
982999
"ALTER TABLE `product` DROP INDEX `uuid`",
@@ -1019,20 +1036,21 @@ def test_migrate(mocker: MockerFixture):
10191036
"ALTER TABLE `config` ADD UNIQUE INDEX `name` (`name`)",
10201037
"ALTER TABLE `config` DROP FOREIGN KEY `fk_config_user_17daa970`",
10211038
"ALTER TABLE `config` ALTER COLUMN `status` SET DEFAULT 1",
1022-
"ALTER TABLE `email` ADD `user_id` INT NOT NULL",
10231039
"ALTER TABLE `config` DROP COLUMN `user_id`",
1040+
"ALTER TABLE `config` RENAME TO `configs`",
1041+
"ALTER TABLE `email` ADD `user_id` INT NOT NULL",
10241042
"ALTER TABLE `email` DROP COLUMN `address`",
10251043
"ALTER TABLE `email` DROP COLUMN `config_id`",
10261044
"ALTER TABLE `email` DROP FOREIGN KEY `fk_email_config_76a9dc71`",
1027-
"ALTER TABLE `config` RENAME TO `configs`",
1028-
"ALTER TABLE `product` RENAME COLUMN `pic` TO `image`",
10291045
"ALTER TABLE `email` RENAME COLUMN `email_id` TO `id`",
1046+
"ALTER TABLE `email` DROP INDEX `company`, ADD INDEX (`idx_email_company_1c9234`)",
1047+
"ALTER TABLE `email` DROP INDEX `idx_email_email_4a1a33`",
1048+
"ALTER TABLE `product` RENAME COLUMN `pic` TO `image`",
10301049
"ALTER TABLE `product` ADD `uuid` INT NOT NULL UNIQUE",
10311050
"ALTER TABLE `product` ADD UNIQUE INDEX `uuid` (`uuid`)",
10321051
"ALTER TABLE `product` DROP INDEX `idx_product_name_869427`",
10331052
"ALTER TABLE `product` DROP COLUMN `price`",
10341053
"ALTER TABLE `product` DROP COLUMN `no`",
1035-
"ALTER TABLE `email` DROP INDEX `idx_email_email_4a1a33`",
10361054
"ALTER TABLE `product` DROP INDEX `uid_product_name_869427`",
10371055
"ALTER TABLE `product` DROP INDEX `idx_product_no_e4d701`",
10381056
"ALTER TABLE `product` ALTER COLUMN `view_num` DROP DEFAULT",
@@ -1074,6 +1092,8 @@ def test_migrate(mocker: MockerFixture):
10741092
'ALTER TABLE "email" DROP COLUMN "user_id"',
10751093
'ALTER TABLE "email" ADD CONSTRAINT "fk_email_config_76a9dc71" FOREIGN KEY ("config_id") REFERENCES "config" ("id") ON DELETE CASCADE',
10761094
'ALTER TABLE "email" ADD "config_id" INT NOT NULL UNIQUE',
1095+
'DROP INDEX IF EXISTS "idx_email_company_1c9234"',
1096+
'CREATE UNIQUE INDEX IF NOT EXISTS "uid_email_company_1c9234" ON "email" ("company")',
10771097
'DROP INDEX IF EXISTS "uid_product_uuid_d33c18"',
10781098
'ALTER TABLE "product" DROP COLUMN "uuid"',
10791099
'ALTER TABLE "product" ALTER COLUMN "view_num" SET DEFAULT 0',
@@ -1121,6 +1141,8 @@ def test_migrate(mocker: MockerFixture):
11211141
'ALTER TABLE "email" RENAME COLUMN "email_id" TO "id"',
11221142
'ALTER TABLE "email" DROP COLUMN "config_id"',
11231143
'ALTER TABLE "email" DROP CONSTRAINT IF EXISTS "fk_email_config_76a9dc71"',
1144+
'CREATE INDEX IF NOT EXISTS "idx_email_company_1c9234" ON "email" ("company")',
1145+
'DROP INDEX IF EXISTS "uid_email_company_1c9234"',
11241146
'ALTER TABLE "product" ADD "uuid" INT NOT NULL UNIQUE',
11251147
'CREATE UNIQUE INDEX IF NOT EXISTS "uid_product_uuid_d33c18" ON "product" ("uuid")',
11261148
'ALTER TABLE "product" ALTER COLUMN "view_num" DROP DEFAULT',

tests/test_sqlite_migrate.py

Lines changed: 62 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1+
from __future__ import annotations
2+
13
import contextlib
24
import os
35
import shlex
46
import shutil
57
import subprocess
8+
from collections.abc import Generator
9+
from contextlib import contextmanager
610
from pathlib import Path
711

8-
from aerich.ddl.sqlite import SqliteDDL
9-
from aerich.migrate import Migrate
10-
from tests._utils import chdir, copy_files
12+
from tests._utils import Dialect, chdir, copy_files
1113

1214

1315
def run_aerich(cmd: str) -> None:
@@ -22,19 +24,67 @@ def run_shell(cmd: str) -> subprocess.CompletedProcess:
2224
return subprocess.run(shlex.split(cmd), env=envs)
2325

2426

25-
def test_sqlite_migrate(tmp_path: Path) -> None:
26-
if (ddl := getattr(Migrate, "ddl", None)) and not isinstance(ddl, SqliteDDL):
27-
return
27+
def _get_empty_db() -> Path:
28+
if (db_file := Path("db.sqlite3")).exists():
29+
db_file.unlink()
30+
return db_file
31+
32+
33+
@contextmanager
34+
def prepare_sqlite_project(tmp_path: Path) -> Generator[tuple[Path, str]]:
2835
test_dir = Path(__file__).parent
2936
asset_dir = test_dir / "assets" / "sqlite_migrate"
3037
with chdir(tmp_path):
3138
files = ("models.py", "settings.py", "_tests.py")
3239
copy_files(*(asset_dir / f for f in files), target_dir=Path())
3340
models_py, settings_py, test_py = (Path(f) for f in files)
3441
copy_files(asset_dir / "conftest_.py", target_dir=Path("conftest.py"))
35-
if (db_file := Path("db.sqlite3")).exists():
36-
db_file.unlink()
37-
MODELS = models_py.read_text("utf-8")
42+
_get_empty_db()
43+
yield models_py, models_py.read_text("utf-8")
44+
45+
46+
def test_sqlite_migrate_alter_indexed_unique(tmp_path: Path) -> None:
47+
if not Dialect.is_sqlite():
48+
return
49+
with prepare_sqlite_project(tmp_path) as (models_py, models_text):
50+
models_py.write_text(models_text.replace("db_index=False", "db_index=True"))
51+
run_aerich("aerich init -t settings.TORTOISE_ORM")
52+
run_aerich("aerich init-db")
53+
r = run_shell("pytest -s _tests.py::test_allow_duplicate")
54+
assert r.returncode == 0
55+
models_py.write_text(models_text.replace("db_index=False", "unique=True"))
56+
run_aerich("aerich migrate") # migrations/models/1_
57+
run_aerich("aerich upgrade")
58+
r = run_shell("pytest _tests.py::test_unique_is_true")
59+
assert r.returncode == 0
60+
models_py.write_text(models_text.replace("db_index=False", "db_index=True"))
61+
run_aerich("aerich migrate") # migrations/models/2_
62+
run_aerich("aerich upgrade")
63+
r = run_shell("pytest -s _tests.py::test_allow_duplicate")
64+
assert r.returncode == 0
65+
66+
67+
M2M_WITH_CUSTOM_THROUGH = """
68+
groups = fields.ManyToManyField("models.Group", through="foo_group")
69+
70+
class Group(Model):
71+
name = fields.CharField(max_length=60)
72+
73+
class FooGroup(Model):
74+
foo = fields.ForeignKeyField("models.Foo")
75+
group = fields.ForeignKeyField("models.Group")
76+
is_active = fields.BooleanField(default=False)
77+
78+
class Meta:
79+
table = "foo_group"
80+
"""
81+
82+
83+
def test_sqlite_migrate(tmp_path: Path) -> None:
84+
if not Dialect.is_sqlite():
85+
return
86+
with prepare_sqlite_project(tmp_path) as (models_py, models_text):
87+
MODELS = models_text
3888
run_aerich("aerich init -t settings.TORTOISE_ORM")
3989
config_file = Path("pyproject.toml")
4090
modify_time = config_file.stat().st_mtime
@@ -84,7 +134,7 @@ def test_sqlite_migrate(tmp_path: Path) -> None:
84134
# Initial with indexed field and then drop it
85135
migrations_dir = Path("migrations/models")
86136
shutil.rmtree(migrations_dir)
87-
db_file.unlink()
137+
db_file = _get_empty_db()
88138
models_py.write_text(MODELS + " age = fields.IntField(db_index=True)")
89139
run_aerich("aerich init -t settings.TORTOISE_ORM")
90140
run_aerich("aerich init-db")
@@ -119,21 +169,7 @@ def test_sqlite_migrate(tmp_path: Path) -> None:
119169
assert "[tool.aerich]" in config_file.read_text()
120170

121171
# add m2m with custom model for through
122-
new = """
123-
groups = fields.ManyToManyField("models.Group", through="foo_group")
124-
125-
class Group(Model):
126-
name = fields.CharField(max_length=60)
127-
128-
class FooGroup(Model):
129-
foo = fields.ForeignKeyField("models.Foo")
130-
group = fields.ForeignKeyField("models.Group")
131-
is_active = fields.BooleanField(default=False)
132-
133-
class Meta:
134-
table = "foo_group"
135-
"""
136-
models_py.write_text(MODELS + new)
172+
models_py.write_text(MODELS + M2M_WITH_CUSTOM_THROUGH)
137173
run_aerich("aerich migrate")
138174
run_aerich("aerich upgrade")
139175
migration_file_1 = list(migrations_dir.glob("1_*.py"))[0]
@@ -148,8 +184,7 @@ class Meta:
148184
class Group(Model):
149185
name = fields.CharField(max_length=60)
150186
"""
151-
if db_file.exists():
152-
db_file.unlink()
187+
_get_empty_db()
153188
if migrations_dir.exists():
154189
shutil.rmtree(migrations_dir)
155190
models_py.write_text(MODELS)

0 commit comments

Comments
 (0)