Skip to content

fix(model): only carry scalar column defaults as server_default in migrations#6770

Open
anxkhn wants to merge 1 commit into
reflex-dev:mainfrom
anxkhn:patch-2
Open

fix(model): only carry scalar column defaults as server_default in migrations#6770
anxkhn wants to merge 1 commit into
reflex-dev:mainfrom
anxkhn:patch-2

Conversation

@anxkhn

@anxkhn anxkhn commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Type of change

  • Bug fix (non-breaking change which fixes an issue)

What / why

reflex db makemigrations and reflex db migrate --autogenerate crash with a
sqlalchemy.exc.CompileError when the autogenerated migration adds a column that
has a callable default to an existing table. This is the single most common
real-world schema change: adding a created timestamp or a UUID column, e.g.

created: datetime.datetime = rx.Field(default_factory=datetime.datetime.now)
# or
id: uuid.UUID = rx.Field(default=uuid.uuid4)

Root cause is in the AddColumnOp rewriter registered during autogeneration in
reflex/model.py. To carry a sqlmodel default onto existing rows, it copies the
column default into a server_default by wrapping op.column.default.arg in
sqlalchemy.literal(...):

if op.column.default is not None and op.column.server_default is None:
    op.column.server_default = sqlalchemy.DefaultClause(
        sqlalchemy.sql.expression.literal(op.column.default.arg),
    )

For a scalar default (Field(default=7)) op.column.default.arg is the value
(7), which renders fine. For a callable default, SQLAlchemy stores a
CallableColumnDefault whose .arg is the Python function object itself, not a
value. literal(<function>) becomes a bind parameter with NullType, and when
the migration script is rendered with literal_binds=True SQLAlchemy raises:

sqlalchemy.exc.CompileError: No literal value renderer is available for literal
value "<function datetime.now at 0x...>" with datatype NULL

so migration-script generation aborts.

Fix

Gate the server_default rewrite on op.column.default.is_scalar, so only scalar
defaults are carried as a SQL literal:

if (
    op.column.default is not None
    and op.column.default.is_scalar
    and op.column.server_default is None
):
    ...

Callable defaults then keep alembic's default behavior (no server_default), which
is correct: a per-row Python callable cannot be represented as a single SQL literal.
Scalar server_default behavior is unchanged. is_scalar is defined on the base
DefaultGenerator (class-level False), so this is safe for every default type
alembic can attach.

Tests

Added tests/units/test_model.py::test_automigration_add_column_with_callable_default,
which creates a table, migrates to head, then adds callable-default columns
(default_factory=datetime.now, a nullable default_factory) and runs the
autogenerate/migrate path. It fails with the CompileError above on main and
passes with the fix. It also asserts that a scalar default alongside the callable
one still works and that rows read back correctly.

Local checks (all green):

uv run pytest tests/units/test_model.py     # 6 passed
uv run ruff check reflex/model.py tests/units/test_model.py
uv run ruff format --check reflex/model.py tests/units/test_model.py
uv run pyright reflex/model.py tests/units/test_model.py    # 0 errors

…grations

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 <function ...>".

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>
@anxkhn anxkhn requested a review from a team as a code owner July 15, 2026 05:36
@greptile-apps

greptile-apps Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a CompileError crash in reflex db makemigrations / migrate when autogenerating migrations that add a column with a callable default (e.g. default_factory=datetime.now) to an existing table. The root cause was the AddColumnOp rewriter unconditionally passing op.column.default.arg — a Python function object for callable defaults — into sqlalchemy.literal(), which cannot render a non-scalar value as a SQL literal.

  • reflex/model.py: Gates the server_default rewrite on op.column.default.is_scalar, so only scalar defaults (e.g. default=5) are carried as a SQL literal; callable defaults are left without a server_default, which is correct since they cannot be represented as a single SQL expression.
  • tests/units/test_model.py: Adds an end-to-end test that exercises adding callable-default columns (default_factory=datetime.now, a nullable lambda default) alongside a scalar default, verifying the migration completes without error and that row data round-trips correctly.

Confidence Score: 5/5

Safe to merge — the change is a minimal, targeted guard on an existing code path with no behavioral change for scalar defaults and correct handling of callable defaults.

The one-line guard adds op.column.default.is_scalar which is a stable SQLAlchemy class-level attribute that cleanly distinguishes scalar from callable defaults. Scalar server_default behavior is entirely unchanged, and the new test exercises both the crash scenario and the scalar path end-to-end across multiple migration steps.

No files require special attention.

Important Files Changed

Filename Overview
reflex/model.py Adds op.column.default.is_scalar guard to prevent callable defaults from being passed to sqlalchemy.literal(), fixing the CompileError crash during migration generation.
tests/units/test_model.py Adds test_automigration_add_column_with_callable_default covering the crash scenario end-to-end; verifies callable defaults, scalar defaults, and nullable callable defaults across multiple migration steps.
news/6706.bugfix.md Changelog entry for the bug fix; accurately describes the problem and resolution.

Reviews (2): Last reviewed commit: "fix(model): only carry scalar column def..." | Re-trigger Greptile

Comment thread tests/units/test_model.py
@codspeed-hq

codspeed-hq Bot commented Jul 15, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing anxkhn:patch-2 (c68b001) with main (65a2889)2

Open in CodSpeed

Footnotes

  1. 8 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (127e4d8) during the generation of this report, so 65a2889 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c68b001a8c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread reflex/model.py
# Python function itself, so leave server_default unset.
if (
op.column.default is not None
and op.column.default.is_scalar

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle existing rows for non-null callable defaults

When a callable-default column is non-nullable and the table already contains rows, this branch now leaves server_default unset, so Alembic emits an add_column with nullable=False and no default; migrate(autogenerate=True) then applies that migration immediately and databases such as SQLite/Postgres reject it because existing rows would receive NULL. The new regression test avoids this by adding created before inserting any rows, so adding a typical created: datetime = Field(default_factory=datetime.now) column to a populated table remains broken.

Useful? React with 👍 / 👎.

@Alek99 Alek99 closed this Jul 15, 2026
@Alek99 Alek99 reopened this Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants