Skip to content

Commit f0eb9aa

Browse files
authoredMar 18, 2025··
add automatic generation of server_defaults for improving migrations (#306)
Changes: *-add automatic generation of server_defaults for improving migrations - 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. - `get_default_value` is now also overwritable by factories. - JSONField `default` is deepcopied to prevent accidental modifications of the default. There is no need anymore to provide a lambda. - document server_onupdate and prevent it in some fields -fix automigration test - remove undocumented BooleanField default
1 parent d643ed8 commit f0eb9aa

23 files changed

+536
-58
lines changed
 

‎docs/fields/index.md

+20-7
Original file line numberDiff line numberDiff line change
@@ -24,22 +24,34 @@ Check the [unique_together](../models.md#unique-together) for more details.
2424
- `comment` - A comment to be added with the field in the SQL database.
2525
- `secret` - A special attribute that allows to call the [exclude_secrets](../queries/secrets.md#exclude-secrets) and avoid
2626
accidental leakage of sensitive data.
27+
- `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`.
28+
- `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:
29+
- `False` - Default for basic fields. Disables the feature. For field authors.
30+
- `None` - Default for basic single column fields. When not disabled by the `allow_auto_compute_server_defaults` setting,
31+
the field `null` attribute is `False` and the `default` is not a callable, the server_default is calculated. For field authors.
32+
- `"ignore_null"` - Like for `None` just ignore the null attribute for the decision. For field authors.
33+
- `True` - When no explicit server_default is set, evaluate default for it. It also has a higher preference than `allow_auto_compute_server_defaults`.
34+
Only for endusers. The default must be compatible with the server_default.
2735

2836
All fields are required unless one of the following is set:
2937

3038
- `null` - A boolean. Determine if a column allows null.
3139

3240
<sup>Set default to `None`</sup>
3341

34-
- `server_default` - instance, str, Unicode or a SQLAlchemy `sqlalchemy.sql.expression.text`
35-
construct representing the DDL DEFAULT value for the column.
42+
- `server_default` - instance, str, None or a SQLAlchemy `sqlalchemy.sql.expression.text` construct representing the DDL DEFAULT value for the column.
43+
If None is provided the automatic server_default generation is disabled. The default set here always disables the automatic generation of `server_default`.
3644
- `default` - A value or a callable (function).
3745
- `auto_now` or `auto_now_add` - Only for DateTimeField and DateField
3846

3947

4048
!!! Tip
4149
Despite not always advertised you can pass valid keyword arguments for pydantic FieldInfo (they are in most cases just passed through).
4250

51+
!!! Warning
52+
When `auto_compute_server_default` is `True` the default is in `BaseField.__init__` evaluated always (overwrites safety checks and settings).
53+
Here are no contextvars set. So be careful when you pass a callable to `default`.
54+
4355
## Available fields
4456

4557
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):
149161
is_active: bool = edgy.BooleanField(default=True)
150162
is_completed: bool = edgy.BooleanField(default=False)
151163
...
152-
153164
```
154165

166+
!!! Note
167+
Until edgy 0.29.0 there was an undocumented default of `False`.
168+
155169
#### CharField
156170

157171
```python
@@ -702,11 +716,13 @@ import edgy
702716
class MyModel(edgy.Model):
703717
data: Dict[str, Any] = edgy.JSONField(default={})
704718
...
705-
706719
```
707720

708721
Simple JSON representation object.
709722

723+
!!! Note
724+
Mutable default values (list, dict) are deep-copied to ensure that the default is not manipulated accidentally.
725+
710726

711727
#### BinaryField
712728

@@ -739,7 +755,6 @@ class User(edgy.Model):
739755
class MyModel(edgy.Model):
740756
user: User = edgy.OneToOne("User")
741757
...
742-
743758
```
744759

745760
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.
10651080

10661081
Note: There is one exception of a QuerySet method which use a model instance as `CURRENT_INSTANCE`: `create`.
10671082

1068-
10691083
#### Finding out which values are explicit set
10701084

10711085
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
11001114

11011115
The third mode `load` is only relevant for models and querysets.
11021116

1103-
11041117
## Customizing fields after model initialization
11051118

11061119
Dangerous! There can be many side-effects, especcially for non-metafields (have columns or attributes).

‎docs/migrations/migrations.md

+34-4
Original file line numberDiff line numberDiff line change
@@ -446,9 +446,9 @@ Edgy uses more intuitive names.
446446

447447
## Migrate to new non-nullable fields
448448

449-
Sometimes you want to add fields to a model which are required afterwards.
449+
Sometimes you want to add fields to a model which are required afterwards in the database. Here are some ways to archive this.
450450

451-
### With server_default
451+
### With explicit server_default (`allow_auto_compute_server_defaults=False`)
452452

453453
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:
454454

@@ -480,8 +480,38 @@ Here is a basic example:
480480
edgy makemigration
481481
edgy migrate
482482
```
483+
### With implicit server_default (`allow_auto_compute_server_defaults=True` (default))
484+
485+
This is the easiest way; it only works with fields which allow `auto_compute_server_default`, which are the most.
486+
Notable exceptions are Relationship fields and FileFields.
487+
488+
You just add a default... and that was it.
489+
490+
1. Create the field with a default
491+
``` python
492+
class CustomModel(edgy.Model):
493+
active: bool = edgy.fields.BooleanField(default=True)
494+
...
495+
```
496+
2. Generate the migrations and migrate
497+
``` sh
498+
edgy makemigration
499+
edgy migrate
500+
```
501+
502+
In case of `allow_auto_compute_server_defaults=False` you can enable the auto-compute of a server_default
503+
by passing `auto_compute_server_default=True` to the field. The first step would be here:
504+
505+
``` python
506+
class CustomModel(edgy.Model):
507+
active: bool = edgy.fields.BooleanField(default=True, auto_compute_server_default=True)
508+
...
509+
```
510+
511+
To disable the behaviour for one field you can either pass `auto_compute_server_default=False` or `server_default=None` to the field.
483512

484513
### With null-field
514+
485515
Null-field is a feature to make fields nullable for one makemigration/revision. You can either specify
486516
`model:field_name` or just `:field_name` for automatic detection of models.
487517
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
490520
You can also pass callables, which are executed in context of the `extract_column_values` method and have all of the context variables available.
491521

492522
Let's see how to implement the last example with null-field and we add also ContentTypes.
493-
1. Add the field with the default (not server-default).
523+
1. Add the field with the default (and no server-default).
494524
``` python
495525
class CustomModel(edgy.Model):
496-
active: bool = edgy.fields.BooleanField(default=True)
526+
active: bool = edgy.fields.BooleanField(default=True, server_default=None)
497527
...
498528
```
499529
2. Apply null-field to CustomModel:active and also for all models with active content_type.

‎docs/release-notes.md

+23
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,29 @@ hide:
66

77
# Release Notes
88

9+
## 0.29
10+
11+
### Added
12+
13+
- Convert for most fields defaults to server_default to ease migrations. There are some exceptions.
14+
- Add the setting `allow_auto_compute_server_defaults` which allows to disable the automatic generation of server defaults.
15+
16+
### Changed
17+
18+
- `get_default_value` is now also overwritable by factories.
19+
- The undocumented default of `BooleanField` of `False` is removed.
20+
21+
### Fixed
22+
23+
- JSONField `default` is deepcopied to prevent accidental modifications of the default.
24+
There is no need anymore to provide a lambda.
25+
26+
### Breaking
27+
28+
- The undocumented default of `BooleanField` of `False` is removed.
29+
- Fields compute now `server_default`s for a set default. This can be turned off via the `allow_auto_compute_server_defaults` setting.
30+
31+
932
## 0.28.2
1033

1134
### Fixed

‎docs_src/models/constraints.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
class User(edgy.Model):
1111
name = edgy.fields.CharField(max_length=255)
12-
is_admin = edgy.fields.BooleanField()
12+
is_admin = edgy.fields.BooleanField(default=False)
1313
age = edgy.IntegerField(null=True)
1414

1515
class Meta:

‎edgy/conf/global_settings.py

+1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class MigrationSettings(BaseSettings):
4747

4848
class EdgySettings(MediaSettings, MigrationSettings):
4949
model_config = SettingsConfigDict(extra="allow", ignored_types=(cached_property,))
50+
allow_auto_compute_server_defaults: bool = True
5051
preloads: Union[list[str], tuple[str, ...]] = ()
5152
extensions: Union[list[ExtensionProtocol], tuple[ExtensionProtocol, ...]] = ()
5253
ipython_args: Union[list[str], tuple[str, ...]] = ("--no-banner",)

‎edgy/core/connection/registry.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ async def apply_default_force_nullable_fields(
206206
if filter_db_url and str(model.database.url) != filter_db_url:
207207
continue
208208
model_specific_defaults = model_defaults.get(model_name) or {}
209-
filter_kwargs = {field_name: None for field_name in field_set}
209+
filter_kwargs = dict.fromkeys(field_set)
210210
for obj in await model.query.filter(**filter_kwargs):
211211
kwargs = {k: v for k, v in obj.extract_db_fields().items() if k not in field_set}
212212
kwargs.update(model_specific_defaults)

‎edgy/core/db/fields/base.py

+44-2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from pydantic import BaseModel
1717
from pydantic.fields import FieldInfo
1818

19+
from edgy.conf import settings
1920
from edgy.core.db.context_vars import CURRENT_PHASE, FORCE_FIELDS_NULLABLE, MODEL_GETATTR_BEHAVIOR
2021
from edgy.types import Undefined
2122

@@ -59,6 +60,7 @@ class BaseField(BaseFieldType, FieldInfo):
5960
"lte": "__le__",
6061
"le": "__le__",
6162
}
63+
auto_compute_server_default: Union[bool, None, Literal["ignore_null"]] = False
6264

6365
def __init__(
6466
self,
@@ -85,6 +87,40 @@ def __init__(
8587
default = None
8688
if default is not Undefined:
8789
self.default = default
90+
# check if there was an explicit defined server_default=None
91+
if (
92+
default is not None
93+
and default is not Undefined
94+
and self.server_default is None
95+
and "server_default" not in kwargs
96+
):
97+
if not callable(default):
98+
if self.auto_compute_server_default is None:
99+
auto_compute_server_default: bool = (
100+
not self.null and settings.allow_auto_compute_server_defaults
101+
)
102+
elif self.auto_compute_server_default == "ignore_null":
103+
auto_compute_server_default = settings.allow_auto_compute_server_defaults
104+
else:
105+
auto_compute_server_default = self.auto_compute_server_default
106+
else:
107+
auto_compute_server_default = bool(self.auto_compute_server_default)
108+
109+
if auto_compute_server_default:
110+
# required because the patching is done later
111+
if hasattr(self, "factory") and getattr(
112+
self.factory, "customize_default_for_server_default", None
113+
):
114+
self.server_default = self.factory.customize_default_for_server_default(
115+
self, default, original_fn=self.customize_default_for_server_default
116+
)
117+
else:
118+
self.server_default = self.customize_default_for_server_default(default)
119+
120+
def customize_default_for_server_default(self, default: Any) -> Any:
121+
if callable(default):
122+
default = default()
123+
return sqlalchemy.text(":value").bindparams(value=default)
88124

89125
def get_columns_nullable(self) -> bool:
90126
"""
@@ -187,9 +223,12 @@ def get_default_values(self, field_name: str, cleaned_data: dict[str, Any]) -> d
187223

188224
class Field(BaseField):
189225
"""
190-
Field with fallbacks and used for factories.
226+
Single column field used by factories.
191227
"""
192228

229+
# safe here as we have only one column
230+
auto_compute_server_default: Union[bool, None, Literal["ignore_null"]] = None
231+
193232
def check(self, value: Any) -> Any:
194233
"""
195234
Runs the checks for the fields being validated. Single Column.
@@ -198,7 +237,7 @@ def check(self, value: Any) -> Any:
198237

199238
def clean(self, name: str, value: Any, for_query: bool = False) -> dict[str, Any]:
200239
"""
201-
Runs the checks for the fields being validated. Multiple columns possible
240+
Converts a field value via check method to a column value.
202241
"""
203242
return {name: self.check(value)}
204243

@@ -219,6 +258,9 @@ def get_column(self, name: str) -> Optional[sqlalchemy.Column]:
219258
)
220259

221260
def get_columns(self, name: str) -> Sequence[sqlalchemy.Column]:
261+
"""
262+
Return the single column from get_column for the field declared.
263+
"""
222264
column = self.get_column(name)
223265
if column is None:
224266
return []

‎edgy/core/db/fields/core.py

+28-18
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import copy
12
import datetime
23
import decimal
34
import enum
@@ -10,6 +11,7 @@
1011
from secrets import compare_digest
1112
from typing import TYPE_CHECKING, Annotated, Any, Optional, Union, cast
1213

14+
import orjson
1315
import pydantic
1416
import sqlalchemy
1517
from monkay import Monkay
@@ -257,28 +259,10 @@ class BooleanField(FieldFactory, bool_type):
257259

258260
field_type = bool
259261

260-
def __new__( # type: ignore
261-
cls,
262-
*,
263-
default: Union[None, bool, Callable[[], bool]] = False,
264-
**kwargs: Any,
265-
) -> BaseFieldType:
266-
if default is not None:
267-
kwargs["default"] = default
268-
return super().__new__(cls, **kwargs)
269-
270262
@classmethod
271263
def get_column_type(cls, kwargs: dict[str, Any]) -> Any:
272264
return sqlalchemy.Boolean()
273265

274-
@classmethod
275-
def validate(cls, kwargs: dict[str, Any]) -> None:
276-
super().validate(kwargs)
277-
278-
default = kwargs.get("default")
279-
if default is not None and isinstance(default, bool):
280-
kwargs.setdefault("server_default", sqlalchemy.text("true" if default else "false"))
281-
282266

283267
class DateTimeField(_AutoNowMixin, datetime.datetime):
284268
"""Representation of a datetime field"""
@@ -387,6 +371,24 @@ class JSONField(FieldFactory, pydantic.Json): # type: ignore
387371
def get_column_type(cls, kwargs: dict[str, Any]) -> Any:
388372
return sqlalchemy.JSON()
389373

374+
@classmethod
375+
def get_default_value(cls, field_obj: BaseFieldType, original_fn: Any = None) -> Any:
376+
default = original_fn()
377+
# copy mutable structures
378+
if isinstance(default, (list, dict)):
379+
default = copy.deepcopy(default)
380+
return default
381+
382+
@classmethod
383+
def customize_default_for_server_default(
384+
cls, field_obj: BaseFieldType, default: Any, original_fn: Any = None
385+
) -> Any:
386+
if callable(default):
387+
default = default()
388+
if not isinstance(default, str):
389+
default = orjson.dumps(default)
390+
return sqlalchemy.text(":value").bindparams(value=default)
391+
390392

391393
class BinaryField(FieldFactory, bytes):
392394
"""Representation of a binary"""
@@ -450,6 +452,14 @@ def get_column_type(cls, kwargs: dict[str, Any]) -> sqlalchemy.Enum:
450452
choice_class = kwargs.get("choices")
451453
return sqlalchemy.Enum(choice_class)
452454

455+
@classmethod
456+
def customize_default_for_server_default(
457+
cls, field_obj: BaseFieldType, default: Any, original_fn: Any = None
458+
) -> Any:
459+
if callable(default):
460+
default = default()
461+
return sqlalchemy.text(":value").bindparams(value=default.name)
462+
453463

454464
class PasswordField(CharField):
455465
"""

‎edgy/core/db/fields/factories.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
default_methods_overwritable_by_factory.discard("get_column_names")
1818
default_methods_overwritable_by_factory.discard("__init__")
1919

20+
# useful helpers
21+
default_methods_overwritable_by_factory.add("get_default_value")
22+
2023
# extra methods
2124
default_methods_overwritable_by_factory.add("__set__")
2225
default_methods_overwritable_by_factory.add("__get__")
@@ -167,7 +170,6 @@ def __new__(
167170
on_update: str = CASCADE,
168171
on_delete: str = RESTRICT,
169172
related_name: Union[str, Literal[False]] = "",
170-
server_onupdate: Any = None,
171173
**kwargs: Any,
172174
) -> BaseFieldType:
173175
kwargs = {

0 commit comments

Comments
 (0)
Please sign in to comment.