Skip to content

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

Open
anxkhn wants to merge 3 commits 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 3 commits 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

@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 when autogenerating a migration that adds a column with a callable default (e.g. default_factory=datetime.datetime.now) to an existing table. The root cause was that the AddColumnOp rewriter was passing the raw callable object to sqlalchemy.literal(), which cannot render a function as a SQL literal with literal_binds=True.

  • reflex/model.py: The rewriter now evaluates callable defaults once via default.arg(None) before wrapping in DefaultClause, so the evaluated value (e.g. a datetime or string) is used as the SQL literal server_default instead of the function object itself.
  • tests/units/test_model.py: A new end-to-end test exercises the autogenerate/migrate path for datetime callable defaults, a scalar default, and a nullable lambda callable default, verifying that existing rows receive the evaluated server default and new rows get fresh Python-side defaults.

Confidence Score: 4/5

Safe to merge for the common datetime/string callable-default case, but the callable evaluation approach can silently assign the same UUID to all existing rows when a unique-constrained callable default is added to a populated table.

The rewriter evaluates callable defaults once at migration-script generation time. For timestamp and fixed-string callables this is correct. For callables designed to produce a unique value per row (uuid.uuid4), every pre-existing row receives the same evaluated value, failing visibly with a unique constraint or silently producing duplicate identifiers without one.

Files Needing Attention: reflex/model.py — the render_add_column_with_server_default rewriter, specifically the one-shot callable evaluation on line 416 and the lack of a uniqueness check before setting server_default.

Important Files Changed

Filename Overview
reflex/model.py Callable defaults are now evaluated at migration generation time instead of being skipped; works correctly for datetime but produces duplicate values for unique-constrained callables like uuid.uuid4.
tests/units/test_model.py New test covers datetime and string callable defaults with non-unique values; no coverage for unique-constrained callable defaults (e.g. uuid.uuid4 with unique=True) that could surface the duplication bug.
news/6706.bugfix.md Changelog entry correctly describes that callable defaults are evaluated before being carried as server_default, matching the actual implementation.

Reviews (4): Last reviewed commit: "test(model): isolate callable default mi..." | Re-trigger Greptile

Comment thread tests/units/test_model.py Outdated
@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 (84ceadc) with main (1358e00)

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.

@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 Outdated
anxkhn added 3 commits July 28, 2026 00:09
…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>
Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
@anxkhn

anxkhn commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

fixed the full-matrix regression in 84ceadc. the new model migration test now clears its final metadata, so it no longer pollutes the following sqlalchemy automigration test. both automigration suites pass together (9 tests), and ruff plus pyright pass. @reflex-dev please take another look when ci completes.

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