Skip to content

Commit e52a075

Browse files
committed
feat(indexes): add nulls_not_distinct option to PostgreSQLIndex
Add unique and nulls_not_distinct parameters to PostgreSQLIndex so users can create PostgreSQL unique indexes that treat NULL values as equal. This implements the direction from abondar in #1641: expose the option on tortoise.contrib.postgres.indexes.PostgreSQLIndex instead of changing unique_together behavior. Fixes #1641
1 parent 8c12adc commit e52a075

7 files changed

Lines changed: 224 additions & 14 deletions

File tree

tests/migrations/test_schema_editor_sql.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from tests.utils.fake_client import FakeClient
88
from tortoise import fields
99
from tortoise.contrib.postgres.fields import TSVectorField
10-
from tortoise.contrib.postgres.indexes import GinIndex
10+
from tortoise.contrib.postgres.indexes import GinIndex, PostgreSQLIndex
1111
from tortoise.indexes import Index
1212
from tortoise.migrations.schema_editor.base import BaseSchemaEditor
1313
from tortoise.migrations.schema_editor.base_postgres import BasePostgresSchemaEditor
@@ -192,6 +192,73 @@ class Meta:
192192
) == client.executed[0]
193193

194194

195+
@pytest.mark.asyncio
196+
async def test_add_unique_index_generates_nulls_not_distinct_sql() -> None:
197+
class Customer(Model):
198+
id = fields.IntField(pk=True)
199+
shop_id = fields.IntField()
200+
phone_number = fields.CharField(max_length=20)
201+
deleted_at = fields.DatetimeField(null=True)
202+
203+
class Meta:
204+
table = "customer"
205+
app = "models"
206+
indexes = [
207+
PostgreSQLIndex(
208+
fields=("shop_id", "phone_number", "deleted_at"),
209+
unique=True,
210+
nulls_not_distinct=True,
211+
)
212+
]
213+
214+
client = FakeClient("postgres", inline_comment=False)
215+
editor = BasePostgresSchemaEditor(client)
216+
217+
index = cast(Index, Customer._meta.indexes[0])
218+
await editor.add_index(Customer, index)
219+
220+
assert client.executed
221+
expected_name = editor._generate_index_name("uidx", Customer, ["shop_id", "phone_number", "deleted_at"])
222+
assert (
223+
f'CREATE UNIQUE INDEX "{expected_name}" ON "customer"'
224+
f' ("shop_id", "phone_number", "deleted_at") NULLS NOT DISTINCT;'
225+
) == client.executed[0]
226+
227+
228+
@pytest.mark.asyncio
229+
async def test_add_partial_unique_index_generates_nulls_not_distinct_before_where() -> None:
230+
class Customer(Model):
231+
id = fields.IntField(pk=True)
232+
shop_id = fields.IntField()
233+
phone_number = fields.CharField(max_length=20)
234+
deleted_at = fields.DatetimeField(null=True)
235+
236+
class Meta:
237+
table = "customer"
238+
app = "models"
239+
indexes = [
240+
PostgreSQLIndex(
241+
fields=("shop_id", "phone_number", "deleted_at"),
242+
unique=True,
243+
nulls_not_distinct=True,
244+
condition={"shop_id": 1},
245+
)
246+
]
247+
248+
client = FakeClient("postgres", inline_comment=False)
249+
editor = BasePostgresSchemaEditor(client)
250+
251+
index = cast(Index, Customer._meta.indexes[0])
252+
await editor.add_index(Customer, index)
253+
254+
assert client.executed
255+
expected_name = editor._generate_index_name("uidx", Customer, ["shop_id", "phone_number", "deleted_at"])
256+
assert (
257+
f'CREATE UNIQUE INDEX "{expected_name}" ON "customer"'
258+
f' ("shop_id", "phone_number", "deleted_at") NULLS NOT DISTINCT WHERE shop_id = 1;'
259+
) == client.executed[0]
260+
261+
195262
@pytest.mark.asyncio
196263
async def test_alter_generated_field_raises() -> None:
197264
class OldSearchDocument(Model):
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from tortoise import Model, fields
2+
3+
from tortoise.contrib.postgres.indexes import PostgreSQLIndex
4+
5+
6+
class Customer(Model):
7+
shop_id = fields.IntField()
8+
phone_number = fields.CharField(max_length=20)
9+
deleted_at = fields.DatetimeField(null=True)
10+
11+
class Meta:
12+
indexes = [
13+
PostgreSQLIndex(
14+
fields=("shop_id", "phone_number", "deleted_at"),
15+
unique=True,
16+
nulls_not_distinct=True,
17+
),
18+
]

tests/schema/test_generate_schema.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1928,6 +1928,34 @@ async def test_psycopg_index_safe():
19281928
await _teardown_tortoise()
19291929

19301930

1931+
@pytest.mark.asyncio
1932+
async def test_asyncpg_unique_index_nulls_not_distinct():
1933+
await _reset_tortoise()
1934+
try:
1935+
await _init_for_asyncpg("tests.schema.models_postgres_unique_index")
1936+
sql = get_schema_sql(connections.get("default"), safe=False)
1937+
assert (
1938+
'CREATE UNIQUE INDEX "uidx_customer_shop_id_c9ba87" ON "customer"'
1939+
' ("shop_id", "phone_number", "deleted_at") NULLS NOT DISTINCT;' in sql
1940+
)
1941+
finally:
1942+
await _teardown_tortoise()
1943+
1944+
1945+
@pytest.mark.asyncio
1946+
async def test_psycopg_unique_index_nulls_not_distinct():
1947+
await _reset_tortoise()
1948+
try:
1949+
await _init_for_psycopg("tests.schema.models_postgres_unique_index")
1950+
sql = get_schema_sql(connections.get("default"), safe=False)
1951+
assert (
1952+
'CREATE UNIQUE INDEX "uidx_customer_shop_id_c9ba87" ON "customer"'
1953+
' ("shop_id", "phone_number", "deleted_at") NULLS NOT DISTINCT;' in sql
1954+
)
1955+
finally:
1956+
await _teardown_tortoise()
1957+
1958+
19311959
@pytest.mark.asyncio
19321960
async def test_psycopg_m2m_no_auto_create():
19331961
await _reset_tortoise()

tortoise/backends/base_postgres/schema_generator.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
class BasePostgresSchemaGenerator(BaseSchemaGenerator):
1515
DIALECT = "postgres"
1616
INDEX_CREATE_TEMPLATE = (
17-
'CREATE INDEX {exists}"{index_name}" ON {table_name} {index_type}({fields}){extra};'
17+
'CREATE INDEX {exists}"{index_name}" ON {table_name} {index_type}({fields}){nulls_not_distinct}{extra};'
1818
)
1919
UNIQUE_INDEX_CREATE_TEMPLATE = INDEX_CREATE_TEMPLATE.replace("INDEX", "UNIQUE INDEX")
2020
TABLE_COMMENT_TEMPLATE = "COMMENT ON TABLE {table} IS '{comment}';"
@@ -79,10 +79,40 @@ def _get_index_sql(
7979
index_name: str | None = None,
8080
index_type: str | None = None,
8181
extra: str | None = None,
82+
unique: bool = False,
83+
nulls_not_distinct: bool = False,
8284
) -> str:
8385
if index_type:
8486
index_type = f"USING {index_type}"
8587

86-
return super()._get_index_sql(
87-
model, field_names, safe, index_name=index_name, index_type=index_type, extra=extra
88+
nulls_not_distinct_sql = " NULLS NOT DISTINCT" if unique and nulls_not_distinct else ""
89+
template = self.UNIQUE_INDEX_CREATE_TEMPLATE if unique else self.INDEX_CREATE_TEMPLATE
90+
prefix = "uidx" if unique else "idx"
91+
92+
return template.format(
93+
exists="IF NOT EXISTS " if safe else "",
94+
index_name=index_name or self._get_index_name(prefix, model, field_names),
95+
index_type=f"{index_type} " if index_type else "",
96+
table_name=self._qualify_table_name(model._meta.db_table, model._meta.schema),
97+
fields=self._format_index_fields(field_names),
98+
nulls_not_distinct=nulls_not_distinct_sql,
99+
extra=f"{extra}" if extra else "",
100+
)
101+
102+
def _get_unique_index_sql(
103+
self,
104+
exists: str,
105+
table_name: str,
106+
field_names: Sequence[str],
107+
schema: str | None = None,
108+
) -> str:
109+
index_name = self._get_index_name("uidx", table_name, field_names)
110+
return self.UNIQUE_INDEX_CREATE_TEMPLATE.format(
111+
exists=exists,
112+
index_name=index_name,
113+
index_type="",
114+
table_name=self._qualify_table_name(table_name, schema),
115+
fields=", ".join([self.quote(f) for f in field_names]),
116+
nulls_not_distinct="",
117+
extra="",
88118
)

tortoise/contrib/postgres/indexes.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,64 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from pypika_tortoise.terms import Term
6+
7+
from tortoise.expressions import Expression
18
from tortoise.indexes import PartialIndex
29

310

411
class PostgreSQLIndex(PartialIndex):
5-
pass
12+
"""
13+
Base class for PostgreSQL-specific indexes.
14+
15+
:param expressions: The expressions on which the index is desired.
16+
:param fields: A tuple or list of field names on which the index is desired.
17+
:param name: The name of the index.
18+
:param condition: Optional WHERE condition for partial indexes.
19+
:param unique: Whether the index should enforce uniqueness.
20+
:param nulls_not_distinct: For unique indexes, treat NULL values as equal.
21+
:raises ValueError: If params conflict.
22+
"""
23+
24+
def __init__(
25+
self,
26+
*expressions: Term | Expression,
27+
fields: tuple[str, ...] | list[str] | None = None,
28+
name: str | None = None,
29+
condition: dict | None = None,
30+
unique: bool = False,
31+
nulls_not_distinct: bool = False,
32+
) -> None:
33+
super().__init__(*expressions, fields=fields, name=name, condition=condition)
34+
self.unique = unique
35+
self.nulls_not_distinct = nulls_not_distinct
36+
37+
def get_sql(self, schema_generator, model, safe):
38+
return schema_generator._get_index_sql(
39+
model,
40+
self.field_names,
41+
safe,
42+
index_name=self.name,
43+
index_type=self.INDEX_TYPE,
44+
extra=self.extra,
45+
unique=self.unique,
46+
nulls_not_distinct=self.nulls_not_distinct,
47+
)
48+
49+
def describe(self) -> dict:
50+
result = super().describe()
51+
result["unique"] = self.unique
52+
result["nulls_not_distinct"] = self.nulls_not_distinct
53+
return result
54+
55+
def deconstruct(self) -> tuple[str, list[Any], dict[str, Any]]:
56+
path, args, kwargs = super().deconstruct()
57+
if self.unique:
58+
kwargs["unique"] = self.unique
59+
if self.nulls_not_distinct:
60+
kwargs["nulls_not_distinct"] = self.nulls_not_distinct
61+
return path, args, kwargs
662

763

864
class BloomIndex(PostgreSQLIndex):

tortoise/migrations/schema_editor/base.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -738,7 +738,8 @@ def _index_name_for_model(self, model: type[Model], index: Index) -> str:
738738
if index.name:
739739
return index.name
740740
index.resolve_expressions(model)
741-
return self._generate_index_name("idx", model, list(index.field_names))
741+
prefix = "uidx" if getattr(index, "unique", False) else "idx"
742+
return self._generate_index_name(prefix, model, list(index.field_names))
742743

743744
def _constraint_name_for_model(self, model: type[Model], constraint: UniqueConstraint) -> str:
744745
if constraint.name:
@@ -809,6 +810,8 @@ async def add_index(self, model: type[Model], index: Index) -> None:
809810
index_name=self._index_name_for_model(model, index),
810811
index_type=index.INDEX_TYPE,
811812
extra=index.extra,
813+
unique=getattr(index, "unique", False),
814+
nulls_not_distinct=getattr(index, "nulls_not_distinct", False),
812815
)
813816
if index_sql:
814817
await self._run_sql(index_sql)

tortoise/migrations/schema_editor/base_postgres.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
class BasePostgresSchemaEditor(BaseSchemaEditor):
1111
DIALECT = "postgres"
1212
INDEX_CREATE_TEMPLATE = (
13-
'CREATE INDEX "{index_name}" ON {table_name} {index_type}({fields}){extra};'
13+
'CREATE INDEX "{index_name}" ON {table_name} {index_type}({fields}){nulls_not_distinct}{extra};'
1414
)
1515
UNIQUE_INDEX_CREATE_TEMPLATE = INDEX_CREATE_TEMPLATE.replace("INDEX", "UNIQUE INDEX")
1616
TABLE_COMMENT_TEMPLATE = "COMMENT ON TABLE {table} IS '{comment}';"
@@ -78,16 +78,23 @@ def _get_index_sql(
7878
index_name: str | None = None,
7979
index_type: str | None = None,
8080
extra: str | None = None,
81+
unique: bool = False,
82+
nulls_not_distinct: bool = False,
8183
) -> str:
8284
if index_type:
8385
index_type = f"USING {index_type}"
84-
return super()._get_index_sql(
85-
model,
86-
list(field_names),
87-
safe,
88-
index_name=index_name,
89-
index_type=index_type,
90-
extra=extra,
86+
87+
nulls_not_distinct_sql = " NULLS NOT DISTINCT" if unique and nulls_not_distinct else ""
88+
template = self.UNIQUE_INDEX_CREATE_TEMPLATE if unique else self.INDEX_CREATE_TEMPLATE
89+
prefix = "uidx" if unique else "idx"
90+
91+
return template.format(
92+
index_name=index_name or self._generate_index_name(prefix, model, field_names),
93+
table_name=self._qualify_table_name(model._meta.db_table, model._meta.schema),
94+
index_type=f"{index_type} " if index_type else "",
95+
fields=self._format_index_fields(list(field_names)),
96+
nulls_not_distinct=nulls_not_distinct_sql,
97+
extra=f"{extra}" if extra else "",
9198
)
9299

93100
def _escape_default_value(self, default: object) -> str:
@@ -103,6 +110,7 @@ def _get_unique_index_sql(
103110
table_name=self._qualify_table_name(table_name, schema),
104111
index_type="",
105112
fields=", ".join([self.quote(f) for f in field_names]),
113+
nulls_not_distinct="",
106114
extra="",
107115
)
108116

0 commit comments

Comments
 (0)