diff --git a/docs/models/index.md b/docs/models/index.md index cfe9d39..818a667 100644 --- a/docs/models/index.md +++ b/docs/models/index.md @@ -357,11 +357,16 @@ You can overwrite this parameter by providing `Meta` class `tablename` argument. On a model level you can also set model-wise constraints on sql columns. -Right now only `UniqueColumns` constraint is present. +Right now only `IndexColumns` and `UniqueColumns` constraints are supported. + +!!!note + Note that both constraints should be used only if you want to set a name on constraint or want to set the index on multiple columns, otherwise `index` and `unique` properties on ormar fields are preferred. !!!tip To read more about columns constraints like `primary_key`, `unique`, `ForeignKey` etc. visit [fields][fields]. +#### UniqueColumns + You can set this parameter by providing `Meta` class `constraints` argument. ```Python hl_lines="14-17" @@ -373,6 +378,20 @@ You can set this parameter by providing `Meta` class `constraints` argument. To set one column as unique use [`unique`](../fields/common-parameters.md#unique) common parameter. Of course you can set many columns as unique with this param but each of them will be checked separately. +#### IndexColumns + +You can set this parameter by providing `Meta` class `constraints` argument. + +```Python hl_lines="14-17" +--8<-- "../docs_src/models/docs017.py" +``` + +!!!note + Note that constraints are meant for combination of columns that should be in the index. + To set one column index use [`unique`](../fields/common-parameters.md#index) common parameter. + Of course, you can set many columns as indexes with this param but each of them will be a separate index. + + ### Pydantic configuration As each `ormar.Model` is also a `pydantic` model, you might want to tweak the settings of the pydantic configuration. diff --git a/docs/releases.md b/docs/releases.md index c7229cc..8255001 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,3 +1,9 @@ +# 0.10.19 + +## ✨ Features + +* Add support for multi-column non-unique `IndexColumns` in `Meta.constraints` [#307](https://github.com/collerek/ormar/issues/307) + # 0.10.18 ## 🐛 Fixes diff --git a/docs_src/models/docs017.py b/docs_src/models/docs017.py new file mode 100644 index 0000000..b1ba5e5 --- /dev/null +++ b/docs_src/models/docs017.py @@ -0,0 +1,21 @@ +import databases +import sqlalchemy + +import ormar + +database = databases.Database("sqlite:///db.sqlite") +metadata = sqlalchemy.MetaData() + + +class Course(ormar.Model): + class Meta: + database = database + metadata = metadata + # define your constraints in Meta class of the model + # it's a list that can contain multiple constraints + # hera a combination of name and column will have a compound index in the db + constraints = [ormar.IndexColumns("name", "completed")] + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100) + completed: bool = ormar.Boolean(default=False) diff --git a/ormar/__init__.py b/ormar/__init__.py index 58e2fc5..54d926f 100644 --- a/ormar/__init__.py +++ b/ormar/__init__.py @@ -51,6 +51,7 @@ from ormar.fields import ( Float, ForeignKey, ForeignKeyField, + IndexColumns, Integer, JSON, LargeBinary, @@ -102,6 +103,7 @@ __all__ = [ "Undefined", "UUID", "UniqueColumns", + "IndexColumns", "QuerySetProtocol", "RelationProtocol", "ModelMeta", diff --git a/ormar/fields/__init__.py b/ormar/fields/__init__.py index a879735..9ea9387 100644 --- a/ormar/fields/__init__.py +++ b/ormar/fields/__init__.py @@ -5,7 +5,8 @@ as well as relation Fields (ForeignKey, ManyToMany). Also a definition for custom CHAR based sqlalchemy UUID field """ from ormar.fields.base import BaseField -from ormar.fields.foreign_key import ForeignKey, ForeignKeyField, UniqueColumns +from ormar.fields.constraints import IndexColumns, UniqueColumns +from ormar.fields.foreign_key import ForeignKey, ForeignKeyField from ormar.fields.many_to_many import ManyToMany, ManyToManyField from ormar.fields.model_fields import ( BigInteger, @@ -36,6 +37,7 @@ __all__ = [ "DateTime", "String", "JSON", + "IndexColumns", "Integer", "Text", "Float", @@ -45,7 +47,6 @@ __all__ = [ "ManyToMany", "ManyToManyField", "BaseField", - "UniqueColumns", "ForeignKeyField", "ThroughField", "Through", @@ -54,4 +55,5 @@ __all__ = [ "DECODERS_MAP", "ENCODERS_MAP", "LargeBinary", + "UniqueColumns", ] diff --git a/ormar/fields/constraints.py b/ormar/fields/constraints.py new file mode 100644 index 0000000..435b940 --- /dev/null +++ b/ormar/fields/constraints.py @@ -0,0 +1,22 @@ +from typing import Any + +from sqlalchemy import Index, UniqueConstraint + + +class UniqueColumns(UniqueConstraint): + """ + Subclass of sqlalchemy.UniqueConstraint. + Used to avoid importing anything from sqlalchemy by user. + """ + + +class IndexColumns(Index): + def __init__(self, *args: Any, name: str = None) -> None: + if not name: + name = "TEMPORARY_NAME" + super().__init__(name, *args) + + """ + Subclass of sqlalchemy.Index. + Used to avoid importing anything from sqlalchemy by user. + """ diff --git a/ormar/fields/foreign_key.py b/ormar/fields/foreign_key.py index de0774c..2326a85 100644 --- a/ormar/fields/foreign_key.py +++ b/ormar/fields/foreign_key.py @@ -18,7 +18,6 @@ from typing import ( import sqlalchemy from pydantic import BaseModel, create_model from pydantic.typing import ForwardRef, evaluate_forwardref -from sqlalchemy import UniqueConstraint import ormar # noqa I101 from ormar.exceptions import ModelDefinitionError, RelationshipInstanceError @@ -160,13 +159,6 @@ def validate_not_allowed_fields(kwargs: Dict) -> None: ) -class UniqueColumns(UniqueConstraint): - """ - Subclass of sqlalchemy.UniqueConstraint. - Used to avoid importing anything from sqlalchemy by user. - """ - - @dataclass class ForeignKeyConstraint: """ diff --git a/ormar/models/helpers/sqlalchemy.py b/ormar/models/helpers/sqlalchemy.py index 3ad53f1..22de0e3 100644 --- a/ormar/models/helpers/sqlalchemy.py +++ b/ormar/models/helpers/sqlalchemy.py @@ -285,24 +285,40 @@ def populate_meta_sqlalchemy_table_if_required(meta: "ModelMeta") -> None: :param meta: Meta class of the Model without sqlalchemy table constructed :type meta: Model class Meta - :return: class with populated Meta.table - :rtype: Model class """ if not hasattr(meta, "table") and check_for_null_type_columns_from_forward_refs( meta ): - for constraint in meta.constraints: - if isinstance(constraint, sqlalchemy.UniqueConstraint): - constraint.name = ( - f"uc_{meta.tablename}_" - f'{"_".join([str(col) for col in constraint._pending_colargs])}' - ) + set_constraint_names(meta=meta) table = sqlalchemy.Table( meta.tablename, meta.metadata, *meta.columns, *meta.constraints ) meta.table = table +def set_constraint_names(meta: "ModelMeta") -> None: + """ + Populates the names on IndexColumn and UniqueColumns constraints. + + :param meta: Meta class of the Model without sqlalchemy table constructed + :type meta: Model class Meta + """ + for constraint in meta.constraints: + if isinstance(constraint, sqlalchemy.UniqueConstraint) and not constraint.name: + constraint.name = ( + f"uc_{meta.tablename}_" + f'{"_".join([str(col) for col in constraint._pending_colargs])}' + ) + elif ( + isinstance(constraint, sqlalchemy.Index) + and constraint.name == "TEMPORARY_NAME" + ): + constraint.name = ( + f"ix_{meta.tablename}_" + f'{"_".join([col for col in constraint._pending_colargs])}' + ) + + def update_column_definition( model: Union[Type["Model"], Type["NewBaseModel"]], field: "ForeignKeyField" ) -> None: diff --git a/ormar/models/metaclass.py b/ormar/models/metaclass.py index 5a40c0c..e3c67e8 100644 --- a/ormar/models/metaclass.py +++ b/ormar/models/metaclass.py @@ -17,6 +17,7 @@ import sqlalchemy from sqlalchemy.sql.schema import ColumnCollectionConstraint import ormar # noqa I100 +import ormar.fields.constraints from ormar import ModelDefinitionError # noqa I100 from ormar.exceptions import ModelError from ormar.fields import BaseField @@ -219,7 +220,8 @@ def update_attrs_from_base_meta( # noqa: CCR001 parent_value=parent_value, ) parent_value = [ - ormar.UniqueColumns(*x._pending_colargs) for x in parent_value + ormar.fields.constraints.UniqueColumns(*x._pending_colargs) + for x in parent_value ] if isinstance(current_value, list): current_value.extend(parent_value) diff --git a/requirements.txt b/requirements.txt index ae5958e..2bc0baa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,4 @@ -databases[sqlite]>=0.3.2,<0.5.1 -databases[postgresql]>=0.3.2,<0.5.1 -databases[mysql]>=0.3.2,<0.5.1 +databases>=0.3.2,<0.5.2 pydantic >=1.6.1,!=1.7,!=1.7.1,!=1.7.2,!=1.7.3,!=1.8,!=1.8.1,<=1.8.2 sqlalchemy>=1.3.18,<=1.4.23 typing_extensions>=3.7,<3.10.0.3 diff --git a/setup.py b/setup.py index 8d90002..50fc9bc 100644 --- a/setup.py +++ b/setup.py @@ -63,7 +63,7 @@ setup( python_requires=">=3.6", data_files=[("", ["LICENSE.md"])], install_requires=[ - "databases>=0.3.2,<0.5.1", + "databases>=0.3.2,<0.5.2", "pydantic>=1.6.1,!=1.7,!=1.7.1,!=1.7.2,!=1.7.3,!=1.8,!=1.8.1,<=1.8.2", "sqlalchemy>=1.3.18,<=1.4.23", "typing_extensions>=3.7,<3.10.0.3", diff --git a/tests/test_inheritance_and_pydantic_generation/test_inheritance_concrete.py b/tests/test_inheritance_and_pydantic_generation/test_inheritance_concrete.py index fda8e7c..58ca3a6 100644 --- a/tests/test_inheritance_and_pydantic_generation/test_inheritance_concrete.py +++ b/tests/test_inheritance_and_pydantic_generation/test_inheritance_concrete.py @@ -8,6 +8,7 @@ import sqlalchemy as sa from sqlalchemy import create_engine import ormar +import ormar.fields.constraints from ormar import ModelDefinitionError, property_field from ormar.exceptions import ModelError from tests.settings import DATABASE_URL @@ -45,7 +46,9 @@ class DateFieldsModel(ormar.Model): abstract = True metadata = metadata database = db - constraints = [ormar.UniqueColumns("creation_date", "modification_date")] + constraints = [ + ormar.fields.constraints.UniqueColumns("creation_date", "modification_date") + ] created_date: datetime.datetime = ormar.DateTime( default=datetime.datetime.now, name="creation_date" @@ -58,7 +61,7 @@ class DateFieldsModel(ormar.Model): class Category(DateFieldsModel, AuditModel): class Meta(ormar.ModelMeta): tablename = "categories" - constraints = [ormar.UniqueColumns("name", "code")] + constraints = [ormar.fields.constraints.UniqueColumns("name", "code")] id: int = ormar.Integer(primary_key=True) name: str = ormar.String(max_length=50, unique=True, index=True) diff --git a/tests/test_meta_constraints/test_index_constraints.py b/tests/test_meta_constraints/test_index_constraints.py new file mode 100644 index 0000000..21bd1b6 --- /dev/null +++ b/tests/test_meta_constraints/test_index_constraints.py @@ -0,0 +1,68 @@ +import asyncpg # type: ignore +import databases +import pytest +import sqlalchemy + +import ormar.fields.constraints +from tests.settings import DATABASE_URL + +database = databases.Database(DATABASE_URL, force_rollback=True) +metadata = sqlalchemy.MetaData() + + +class Product(ormar.Model): + class Meta: + tablename = "products" + metadata = metadata + database = database + constraints = [ + ormar.fields.constraints.IndexColumns("company", "name", name="my_index"), + ormar.fields.constraints.IndexColumns("location", "company_type"), + ] + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100) + company: str = ormar.String(max_length=200) + location: str = ormar.String(max_length=200) + company_type: str = ormar.String(max_length=200) + + +@pytest.fixture(autouse=True, scope="module") +def create_test_database(): + engine = sqlalchemy.create_engine(DATABASE_URL) + metadata.drop_all(engine) + metadata.create_all(engine) + yield + metadata.drop_all(engine) + + +def test_table_structure(): + assert len(Product.Meta.table.indexes) > 0 + indexes = sorted( + list(Product.Meta.table.indexes), key=lambda x: x.name, reverse=True + ) + test_index = indexes[0] + assert test_index.name == "my_index" + assert [col.name for col in test_index.columns] == ["company", "name"] + + test_index = indexes[1] + assert test_index.name == "ix_products_location_company_type" + assert [col.name for col in test_index.columns] == ["location", "company_type"] + + +@pytest.mark.asyncio +async def test_index_is_not_unique(): + async with database: + async with database.transaction(force_rollback=True): + await Product.objects.create( + name="Cookies", company="Nestle", location="A", company_type="B" + ) + await Product.objects.create( + name="Mars", company="Mars", location="B", company_type="Z" + ) + await Product.objects.create( + name="Mars", company="Nestle", location="C", company_type="X" + ) + await Product.objects.create( + name="Mars", company="Mars", location="D", company_type="Y" + ) diff --git a/tests/test_meta_constraints/test_unique_constraints.py b/tests/test_meta_constraints/test_unique_constraints.py index 3126d2a..6a1bb58 100644 --- a/tests/test_meta_constraints/test_unique_constraints.py +++ b/tests/test_meta_constraints/test_unique_constraints.py @@ -1,4 +1,3 @@ -import asyncio import sqlite3 import asyncpg # type: ignore @@ -7,7 +6,7 @@ import pymysql import pytest import sqlalchemy -import ormar +import ormar.fields.constraints from tests.settings import DATABASE_URL database = databases.Database(DATABASE_URL, force_rollback=True) @@ -19,22 +18,15 @@ class Product(ormar.Model): tablename = "products" metadata = metadata database = database - constraints = [ormar.UniqueColumns("name", "company")] + constraints = [ormar.fields.constraints.UniqueColumns("name", "company")] id: int = ormar.Integer(primary_key=True) name: str = ormar.String(max_length=100) company: str = ormar.String(max_length=200) -@pytest.fixture(scope="module") -def event_loop(): - loop = asyncio.get_event_loop() - yield loop - loop.close() - - @pytest.fixture(autouse=True, scope="module") -async def create_test_database(): +def create_test_database(): engine = sqlalchemy.create_engine(DATABASE_URL) metadata.drop_all(engine) metadata.create_all(engine)