add multi column non unique columns
This commit is contained in:
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
21
docs_src/models/docs017.py
Normal file
21
docs_src/models/docs017.py
Normal 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)
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
22
ormar/fields/constraints.py
Normal file
22
ormar/fields/constraints.py
Normal 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.
|
||||||
|
"""
|
||||||
@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
2
setup.py
2
setup.py
@ -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",
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
68
tests/test_meta_constraints/test_index_constraints.py
Normal file
68
tests/test_meta_constraints/test_index_constraints.py
Normal 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"
|
||||||
|
)
|
||||||
@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user