diff --git a/docs/tutorial/models.md b/docs/tutorial/models.md index 0a687f9..69b1ec5 100644 --- a/docs/tutorial/models.md +++ b/docs/tutorial/models.md @@ -23,6 +23,11 @@ User here is a model with one field; username. Field of username is str type, an Also, It includes a one more field named `id`. This field is automatically generated by Pydbm and these fields are equal. +> **Note:** The `id` field is read-only. It cannot be overwritten, redefined, or have its type changed. +> You can only change the id creation behavior by using `unique_together` in the `Config` class. +> Attempting to define `id` in your model annotations, pass it to the constructor, or modify it after creation +> will raise a `ReadOnlyFieldError`. + Now, let's create a user. ```python diff --git a/src/pydbm/exceptions.py b/src/pydbm/exceptions.py index 056ef27..5928fea 100644 --- a/src/pydbm/exceptions.py +++ b/src/pydbm/exceptions.py @@ -7,6 +7,7 @@ "ValidationError", "EmptyModelError", "UnnecessaryParamsError", + "ReadOnlyFieldError", ) @@ -44,3 +45,9 @@ class UnnecessaryParamsError(PydbmBaseException, ValueError): """Exception for invalid params.""" pass + + +class ReadOnlyFieldError(PydbmBaseException, AttributeError): + """Exception for attempting to overwrite or redefine a read-only field.""" + + pass diff --git a/src/pydbm/models/fields/auto.py b/src/pydbm/models/fields/auto.py index 58c4551..95859a0 100644 --- a/src/pydbm/models/fields/auto.py +++ b/src/pydbm/models/fields/auto.py @@ -5,6 +5,7 @@ import uuid from pydbm import contstant as C +from pydbm.exceptions import ReadOnlyFieldError from pydbm.models.fields.base import BaseField if typing.TYPE_CHECKING: @@ -35,7 +36,11 @@ def __init__(self, field_name: str, field_type: SupportedClassT, *, unique_toget super().__init__(default_factory=self.generate_id, **kwargs) def __set__(self, instance: DbmModel, value: typing.Any) -> None: - if (fields := getattr(instance, "fields", None)) is not None and C.PRIMARY_KEY not in fields: + if getattr(instance, "fields", None) is not None: + if hasattr(instance, self.private_name): + raise ReadOnlyFieldError( + f"'{C.PRIMARY_KEY}' field is read-only and cannot be modified after creation." + ) return super().__set__(instance, value) def __call__(self: Self, fields: dict[str, typing.Any] | None = None, *args, **kwargs) -> Self: # type: ignore[valid-type, override] # noqa: E501 diff --git a/src/pydbm/models/meta.py b/src/pydbm/models/meta.py index c038a28..ca8d31a 100644 --- a/src/pydbm/models/meta.py +++ b/src/pydbm/models/meta.py @@ -5,7 +5,7 @@ from pydbm import contstant as C from pydbm import typing_extra from pydbm.database import DatabaseManager -from pydbm.exceptions import EmptyModelError, PydbmBaseException, UnnecessaryParamsError +from pydbm.exceptions import EmptyModelError, PydbmBaseException, ReadOnlyFieldError, UnnecessaryParamsError from pydbm.inspect_extra import get_obj_annotations from pydbm.models.fields import AutoField, Field, Undefined @@ -31,6 +31,11 @@ class Meta(type): @staticmethod def __new__(mcs, cls_name: str, bases: tuple[Meta, ...], namespace: dict[str, typing.Any], **kwargs: typing.Any) -> type: # noqa: E501 annotations = namespace.pop("__annotations__", {}) + if [b for b in bases if isinstance(b, mcs)] and C.PRIMARY_KEY in annotations: + raise ReadOnlyFieldError( + f"'{C.PRIMARY_KEY}' field is auto-generated and cannot be overwritten or change type." + " Use 'unique_together' in Config to change id creation behavior." + ) annotations[C.PRIMARY_KEY] = str slots = mcs.generate_slots(annotations) if not [b for b in bases if isinstance(b, mcs)]: @@ -62,6 +67,11 @@ def __init__(cls, cls_name: str, bases: tuple[Meta, ...], namespace: dict[str, t setattr(cls, key, value) def __call__(cls, **kwargs): + if C.PRIMARY_KEY in kwargs: + raise ReadOnlyFieldError( + f"'{C.PRIMARY_KEY}' field is auto-generated and cannot be passed as an argument." + ) + for extra_field_name in (set(kwargs.keys()) - set(cls.__annotations__.keys())): raise UnnecessaryParamsError(f"{extra_field_name} is not defined in {cls.__name__}") diff --git a/tests/models/test_base.py b/tests/models/test_base.py index c9767c1..d4b36e3 100644 --- a/tests/models/test_base.py +++ b/tests/models/test_base.py @@ -3,7 +3,7 @@ import pytest from pydbm import DbmModel -from pydbm.exceptions import EmptyModelError +from pydbm.exceptions import EmptyModelError, ReadOnlyFieldError class Model(DbmModel): @@ -278,11 +278,11 @@ class EmptyModel(DbmModel): def test_base_only_id_field_model(): - with pytest.raises(EmptyModelError) as cm: + with pytest.raises(ReadOnlyFieldError) as cm: class EmptyModel(DbmModel): id: str - assert str(cm.value) == "Empty model is not allowed." + assert str(cm.value) == "'id' field is auto-generated and cannot be overwritten or change type. Use 'unique_together' in Config to change id creation behavior." def test_base_update_obj_on_db_when_updating_the_field_on_the_instance(teardown_db): @@ -448,3 +448,44 @@ class Config: assert created2 is False assert instance.id == instance2.id assert Model.objects.count() == 1 + + +def test_id_field_cannot_be_defined_in_model(): + with pytest.raises(ReadOnlyFieldError) as cm: + class Model(DbmModel): + id: str + name: str + + assert "'id' field is auto-generated" in str(cm.value) + + +def test_id_field_cannot_change_type(): + with pytest.raises(ReadOnlyFieldError) as cm: + class Model(DbmModel): + id: int + name: str + + assert "'id' field is auto-generated" in str(cm.value) + + +def test_id_field_cannot_be_passed_to_constructor(): + class Model(DbmModel): + name: str + + with pytest.raises(ReadOnlyFieldError) as cm: + Model(id="custom_id", name="test") + + assert "'id' field is auto-generated and cannot be passed as an argument" in str(cm.value) + + +def test_id_field_cannot_be_overwritten_after_creation(): + class Model(DbmModel): + name: str + + model = Model(name="test") + assert model.id is not None + + with pytest.raises(ReadOnlyFieldError) as cm: + model.id = "new_id" + + assert "'id' field is read-only and cannot be modified after creation" in str(cm.value)