diff --git a/docs/fields/index.md b/docs/fields/index.md index df018b23..1f975bec 100644 --- a/docs/fields/index.md +++ b/docs/fields/index.md @@ -24,6 +24,14 @@ Check the [unique_together](../models.md#unique-together) for more details. - `comment` - A comment to be added with the field in the SQL database. - `secret` - A special attribute that allows to call the [exclude_secrets](../queries/secrets.md#exclude-secrets) and avoid accidental leakage of sensitive data. +- `server_onupdate` - Like a `server_default` for updates. You may can use the fields `customize_default_for_server_default` to convert a static python value to `server_onupdate`. +- `auto_compute_server_default` - A special attribute which allows to calculate the `server_default` from the `default` if not set explicitly and a default was set. It has four possible values: + - `False` - Default for basic fields. Disables the feature. For field authors. + - `None` - Default for basic single column fields. When not disabled by the `allow_auto_compute_server_defaults` setting, + the field `null` attribute is `False` and the `default` is not a callable, the server_default is calculated. For field authors. + - `"ignore_null"` - Like for `None` just ignore the null attribute for the decision. For field authors. + - `True` - When no explicit server_default is set, evaluate default for it. It also has a higher preference than `allow_auto_compute_server_defaults`. + Only for endusers. The default must be compatible with the server_default. All fields are required unless one of the following is set: @@ -31,8 +39,8 @@ All fields are required unless one of the following is set: Set default to `None` -- `server_default` - instance, str, Unicode or a SQLAlchemy `sqlalchemy.sql.expression.text` -construct representing the DDL DEFAULT value for the column. +- `server_default` - instance, str, None or a SQLAlchemy `sqlalchemy.sql.expression.text` construct representing the DDL DEFAULT value for the column. + If None is provided the automatic server_default generation is disabled. The default set here always disables the automatic generation of `server_default`. - `default` - A value or a callable (function). - `auto_now` or `auto_now_add` - Only for DateTimeField and DateField @@ -40,6 +48,10 @@ construct representing the DDL DEFAULT value for the column. !!! Tip Despite not always advertised you can pass valid keyword arguments for pydantic FieldInfo (they are in most cases just passed through). +!!! Warning + When `auto_compute_server_default` is `True` the default is in `BaseField.__init__` evaluated always (overwrites safety checks and settings). + Here are no contextvars set. So be careful when you pass a callable to `default`. + ## Available fields All the values you can pass in any Pydantic [Field](https://docs.pydantic.dev/latest/concepts/fields/) @@ -149,9 +161,11 @@ class MyModel(edgy.Model): is_active: bool = edgy.BooleanField(default=True) is_completed: bool = edgy.BooleanField(default=False) ... - ``` +!!! Note + Until edgy 0.29.0 there was an undocumented default of `False`. + #### CharField ```python @@ -702,11 +716,13 @@ import edgy class MyModel(edgy.Model): data: Dict[str, Any] = edgy.JSONField(default={}) ... - ``` Simple JSON representation object. +!!! Note + Mutable default values (list, dict) are deep-copied to ensure that the default is not manipulated accidentally. + #### BinaryField @@ -739,7 +755,6 @@ class User(edgy.Model): class MyModel(edgy.Model): user: User = edgy.OneToOne("User") ... - ``` Derives from the same as [ForeignKey](#foreignkey) and applies a One to One direction. @@ -1065,7 +1080,6 @@ Note: When using in-db updates of QuerySet there is no instance. Note: There is one exception of a QuerySet method which use a model instance as `CURRENT_INSTANCE`: `create`. - #### Finding out which values are explicit set The `EXPLICIT_SPECIFIED_VALUES` ContextVar is either None or contains the key names of the explicit specified values. @@ -1100,7 +1114,6 @@ Fields using `__get__` must consider the context_var `MODEL_GETATTR_BEHAVIOR`. T The third mode `load` is only relevant for models and querysets. - ## Customizing fields after model initialization Dangerous! There can be many side-effects, especcially for non-metafields (have columns or attributes). diff --git a/docs/migrations/migrations.md b/docs/migrations/migrations.md index 8167c72b..95f31e88 100644 --- a/docs/migrations/migrations.md +++ b/docs/migrations/migrations.md @@ -446,9 +446,9 @@ Edgy uses more intuitive names. ## Migrate to new non-nullable fields -Sometimes you want to add fields to a model which are required afterwards. +Sometimes you want to add fields to a model which are required afterwards in the database. Here are some ways to archive this. -### With server_default +### With explicit server_default (`allow_auto_compute_server_defaults=False`) This is a bit more work and requires a supported field (all single-column fields and some multiple-column fields like CompositeField). It works as follows: @@ -480,8 +480,38 @@ Here is a basic example: edgy makemigration edgy migrate ``` +### With implicit server_default (`allow_auto_compute_server_defaults=True` (default)) + +This is the easiest way; it only works with fields which allow `auto_compute_server_default`, which are the most. +Notable exceptions are Relationship fields and FileFields. + +You just add a default... and that was it. + +1. Create the field with a default + ``` python + class CustomModel(edgy.Model): + active: bool = edgy.fields.BooleanField(default=True) + ... + ``` +2. Generate the migrations and migrate + ``` sh + edgy makemigration + edgy migrate + ``` + +In case of `allow_auto_compute_server_defaults=False` you can enable the auto-compute of a server_default +by passing `auto_compute_server_default=True` to the field. The first step would be here: + +``` python +class CustomModel(edgy.Model): + active: bool = edgy.fields.BooleanField(default=True, auto_compute_server_default=True) + ... +``` + +To disable the behaviour for one field you can either pass `auto_compute_server_default=False` or `server_default=None` to the field. ### With null-field + Null-field is a feature to make fields nullable for one makemigration/revision. You can either specify `model:field_name` or just `:field_name` for automatic detection of models. Non-existing models are ignored, and only models in `registry.models` are migrated. @@ -490,10 +520,10 @@ The `model_defaults` argument can be used to provide one-time defaults that over You can also pass callables, which are executed in context of the `extract_column_values` method and have all of the context variables available. Let's see how to implement the last example with null-field and we add also ContentTypes. -1. Add the field with the default (not server-default). +1. Add the field with the default (and no server-default). ``` python class CustomModel(edgy.Model): - active: bool = edgy.fields.BooleanField(default=True) + active: bool = edgy.fields.BooleanField(default=True, server_default=None) ... ``` 2. Apply null-field to CustomModel:active and also for all models with active content_type. diff --git a/docs/release-notes.md b/docs/release-notes.md index 7585ca24..7cf469bd 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -6,6 +6,29 @@ hide: # Release Notes +## 0.29 + +### Added + +- Convert for most fields defaults to server_default to ease migrations. There are some exceptions. +- Add the setting `allow_auto_compute_server_defaults` which allows to disable the automatic generation of server defaults. + +### Changed + +- `get_default_value` is now also overwritable by factories. +- The undocumented default of `BooleanField` of `False` is removed. + +### Fixed + +- JSONField `default` is deepcopied to prevent accidental modifications of the default. + There is no need anymore to provide a lambda. + +### Breaking + +- The undocumented default of `BooleanField` of `False` is removed. +- Fields compute now `server_default`s for a set default. This can be turned off via the `allow_auto_compute_server_defaults` setting. + + ## 0.28.2 ### Fixed diff --git a/docs_src/models/constraints.py b/docs_src/models/constraints.py index 9fb46660..c7abfec4 100644 --- a/docs_src/models/constraints.py +++ b/docs_src/models/constraints.py @@ -9,7 +9,7 @@ class User(edgy.Model): name = edgy.fields.CharField(max_length=255) - is_admin = edgy.fields.BooleanField() + is_admin = edgy.fields.BooleanField(default=False) age = edgy.IntegerField(null=True) class Meta: diff --git a/edgy/conf/global_settings.py b/edgy/conf/global_settings.py index 7d2ab874..1d0b999d 100644 --- a/edgy/conf/global_settings.py +++ b/edgy/conf/global_settings.py @@ -47,6 +47,7 @@ class MigrationSettings(BaseSettings): class EdgySettings(MediaSettings, MigrationSettings): model_config = SettingsConfigDict(extra="allow", ignored_types=(cached_property,)) + allow_auto_compute_server_defaults: bool = True preloads: Union[list[str], tuple[str, ...]] = () extensions: Union[list[ExtensionProtocol], tuple[ExtensionProtocol, ...]] = () ipython_args: Union[list[str], tuple[str, ...]] = ("--no-banner",) diff --git a/edgy/core/connection/registry.py b/edgy/core/connection/registry.py index aaa94d60..534feaf7 100644 --- a/edgy/core/connection/registry.py +++ b/edgy/core/connection/registry.py @@ -206,7 +206,7 @@ async def apply_default_force_nullable_fields( if filter_db_url and str(model.database.url) != filter_db_url: continue model_specific_defaults = model_defaults.get(model_name) or {} - filter_kwargs = {field_name: None for field_name in field_set} + filter_kwargs = dict.fromkeys(field_set) for obj in await model.query.filter(**filter_kwargs): kwargs = {k: v for k, v in obj.extract_db_fields().items() if k not in field_set} kwargs.update(model_specific_defaults) diff --git a/edgy/core/db/fields/base.py b/edgy/core/db/fields/base.py index 994a7898..674177e8 100644 --- a/edgy/core/db/fields/base.py +++ b/edgy/core/db/fields/base.py @@ -16,6 +16,7 @@ from pydantic import BaseModel from pydantic.fields import FieldInfo +from edgy.conf import settings from edgy.core.db.context_vars import CURRENT_PHASE, FORCE_FIELDS_NULLABLE, MODEL_GETATTR_BEHAVIOR from edgy.types import Undefined @@ -59,6 +60,7 @@ class BaseField(BaseFieldType, FieldInfo): "lte": "__le__", "le": "__le__", } + auto_compute_server_default: Union[bool, None, Literal["ignore_null"]] = False def __init__( self, @@ -85,6 +87,40 @@ def __init__( default = None if default is not Undefined: self.default = default + # check if there was an explicit defined server_default=None + if ( + default is not None + and default is not Undefined + and self.server_default is None + and "server_default" not in kwargs + ): + if not callable(default): + if self.auto_compute_server_default is None: + auto_compute_server_default: bool = ( + not self.null and settings.allow_auto_compute_server_defaults + ) + elif self.auto_compute_server_default == "ignore_null": + auto_compute_server_default = settings.allow_auto_compute_server_defaults + else: + auto_compute_server_default = self.auto_compute_server_default + else: + auto_compute_server_default = bool(self.auto_compute_server_default) + + if auto_compute_server_default: + # required because the patching is done later + if hasattr(self, "factory") and getattr( + self.factory, "customize_default_for_server_default", None + ): + self.server_default = self.factory.customize_default_for_server_default( + self, default, original_fn=self.customize_default_for_server_default + ) + else: + self.server_default = self.customize_default_for_server_default(default) + + def customize_default_for_server_default(self, default: Any) -> Any: + if callable(default): + default = default() + return sqlalchemy.text(":value").bindparams(value=default) def get_columns_nullable(self) -> bool: """ @@ -187,9 +223,12 @@ def get_default_values(self, field_name: str, cleaned_data: dict[str, Any]) -> d class Field(BaseField): """ - Field with fallbacks and used for factories. + Single column field used by factories. """ + # safe here as we have only one column + auto_compute_server_default: Union[bool, None, Literal["ignore_null"]] = None + def check(self, value: Any) -> Any: """ Runs the checks for the fields being validated. Single Column. @@ -198,7 +237,7 @@ def check(self, value: Any) -> Any: def clean(self, name: str, value: Any, for_query: bool = False) -> dict[str, Any]: """ - Runs the checks for the fields being validated. Multiple columns possible + Converts a field value via check method to a column value. """ return {name: self.check(value)} @@ -219,6 +258,9 @@ def get_column(self, name: str) -> Optional[sqlalchemy.Column]: ) def get_columns(self, name: str) -> Sequence[sqlalchemy.Column]: + """ + Return the single column from get_column for the field declared. + """ column = self.get_column(name) if column is None: return [] diff --git a/edgy/core/db/fields/core.py b/edgy/core/db/fields/core.py index ee065fa7..9d2151eb 100644 --- a/edgy/core/db/fields/core.py +++ b/edgy/core/db/fields/core.py @@ -1,3 +1,4 @@ +import copy import datetime import decimal import enum @@ -10,6 +11,7 @@ from secrets import compare_digest from typing import TYPE_CHECKING, Annotated, Any, Optional, Union, cast +import orjson import pydantic import sqlalchemy from monkay import Monkay @@ -257,28 +259,10 @@ class BooleanField(FieldFactory, bool_type): field_type = bool - def __new__( # type: ignore - cls, - *, - default: Union[None, bool, Callable[[], bool]] = False, - **kwargs: Any, - ) -> BaseFieldType: - if default is not None: - kwargs["default"] = default - return super().__new__(cls, **kwargs) - @classmethod def get_column_type(cls, kwargs: dict[str, Any]) -> Any: return sqlalchemy.Boolean() - @classmethod - def validate(cls, kwargs: dict[str, Any]) -> None: - super().validate(kwargs) - - default = kwargs.get("default") - if default is not None and isinstance(default, bool): - kwargs.setdefault("server_default", sqlalchemy.text("true" if default else "false")) - class DateTimeField(_AutoNowMixin, datetime.datetime): """Representation of a datetime field""" @@ -387,6 +371,24 @@ class JSONField(FieldFactory, pydantic.Json): # type: ignore def get_column_type(cls, kwargs: dict[str, Any]) -> Any: return sqlalchemy.JSON() + @classmethod + def get_default_value(cls, field_obj: BaseFieldType, original_fn: Any = None) -> Any: + default = original_fn() + # copy mutable structures + if isinstance(default, (list, dict)): + default = copy.deepcopy(default) + return default + + @classmethod + def customize_default_for_server_default( + cls, field_obj: BaseFieldType, default: Any, original_fn: Any = None + ) -> Any: + if callable(default): + default = default() + if not isinstance(default, str): + default = orjson.dumps(default) + return sqlalchemy.text(":value").bindparams(value=default) + class BinaryField(FieldFactory, bytes): """Representation of a binary""" @@ -450,6 +452,14 @@ def get_column_type(cls, kwargs: dict[str, Any]) -> sqlalchemy.Enum: choice_class = kwargs.get("choices") return sqlalchemy.Enum(choice_class) + @classmethod + def customize_default_for_server_default( + cls, field_obj: BaseFieldType, default: Any, original_fn: Any = None + ) -> Any: + if callable(default): + default = default() + return sqlalchemy.text(":value").bindparams(value=default.name) + class PasswordField(CharField): """ diff --git a/edgy/core/db/fields/factories.py b/edgy/core/db/fields/factories.py index b0631daa..6875075c 100644 --- a/edgy/core/db/fields/factories.py +++ b/edgy/core/db/fields/factories.py @@ -17,6 +17,9 @@ default_methods_overwritable_by_factory.discard("get_column_names") default_methods_overwritable_by_factory.discard("__init__") +# useful helpers +default_methods_overwritable_by_factory.add("get_default_value") + # extra methods default_methods_overwritable_by_factory.add("__set__") default_methods_overwritable_by_factory.add("__get__") @@ -167,7 +170,6 @@ def __new__( on_update: str = CASCADE, on_delete: str = RESTRICT, related_name: Union[str, Literal[False]] = "", - server_onupdate: Any = None, **kwargs: Any, ) -> BaseFieldType: kwargs = { diff --git a/edgy/core/db/fields/file_field.py b/edgy/core/db/fields/file_field.py index 1b499738..d1bfbc10 100644 --- a/edgy/core/db/fields/file_field.py +++ b/edgy/core/db/fields/file_field.py @@ -288,10 +288,19 @@ def __new__( # type: ignore @classmethod def validate(cls, kwargs: dict[str, Any]) -> None: super().validate(kwargs) + if kwargs.get("auto_compute_server_default"): + raise FieldDefinitionError( + '"auto_compute_server_default" is not supported for FileField or ImageField.' + ) from None + kwargs["auto_compute_server_default"] = False if kwargs.get("server_default"): raise FieldDefinitionError( '"server_default" is not supported for FileField or ImageField.' ) from None + if kwargs.get("server_onupdate"): + raise FieldDefinitionError( + '"server_onupdate" is not supported for FileField or ImageField.' + ) from None if kwargs.get("mime_use_magic"): try: import magic # noqa: F401 # pyright: ignore[reportMissingImports] diff --git a/edgy/core/db/fields/foreign_keys.py b/edgy/core/db/fields/foreign_keys.py index 92ffacd1..704bedcf 100644 --- a/edgy/core/db/fields/foreign_keys.py +++ b/edgy/core/db/fields/foreign_keys.py @@ -140,7 +140,7 @@ def related_columns(self) -> dict[str, Optional[sqlalchemy.Column]]: elif target.pkcolumns: # placeholder for extracting column # WARNING: this can recursively loop - columns = {col: None for col in target.pkcolumns} + columns = dict.fromkeys(target.pkcolumns) return columns def expand_relationship(self, value: Any) -> Any: @@ -376,9 +376,18 @@ def __new__( # type: ignore @classmethod def validate(cls, kwargs: dict[str, Any]) -> None: super().validate(kwargs) + if kwargs.get("auto_compute_server_default"): + raise FieldDefinitionError( + '"auto_compute_server_default" is not supported for ForeignKey.' + ) from None + kwargs["auto_compute_server_default"] = False if kwargs.get("server_default"): raise FieldDefinitionError( - '"server_default" is not supported for ForeignKeys.' + '"server_default" is not supported for ForeignKey.' + ) from None + if kwargs.get("server_onupdate"): + raise FieldDefinitionError( + '"server_onupdate" is not supported for ForeignKey.' ) from None embed_parent = kwargs.get("embed_parent") if embed_parent and "__" in embed_parent[1]: diff --git a/edgy/core/db/fields/many_to_many.py b/edgy/core/db/fields/many_to_many.py index 27dfa25e..077b585a 100644 --- a/edgy/core/db/fields/many_to_many.py +++ b/edgy/core/db/fields/many_to_many.py @@ -406,10 +406,19 @@ def __new__( # type: ignore @classmethod def validate(cls, kwargs: dict[str, Any]) -> None: super().validate(kwargs) + if kwargs.get("auto_compute_server_default"): + raise FieldDefinitionError( + '"auto_compute_server_default" is not supported for ManyToMany.' + ) from None + kwargs["auto_compute_server_default"] = False if kwargs.get("server_default"): raise FieldDefinitionError( '"server_default" is not supported for ManyToMany.' ) from None + if kwargs.get("server_onupdate"): + raise FieldDefinitionError( + '"server_onupdate" is not supported for ManyToMany.' + ) from None embed_through = kwargs.get("embed_through") if embed_through and "__" in embed_through: raise FieldDefinitionError('"embed_through" cannot contain "__".') diff --git a/edgy/core/db/fields/mixins.py b/edgy/core/db/fields/mixins.py index e4c254c7..467f0d3a 100644 --- a/edgy/core/db/fields/mixins.py +++ b/edgy/core/db/fields/mixins.py @@ -59,7 +59,7 @@ def get_default_values( ) -> dict[str, Any]: if self.increment_on_save != 0: phase = CURRENT_PHASE.get() - if phase in "prepare_update": + if phase == "prepare_update": return {field_name: None} return super().get_default_values(field_name, cleaned_data) @@ -158,4 +158,6 @@ def __new__( # type: ignore if auto_now_add or auto_now: # date.today cannot handle timezone so use alway datetime and convert back to date kwargs["default"] = partial(datetime.datetime.now, default_timezone) + # ensure no automatic calculation happens + kwargs.setdefault("auto_compute_server_default", False) return super().__new__(cls, **kwargs) diff --git a/edgy/core/db/fields/types.py b/edgy/core/db/fields/types.py index 164f10c7..2f766ea1 100644 --- a/edgy/core/db/fields/types.py +++ b/edgy/core/db/fields/types.py @@ -31,6 +31,7 @@ class _ColumnDefinition: index: bool = False unique: bool = False comment: Optional[str] = None + # keep both any, so multi-column field authors can set a dict server_default: Optional[Any] = None server_onupdate: Optional[Any] = None @@ -173,6 +174,17 @@ def get_default_values(self, field_name: str, cleaned_data: dict[str, Any]) -> A """ + @abstractmethod + def customize_default_for_server_default(self, value: Any) -> Any: + """ + Modify default for server_default. + + Args: + field_name: the field name (can be different from name) + cleaned_data: currently validated data. Useful to check if the default was already applied. + + """ + @abstractmethod def embed_field( self, diff --git a/pyproject.toml b/pyproject.toml index 5c4fff86..cda3f665 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,8 +72,6 @@ Source = "https://github.com/dymmond/edgy" edgy = "edgy.__main__:run_cli" [project.optional-dependencies] - -# we need it anyway in databasez test = ["faker>=33.3.1", "sqlalchemy_utils>=0.41.1"] testing = [ "anyio>=4.0.0,<5", diff --git a/tests/cli/main.py b/tests/cli/main.py index cb0990ee..4e0b7e0c 100644 --- a/tests/cli/main.py +++ b/tests/cli/main.py @@ -36,8 +36,6 @@ class User(edgy.StrictModel): # simple default active = edgy.fields.BooleanField(server_default=sqlalchemy.text("true"), default=False) profile = edgy.fields.ForeignKey("Profile", null=False, default=complex_default) - # auto server defaults - is_staff = edgy.fields.BooleanField() class Meta: registry = models diff --git a/tests/cli/main_server_defaults.py b/tests/cli/main_server_defaults.py new file mode 100644 index 00000000..7de42028 --- /dev/null +++ b/tests/cli/main_server_defaults.py @@ -0,0 +1,72 @@ +import os +from enum import Enum + +import pytest + +import edgy +from edgy import Instance +from edgy.contrib.permissions import BasePermission +from tests.settings import TEST_DATABASE + +pytestmark = pytest.mark.anyio +models = edgy.Registry( + database=TEST_DATABASE, + with_content_type=os.environ.get("TEST_NO_CONTENT_TYPE", "false") != "true", +) + +basedir = os.path.abspath(os.path.dirname(__file__)) + + +class UserTypeEnum(Enum): + INTERNAL = "Internal" + SYSTEM = "System" + EXTERNAL = "External" + + +class User(edgy.StrictModel): + name = edgy.fields.CharField(max_length=100) + if os.environ.get("TEST_ADD_AUTO_SERVER_DEFAULTS", "false") == "true": + # auto server defaults + active = edgy.fields.BooleanField(default=True) + is_staff = edgy.fields.BooleanField(default=False) + age = edgy.fields.IntegerField(default=18) + size = edgy.fields.DecimalField(default="1.8", decimal_places=2) + blob = edgy.fields.BinaryField(default=b"abc") + # needs special library for alembic enum migrations + # user_type = edgy.fields.ChoiceField(choices=UserTypeEnum, default=UserTypeEnum.INTERNAL) + data = edgy.fields.JSONField(default={"test": "test"}) + + class Meta: + registry = models + + +class Group(edgy.StrictModel): + name = edgy.fields.CharField(max_length=100) + users = edgy.fields.ManyToMany( + "User", embed_through=False, through_tablename=edgy.NEW_M2M_NAMING + ) + content_type = edgy.fields.ExcludeField() + + class Meta: + registry = models + + +class Permission(BasePermission): + users = edgy.fields.ManyToMany( + "User", embed_through=False, through_tablename=edgy.NEW_M2M_NAMING + ) + groups = edgy.fields.ManyToMany( + "Group", embed_through=False, through_tablename=edgy.NEW_M2M_NAMING + ) + name_model: str = edgy.fields.CharField(max_length=100, null=True) + if os.environ.get("TEST_NO_CONTENT_TYPE", "false") != "true": + obj = edgy.fields.ForeignKey("ContentType", null=True) + content_type = edgy.fields.ExcludeField() + + class Meta: + registry = models + if os.environ.get("TEST_NO_CONTENT_TYPE", "false") != "true": + unique_together = [("name", "name_model", "obj")] + + +edgy.monkay.set_instance(Instance(registry=models)) diff --git a/tests/cli/test_nullable_fields.py b/tests/cli/test_nullable_fields.py index 45f1406d..4718dc3a 100644 --- a/tests/cli/test_nullable_fields.py +++ b/tests/cli/test_nullable_fields.py @@ -201,7 +201,6 @@ async def main(): async with main.models: user = await main.User.query.get(name="edgy") assert user.active - assert not user.is_staff assert user.content_type.name == "User" assert user.profile == await main.Profile.query.get(name="edgy") assert user.profile.content_type.name == "Profile" @@ -212,7 +211,6 @@ async def main(): async with main.models: user = await main.User.query.get(name="edgy") assert user.active - assert not user.is_staff assert user.content_type.name == "User" assert user.profile == await main.Profile.query.get(name="edgy") diff --git a/tests/cli/test_server_default_fields.py b/tests/cli/test_server_default_fields.py new file mode 100644 index 00000000..8a2d0457 --- /dev/null +++ b/tests/cli/test_server_default_fields.py @@ -0,0 +1,237 @@ +import contextlib +import decimal +import os +import shutil +import sys +from asyncio import run +from pathlib import Path + +import pytest +import sqlalchemy +from sqlalchemy.ext.asyncio import create_async_engine + +from tests.cli.utils import arun_cmd +from tests.settings import DATABASE_URL + +pytestmark = pytest.mark.anyio + +base_path = Path(os.path.abspath(__file__)).absolute().parent + + +@pytest.fixture(scope="function", autouse=True) +def cleanup_folders(): + with contextlib.suppress(OSError): + shutil.rmtree(str(base_path / "migrations")) + with contextlib.suppress(OSError): + shutil.rmtree(str(base_path / "migrations2")) + + yield + with contextlib.suppress(OSError): + shutil.rmtree(str(base_path / "migrations")) + with contextlib.suppress(OSError): + shutil.rmtree(str(base_path / "migrations2")) + + +async def recreate_db(): + engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT") + try: + async with engine.connect() as conn: + await conn.execute(sqlalchemy.text("DROP DATABASE test_edgy")) + except Exception: + pass + async with engine.connect() as conn: + await conn.execute(sqlalchemy.text("CREATE DATABASE test_edgy")) + + +@pytest.fixture(scope="function", autouse=True) +async def cleanup_prepare_db(): + await recreate_db() + + +@pytest.mark.parametrize( + "template_param", + ["", " -t default", " -t plain", " -t url"], + ids=["default_empty", "default", "plain", "url"], +) +async def test_migrate_server_defaults_upgrade(template_param): + os.chdir(base_path) + assert not (base_path / "migrations").exists() + (o, e, ss) = await arun_cmd( + "tests.cli.main_server_defaults", + f"edgy init{template_param}", + ) + assert ss == 0 + + (o, e, ss) = await arun_cmd("tests.cli.main_server_defaults", "edgy makemigrations") + assert ss == 0 + assert b"No changes in schema detected" not in o + + (o, e, ss) = await arun_cmd("tests.cli.main_server_defaults", "edgy migrate") + assert ss == 0 + + (o, e, ss) = await arun_cmd( + "tests.cli.main_server_defaults", + f"python {__file__} add", + with_app_environment=False, + extra_env={ + "EDGY_SETTINGS_MODULE": "tests.settings.multidb.TestSettings", + }, + ) + assert ss == 0 + + (o, e, ss) = await arun_cmd( + "tests.cli.main_server_defaults", + "edgy makemigrations", + extra_env={"TEST_ADD_AUTO_SERVER_DEFAULTS": "true"}, + ) + + (o, e, ss) = await arun_cmd( + "tests.cli.main_server_defaults", + "edgy migrate", + extra_env={"TEST_ADD_AUTO_SERVER_DEFAULTS": "true"}, + ) + assert ss == 0 + + (o, e, ss) = await arun_cmd( + "tests.cli.main_server_defaults", + f"python {__file__} add2", + extra_env={ + "EDGY_SETTINGS_MODULE": "tests.settings.multidb.TestSettings", + "TEST_ADD_AUTO_SERVER_DEFAULTS": "true", + }, + ) + assert ss == 0 + (o, e, ss) = await arun_cmd( + "tests.cli.main_server_defaults", + f"python {__file__} check", + with_app_environment=False, + extra_env={ + "EDGY_SETTINGS_MODULE": "tests.settings.multidb.TestSettings", + "TEST_ADD_AUTO_SERVER_DEFAULTS": "true", + }, + ) + assert ss == 0 + + migrations = list((base_path / "migrations" / "versions").glob("*.py")) + assert len(migrations) == 2 + + +@pytest.mark.parametrize( + "template_param", + ["", " -t default", " -t plain", " -t url"], + ids=["default_empty", "default", "plain", "url"], +) +async def test_no_migration_when_switching_to_asd(template_param): + os.chdir(base_path) + assert not (base_path / "migrations").exists() + (o, e, ss) = await arun_cmd( + "tests.cli.main_server_defaults", + f"edgy init{template_param}", + ) + assert ss == 0 + + (o, e, ss) = await arun_cmd( + "tests.cli.main_server_defaults", + "edgy makemigrations", + extra_env={ + "TEST_ADD_AUTO_SERVER_DEFAULTS": "true", + "EDGY_SETTINGS_MODULE": "tests.settings.disabled_auto_server_defaults.TestSettings", + }, + ) + assert ss == 0 + + (o, e, ss) = await arun_cmd( + "tests.cli.main_server_defaults", + "edgy migrate", + extra_env={ + "TEST_ADD_AUTO_SERVER_DEFAULTS": "true", + "EDGY_SETTINGS_MODULE": "tests.settings.disabled_auto_server_defaults.TestSettings", + }, + ) + assert ss == 0 + + (o, e, ss) = await arun_cmd( + "tests.cli.main_server_defaults", + "edgy makemigrations", + extra_env={"TEST_ADD_AUTO_SERVER_DEFAULTS": "true"}, + ) + assert ss == 0 + + migrations = list((base_path / "migrations" / "versions").glob("*.py")) + assert len(migrations) == 1 + + +@pytest.mark.parametrize( + "template_param", + ["", " -t default", " -t plain", " -t url"], + ids=["default_empty", "default", "plain", "url"], +) +async def test_no_migration_when_switching_from_asd(template_param): + os.chdir(base_path) + assert not (base_path / "migrations").exists() + (o, e, ss) = await arun_cmd( + "tests.cli.main_server_defaults", + f"edgy init{template_param}", + ) + assert ss == 0 + + (o, e, ss) = await arun_cmd( + "tests.cli.main_server_defaults", + "edgy makemigrations", + extra_env={"TEST_ADD_AUTO_SERVER_DEFAULTS": "true"}, + ) + assert ss == 0 + + (o, e, ss) = await arun_cmd( + "tests.cli.main_server_defaults", + "edgy migrate", + extra_env={"TEST_ADD_AUTO_SERVER_DEFAULTS": "true"}, + ) + assert ss == 0 + + (o, e, ss) = await arun_cmd( + "tests.cli.main_server_defaults", + "edgy makemigrations", + extra_env={ + "TEST_ADD_AUTO_SERVER_DEFAULTS": "true", + "EDGY_SETTINGS_MODULE": "tests.settings.disabled_auto_server_defaults.TestSettings", + }, + ) + assert ss == 0 + + migrations = list((base_path / "migrations" / "versions").glob("*.py")) + assert len(migrations) == 1 + + +async def main(): + if sys.argv[1] == "add": + from tests.cli import main_server_defaults as main + + async with main.models: + user = await main.User.query.create(name="edgy") + elif sys.argv[1] == "add2": + from tests.cli import main_server_defaults as main + + async with main.models: + user = await main.User.query.create(name="edgy2", active=False) + elif sys.argv[1] == "check": + from tests.cli import main_server_defaults as main + + async with main.models: + user = await main.User.query.get(name="edgy") + assert user.active + assert not user.is_staff + assert user.age == 18 + assert user.size == decimal.Decimal("1.8") + assert user.blob == b"abc" + assert user.data == {"test": "test"} + # assert user.user_type == main.UserTypeEnum.INTERNAL + assert user.content_type.name == "User" + + user2 = await main.User.query.get(name="edgy2") + assert not user2.active + assert user.content_type.name == "User" + + +if __name__ == "__main__": + run(main()) diff --git a/tests/fields/test_fields.py b/tests/fields/test_fields.py index f6353abe..b40548ac 100644 --- a/tests/fields/test_fields.py +++ b/tests/fields/test_fields.py @@ -193,9 +193,6 @@ def test_can_create_boolean_field(): field = BooleanField(default=True) assert field.default is True - field = BooleanField() - assert field.default is False - def test_can_create_datetime_field(): field = DateTimeField(auto_now=True) @@ -260,10 +257,15 @@ def test_can_overwrite_method_autonow_field(mocker): def test_can_create_json_field(): - field = JSONField(default={"json": "json"}) + default = {"json": "json"} + field = JSONField(default=default) assert isinstance(field, BaseField) assert field.default == {"json": "json"} + # test if return mutations affect the default + returned = field.get_default_value() + returned["json"] = "not_json" + assert default == {"json": "json"} def test_can_create_binary_field(): diff --git a/tests/settings/disabled_auto_server_defaults.py b/tests/settings/disabled_auto_server_defaults.py new file mode 100644 index 00000000..96d3ebe4 --- /dev/null +++ b/tests/settings/disabled_auto_server_defaults.py @@ -0,0 +1,12 @@ +import os +from pathlib import Path +from typing import Union + +from edgy.contrib.multi_tenancy.settings import TenancySettings + + +class TestSettings(TenancySettings): + tenant_model: str = "Tenant" + auth_user_model: str = "User" + media_root: Union[str, os.PathLike] = Path(__file__).parent.parent / "test_media/" + allow_auto_compute_server_defaults: bool = False diff --git a/tests/test_automigrations.py b/tests/test_automigrations.py index ece65c2a..a00a706b 100644 --- a/tests/test_automigrations.py +++ b/tests/test_automigrations.py @@ -24,7 +24,7 @@ @pytest.fixture(autouse=True, scope="function") async def cleanup_db(): rmtree("test_migrations", ignore_errors=True) - await asyncio.sleep(2) + await asyncio.sleep(0.5) with suppress(Exception): await database.drop_database(database.url) yield @@ -44,7 +44,7 @@ class AddUserExtension(ExtensionProtocol): def apply(self, monkay_instance): UserCopy = User.copy_edgy_model() - UserCopy.add_to_registry(monkay_instance.instance.registry) + UserCopy.add_to_registry(monkay_instance.instance.registry, name="User") class Config(EdgySettings): @@ -63,8 +63,11 @@ async def _prepare(with_upgrade: bool): with ( edgy.monkay.with_extensions({}), edgy.monkay.with_settings(Config()), - edgy.monkay.with_instance(edgy.Instance(Registry(database)), apply_extensions=True), + edgy.monkay.with_instance(edgy.Instance(Registry(database)), apply_extensions=False), ): + edgy.monkay.evaluate_settings() + edgy.monkay.apply_extensions() + assert "User" in edgy.monkay.instance.registry.models await asyncio.to_thread(init) await asyncio.to_thread(migrate) if with_upgrade: @@ -78,8 +81,7 @@ def prepare(with_upgrade: bool): @pytest.mark.parametrize("run", [1, 2]) async def test_automigrate_manual(run): subprocess = await asyncio.create_subprocess_exec(sys.executable, __file__, "prepare", "true") - out, err = await subprocess.communicate() - assert not err + await subprocess.communicate() async with Registry(database) as registry: UserCopy = User.copy_edgy_model() UserCopy.add_to_registry(registry) @@ -93,8 +95,7 @@ async def test_automigrate_manual(run): @pytest.mark.parametrize("run", [1, 2]) async def test_automigrate_automatic(run): subprocess = await asyncio.create_subprocess_exec(sys.executable, __file__, "prepare", "false") - out, err = await subprocess.communicate() - assert not err + await subprocess.communicate() async with create_registry(database) as registry: registry.refresh_metadata() assert "User" in registry.models @@ -111,8 +112,7 @@ async def test_automigrate_automatic(run): async def test_automigrate_disabled(): subprocess = await asyncio.create_subprocess_exec(sys.executable, __file__, "prepare", "false") - out, err = await subprocess.communicate() - assert not err + await subprocess.communicate() with edgy.monkay.with_settings(Config(allow_automigrations=False)): async with create_registry(database) as registry: assert "User" not in registry.models @@ -120,6 +120,5 @@ async def test_automigrate_disabled(): if __name__ == "__main__": # noqa: SIM102 - print("enter", sys.argv) if sys.argv[1] == "prepare": prepare(sys.argv[2] == "true") diff --git a/tests/test_constraints.py b/tests/test_constraints.py index e979a976..6dd8b62d 100644 --- a/tests/test_constraints.py +++ b/tests/test_constraints.py @@ -14,7 +14,7 @@ class User(edgy.StrictModel): name = edgy.fields.CharField(max_length=255) - is_admin = edgy.fields.BooleanField() + is_admin = edgy.fields.BooleanField(default=False) age = edgy.IntegerField(null=True) class Meta: