Skip to content

Commit e910772

Browse files
authored
Add case-insensitive unique constraints (#1738)
* feat: add case-insensitive unique constraints Add IndexColumns(case_insensitive=True) to build a case-insensitive unique index by wrapping each named column in LOWER(). IndexColumns now also accepts SQLAlchemy expressions for arbitrary functional indexes, and index auto-naming derives names from the index expressions so functional indexes get valid names. Closes #615 * test: enforce and document functional unique index with UPPER Add a runtime test that a raw SQLAlchemy expression functional unique index (UPPER(name)) enforces case-insensitive uniqueness, and switch the standalone-expression example from LOWER to UPPER in docs and test so it is distinct from the built-in case_insensitive=True path.
1 parent 568f4da commit e910772

5 files changed

Lines changed: 164 additions & 8 deletions

File tree

docs/models/index.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,36 @@ You can set this parameter by providing `ormar_config` object `constraints` argu
359359
To set one column index use [`unique`](../fields/common-parameters.md#index) common parameter.
360360
Of course, you can set many columns as indexes with this param but each of them will be a separate index.
361361

362+
##### Case-insensitive unique index
363+
364+
To enforce uniqueness while ignoring case (for example so that `John` and `JOHN`
365+
cannot both exist), pass `case_insensitive=True` together with `unique=True`. Ormar
366+
wraps each named column in `LOWER()` and builds a unique functional index:
367+
368+
```python
369+
import ormar
370+
371+
class Person(ormar.Model):
372+
ormar_config = base_ormar_config.copy(
373+
constraints=[
374+
ormar.IndexColumns(
375+
"first_name", "last_name", unique=True, case_insensitive=True
376+
)
377+
],
378+
)
379+
380+
id: int = ormar.Integer(primary_key=True)
381+
first_name: str = ormar.String(max_length=100)
382+
last_name: str = ormar.String(max_length=100)
383+
```
384+
385+
!!!note
386+
Case-insensitive uniqueness is a unique *functional index*, not a `UniqueColumns`
387+
constraint, because a SQL unique constraint cannot hold expressions like `LOWER()`.
388+
For other functional indexes you can pass SQLAlchemy expressions directly, e.g.
389+
`ormar.IndexColumns(sqlalchemy.func.upper(sqlalchemy.column("name")), unique=True)`.
390+
On MySQL this requires version 8.0.13 or newer.
391+
362392
#### CheckColumns
363393

364394
You can set this parameter by providing `ormar_config` object `constraints` argument.

docs/releases.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Release notes
22

3+
## Unreleased
4+
5+
### ✨ Features
6+
7+
* Add `IndexColumns(..., case_insensitive=True)` to build a case-insensitive
8+
unique index (each named column wrapped in `LOWER()`), so values that differ
9+
only in case cannot both be stored. `IndexColumns` now also accepts SQLAlchemy
10+
expressions for arbitrary functional indexes. On MySQL this needs 8.0.13+.
11+
[#615](https://github.com/collerek/ormar/issues/615)
12+
313
## 0.26.0
414

515
### ✨ Features

ormar/fields/constraints.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Any, Optional
22

3-
from sqlalchemy import CheckConstraint, Index, UniqueConstraint
3+
from sqlalchemy import CheckConstraint, Index, UniqueConstraint, column, func
44

55

66
class UniqueColumns(UniqueConstraint):
@@ -11,16 +11,40 @@ class UniqueColumns(UniqueConstraint):
1111

1212

1313
class IndexColumns(Index):
14-
def __init__(self, *args: Any, name: Optional[str] = None, **kw: Any) -> None:
15-
if not name:
16-
name = "TEMPORARY_NAME"
17-
super().__init__(name, *args, **kw)
18-
1914
"""
2015
Subclass of sqlalchemy.Index.
2116
Used to avoid importing anything from sqlalchemy by user.
17+
18+
Passing ``unique=True`` together with ``case_insensitive=True`` builds a
19+
case-insensitive unique index by wrapping each named column in ``LOWER()``,
20+
which is how case-insensitive uniqueness is expressed (a regular
21+
UniqueConstraint cannot hold SQL expressions).
2222
"""
2323

24+
def __init__(
25+
self,
26+
*args: Any,
27+
name: Optional[str] = None,
28+
case_insensitive: bool = False,
29+
**kw: Any,
30+
) -> None:
31+
"""
32+
:param args: column names, or SQL expressions for a functional index
33+
:type args: Any
34+
:param name: optional explicit index name
35+
:type name: Optional[str]
36+
:param case_insensitive: wrap each named column in ``LOWER()`` so the
37+
index (and its uniqueness, when ``unique=True``) ignores case
38+
:type case_insensitive: bool
39+
:param kw: additional keyword arguments passed to sqlalchemy.Index
40+
:type kw: Any
41+
"""
42+
if case_insensitive:
43+
args = tuple(func.lower(column(arg)) for arg in args)
44+
if not name:
45+
name = "TEMPORARY_NAME"
46+
super().__init__(name, *args, **kw)
47+
2448

2549
class CheckColumns(CheckConstraint):
2650
"""

ormar/models/helpers/sqlalchemy.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
2-
from typing import TYPE_CHECKING, ForwardRef, Optional, Union
2+
import re
3+
from typing import TYPE_CHECKING, Any, ForwardRef, Optional, Union
34

45
import sqlalchemy
56

@@ -363,6 +364,20 @@ def populate_config_sqlalchemy_table_if_required(config: "OrmarConfig") -> None:
363364
config.table = table
364365

365366

367+
def index_name_fragment(expression: Any) -> str:
368+
"""
369+
Returns an identifier-safe name fragment for an index column or SQL
370+
expression, so auto-generated index names work for functional indexes
371+
(e.g. LOWER(name)) as well as plain columns.
372+
373+
:param expression: an index column name or SQL expression element
374+
:type expression: Any
375+
:return: sanitized fragment containing only alphanumerics and underscores
376+
:rtype: str
377+
"""
378+
return re.sub(r"[^0-9a-zA-Z]+", "_", str(expression)).strip("_")
379+
380+
366381
def set_constraint_names(config: "OrmarConfig") -> None:
367382
"""
368383
Populates the names on IndexColumns and UniqueColumns and CheckColumns constraints.
@@ -382,7 +397,7 @@ def set_constraint_names(config: "OrmarConfig") -> None:
382397
):
383398
constraint.name = (
384399
f"ix_{config.tablename}_"
385-
f"{'_'.join([col for col in constraint._pending_colargs])}"
400+
f"{'_'.join(index_name_fragment(e) for e in constraint.expressions)}"
386401
)
387402
elif isinstance(constraint, sqlalchemy.CheckConstraint) and not constraint.name:
388403
sql_condition: str = str(constraint.sqltext).replace(" ", "_")
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import pytest
2+
import sqlalchemy
3+
4+
import ormar.fields.constraints
5+
from tests.lifespan import init_tests
6+
from tests.settings import create_config
7+
8+
base_ormar_config = create_config()
9+
10+
11+
class Person(ormar.Model):
12+
ormar_config = base_ormar_config.copy(
13+
tablename="persons",
14+
constraints=[
15+
ormar.fields.constraints.IndexColumns(
16+
"first_name", "last_name", unique=True, case_insensitive=True
17+
)
18+
],
19+
)
20+
21+
id: int = ormar.Integer(primary_key=True)
22+
first_name: str = ormar.String(max_length=100)
23+
last_name: str = ormar.String(max_length=100)
24+
25+
26+
class Tag(ormar.Model):
27+
ormar_config = base_ormar_config.copy(
28+
tablename="tags",
29+
constraints=[
30+
ormar.fields.constraints.IndexColumns(
31+
sqlalchemy.func.upper(sqlalchemy.column("name")), unique=True
32+
)
33+
],
34+
)
35+
36+
id: int = ormar.Integer(primary_key=True)
37+
name: str = ormar.String(max_length=100)
38+
39+
40+
create_test_database = init_tests(base_ormar_config)
41+
42+
43+
def test_case_insensitive_unique_index_structure():
44+
indexes = list(Person.ormar_config.table.indexes)
45+
assert len(indexes) == 1
46+
index = indexes[0]
47+
assert index.unique is True
48+
assert index.name == "ix_persons_lower_first_name_lower_last_name"
49+
50+
51+
def test_functional_index_expression_gets_safe_autoname():
52+
indexes = list(Tag.ormar_config.table.indexes)
53+
assert len(indexes) == 1
54+
index = indexes[0]
55+
assert index.unique is True
56+
assert index.name == "ix_tags_upper_name"
57+
58+
59+
@pytest.mark.asyncio
60+
async def test_case_insensitive_uniqueness_is_enforced():
61+
async with base_ormar_config.database:
62+
async with base_ormar_config.database.transaction(force_rollback=True):
63+
await Person.objects.create(first_name="John", last_name="Doe")
64+
await Person.objects.create(first_name="Jane", last_name="Doe")
65+
66+
with pytest.raises(sqlalchemy.exc.IntegrityError):
67+
await Person.objects.create(first_name="JOHN", last_name="DOE")
68+
69+
70+
@pytest.mark.asyncio
71+
async def test_functional_expression_uniqueness_is_enforced():
72+
async with base_ormar_config.database:
73+
async with base_ormar_config.database.transaction(force_rollback=True):
74+
await Tag.objects.create(name="Python")
75+
76+
with pytest.raises(sqlalchemy.exc.IntegrityError):
77+
await Tag.objects.create(name="PYTHON")

0 commit comments

Comments
 (0)