Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ Changelog
1.1
===

1.1.1
-----

Added
^^^^^
- **``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)


Changed
^^^^^^^
- ``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)


1.1.0
-----

Expand Down
6 changes: 6 additions & 0 deletions docs/fields.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ Relational Fields
:members: ForeignKeyField, OneToOneField, ManyToManyField
:exclude-members: to_db_value, to_python_value

DB Default Expressions
----------------------

.. automodule:: tortoise.fields.db_defaults
:members:

DB Specific Fields
------------------

Expand Down
13 changes: 12 additions & 1 deletion docs/models.rst
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,18 @@ Every model should be derived from ``Model`` or its subclasses. Custom ``Model``
This model will not affect the schema, but it will be available for inheritance.


Further we have field ``fields.DatetimeField(auto_now=True)``. Options ``auto_now`` and ``auto_now_add`` work like Django's options.
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``:

.. code-block:: python3

from tortoise.fields import DatetimeField, Now

class MyModel(Model):
# Python-only: value set by ORM on save, no DB DEFAULT
modified = DatetimeField(auto_now=True)

# DB-level: emits DEFAULT CURRENT_TIMESTAMP in the schema
created_at = DatetimeField(db_default=Now())

Use of ``__models__``
---------------------
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from tortoise import fields, migrations
from tortoise.fields.db_defaults import Now, SqlDefault
from tortoise.migrations import operations as ops


class Migration(migrations.Migration):
dependencies = [("erp", "0020_drop_db_default")]

initial = False

operations = [
ops.AlterField(
model_name="Product",
name="created_at",
field=fields.DatetimeField(db_default=Now(), auto_now=False, auto_now_add=False),
),
ops.AddField(
model_name="Product",
name="tracking_id",
field=fields.CharField(
null=True, db_default=SqlDefault("(lower(hex(randomblob(16))))"), max_length=36
),
),
]
8 changes: 7 additions & 1 deletion examples/comprehensive_migrations_project/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from enum import Enum, IntEnum

from tortoise import fields, models
from tortoise.fields import Now, SqlDefault


class OrderStatus(IntEnum):
Expand Down Expand Up @@ -117,7 +118,12 @@ class Product(models.Model):
processing_time = fields.TimeDeltaField(null=True, description="Average time to fulfill")
is_active = fields.BooleanField(default=True)
stock_quantity = fields.IntField(db_default=10)
created_at = fields.DatetimeField(auto_now_add=True)
tracking_id = fields.CharField(
max_length=36,
null=True,
db_default=SqlDefault("(lower(hex(randomblob(16))))"),
)
created_at = fields.DatetimeField(db_default=Now())

def __str__(self) -> str:
return f"{self.product_code}: {self.name}"
Expand Down
263 changes: 263 additions & 0 deletions tests/fields/test_db_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,3 +534,266 @@ async def test_add_field_without_db_default_no_default_clause():
sql = client.executed[0]
assert "ADD COLUMN" in sql
assert "DEFAULT" not in sql


# ============================================================================
# SqlDefault and Now expression tests
# ============================================================================


def test_sql_default_construction_and_get_sql():
"""SqlDefault construction and get_sql."""
from tortoise.fields.db_defaults import SqlDefault

sd = SqlDefault("CURRENT_TIMESTAMP")
assert sd.get_sql() == "CURRENT_TIMESTAMP"
assert sd.get_sql("some_context") == "CURRENT_TIMESTAMP"


def test_now_construction():
"""Now construction."""
from tortoise.fields.db_defaults import Now

n = Now()
assert n.get_sql() == "CURRENT_TIMESTAMP"


def test_now_dialect_mysql():
"""Now emits CURRENT_TIMESTAMP(6) for MySQL."""
from tortoise.fields.db_defaults import Now

n = Now()
assert n.get_sql(dialect="mysql") == "CURRENT_TIMESTAMP(6)"


def test_now_dialect_other():
"""Now emits plain CURRENT_TIMESTAMP for non-MySQL dialects."""
from tortoise.fields.db_defaults import Now

n = Now()
for dialect in ("sqlite", "postgres", "mssql", "oracle", "sql"):
assert n.get_sql(dialect=dialect) == "CURRENT_TIMESTAMP"


def test_sql_default_equality_and_hashing():
"""SqlDefault equality and hashing."""
from tortoise.fields.db_defaults import SqlDefault

a = SqlDefault("X")
b = SqlDefault("X")
c = SqlDefault("Y")
assert a == b
assert a != c
assert hash(a) == hash(b)
# Can be used in sets/dicts
s = {a, b, c}
assert len(s) == 2


def test_sql_default_repr():
"""SqlDefault repr."""
from tortoise.fields.db_defaults import SqlDefault

assert repr(SqlDefault("CURRENT_TIMESTAMP")) == "SqlDefault('CURRENT_TIMESTAMP')"


def test_now_repr():
"""Now repr."""
from tortoise.fields.db_defaults import Now

assert repr(Now()) == "Now()"


def test_sql_default_passes_field_validation():
"""SqlDefault is not callable -- passes field validation."""
from tortoise.fields.db_defaults import SqlDefault

# Should not raise
f = fields.DatetimeField(db_default=SqlDefault("CURRENT_TIMESTAMP"))
assert f.has_db_default() is True


def test_callable_still_raises_with_updated_message():
"""Callable still raises with SqlDefault in message."""
with pytest.raises(ConfigurationError, match="SqlDefault"):
fields.IntField(db_default=lambda: 1)


# ============================================================================
# Schema generation with SqlDefault / Now
# ============================================================================


def test_schema_generation_with_sql_default():
"""Schema generation with SqlDefault."""
from tortoise.fields.db_defaults import SqlDefault

f = fields.DatetimeField(db_default=SqlDefault("CURRENT_TIMESTAMP"))
sql = _get_sqlite_default_sql(f)
assert sql == " DEFAULT CURRENT_TIMESTAMP"


def test_schema_generation_with_now():
"""Schema generation with Now()."""
from tortoise.fields.db_defaults import Now

f = fields.DatetimeField(db_default=Now())
sql = _get_sqlite_default_sql(f)
assert sql == " DEFAULT CURRENT_TIMESTAMP"


def test_schema_generation_with_custom_sql_expression():
"""Schema generation with custom SQL expression."""
from tortoise.fields.db_defaults import SqlDefault

f = fields.CharField(max_length=100, db_default=SqlDefault("'unknown'"))
sql = _get_sqlite_default_sql(f)
assert sql == " DEFAULT 'unknown'"


def test_schema_generation_literal_db_default_still_works():
"""Literal db_default still works (no regression)."""
f = fields.IntField(db_default=42)
sql = _get_sqlite_default_sql(f)
assert "DEFAULT" in sql
assert "42" in sql


# ============================================================================
# Migration editor with SqlDefault / Now
# ============================================================================


@pytest.mark.asyncio
async def test_add_field_with_sql_default():
"""Migration add_field with SqlDefault."""
from tortoise.fields.db_defaults import Now

WidgetModel = _make_model(
"Widget",
"widget",
id=fields.IntField(pk=True),
created=fields.DatetimeField(db_default=Now()),
)

editor, client = _make_test_editor()
await editor.add_field(WidgetModel, "created")

assert len(client.executed) == 1
sql = client.executed[0]
assert "DEFAULT CURRENT_TIMESTAMP" in sql


@pytest.mark.asyncio
async def test_alter_field_set_default_with_sql_default():
"""Migration alter_field SET DEFAULT with SqlDefault."""
from tortoise.fields.db_defaults import Now

OldModel = _make_model(
"Widget",
"widget",
id=fields.IntField(pk=True),
created=fields.DatetimeField(),
)
NewModel = _make_model(
"Widget",
"widget",
id=fields.IntField(pk=True),
created=fields.DatetimeField(db_default=Now()),
)

editor, client = _make_test_editor()
await editor.alter_field(OldModel, NewModel, "created")

assert len(client.executed) == 1
sql = client.executed[0]
assert "SET DEFAULT" in sql
assert "CURRENT_TIMESTAMP" in sql


@pytest.mark.asyncio
async def test_alter_field_change_from_literal_to_sql_default():
"""Migration alter_field change from literal to SqlDefault."""
from tortoise.fields.db_defaults import SqlDefault

OldModel = _make_model(
"Widget",
"widget",
id=fields.IntField(pk=True),
score=fields.IntField(db_default=42),
)
NewModel = _make_model(
"Widget",
"widget",
id=fields.IntField(pk=True),
score=fields.IntField(db_default=SqlDefault("NOW()")),
)

editor, client = _make_test_editor()
await editor.alter_field(OldModel, NewModel, "score")

assert len(client.executed) == 1
sql = client.executed[0]
assert "SET DEFAULT" in sql
assert "NOW()" in sql


# ============================================================================
# describe() and deconstruct() with SqlDefault / Now
# ============================================================================


def test_describe_serializable_with_now():
"""describe(serializable=True) with Now."""
from tortoise.fields.db_defaults import Now

f = fields.DatetimeField(db_default=Now())
f.model_field_name = "created"
desc = f.describe(serializable=True)
assert desc["db_default"] == "Now()"


def test_describe_non_serializable_with_now():
"""describe(serializable=False) with Now."""
from tortoise.fields.db_defaults import Now

n = Now()
f = fields.DatetimeField(db_default=n)
f.model_field_name = "created"
desc = f.describe(serializable=False)
assert desc["db_default"] is n


def test_deconstruct_with_now():
"""deconstruct() preserves Now instance."""
from tortoise.fields.db_defaults import Now

n = Now()
f = fields.DatetimeField(db_default=n)
f.model_field_name = "created"
path, args, kwargs = f.deconstruct()
assert kwargs["db_default"] is n


def test_render_value_with_now():
"""render_value() preserves Now() in migration files."""
from tortoise.fields.db_defaults import Now
from tortoise.migrations.writer import ImportManager, render_value

imports = ImportManager()
n = Now()
result = render_value(n, imports)
assert result == "Now()"
assert "Now" in str(imports)


def test_render_value_with_sql_default():
"""render_value() preserves SqlDefault in migration files."""
from tortoise.fields.db_defaults import SqlDefault
from tortoise.migrations.writer import ImportManager, render_value

imports = ImportManager()
sd = SqlDefault("gen_random_uuid()")
result = render_value(sd, imports)
assert result == "SqlDefault('gen_random_uuid()')"
assert "SqlDefault" in str(imports)
Loading