Skip to content

Commit 8dfdde4

Browse files
committed
Add ColumnPrefixIndex for MySQL column prefix key parts
Signed-off-by: saJaeHyukc <[email protected]>
1 parent 00e277d commit 8dfdde4

File tree

5 files changed

+203
-0
lines changed

5 files changed

+203
-0
lines changed

docs/exposition.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,3 +355,33 @@ specific one:
355355
self.run_it()
356356
357357
:ref:`Read more <test_utilities>`
358+
359+
-------
360+
Indexes
361+
-------
362+
363+
MySQL-specific index types for optimizing your queries.
364+
365+
Column Prefix Index
366+
------------------
367+
368+
An index class that allows you to create indexes with column prefix lengths,
369+
which can be both space-efficient and useful for queries that match against
370+
the start of strings:
371+
372+
.. code-block:: python
373+
374+
class Article(Model):
375+
title = models.CharField(max_length=200)
376+
content = models.TextField()
377+
378+
class Meta:
379+
indexes = [
380+
ColumnPrefixIndex(
381+
fields=["title", "content"],
382+
prefix_lengths=(10, 50),
383+
name="title_content_prefix_idx",
384+
),
385+
]
386+
387+
:doc:`Read more <indexes>`

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ action, or get started with :doc:`installation`. Otherwise, take your pick:
2424
migration_operations
2525
form_fields
2626
validators
27+
indexes
2728
cache
2829
locks
2930
status

docs/indexes.rst

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
Indexes
2+
=======
3+
4+
.. currentmodule:: django_mysql.models
5+
6+
Django-MySQL includes a custom index class that extends Django's built-in index
7+
functionality for MySQL-specific features.
8+
9+
ColumnPrefixIndex
10+
----------------
11+
12+
.. class:: ColumnPrefixIndex(*expressions, prefix_lengths, **kwargs)
13+
14+
A custom index class that allows you to create indexes with column prefix
15+
lengths in MySQL. This is particularly useful for indexing ``TEXT`` or long
16+
``VARCHAR`` columns where you want to index only the first N characters.
17+
18+
MySQL allows you to create indexes that only include the first N characters of
19+
a column, which can be both space-efficient and useful for queries that match
20+
against the start of strings, such as ``istartswith`` lookups.
21+
22+
For more details about column prefix indexes in MySQL, see the
23+
`MySQL documentation on CREATE INDEX column-prefixes
24+
<https://dev.mysql.com/doc/refman/8.0/en/create-index.html#create-index-column-prefixes>`_.
25+
26+
Arguments:
27+
expressions: The fields or expressions to index
28+
prefix_lengths: A sequence of integers specifying the prefix length for each column
29+
**kwargs: Additional arguments passed to Django's Index class
30+
31+
Example:
32+
33+
.. code-block:: python
34+
35+
from django.db import models
36+
from django_mysql.models import ColumnPrefixIndex
37+
38+
39+
class Article(models.Model):
40+
title = models.CharField(max_length=200)
41+
content = models.TextField()
42+
43+
class Meta:
44+
indexes = [
45+
ColumnPrefixIndex(
46+
fields=["title", "content"],
47+
prefix_lengths=(10, 50),
48+
name="title_content_prefix_idx",
49+
),
50+
]
51+
52+
This will create an index equivalent to the following SQL:
53+
54+
.. code-block:: sql
55+
56+
CREATE INDEX title_content_prefix_idx ON article (title(10), content(50));
57+
58+
Such indexes can be particularly efficient for ``istartswith`` queries:
59+
60+
.. code-block:: python
61+
62+
# This query can use the prefix index
63+
Article.objects.filter(title__istartswith="Django")

src/django_mysql/models/indexes.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Sequence
4+
from typing import Any
5+
6+
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
7+
from django.db.backends.ddl_references import Statement
8+
from django.db.models import Index, Model
9+
from django.db.models.expressions import BaseExpression, Combinable
10+
11+
12+
class ColumnPrefixIndex(Index):
13+
def __init__(
14+
self,
15+
*expressions: BaseExpression | Combinable | str,
16+
prefix_lengths: Sequence[int],
17+
**kwargs: Any,
18+
) -> None:
19+
super().__init__(*expressions, **kwargs)
20+
self.prefix_lengths = tuple(prefix_lengths)
21+
22+
def deconstruct(self) -> Any:
23+
path, args, kwargs = super().deconstruct()
24+
if self.prefix_lengths is not None:
25+
kwargs["prefix_lengths"] = self.prefix_lengths
26+
return path, args, kwargs
27+
28+
def create_sql(
29+
self,
30+
model: type[Model],
31+
schema_editor: BaseDatabaseSchemaEditor,
32+
using: str = "",
33+
**kwargs: Any,
34+
) -> Statement:
35+
statement = super().create_sql(model, schema_editor, using=using, **kwargs)
36+
qn = schema_editor.quote_name
37+
statement.parts["columns"] = ", ".join(
38+
f"{qn(model._meta.get_field(field).column)}({length})"
39+
for field, length in zip(self.fields, self.prefix_lengths)
40+
)
41+
return statement

tests/testapp/test_index.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from __future__ import annotations
2+
3+
from django.db import connection
4+
from django.test import SimpleTestCase, TransactionTestCase
5+
6+
from django_mysql.models.indexes import ColumnPrefixIndex
7+
from tests.testapp.models import CharSetModel
8+
9+
10+
class ColumnPrefixIndexTests(SimpleTestCase):
11+
def test_deconstruct(self):
12+
index = ColumnPrefixIndex(
13+
fields=["field", "field2"],
14+
prefix_lengths=(10, 20),
15+
name="dm_field_field2_pfx",
16+
)
17+
path, args, kwargs = index.deconstruct()
18+
self.assertEqual(path, "django_mysql.models.indexes.ColumnPrefixIndex")
19+
self.assertEqual(args, ())
20+
self.assertEqual(
21+
kwargs,
22+
{
23+
"name": "dm_field_field2_pfx",
24+
"fields": ["field", "field2"],
25+
"prefix_lengths": (10, 20),
26+
},
27+
)
28+
29+
30+
class SchemaTests(TransactionTestCase):
31+
def get_constraints(self, table):
32+
return connection.introspection.get_constraints(connection.cursor(), table)
33+
34+
def test_column_prefix_index_create_sql(self):
35+
index = ColumnPrefixIndex(
36+
fields=["field", "field2"],
37+
prefix_lengths=(10, 20),
38+
name="dm_name_email_pfx",
39+
)
40+
with connection.schema_editor() as editor:
41+
statement = index.create_sql(CharSetModel, editor)
42+
sql = str(statement)
43+
44+
self.assertIn("`field`(10)", sql)
45+
self.assertIn("`field2`(20)", sql)
46+
47+
def test_column_prefix_index(self):
48+
table = CharSetModel._meta.db_table
49+
index_name = "dm_name_email_pfx"
50+
index = ColumnPrefixIndex(
51+
fields=["field", "field2"], prefix_lengths=(10, 20), name=index_name
52+
)
53+
54+
# Ensure the table is there and doesn't have an index.
55+
self.assertNotIn(index_name, self.get_constraints(table))
56+
57+
# Add the index.
58+
with connection.schema_editor() as editor:
59+
editor.add_index(CharSetModel, index)
60+
61+
constraints = self.get_constraints(table)
62+
self.assertIn(index_name, constraints)
63+
self.assertEqual(constraints[index_name]["type"], ColumnPrefixIndex.suffix)
64+
65+
# Drop the index.
66+
with connection.schema_editor() as editor:
67+
editor.remove_index(CharSetModel, index)
68+
self.assertNotIn(index_name, self.get_constraints(table))

0 commit comments

Comments
 (0)