Referential Actions Enum Class (#735)

* feat: add action enum class to referential actions

* feat: write validation func for action name string

* test: write test for validation referential action

* fix: backend database running for action test

* fix: set the string type of value enum class

* fix: debuging return statement type for validation

* fix: return non empty for empty action

* refactor: change in line return if statement

* fix: add iterate method in read document md

* fix: update foreign key docstring types

* docs: write documention of refernal actions

* docs: complete referential actions descriptions

* refactor: rename and reposition referential action

* refactor: change validate referential action func

* test: add assert check for really deleted rows

* fix: debug error problem in renamed enum class

* fix: apply black formatted codes

* docs: update the document for referential actions

* docs: added note for server default argument

Co-authored-by: collerek <collerek@gmail.com>
This commit is contained in:
Sepehr Bazyar
2022-07-22 17:35:37 +04:30
committed by GitHub
parent bbc214daf2
commit 991d4a2a2c
7 changed files with 160 additions and 8 deletions

View File

@ -6,6 +6,7 @@ Following methods allow you to load data from the database.
* `get_or_create(_defaults: Optional[Dict[str, Any]] = None, *args, **kwargs) -> Tuple[Model, bool]`
* `first(*args, **kwargs) -> Model`
* `all(*args, **kwargs) -> List[Optional[Model]]`
* `iterate(*args, **kwargs) -> AsyncGenerator[Model]`
* `Model`

View File

@ -1,6 +1,9 @@
# ForeignKey
`ForeignKey(to, related_name=None)` has required parameters `to` that takes target `Model` class.
`ForeignKey(to: Model, *, name: str = None, unique: bool = False, nullable: bool = True,
related_name: str = None, virtual: bool = False, onupdate: Union[ReferentialAction, str] = None,
ondelete: Union[ReferentialAction, str] = None, **kwargs: Any)`
has required parameters `to` that takes target `Model` class.
Sqlalchemy column and Type are automatically taken from target `Model`.
@ -182,6 +185,41 @@ But you can overwrite this name by providing `related_name` parameter like below
the `related_name` for you. Therefore, in that situation you **have to** provide `related_name`
for all but one (one can be default and generated) or all related fields.
## Referential Actions
When an object referenced by a ForeignKey is changed (deleted or updated),
ormar will set the SQL constraint specified by the `ondelete` and `onupdate` argument.
The possible values for `ondelete` and `onupdate` are found in `ormar.ReferentialAction`:
!!!note
Instead of `ormar.ReferentialAction`, you can directly pass string values to these two arguments, but this is not recommended because it will break the integrity.
### CASCADE
Whenever rows in the parent (referenced) table are deleted (or updated), the respective rows of the child (referencing) table with a matching foreign key column will be deleted (or updated) as well. This is called a cascade delete (or update).
### RESTRICT
A value cannot be updated or deleted when a row exists in a referencing or child table that references the value in the referenced table.
Similarly, a row cannot be deleted as long as there is a reference to it from a referencing or child table.
### SET_NULL
Set the ForeignKey to `None`; this is only possible if `nullable` is True.
### SET_DEFAULT
Set the ForeignKey to its default value; a `server_default` for the ForeignKey must be set.
!!!note
Note that the `default` value is not allowed and you must do this through `server_default`, which you can read about in [this section][server_default].
### DO_NOTHING
Take `NO ACTION`; NO ACTION and RESTRICT are very much alike. The main difference between NO ACTION and RESTRICT is that with NO ACTION the referential integrity check is done after trying to alter the table. RESTRICT does the check before trying to execute the UPDATE or DELETE statement. Both referential actions act the same if the referential integrity check fails: the UPDATE or DELETE statement will result in an error.
## Relation Setup
You have several ways to set-up a relationship connection.
@ -242,4 +280,5 @@ Finally you can explicitly set it to None (default behavior if no value passed).
[exists]: ./queries.md#exists
[fields]: ./queries.md#fields
[exclude_fields]: ./queries.md#exclude_fields
[order_by]: ./queries.md#order_by
[order_by]: ./queries.md#order_by
[server_default]: ../fields/common-parameters.md#server-default

View File

@ -57,7 +57,6 @@ from ormar.fields import (
Float,
ForeignKey,
ForeignKeyField,
IndexColumns,
Integer,
JSON,
LargeBinary,
@ -70,7 +69,9 @@ from ormar.fields import (
Time,
UUID,
UniqueColumns,
IndexColumns,
CheckColumns,
ReferentialAction,
) # noqa: I100
from ormar.models import ExcludableItems, Extra, Model
from ormar.models.metaclass import ModelMeta
@ -103,6 +104,7 @@ __all__ = [
"Float",
"ManyToMany",
"Model",
"Action",
"ModelDefinitionError",
"MultipleMatches",
"NoMatch",
@ -114,6 +116,7 @@ __all__ = [
"UniqueColumns",
"IndexColumns",
"CheckColumns",
"ReferentialAction",
"QuerySetProtocol",
"RelationProtocol",
"ModelMeta",

View File

@ -28,6 +28,7 @@ from ormar.fields.model_fields import (
from ormar.fields.parsers import DECODERS_MAP, ENCODERS_MAP, SQL_ENCODERS_MAP
from ormar.fields.sqlalchemy_encrypted import EncryptBackend, EncryptBackends
from ormar.fields.through_field import Through, ThroughField
from ormar.fields.referential_actions import ReferentialAction
__all__ = [
"Decimal",
@ -38,7 +39,6 @@ __all__ = [
"DateTime",
"String",
"JSON",
"IndexColumns",
"Integer",
"Text",
"Float",
@ -59,4 +59,7 @@ __all__ = [
"SQL_ENCODERS_MAP",
"LargeBinary",
"UniqueColumns",
"IndexColumns",
"CheckColumns",
"ReferentialAction",
]

View File

@ -21,6 +21,7 @@ from pydantic.typing import ForwardRef, evaluate_forwardref
import ormar # noqa I101
from ormar.exceptions import ModelDefinitionError, RelationshipInstanceError
from ormar.fields.referential_actions import ReferentialAction
from ormar.fields.base import BaseField
if TYPE_CHECKING: # pragma no cover
@ -159,6 +160,27 @@ def validate_not_allowed_fields(kwargs: Dict) -> None:
)
def validate_referential_action(
action: Optional[Union[ReferentialAction, str]],
) -> Optional[str]:
"""
Validation `onupdate` and `ondelete` action cast to a string value
:raises ModelDefinitionError: if action is a not valid name string value
:param action: referential action attribute or name string
:type action: Optional[Union[ReferentialAction, str]]
:rtype: Optional[str]
"""
if action is not None and not isinstance(action, ReferentialAction):
try:
action = ReferentialAction(action.upper())
except (ValueError, AttributeError):
raise ModelDefinitionError(f"{action} ReferentialAction not supported.")
return action.value if action is not None else None
@dataclass
class ForeignKeyConstraint:
"""
@ -190,8 +212,8 @@ def ForeignKey( # type: ignore # noqa CFQ002
nullable: bool = True,
related_name: str = None,
virtual: bool = False,
onupdate: str = None,
ondelete: str = None,
onupdate: Union[ReferentialAction, str] = None,
ondelete: Union[ReferentialAction, str] = None,
**kwargs: Any,
) -> "T":
"""
@ -215,16 +237,19 @@ def ForeignKey( # type: ignore # noqa CFQ002
:type virtual: bool
:param onupdate: parameter passed to sqlalchemy.ForeignKey.
How to treat child rows on update of parent (the one where FK is defined) model.
:type onupdate: str
:type onupdate: Union[ReferentialAction, str]
:param ondelete: parameter passed to sqlalchemy.ForeignKey.
How to treat child rows on delete of parent (the one where FK is defined) model.
:type ondelete: str
:type ondelete: Union[ReferentialAction, str]
:param kwargs: all other args to be populated by BaseField
:type kwargs: Any
:return: ormar ForeignKeyField with relation to selected model
:rtype: ForeignKeyField
"""
onupdate = validate_referential_action(action=onupdate)
ondelete = validate_referential_action(action=ondelete)
owner = kwargs.pop("owner", None)
self_reference = kwargs.pop("self_reference", False)

View File

@ -0,0 +1,26 @@
"""
Gathers all referential actions by ormar.
"""
from enum import Enum
class ReferentialAction(Enum):
"""
Because the database management system(DBMS) enforces referential constraints,
it must ensure data integrity
if rows in a referenced table are to be deleted (or updated).
If dependent rows in referencing tables still exist,
those references have to be considered.
SQL specifies 5 different referential actions
that shall take place in such occurrences.
"""
CASCADE: str = "CASCADE"
RESTRICT: str = "RESTRICT"
SET_NULL: str = "SET NULL"
SET_DEFAULT: str = "SET DEFAULT"
DO_NOTHING: str = "NO ACTION"

View File

@ -5,6 +5,7 @@ import pytest
import sqlalchemy
import ormar
from ormar.fields.foreign_key import validate_referential_action
from tests.settings import DATABASE_URL
database = databases.Database(DATABASE_URL)
@ -33,6 +34,35 @@ class Album(ormar.Model):
artist: Optional[Artist] = ormar.ForeignKey(Artist, ondelete="CASCADE")
class A(ormar.Model):
class Meta:
metadata = metadata
database = database
id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=64, nullalbe=False)
class B(ormar.Model):
class Meta:
metadata = metadata
database = database
id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=64, nullalbe=False)
a: A = ormar.ForeignKey(to=A, ondelete=ormar.ReferentialAction.CASCADE)
class C(ormar.Model):
class Meta:
metadata = metadata
database = database
id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=64, nullalbe=False)
b: B = ormar.ForeignKey(to=B, ondelete=ormar.ReferentialAction.CASCADE)
@pytest.fixture(autouse=True, scope="module")
def create_test_database():
metadata.drop_all(engine)
@ -53,3 +83,28 @@ def test_simple_cascade():
assert fks[0]["constrained_columns"][0] == "artist"
assert fks[0]["referred_columns"][0] == "id"
assert fks[0]["options"].get("ondelete") == "CASCADE"
def test_validations_referential_action():
CASCADE = ormar.ReferentialAction.CASCADE.value
assert validate_referential_action(None) == None
assert validate_referential_action("cascade") == CASCADE
assert validate_referential_action(ormar.ReferentialAction.CASCADE) == CASCADE
with pytest.raises(ormar.ModelDefinitionError):
validate_referential_action("NOT VALID")
@pytest.mark.asyncio
async def test_cascade_clear():
async with database:
async with database.transaction(force_rollback=True):
a = await A.objects.create(name="a")
b = await B.objects.create(name="b", a=a)
c = await C.objects.create(name="c", b=b)
await a.bs.clear(keep_reversed=False)
assert await B.objects.count() == 0
assert await C.objects.count() == 0