Skip to content

Commit 12a391f

Browse files
authored
Introduce SqlDefault for using expression as db_default (#2106)
1 parent e119288 commit 12a391f

File tree

21 files changed

+491
-133
lines changed

21 files changed

+491
-133
lines changed

CHANGELOG.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,19 @@ Changelog
88
1.1
99
===
1010

11+
1.1.1
12+
-----
13+
14+
Added
15+
^^^^^
16+
- **``SqlDefault`` and ``Now`` expressions for ``db_default``** — use ``db_default=SqlDefault("...")`` to emit raw SQL expressions (e.g. ``CURRENT_TIMESTAMP``) as database defaults. ``Now()`` is a convenience shorthand for ``SqlDefault("CURRENT_TIMESTAMP")``. (#2104)
17+
18+
19+
Changed
20+
^^^^^^^
21+
- ``Field(default=...)`` and ``auto_now`` / ``auto_now_add`` no longer emits a ``DEFAULT`` clause in ``generate_schemas()``. The ``default`` parameter is Python-only; use ``db_default`` for database-level defaults. This aligns ``generate_schemas()`` with migrations, which don't emitted ``DEFAULT`` for ``default=``. (#2104)
22+
23+
1124
1.1.0
1225
-----
1326

docs/fields.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ Relational Fields
4949
:members: ForeignKeyField, OneToOneField, ManyToManyField
5050
:exclude-members: to_db_value, to_python_value
5151

52+
DB Default Expressions
53+
----------------------
54+
55+
.. automodule:: tortoise.fields.db_defaults
56+
:members:
57+
5258
DB Specific Fields
5359
------------------
5460

docs/models.rst

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,18 @@ Every model should be derived from ``Model`` or its subclasses. Custom ``Model``
7171
This model will not affect the schema, but it will be available for inheritance.
7272

7373

74-
Further we have field ``fields.DatetimeField(auto_now=True)``. Options ``auto_now`` and ``auto_now_add`` work like Django's options.
74+
Further we have field ``fields.DatetimeField(auto_now=True)``. Options ``auto_now`` and ``auto_now_add`` work like Django's options — they are handled purely in Python and do **not** add a ``DEFAULT`` clause to the database schema. If you need a database-level default timestamp, use ``db_default``:
75+
76+
.. code-block:: python3
77+
78+
from tortoise.fields import DatetimeField, Now
79+
80+
class MyModel(Model):
81+
# Python-only: value set by ORM on save, no DB DEFAULT
82+
modified = DatetimeField(auto_now=True)
83+
84+
# DB-level: emits DEFAULT CURRENT_TIMESTAMP in the schema
85+
created_at = DatetimeField(db_default=Now())
7586
7687
Use of ``__models__``
7788
---------------------
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from tortoise import fields, migrations
2+
from tortoise.fields.db_defaults import Now, SqlDefault
3+
from tortoise.migrations import operations as ops
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [("erp", "0020_drop_db_default")]
8+
9+
initial = False
10+
11+
operations = [
12+
ops.AlterField(
13+
model_name="Product",
14+
name="created_at",
15+
field=fields.DatetimeField(db_default=Now(), auto_now=False, auto_now_add=False),
16+
),
17+
ops.AddField(
18+
model_name="Product",
19+
name="tracking_id",
20+
field=fields.CharField(
21+
null=True, db_default=SqlDefault("(lower(hex(randomblob(16))))"), max_length=36
22+
),
23+
),
24+
]

examples/comprehensive_migrations_project/models.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from enum import Enum, IntEnum
1212

1313
from tortoise import fields, models
14+
from tortoise.fields import Now, SqlDefault
1415

1516

1617
class OrderStatus(IntEnum):
@@ -117,7 +118,12 @@ class Product(models.Model):
117118
processing_time = fields.TimeDeltaField(null=True, description="Average time to fulfill")
118119
is_active = fields.BooleanField(default=True)
119120
stock_quantity = fields.IntField(db_default=10)
120-
created_at = fields.DatetimeField(auto_now_add=True)
121+
tracking_id = fields.CharField(
122+
max_length=36,
123+
null=True,
124+
db_default=SqlDefault("(lower(hex(randomblob(16))))"),
125+
)
126+
created_at = fields.DatetimeField(db_default=Now())
121127

122128
def __str__(self) -> str:
123129
return f"{self.product_code}: {self.name}"

tests/fields/test_db_default.py

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,3 +534,266 @@ async def test_add_field_without_db_default_no_default_clause():
534534
sql = client.executed[0]
535535
assert "ADD COLUMN" in sql
536536
assert "DEFAULT" not in sql
537+
538+
539+
# ============================================================================
540+
# SqlDefault and Now expression tests
541+
# ============================================================================
542+
543+
544+
def test_sql_default_construction_and_get_sql():
545+
"""SqlDefault construction and get_sql."""
546+
from tortoise.fields.db_defaults import SqlDefault
547+
548+
sd = SqlDefault("CURRENT_TIMESTAMP")
549+
assert sd.get_sql() == "CURRENT_TIMESTAMP"
550+
assert sd.get_sql("some_context") == "CURRENT_TIMESTAMP"
551+
552+
553+
def test_now_construction():
554+
"""Now construction."""
555+
from tortoise.fields.db_defaults import Now
556+
557+
n = Now()
558+
assert n.get_sql() == "CURRENT_TIMESTAMP"
559+
560+
561+
def test_now_dialect_mysql():
562+
"""Now emits CURRENT_TIMESTAMP(6) for MySQL."""
563+
from tortoise.fields.db_defaults import Now
564+
565+
n = Now()
566+
assert n.get_sql(dialect="mysql") == "CURRENT_TIMESTAMP(6)"
567+
568+
569+
def test_now_dialect_other():
570+
"""Now emits plain CURRENT_TIMESTAMP for non-MySQL dialects."""
571+
from tortoise.fields.db_defaults import Now
572+
573+
n = Now()
574+
for dialect in ("sqlite", "postgres", "mssql", "oracle", "sql"):
575+
assert n.get_sql(dialect=dialect) == "CURRENT_TIMESTAMP"
576+
577+
578+
def test_sql_default_equality_and_hashing():
579+
"""SqlDefault equality and hashing."""
580+
from tortoise.fields.db_defaults import SqlDefault
581+
582+
a = SqlDefault("X")
583+
b = SqlDefault("X")
584+
c = SqlDefault("Y")
585+
assert a == b
586+
assert a != c
587+
assert hash(a) == hash(b)
588+
# Can be used in sets/dicts
589+
s = {a, b, c}
590+
assert len(s) == 2
591+
592+
593+
def test_sql_default_repr():
594+
"""SqlDefault repr."""
595+
from tortoise.fields.db_defaults import SqlDefault
596+
597+
assert repr(SqlDefault("CURRENT_TIMESTAMP")) == "SqlDefault('CURRENT_TIMESTAMP')"
598+
599+
600+
def test_now_repr():
601+
"""Now repr."""
602+
from tortoise.fields.db_defaults import Now
603+
604+
assert repr(Now()) == "Now()"
605+
606+
607+
def test_sql_default_passes_field_validation():
608+
"""SqlDefault is not callable -- passes field validation."""
609+
from tortoise.fields.db_defaults import SqlDefault
610+
611+
# Should not raise
612+
f = fields.DatetimeField(db_default=SqlDefault("CURRENT_TIMESTAMP"))
613+
assert f.has_db_default() is True
614+
615+
616+
def test_callable_still_raises_with_updated_message():
617+
"""Callable still raises with SqlDefault in message."""
618+
with pytest.raises(ConfigurationError, match="SqlDefault"):
619+
fields.IntField(db_default=lambda: 1)
620+
621+
622+
# ============================================================================
623+
# Schema generation with SqlDefault / Now
624+
# ============================================================================
625+
626+
627+
def test_schema_generation_with_sql_default():
628+
"""Schema generation with SqlDefault."""
629+
from tortoise.fields.db_defaults import SqlDefault
630+
631+
f = fields.DatetimeField(db_default=SqlDefault("CURRENT_TIMESTAMP"))
632+
sql = _get_sqlite_default_sql(f)
633+
assert sql == " DEFAULT CURRENT_TIMESTAMP"
634+
635+
636+
def test_schema_generation_with_now():
637+
"""Schema generation with Now()."""
638+
from tortoise.fields.db_defaults import Now
639+
640+
f = fields.DatetimeField(db_default=Now())
641+
sql = _get_sqlite_default_sql(f)
642+
assert sql == " DEFAULT CURRENT_TIMESTAMP"
643+
644+
645+
def test_schema_generation_with_custom_sql_expression():
646+
"""Schema generation with custom SQL expression."""
647+
from tortoise.fields.db_defaults import SqlDefault
648+
649+
f = fields.CharField(max_length=100, db_default=SqlDefault("'unknown'"))
650+
sql = _get_sqlite_default_sql(f)
651+
assert sql == " DEFAULT 'unknown'"
652+
653+
654+
def test_schema_generation_literal_db_default_still_works():
655+
"""Literal db_default still works (no regression)."""
656+
f = fields.IntField(db_default=42)
657+
sql = _get_sqlite_default_sql(f)
658+
assert "DEFAULT" in sql
659+
assert "42" in sql
660+
661+
662+
# ============================================================================
663+
# Migration editor with SqlDefault / Now
664+
# ============================================================================
665+
666+
667+
@pytest.mark.asyncio
668+
async def test_add_field_with_sql_default():
669+
"""Migration add_field with SqlDefault."""
670+
from tortoise.fields.db_defaults import Now
671+
672+
WidgetModel = _make_model(
673+
"Widget",
674+
"widget",
675+
id=fields.IntField(pk=True),
676+
created=fields.DatetimeField(db_default=Now()),
677+
)
678+
679+
editor, client = _make_test_editor()
680+
await editor.add_field(WidgetModel, "created")
681+
682+
assert len(client.executed) == 1
683+
sql = client.executed[0]
684+
assert "DEFAULT CURRENT_TIMESTAMP" in sql
685+
686+
687+
@pytest.mark.asyncio
688+
async def test_alter_field_set_default_with_sql_default():
689+
"""Migration alter_field SET DEFAULT with SqlDefault."""
690+
from tortoise.fields.db_defaults import Now
691+
692+
OldModel = _make_model(
693+
"Widget",
694+
"widget",
695+
id=fields.IntField(pk=True),
696+
created=fields.DatetimeField(),
697+
)
698+
NewModel = _make_model(
699+
"Widget",
700+
"widget",
701+
id=fields.IntField(pk=True),
702+
created=fields.DatetimeField(db_default=Now()),
703+
)
704+
705+
editor, client = _make_test_editor()
706+
await editor.alter_field(OldModel, NewModel, "created")
707+
708+
assert len(client.executed) == 1
709+
sql = client.executed[0]
710+
assert "SET DEFAULT" in sql
711+
assert "CURRENT_TIMESTAMP" in sql
712+
713+
714+
@pytest.mark.asyncio
715+
async def test_alter_field_change_from_literal_to_sql_default():
716+
"""Migration alter_field change from literal to SqlDefault."""
717+
from tortoise.fields.db_defaults import SqlDefault
718+
719+
OldModel = _make_model(
720+
"Widget",
721+
"widget",
722+
id=fields.IntField(pk=True),
723+
score=fields.IntField(db_default=42),
724+
)
725+
NewModel = _make_model(
726+
"Widget",
727+
"widget",
728+
id=fields.IntField(pk=True),
729+
score=fields.IntField(db_default=SqlDefault("NOW()")),
730+
)
731+
732+
editor, client = _make_test_editor()
733+
await editor.alter_field(OldModel, NewModel, "score")
734+
735+
assert len(client.executed) == 1
736+
sql = client.executed[0]
737+
assert "SET DEFAULT" in sql
738+
assert "NOW()" in sql
739+
740+
741+
# ============================================================================
742+
# describe() and deconstruct() with SqlDefault / Now
743+
# ============================================================================
744+
745+
746+
def test_describe_serializable_with_now():
747+
"""describe(serializable=True) with Now."""
748+
from tortoise.fields.db_defaults import Now
749+
750+
f = fields.DatetimeField(db_default=Now())
751+
f.model_field_name = "created"
752+
desc = f.describe(serializable=True)
753+
assert desc["db_default"] == "Now()"
754+
755+
756+
def test_describe_non_serializable_with_now():
757+
"""describe(serializable=False) with Now."""
758+
from tortoise.fields.db_defaults import Now
759+
760+
n = Now()
761+
f = fields.DatetimeField(db_default=n)
762+
f.model_field_name = "created"
763+
desc = f.describe(serializable=False)
764+
assert desc["db_default"] is n
765+
766+
767+
def test_deconstruct_with_now():
768+
"""deconstruct() preserves Now instance."""
769+
from tortoise.fields.db_defaults import Now
770+
771+
n = Now()
772+
f = fields.DatetimeField(db_default=n)
773+
f.model_field_name = "created"
774+
path, args, kwargs = f.deconstruct()
775+
assert kwargs["db_default"] is n
776+
777+
778+
def test_render_value_with_now():
779+
"""render_value() preserves Now() in migration files."""
780+
from tortoise.fields.db_defaults import Now
781+
from tortoise.migrations.writer import ImportManager, render_value
782+
783+
imports = ImportManager()
784+
n = Now()
785+
result = render_value(n, imports)
786+
assert result == "Now()"
787+
assert "Now" in str(imports)
788+
789+
790+
def test_render_value_with_sql_default():
791+
"""render_value() preserves SqlDefault in migration files."""
792+
from tortoise.fields.db_defaults import SqlDefault
793+
from tortoise.migrations.writer import ImportManager, render_value
794+
795+
imports = ImportManager()
796+
sd = SqlDefault("gen_random_uuid()")
797+
result = render_value(sd, imports)
798+
assert result == "SqlDefault('gen_random_uuid()')"
799+
assert "SqlDefault" in str(imports)

0 commit comments

Comments
 (0)