From ec769986652ec279af8a529ffd6ab75f5fa2e58b Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Mon, 6 Jul 2026 04:23:58 +0530 Subject: [PATCH 1/3] fix(model): only carry scalar column defaults as server_default in migrations The AddColumnOp rewriter used during autogeneration copied every column default into a server_default by wrapping op.column.default.arg in sqlalchemy.literal(). For a callable default (e.g. Field(default_factory=datetime.now) or Field(default=uuid.uuid4)), op.column.default.arg is the Python function object rather than a value, so rendering the migration script raised sqlalchemy.exc.CompileError: "No literal value renderer is available for literal value ". This broke autogenerated migrations for the common case of adding a timestamp or UUID column to an existing table. Gate the server_default rewrite on op.column.default.is_scalar so only scalar defaults are carried as a SQL literal; callable defaults keep alembic's default behavior, which is correct since a per-row Python callable cannot be a single SQL literal. Add a regression test that adds callable-default columns to an existing table and fails with CompileError before the fix. Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com> --- news/6706.bugfix.md | 1 + reflex/model.py | 11 ++++- tests/units/test_model.py | 95 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 news/6706.bugfix.md diff --git a/news/6706.bugfix.md b/news/6706.bugfix.md new file mode 100644 index 00000000000..48b5f59c368 --- /dev/null +++ b/news/6706.bugfix.md @@ -0,0 +1 @@ +Fixed `reflex db makemigrations`/`migrate` crashing with `CompileError` when autogenerating a migration that adds a column with a callable default (e.g. `default_factory=datetime.now` or `default=uuid.uuid4`) to an existing table; only scalar defaults are now carried as a SQL `server_default`. diff --git a/reflex/model.py b/reflex/model.py index a57078a2fdc..74be1701ba8 100644 --- a/reflex/model.py +++ b/reflex/model.py @@ -408,8 +408,15 @@ def render_add_column_with_server_default( op: Any, ): # Carry the sqlmodel default as server_default so that newly added - # columns get the desired default value in existing rows. - if op.column.default is not None and op.column.server_default is None: + # columns get the desired default value in existing rows. Only scalar + # defaults can be rendered as a SQL literal; for callable defaults + # (e.g. default_factory=datetime.now) op.column.default.arg is the + # Python function itself, so leave server_default unset. + if ( + op.column.default is not None + and op.column.default.is_scalar + and op.column.server_default is None + ): op.column.server_default = sqlalchemy.DefaultClause( sqlalchemy.sql.expression.literal(op.column.default.arg), ) diff --git a/tests/units/test_model.py b/tests/units/test_model.py index c3d62d94a17..19cc7e3369a 100644 --- a/tests/units/test_model.py +++ b/tests/units/test_model.py @@ -210,6 +210,101 @@ class AlembicThing(Model, table=True): assert len(list(versions.glob("*.py"))) == 6 +@pytest.mark.filterwarnings( + "ignore:This declarative base already contains a class with the same class name", +) +def test_automigration_add_column_with_callable_default( + tmp_working_dir: Path, + monkeypatch: pytest.MonkeyPatch, + model_registry: type[ModelRegistry], +): + """Test adding a column with a callable default to an existing table. + + A callable default (e.g. ``default_factory=datetime.now``) is a per-row + Python default and cannot be rendered as a SQL literal server_default, so + autogeneration must leave server_default unset instead of crashing while + rendering the migration script. Scalar defaults still get a server_default. + + Args: + tmp_working_dir: directory where database and migrations are stored + monkeypatch: pytest fixture to overwrite attributes + model_registry: clean reflex ModelRegistry + """ + import datetime + + import sqlmodel + + alembic_ini = tmp_working_dir / "alembic.ini" + versions = tmp_working_dir / "alembic" / "versions" + monkeypatch.setattr(reflex.constants, "ALEMBIC_CONFIG", str(alembic_ini)) + + config_mock = mock.Mock() + config_mock.db_url = f"sqlite:///{tmp_working_dir}/reflex.db" + monkeypatch.setattr(reflex.model, "get_config", mock.Mock(return_value=config_mock)) + + alembic_init() + + class AlembicCallable(Model, table=True): # pyright: ignore [reportRedeclaration] + t1: str + + with get_engine().connect() as connection: + assert alembic_autogenerate(connection=connection, message="Initial Revision") + assert migrate() + + model_registry.get_metadata().clear() + + # Add a non-nullable column with a callable default alongside a scalar + # default. Rendering the migration script previously raised CompileError + # because the callable was fed into sqlalchemy.literal(); the scalar default + # must still be carried as a server_default. + class AlembicCallable(Model, table=True): # noqa: F811 # pyright: ignore [reportRedeclaration] + t1: str + created: datetime.datetime = sqlmodel.Field( + default_factory=datetime.datetime.now + ) + count: int = 5 + + assert migrate(autogenerate=True) + assert len(list(versions.glob("*.py"))) == 2 + + now = datetime.datetime.now() + with reflex.model.session() as session: + session.add(AlembicCallable(t1="foo")) + session.commit() + result = session.exec(sqlmodel.select(AlembicCallable)).all() + assert len(result) == 1 + assert result[0].t1 == "foo" + assert result[0].count == 5 + assert result[0].created >= now + + model_registry.get_metadata().clear() + + # A nullable callable default can be added to a table that already has rows: + # existing rows keep NULL while new rows get the callable default. + class AlembicCallable(Model, table=True): + t1: str + created: datetime.datetime = sqlmodel.Field( + default_factory=datetime.datetime.now + ) + count: int = 5 + note: str | None = sqlmodel.Field(default_factory=lambda: "generated") + + assert migrate(autogenerate=True) + assert len(list(versions.glob("*.py"))) == 3 + + with reflex.model.session() as session: + session.add(AlembicCallable(t1="bar")) + session.commit() + result = session.exec(sqlmodel.select(AlembicCallable)).all() + assert len(result) == 2 + # Pre-existing row keeps NULL for the newly added nullable column. + assert result[0].t1 == "foo" + assert result[0].note is None + # Newly inserted row gets the callable default from sqlmodel. + assert result[1].t1 == "bar" + assert result[1].note == "generated" + + class ReflexModel(Model): """A model for testing.""" From 97e48b5edca30926d3704242ec6132a0728faae4 Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:24:19 +0530 Subject: [PATCH 2/3] fix(model): populate callable defaults for existing rows Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com> --- news/6706.bugfix.md | 2 +- reflex/model.py | 15 ++++++------- tests/units/test_model.py | 45 ++++++++++++++++++++++----------------- 3 files changed, 33 insertions(+), 29 deletions(-) diff --git a/news/6706.bugfix.md b/news/6706.bugfix.md index 48b5f59c368..415e5bd6509 100644 --- a/news/6706.bugfix.md +++ b/news/6706.bugfix.md @@ -1 +1 @@ -Fixed `reflex db makemigrations`/`migrate` crashing with `CompileError` when autogenerating a migration that adds a column with a callable default (e.g. `default_factory=datetime.now` or `default=uuid.uuid4`) to an existing table; only scalar defaults are now carried as a SQL `server_default`. +Fixed `reflex db makemigrations`/`migrate` crashing with `CompileError` when autogenerating a migration that adds a column with a callable default (e.g. `default_factory=datetime.now` or `default=uuid.uuid4`) to an existing table; callable defaults are now evaluated before being carried as a SQL `server_default`. diff --git a/reflex/model.py b/reflex/model.py index 74be1701ba8..0007cc3cd8f 100644 --- a/reflex/model.py +++ b/reflex/model.py @@ -409,16 +409,13 @@ def render_add_column_with_server_default( ): # Carry the sqlmodel default as server_default so that newly added # columns get the desired default value in existing rows. Only scalar - # defaults can be rendered as a SQL literal; for callable defaults - # (e.g. default_factory=datetime.now) op.column.default.arg is the - # Python function itself, so leave server_default unset. - if ( - op.column.default is not None - and op.column.default.is_scalar - and op.column.server_default is None - ): + # defaults can be rendered as a SQL literal. Evaluate callable defaults + # once so non-nullable columns can be added to tables with existing rows. + if op.column.default is not None and op.column.server_default is None: + default = op.column.default + value = default.arg(None) if default.is_callable else default.arg op.column.server_default = sqlalchemy.DefaultClause( - sqlalchemy.sql.expression.literal(op.column.default.arg), + sqlalchemy.sql.expression.literal(value), ) return op diff --git a/tests/units/test_model.py b/tests/units/test_model.py index 19cc7e3369a..f197e4a46c4 100644 --- a/tests/units/test_model.py +++ b/tests/units/test_model.py @@ -220,10 +220,8 @@ def test_automigration_add_column_with_callable_default( ): """Test adding a column with a callable default to an existing table. - A callable default (e.g. ``default_factory=datetime.now``) is a per-row - Python default and cannot be rendered as a SQL literal server_default, so - autogeneration must leave server_default unset instead of crashing while - rendering the migration script. Scalar defaults still get a server_default. + A callable default (e.g. ``default_factory=datetime.now``) must be evaluated + before it can be rendered as a SQL literal server_default for existing rows. Args: tmp_working_dir: directory where database and migrations are stored @@ -251,13 +249,17 @@ class AlembicCallable(Model, table=True): # pyright: ignore [reportRedeclaratio assert alembic_autogenerate(connection=connection, message="Initial Revision") assert migrate() + with reflex.model.session() as session: + session.add(AlembicCallable(t1="existing")) + session.commit() + model_registry.get_metadata().clear() # Add a non-nullable column with a callable default alongside a scalar # default. Rendering the migration script previously raised CompileError - # because the callable was fed into sqlalchemy.literal(); the scalar default - # must still be carried as a server_default. - class AlembicCallable(Model, table=True): # noqa: F811 # pyright: ignore [reportRedeclaration] + # because the callable was fed into sqlalchemy.literal(); both defaults must + # be carried as server defaults so the existing row can be migrated. + class AlembicCallable(Model, table=True): # pyright: ignore [reportRedeclaration] t1: str created: datetime.datetime = sqlmodel.Field( default_factory=datetime.datetime.now @@ -272,16 +274,19 @@ class AlembicCallable(Model, table=True): # noqa: F811 # pyright: ignore [repor session.add(AlembicCallable(t1="foo")) session.commit() result = session.exec(sqlmodel.select(AlembicCallable)).all() - assert len(result) == 1 - assert result[0].t1 == "foo" + assert len(result) == 2 + assert result[0].t1 == "existing" assert result[0].count == 5 - assert result[0].created >= now + assert result[0].created < now + assert result[1].t1 == "foo" + assert result[1].count == 5 + assert result[1].created >= now model_registry.get_metadata().clear() - # A nullable callable default can be added to a table that already has rows: - # existing rows keep NULL while new rows get the callable default. - class AlembicCallable(Model, table=True): + # A nullable callable default is evaluated for existing rows and remains a + # Python-side default for new rows. + class AlembicCallable(Model, table=True): # pyright: ignore [reportRedeclaration] t1: str created: datetime.datetime = sqlmodel.Field( default_factory=datetime.datetime.now @@ -296,13 +301,15 @@ class AlembicCallable(Model, table=True): session.add(AlembicCallable(t1="bar")) session.commit() result = session.exec(sqlmodel.select(AlembicCallable)).all() - assert len(result) == 2 - # Pre-existing row keeps NULL for the newly added nullable column. - assert result[0].t1 == "foo" - assert result[0].note is None - # Newly inserted row gets the callable default from sqlmodel. - assert result[1].t1 == "bar" + assert len(result) == 3 + # Pre-existing rows receive the evaluated server default. + assert result[0].t1 == "existing" + assert result[0].note == "generated" + assert result[1].t1 == "foo" assert result[1].note == "generated" + # Newly inserted row gets the callable default from sqlmodel. + assert result[2].t1 == "bar" + assert result[2].note == "generated" class ReflexModel(Model): From 84ceadc222e3300b5aac5e89cff80dbb329e2f3d Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:11:00 +0530 Subject: [PATCH 3/3] test(model): isolate callable default migration Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com> --- tests/units/test_model.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/units/test_model.py b/tests/units/test_model.py index f197e4a46c4..ea71e3cbc7b 100644 --- a/tests/units/test_model.py +++ b/tests/units/test_model.py @@ -311,6 +311,8 @@ class AlembicCallable(Model, table=True): # pyright: ignore [reportRedeclaratio assert result[2].t1 == "bar" assert result[2].note == "generated" + model_registry.get_metadata().clear() + class ReflexModel(Model): """A model for testing."""