add multi column non unique columns

This commit is contained in:
collerek
2021-09-06 16:47:37 +02:00
parent 9f836d80b2
commit cd87303b5c
14 changed files with 180 additions and 37 deletions

View File

@ -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. 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 !!!tip
To read more about columns constraints like `primary_key`, `unique`, `ForeignKey` etc. visit [fields][fields]. 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. You can set this parameter by providing `Meta` class `constraints` argument.
```Python hl_lines="14-17" ```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. 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. 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 ### Pydantic configuration
As each `ormar.Model` is also a `pydantic` model, you might want to tweak the settings of the pydantic configuration. As each `ormar.Model` is also a `pydantic` model, you might want to tweak the settings of the pydantic configuration.

View File

@ -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 # 0.10.18
## 🐛 Fixes ## 🐛 Fixes

View File

@ -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)

View File

@ -51,6 +51,7 @@ from ormar.fields import (
Float, Float,
ForeignKey, ForeignKey,
ForeignKeyField, ForeignKeyField,
IndexColumns,
Integer, Integer,
JSON, JSON,
LargeBinary, LargeBinary,
@ -102,6 +103,7 @@ __all__ = [
"Undefined", "Undefined",
"UUID", "UUID",
"UniqueColumns", "UniqueColumns",
"IndexColumns",
"QuerySetProtocol", "QuerySetProtocol",
"RelationProtocol", "RelationProtocol",
"ModelMeta", "ModelMeta",

View File

@ -5,7 +5,8 @@ as well as relation Fields (ForeignKey, ManyToMany).
Also a definition for custom CHAR based sqlalchemy UUID field Also a definition for custom CHAR based sqlalchemy UUID field
""" """
from ormar.fields.base import BaseField 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.many_to_many import ManyToMany, ManyToManyField
from ormar.fields.model_fields import ( from ormar.fields.model_fields import (
BigInteger, BigInteger,
@ -36,6 +37,7 @@ __all__ = [
"DateTime", "DateTime",
"String", "String",
"JSON", "JSON",
"IndexColumns",
"Integer", "Integer",
"Text", "Text",
"Float", "Float",
@ -45,7 +47,6 @@ __all__ = [
"ManyToMany", "ManyToMany",
"ManyToManyField", "ManyToManyField",
"BaseField", "BaseField",
"UniqueColumns",
"ForeignKeyField", "ForeignKeyField",
"ThroughField", "ThroughField",
"Through", "Through",
@ -54,4 +55,5 @@ __all__ = [
"DECODERS_MAP", "DECODERS_MAP",
"ENCODERS_MAP", "ENCODERS_MAP",
"LargeBinary", "LargeBinary",
"UniqueColumns",
] ]

View File

@ -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.
"""

View File

@ -18,7 +18,6 @@ from typing import (
import sqlalchemy import sqlalchemy
from pydantic import BaseModel, create_model from pydantic import BaseModel, create_model
from pydantic.typing import ForwardRef, evaluate_forwardref from pydantic.typing import ForwardRef, evaluate_forwardref
from sqlalchemy import UniqueConstraint
import ormar # noqa I101 import ormar # noqa I101
from ormar.exceptions import ModelDefinitionError, RelationshipInstanceError 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 @dataclass
class ForeignKeyConstraint: class ForeignKeyConstraint:
""" """

View File

@ -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 :param meta: Meta class of the Model without sqlalchemy table constructed
:type meta: Model class Meta :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( if not hasattr(meta, "table") and check_for_null_type_columns_from_forward_refs(
meta meta
): ):
for constraint in meta.constraints: set_constraint_names(meta=meta)
if isinstance(constraint, sqlalchemy.UniqueConstraint):
constraint.name = (
f"uc_{meta.tablename}_"
f'{"_".join([str(col) for col in constraint._pending_colargs])}'
)
table = sqlalchemy.Table( table = sqlalchemy.Table(
meta.tablename, meta.metadata, *meta.columns, *meta.constraints meta.tablename, meta.metadata, *meta.columns, *meta.constraints
) )
meta.table = table 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( def update_column_definition(
model: Union[Type["Model"], Type["NewBaseModel"]], field: "ForeignKeyField" model: Union[Type["Model"], Type["NewBaseModel"]], field: "ForeignKeyField"
) -> None: ) -> None:

View File

@ -17,6 +17,7 @@ import sqlalchemy
from sqlalchemy.sql.schema import ColumnCollectionConstraint from sqlalchemy.sql.schema import ColumnCollectionConstraint
import ormar # noqa I100 import ormar # noqa I100
import ormar.fields.constraints
from ormar import ModelDefinitionError # noqa I100 from ormar import ModelDefinitionError # noqa I100
from ormar.exceptions import ModelError from ormar.exceptions import ModelError
from ormar.fields import BaseField from ormar.fields import BaseField
@ -219,7 +220,8 @@ def update_attrs_from_base_meta( # noqa: CCR001
parent_value=parent_value, parent_value=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): if isinstance(current_value, list):
current_value.extend(parent_value) current_value.extend(parent_value)

View File

@ -1,6 +1,4 @@
databases[sqlite]>=0.3.2,<0.5.1 databases>=0.3.2,<0.5.2
databases[postgresql]>=0.3.2,<0.5.1
databases[mysql]>=0.3.2,<0.5.1
pydantic >=1.6.1,!=1.7,!=1.7.1,!=1.7.2,!=1.7.3,!=1.8,!=1.8.1,<=1.8.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 sqlalchemy>=1.3.18,<=1.4.23
typing_extensions>=3.7,<3.10.0.3 typing_extensions>=3.7,<3.10.0.3

View File

@ -63,7 +63,7 @@ setup(
python_requires=">=3.6", python_requires=">=3.6",
data_files=[("", ["LICENSE.md"])], data_files=[("", ["LICENSE.md"])],
install_requires=[ 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", "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", "sqlalchemy>=1.3.18,<=1.4.23",
"typing_extensions>=3.7,<3.10.0.3", "typing_extensions>=3.7,<3.10.0.3",

View File

@ -8,6 +8,7 @@ import sqlalchemy as sa
from sqlalchemy import create_engine from sqlalchemy import create_engine
import ormar import ormar
import ormar.fields.constraints
from ormar import ModelDefinitionError, property_field from ormar import ModelDefinitionError, property_field
from ormar.exceptions import ModelError from ormar.exceptions import ModelError
from tests.settings import DATABASE_URL from tests.settings import DATABASE_URL
@ -45,7 +46,9 @@ class DateFieldsModel(ormar.Model):
abstract = True abstract = True
metadata = metadata metadata = metadata
database = db database = db
constraints = [ormar.UniqueColumns("creation_date", "modification_date")] constraints = [
ormar.fields.constraints.UniqueColumns("creation_date", "modification_date")
]
created_date: datetime.datetime = ormar.DateTime( created_date: datetime.datetime = ormar.DateTime(
default=datetime.datetime.now, name="creation_date" default=datetime.datetime.now, name="creation_date"
@ -58,7 +61,7 @@ class DateFieldsModel(ormar.Model):
class Category(DateFieldsModel, AuditModel): class Category(DateFieldsModel, AuditModel):
class Meta(ormar.ModelMeta): class Meta(ormar.ModelMeta):
tablename = "categories" tablename = "categories"
constraints = [ormar.UniqueColumns("name", "code")] constraints = [ormar.fields.constraints.UniqueColumns("name", "code")]
id: int = ormar.Integer(primary_key=True) id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=50, unique=True, index=True) name: str = ormar.String(max_length=50, unique=True, index=True)

View File

@ -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"
)

View File

@ -1,4 +1,3 @@
import asyncio
import sqlite3 import sqlite3
import asyncpg # type: ignore import asyncpg # type: ignore
@ -7,7 +6,7 @@ import pymysql
import pytest import pytest
import sqlalchemy import sqlalchemy
import ormar import ormar.fields.constraints
from tests.settings import DATABASE_URL from tests.settings import DATABASE_URL
database = databases.Database(DATABASE_URL, force_rollback=True) database = databases.Database(DATABASE_URL, force_rollback=True)
@ -19,22 +18,15 @@ class Product(ormar.Model):
tablename = "products" tablename = "products"
metadata = metadata metadata = metadata
database = database database = database
constraints = [ormar.UniqueColumns("name", "company")] constraints = [ormar.fields.constraints.UniqueColumns("name", "company")]
id: int = ormar.Integer(primary_key=True) id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=100) name: str = ormar.String(max_length=100)
company: str = ormar.String(max_length=200) 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") @pytest.fixture(autouse=True, scope="module")
async def create_test_database(): def create_test_database():
engine = sqlalchemy.create_engine(DATABASE_URL) engine = sqlalchemy.create_engine(DATABASE_URL)
metadata.drop_all(engine) metadata.drop_all(engine)
metadata.create_all(engine) metadata.create_all(engine)