diff --git a/tests/fields/test_text.py b/tests/fields/test_text.py index 1d8b59389..3ed81d922 100644 --- a/tests/fields/test_text.py +++ b/tests/fields/test_text.py @@ -1,7 +1,7 @@ import pytest from tests import testmodels -from tortoise.exceptions import ConfigurationError, IntegrityError +from tortoise.exceptions import IntegrityError from tortoise.fields import TextField @@ -36,20 +36,13 @@ async def test_values_list(db): assert values == "baa" -def test_unique_fail(): - msg = "TextField can't be indexed, consider CharField" - with pytest.raises(ConfigurationError, match=msg): - with pytest.warns( - DeprecationWarning, match="`index` is deprecated, please use `db_index` instead" - ): - TextField(index=True) - with pytest.raises(ConfigurationError, match=msg): - TextField(db_index=True) - - -def test_index_fail(): - with pytest.raises(ConfigurationError, match="can't be indexed, consider CharField"): - TextField(index=True) +def test_index_options_are_deferred_to_the_database_dialect(): + assert TextField(unique=True).unique is True + assert TextField(db_index=True).index is True + with pytest.warns( + DeprecationWarning, match="`index` is deprecated, please use `db_index` instead" + ): + assert TextField(index=True).index is True def test_pk_deprecated(): diff --git a/tests/migrations/test_schema_editor_sql.py b/tests/migrations/test_schema_editor_sql.py index 67a9dac31..224fec322 100644 --- a/tests/migrations/test_schema_editor_sql.py +++ b/tests/migrations/test_schema_editor_sql.py @@ -8,6 +8,7 @@ from tortoise import fields from tortoise.contrib.postgres.fields import TSVectorField from tortoise.contrib.postgres.indexes import GinIndex +from tortoise.exceptions import ConfigurationError from tortoise.indexes import Index from tortoise.migrations.schema_editor.base import BaseSchemaEditor from tortoise.migrations.schema_editor.base_postgres import BasePostgresSchemaEditor @@ -53,6 +54,43 @@ async def test_create_model_generates_table_sql() -> None: assert "PRIMARY KEY" in sql +@pytest.mark.asyncio +async def test_postgres_create_model_supports_text_field_indexes() -> None: + class IndexedTextWidget(Model): + id = fields.IntField(primary_key=True) + unique_text = fields.TextField(unique=True) + indexed_text = fields.TextField(db_index=True) + + class Meta: + table = "indexed_text_widget" + app = "models" + + client = FakeClient("postgres", inline_comment=False) + editor = BasePostgresSchemaEditor(client) + + await editor.create_model(IndexedTextWidget) + + assert '"unique_text" TEXT NOT NULL UNIQUE' in client.executed[0] + assert 'ON "indexed_text_widget" ("indexed_text")' in client.executed[0] + + +@pytest.mark.asyncio +async def test_non_postgres_create_model_rejects_text_field_indexes() -> None: + class IndexedTextWidget(Model): + id = fields.IntField(primary_key=True) + indexed_text = fields.TextField(db_index=True) + + class Meta: + table = "indexed_text_widget" + app = "models" + + client = FakeClient("sql") + editor = TestSchemaEditor(client) + + with pytest.raises(ConfigurationError, match="TextField can't be indexed for sql"): + await editor.create_model(IndexedTextWidget) + + @pytest.mark.asyncio async def test_add_field_generates_add_column_sql() -> None: client = FakeClient("sql") diff --git a/tests/schema/models_text_index.py b/tests/schema/models_text_index.py new file mode 100644 index 000000000..46a2a3400 --- /dev/null +++ b/tests/schema/models_text_index.py @@ -0,0 +1,6 @@ +from tortoise import Model, fields + + +class TextIndex(Model): + unique_text = fields.TextField(unique=True) + indexed_text = fields.TextField(db_index=True) diff --git a/tests/schema/test_generate_schema.py b/tests/schema/test_generate_schema.py index 63098bce1..ee7e282db 100644 --- a/tests/schema/test_generate_schema.py +++ b/tests/schema/test_generate_schema.py @@ -183,6 +183,16 @@ async def test_noid(): await _teardown_tortoise() +@pytest.mark.asyncio +async def test_sqlite_rejects_text_field_indexes(): + await _reset_tortoise() + try: + with pytest.raises(ConfigurationError, match="TextField can't be indexed for sqlite"): + await _init_for_sqlite("tests.schema.models_text_index") + finally: + await _teardown_tortoise() + + @pytest.mark.asyncio async def test_minrelation(): await _reset_tortoise() @@ -1063,6 +1073,19 @@ async def test_asyncpg_noid(): await _teardown_tortoise() +@pytest.mark.asyncio +async def test_asyncpg_text_field_indexes(): + await _reset_tortoise() + try: + await _init_for_asyncpg("tests.schema.models_text_index") + sql = get_schema_sql(connections.get("default"), safe=False) + assert '"unique_text" TEXT NOT NULL UNIQUE' in sql + assert 'CREATE INDEX "idx_textindex_indexed_' in sql + assert 'ON "textindex" ("indexed_text")' in sql + finally: + await _teardown_tortoise() + + @pytest.mark.asyncio async def test_asyncpg_table_and_row_comment_generation(): await _reset_tortoise() @@ -1569,6 +1592,19 @@ async def test_psycopg_noid(): await _teardown_tortoise() +@pytest.mark.asyncio +async def test_psycopg_text_field_indexes(): + await _reset_tortoise() + try: + await _init_for_psycopg("tests.schema.models_text_index") + sql = get_schema_sql(connections.get("default"), safe=False) + assert '"unique_text" TEXT NOT NULL UNIQUE' in sql + assert 'CREATE INDEX "idx_textindex_indexed_' in sql + assert 'ON "textindex" ("indexed_text")' in sql + finally: + await _teardown_tortoise() + + @pytest.mark.asyncio async def test_psycopg_table_and_row_comment_generation(): await _reset_tortoise() diff --git a/tortoise/backends/base/schema_generator.py b/tortoise/backends/base/schema_generator.py index d1b9725b5..7b028bcc3 100644 --- a/tortoise/backends/base/schema_generator.py +++ b/tortoise/backends/base/schema_generator.py @@ -429,6 +429,7 @@ def _get_table_sql(self, model: type[Model], safe: bool = True) -> dict: models_tables = [model._meta.db_table for model in models_to_create] for field_name, column_name in model._meta.fields_db_projection.items(): field_object = model._meta.fields_map[field_name] + field_object._validate_indexable(self.DIALECT) comment = self._get_field_comment(field_object, qualified_table_name, column_name) default = self._get_field_default(field_object, table_name, column_name, model) diff --git a/tortoise/fields/base.py b/tortoise/fields/base.py index 81db786c0..42c3eff34 100644 --- a/tortoise/fields/base.py +++ b/tortoise/fields/base.py @@ -244,7 +244,7 @@ def __init__( raise ConfigurationError( f"{self.__class__.__name__} can't set both db_index and index" ) - if not self.indexable and (unique or db_index): + if not self._is_indexable() and (unique or db_index): raise ConfigurationError(f"{self.__class__.__name__} can't be indexed") if (pk := kwargs.pop("pk", None)) is not None: if primary_key is None: @@ -290,6 +290,21 @@ def __init__( self.model: type[Model] = model # type: ignore self.reference: Field | None = None + def _is_indexable(self, dialect: str | None = None) -> bool: + if dialect is not None: + return bool(self.get_for_dialect(dialect, "indexable")) + if self.indexable: + return True + return any( + bool(getattr(getattr(self, name), "indexable", False)) + for name in dir(self) + if name.startswith("_db_") + ) + + def _validate_indexable(self, dialect: str) -> None: + if not self.pk and (self.unique or self.index) and not self._is_indexable(dialect): + raise ConfigurationError(f"{self.__class__.__name__} can't be indexed for {dialect}") + def __copy__(self) -> Field: cls = self.__class__ result = cls.__new__(cls) diff --git a/tortoise/fields/data.py b/tortoise/fields/data.py index 6109d0022..3571a8770 100644 --- a/tortoise/fields/data.py +++ b/tortoise/fields/data.py @@ -259,6 +259,9 @@ def SQL_TYPE(self) -> str: class TextField(Field[str], str): # type: ignore """ Large Text field. + + PostgreSQL supports ``unique=True`` and ``db_index=True``. Other backends reject these + options when generating or migrating the database schema. """ indexable = False @@ -268,7 +271,7 @@ def __init__( self, primary_key: bool | None = None, unique: bool = False, - db_index: bool = False, + db_index: bool | None = None, **kwargs: Any, ) -> None: if primary_key or kwargs.get("pk"): @@ -277,22 +280,10 @@ def __init__( DeprecationWarning, stacklevel=2, ) - if unique: - raise ConfigurationError( - "TextField doesn't support unique indexes, consider CharField or another strategy" - ) - if (index := kwargs.pop("index", None)) is not None: - warnings.warn( - "`index` is deprecated, please use `db_index` instead", - DeprecationWarning, - stacklevel=2, - ) - if index or db_index: - raise ConfigurationError("TextField can't be indexed, consider CharField") - elif db_index: - raise ConfigurationError("TextField can't be indexed, consider CharField") + super().__init__(primary_key=primary_key, unique=unique, db_index=db_index, **kwargs) - super().__init__(primary_key=primary_key, **kwargs) + class _db_postgres: + indexable = True class _db_mysql: SQL_TYPE = "LONGTEXT" diff --git a/tortoise/migrations/schema_editor/base.py b/tortoise/migrations/schema_editor/base.py index 4dda5ec91..c6a1b1b2c 100644 --- a/tortoise/migrations/schema_editor/base.py +++ b/tortoise/migrations/schema_editor/base.py @@ -316,6 +316,7 @@ def _get_model_sql_data(self, model: type[Model]) -> ModelSqlData: for field_name, db_field in model._meta.fields_db_projection.items(): field_object = model._meta.fields_map[field_name] + field_object._validate_indexable(self.DIALECT) comment = ( self._get_column_comment_sql( table=qualified_table_name, @@ -500,6 +501,7 @@ async def delete_model(self, model: type[Model]) -> None: async def add_field(self, model: type[Model], field_name: str) -> None: field = model._meta.fields_map[field_name] + field._validate_indexable(self.DIALECT) if isinstance(field, ManyToManyFieldInstance): table_string = self._get_m2m_table_definition(model, field) if table_string: @@ -614,6 +616,7 @@ async def _alter_generated_field( return False async def _alter_field(self, model: type[Model], old_field: Field, new_field: Field) -> None: + new_field._validate_indexable(self.DIALECT) actions: list[str] = [] old_db_field = old_field.source_field or old_field.model_field_name new_db_field = new_field.source_field or new_field.model_field_name