fix(persistence): compare safe_add_column drift as the database spells it (#6865)

* fix(persistence): compare safe_add_column drift as the database spells it

safe_add_column's drift check compared the model's generic type and
default spellings (String -> STRING, BigInteger -> BIGINTEGER,
server_default="0" -> "DefaultClause('0', ...)", sa.false()) with the
reflected dialect spellings (VARCHAR, BIGINT, Postgres TIMESTAMP, '0').
Only JSON/JSONB ever matched, so every column a revision found already
present was reported as drift:

- any database created before 0023 logged a runs.change_seq warning on
  upgrade, because 0025 re-runs 0023's safe_add_column;
- a schema whose head stamp was lost (the empty branch commits create_all
  before stamping) logged 29 warnings on SQLite and 36 on Postgres, burying
  the real TEXT-vs-JSON drift the check exists to surface.

Render both sides through the migration connection's dialect before
comparing: types via TypeEngine.compile (parameters stripped, the
Postgres time-zone suffix kept), defaults by unwrapping DefaultClause,
compiling SQL expressions and stripping one layer of string quoting.
Types the dialect cannot render (SQLite NullType) stay "missing info".

* docs(changelog): reference #6865 for the drift comparator fix

* fix(persistence): keep an empty-string default distinct from no default

Stripping one layer of quotes turned a reflected DEFAULT '' into "", and
`return text or None` then collapsed it to None. A desired
server_default="" (passed as a bare "") also normalized to None, so
_check_column_drift stopped warning when a column had no default but the
model wanted '' (or the reverse).

Render a plain-string DefaultClause argument as the quoted literal the DDL
emits, and return the unquoted value even when it is empty. None now means
only "no default".
This commit is contained in:
Hyeonsang Cho 2026-10-14 18:56:25 +09:00 committed by GitHub
parent 517a8309a2
commit 268eb55f6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 298 additions and 33 deletions

View File

@ -5917,6 +5917,15 @@ This release closes that milestone with **621 merged pull requests**.
80 such requests failed. The same race in IM conversation binding, such as
a first message arriving together with `/new`, no longer fails the message.
The losing write now updates the row the other one created. ([#6852])
- **persistence:** Startup schema upgrades no longer log a false
`safe_add_column ... drifts from the model definition` warning for columns
that already match the model. The check compared the model's generic type
and default names (`STRING`, `BIGINTEGER`, `DefaultClause('0')`) with the
database's own (`VARCHAR`, `BIGINT`, `'0'`), so every Postgres or SQLite
database created before revision 0023 logged one on upgrade, and a schema
whose head stamp was lost logged 29 (SQLite) or 36 (Postgres), burying the
real type mismatch the warning exists to catch. Both sides are now compared
as the connected database spells them. ([#6865])
### Security
@ -11701,3 +11710,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6843]: https://github.com/bytedance/deer-flow/pull/6843
[#6844]: https://github.com/bytedance/deer-flow/pull/6844
[#6852]: https://github.com/bytedance/deer-flow/pull/6852
[#6865]: https://github.com/bytedance/deer-flow/pull/6865

View File

@ -4494,6 +4494,13 @@
`(thread, run, user)` 唯一约束;在 Postgres 上,80 个这样的请求中有 26 个失败。
IM 会话绑定中的同类竞争(例如首条消息与 `/new` 同时到达)也不再导致消息处理失败。
落后的写入现在会更新另一方创建的记录。([#6852])
- **持久化:** 启动时的 schema 升级不再为已与模型一致的列记录虚假的
`safe_add_column ... drifts from the model definition` 警告。该检查拿模型的通用类型名
和默认值写法(`STRING`、`BIGINTEGER`、`DefaultClause('0')`)去比较数据库自己的写法
(`VARCHAR`、`BIGINT`、`'0'`),因此 revision 0023 之前创建的 Postgres 或 SQLite
数据库升级时都会记录一条,丢失 head stamp 的 schema 会记录 29 条(SQLite)或
36 条(Postgres),淹没了这个警告本应发现的真实类型不一致。现在两侧都按所连接
数据库的写法进行比较。([#6865])
### 安全
@ -9337,3 +9344,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#6843]: https://github.com/bytedance/deer-flow/pull/6843
[#6844]: https://github.com/bytedance/deer-flow/pull/6844
[#6852]: https://github.com/bytedance/deer-flow/pull/6852
[#6865]: https://github.com/bytedance/deer-flow/pull/6865

View File

@ -15,7 +15,7 @@ DeerFlow's application tables (`runs`, `threads_meta`, `feedback`, `users`, `run
| `0019_thread_incarnations` missing local tables/columns | refuse startup; offline recovery required |
| unknown revision, empty version table, or multiple version rows | fail closed and refuse to start |
The legacy branch handles pre-alembic databases that already have at least one DeerFlow-owned table. `create_all` runs first because stamping at `0001_baseline` makes alembic skip the baseline's own `create_table` DDL on the subsequent upgrade — so any baseline table introduced into `Base.metadata` after the user's DB was first provisioned (e.g. the `channel_*` tables from PR #1930 for users upgrading across multiple releases) would otherwise never be created, and the first request hitting that table would 500 with `no such table`. The backfill is **restricted to `_BASELINE_TABLE_NAMES`** so it does not also create tables that future revisions introduce — those revisions' own `op.create_table` would otherwise fail with `relation already exists`. A guard test pins `_BASELINE_TABLE_NAMES` against `0001_baseline.upgrade()`'s actual output, so editing 0001 to add or remove a table forces a matching update to the constant. Column-level shape (pre-#3658 vs post-#3658 vs manual-ALTER for `token_usage_by_model`) is answered by each `versions/*.py` revision via the idempotent helpers in `migrations/_helpers.py` (`safe_add_column` / `safe_drop_column`) which no-op when the change is already present and `logger.warning` on shape drift. **Adding a new ORM column / table only requires a new revision file — no edit to `bootstrap.py` is needed** *unless* the new revision adds a new baseline table (rare; only happens when a new model is part of the baseline rather than introduced by its own revision).
The legacy branch handles pre-alembic databases that already have at least one DeerFlow-owned table. `create_all` runs first because stamping at `0001_baseline` makes alembic skip the baseline's own `create_table` DDL on the subsequent upgrade — so any baseline table introduced into `Base.metadata` after the user's DB was first provisioned (e.g. the `channel_*` tables from PR #1930 for users upgrading across multiple releases) would otherwise never be created, and the first request hitting that table would 500 with `no such table`. The backfill is **restricted to `_BASELINE_TABLE_NAMES`** so it does not also create tables that future revisions introduce — those revisions' own `op.create_table` would otherwise fail with `relation already exists`. A guard test pins `_BASELINE_TABLE_NAMES` against `0001_baseline.upgrade()`'s actual output, so editing 0001 to add or remove a table forces a matching update to the constant. Column-level shape (pre-#3658 vs post-#3658 vs manual-ALTER for `token_usage_by_model`) is answered by each `versions/*.py` revision via the idempotent helpers in `migrations/_helpers.py` (`safe_add_column` / `safe_drop_column`) which no-op when the change is already present and `logger.warning` on shape drift (both sides rendered through the connection's dialect, so model-shaped columns stay quiet). **Adding a new ORM column / table only requires a new revision file — no edit to `bootstrap.py` is needed** *unless* the new revision adds a new baseline table (rare; only happens when a new model is part of the baseline rather than introduced by its own revision).
The empty-DB path keeps using `create_all` because `Base.metadata` is the only authoritative schema source — `create_all` renders both SQLite (JSON, type affinity) and Postgres (JSONB, partial indexes) correctly without anyone having to keep a hand-written baseline in lockstep. `0001_baseline.upgrade()` is therefore almost never executed in practice; it exists as a stamp target + chain root.

View File

@ -27,11 +27,15 @@ JSON`` without ``NOT NULL DEFAULT '{}'``, or the wrong-type variant
would produce on a fresh DB. To surface that silent drift, ``safe_add_column``
compares the existing column's ``nullable`` / ``server_default`` / ``type``
against the desired ``sa.Column`` and emits ``logger.warning`` on mismatch.
Type comparison goes through ``_type_equivalent``, which treats known
dialect-synonym pairs (e.g. ``JSON`` vs ``JSONB``) as equivalent to avoid
false positives while still catching wholesale type mismatches like
``TEXT`` vs ``JSON``. We do not auto-repair -- a warning is enough for
operators to notice and decide.
Both sides are rendered through the migration connection's dialect before
they are compared: the model says ``sa.String`` / ``sa.BigInteger`` /
``server_default="0"`` while reflection reports ``VARCHAR`` / ``BIGINT`` /
``'0'``, so comparing the raw Python objects would warn on every column a
revision finds already present. Type comparison goes through
``_type_equivalent``, which treats known dialect-synonym pairs (e.g. ``JSON``
vs ``JSONB``) as equivalent to avoid false positives while still catching
wholesale type mismatches like ``TEXT`` vs ``JSON``. We do not auto-repair --
a warning is enough for operators to notice and decide.
"""
from __future__ import annotations
@ -48,21 +52,32 @@ def _inspector() -> sa.Inspector:
return sa.inspect(op.get_bind())
def _normalize_default(value: object) -> str | None:
def _normalize_default(value: object, dialect: sa.engine.Dialect | None = None) -> str | None:
"""Normalize a server-default value for cross-source comparison.
The desired value comes from ``sa.Column.server_default`` (a
``DefaultClause`` / ``TextClause`` literal, ``None``, or a Python literal);
the reflected value comes from ``Inspector.get_columns()['default']`` as a
dialect-rendered string. Strip outer parens / whitespace / Postgres-style
type casts so textually-equivalent forms compare equal across dialects.
``DefaultClause`` wrapping a string, ``TextClause`` or SQL expression such
as ``sa.false()``; ``None``; or a Python literal); the reflected value
comes from ``Inspector.get_columns()['default']`` as a dialect-rendered
string. Expressions are compiled with *dialect* (``sa.false()`` is ``0``
on SQLite and ``false`` on Postgres), then outer parens / whitespace /
Postgres-style type casts / one layer of single quotes are stripped so
``server_default="0"`` (DDL ``DEFAULT '0'``), SQLite's reflected ``'0'``
and Postgres's reflected ``0`` or ``'0'::bigint`` all compare equal.
Returns ``None`` only when there is no default: a quoted empty literal
(``server_default=""``, reflected ``''``) stays ``""``.
"""
if value is None:
return None
if isinstance(value, sa.schema.DefaultClause):
value = value.arg
if isinstance(value, str):
# DDL renders a plain-string default as a quoted literal.
value = "'" + value.replace("'", "''") + "'"
if isinstance(value, sa.sql.elements.TextClause):
text = value.text
elif isinstance(value, sa.schema.DefaultClause) and isinstance(value.arg, sa.sql.elements.TextClause):
text = value.arg.text
elif isinstance(value, sa.sql.elements.ClauseElement):
text = str(value.compile(dialect=dialect, compile_kwargs={"literal_binds": True}))
else:
text = str(value)
text = text.strip()
@ -72,23 +87,62 @@ def _normalize_default(value: object) -> str | None:
# Strip Postgres-style type casts like ``'{}'::jsonb``.
if "::" in text:
text = text.split("::", 1)[0].strip()
# Strip one layer of SQL string quoting: a plain-string ``server_default``
# renders as a quoted literal, and dialects disagree on whether a numeric
# column reflects it back quoted. The unquoted value is returned even
# when empty: ``DEFAULT ''`` is a default, unlike no default at all.
if len(text) >= 2 and text.startswith("'") and text.endswith("'"):
return text[1:-1].replace("''", "'")
return text or None
def _normalize_type(value: object) -> str:
def _strip_type_parameters(rendered: str) -> str:
"""Drop every parenthesized group, keeping words around it.
``TIMESTAMP(3) WITH TIME ZONE`` keeps its ``WITH TIME ZONE`` suffix, and
nested reprs such as ``JSONB(astext_type=Text())`` lose the whole group.
"""
out: list[str] = []
depth = 0
for char in rendered:
if char == "(":
depth += 1
elif char == ")":
depth = max(depth - 1, 0)
elif depth == 0:
out.append(char)
return " ".join("".join(out).split())
def _normalize_type(value: object, dialect: sa.engine.Dialect | None = None) -> str:
"""Normalize a SQLAlchemy ``TypeEngine`` (or reflected type) for comparison.
Returns the upper-cased type-class name with any parameters stripped
(e.g. ``JSON()`` → ``"JSON"``, ``VARCHAR(255)`` → ``"VARCHAR"``). Length
parameters are dropped on purpose: drift warnings target wholesale type
A ``TypeEngine`` is rendered as the DDL *dialect* would emit for it, so
the model's generic ``String(128)`` / ``BigInteger()`` /
``DateTime(timezone=True)`` and the reflected ``VARCHAR(128)`` /
``BIGINT`` / Postgres ``TIMESTAMP(timezone=True)`` land on the same name.
Strings are taken as already-rendered type names. Returns the upper-cased
name with any parameters stripped (e.g. ``VARCHAR(255)`` → ``"VARCHAR"``,
Postgres ``TIMESTAMP WITH TIME ZONE`` stays whole). Length parameters are
dropped on purpose: drift warnings target wholesale type
misconfigurations (the JSON-vs-TEXT review case), not dialect-rendered
size defaults. An empty string signals "missing info" -- callers should
not equality-check empty strings.
size defaults. An empty string signals "missing info" (no type, or one
the dialect cannot render, such as SQLite's ``NullType`` for an
unrecognized declared type) -- callers should not equality-check empty
strings.
"""
if value is None:
return ""
s = value if isinstance(value, str) else repr(value)
return s.upper().split("(", 1)[0].strip()
if isinstance(value, str):
rendered = value
elif isinstance(value, sa.types.TypeEngine):
try:
rendered = str(value.compile(dialect=dialect))
except sa.exc.CompileError:
return ""
else:
rendered = repr(value)
return _strip_type_parameters(rendered.upper())
# Known dialect-synonym pairs that must NOT fire a type-drift warning.
@ -104,14 +158,15 @@ def _normalize_type(value: object) -> str:
_EQUIVALENT_TYPE_FAMILIES: tuple[frozenset[str], ...] = (frozenset({"JSON", "JSONB"}),)
def _type_equivalent(actual: object, desired: object) -> bool:
def _type_equivalent(actual: object, desired: object, dialect: sa.engine.Dialect | None = None) -> bool:
"""True if *actual* and *desired* are the same type or a known equivalent.
Returns True when either side is missing reflected info so missing-data
cases never false-positive into a noisy warning.
Both sides are rendered with *dialect* (see ``_normalize_type``). Returns
True when either side is missing reflected info so missing-data cases
never false-positive into a noisy warning.
"""
a = _normalize_type(actual)
d = _normalize_type(desired)
a = _normalize_type(actual, dialect)
d = _normalize_type(desired, dialect)
if not a or not d:
return True
if a == d:
@ -120,7 +175,7 @@ def _type_equivalent(actual: object, desired: object) -> bool:
return any(pair <= fam for fam in _EQUIVALENT_TYPE_FAMILIES)
def _check_column_drift(table: str, desired: sa.Column, actual: dict) -> None:
def _check_column_drift(table: str, desired: sa.Column, actual: dict, dialect: sa.engine.Dialect | None = None) -> None:
"""Warn if an existing column's attributes diverge from the desired model.
Equality is checked on ``nullable`` and ``server_default`` directly, and
@ -128,7 +183,9 @@ def _check_column_drift(table: str, desired: sa.Column, actual: dict) -> None:
pairs like ``JSON`` vs ``JSONB`` as equivalent). The reflected and
desired type reprs are also echoed in the warning payload regardless of
whether type was the failing dimension, so an operator triaging the log
line sees the type context at a glance.
line sees the type context at a glance. *dialect* is the migration
connection's, so both sides are rendered the way that database spells
them.
"""
diffs: list[str] = []
@ -137,13 +194,13 @@ def _check_column_drift(table: str, desired: sa.Column, actual: dict) -> None:
if desired_nullable != actual_nullable:
diffs.append(f"nullable actual={actual_nullable} desired={desired_nullable}")
desired_default = _normalize_default(desired.server_default)
actual_default = _normalize_default(actual.get("default"))
desired_default = _normalize_default(desired.server_default, dialect)
actual_default = _normalize_default(actual.get("default"), dialect)
if desired_default != actual_default:
diffs.append(f"server_default actual={actual_default!r} desired={desired_default!r}")
if not _type_equivalent(actual.get("type"), desired.type):
diffs.append(f"type actual={_normalize_type(actual.get('type'))!r} desired={_normalize_type(desired.type)!r}")
if not _type_equivalent(actual.get("type"), desired.type, dialect):
diffs.append(f"type actual={_normalize_type(actual.get('type'), dialect)!r} desired={_normalize_type(desired.type, dialect)!r}")
if diffs:
logger.warning(
@ -171,7 +228,7 @@ def safe_add_column(table: str, column: sa.Column) -> None:
return
existing = {c["name"]: c for c in insp.get_columns(table)}
if column.name in existing:
_check_column_drift(table, column, existing[column.name])
_check_column_drift(table, column, existing[column.name], insp.dialect)
return
with op.batch_alter_table(table) as batch:
batch.add_column(column)

View File

@ -22,11 +22,14 @@ table-level backfill works.
from __future__ import annotations
import asyncio
import os
import uuid
from pathlib import Path
import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from support.postgres import asyncpg_test_url
# Pre-import models so Base.metadata is populated before bootstrap reads it.
import deerflow.persistence.models # noqa: F401
@ -42,6 +45,7 @@ from deerflow.persistence.bootstrap import (
bootstrap_schema,
)
from deerflow.persistence.migrations._helpers import _normalize_default
from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
# Mark only async tests via the decorator below; module-level pytestmark would
# spuriously warn for the sync ``TestDecideState`` cases.
@ -308,6 +312,8 @@ async def test_legacy_with_manual_workaround_column_warns_on_drift(
# JSON ≈ JSON, so the equivalence check must NOT produce a "type" diff
# entry here -- that would be a false positive on the matching-type case.
assert "type actual=" not in msg, f"unexpected type drift on matching JSON column: {msg!r}"
helper_warnings = [r.getMessage() for r in caplog.records if r.levelname == "WARNING" and r.name == "deerflow.persistence.migrations._helpers"]
assert helper_warnings == [msg], f"expected only the token_usage_by_model drift, got: {helper_warnings!r}"
finally:
await engine.dispose()
@ -348,10 +354,99 @@ async def test_legacy_with_wrong_type_workaround_warns_on_type_drift(
# Nullability + server_default match the model -- no other diffs.
assert "nullable" not in msg, f"unexpected nullability drift on matching column: {msg!r}"
assert "server_default" not in msg, f"unexpected server_default drift on matching column: {msg!r}"
# The seed is the full model schema minus this one column, so every
# other column a revision finds already present matches the model and
# the real drift must not be buried among false positives.
helper_warnings = [r.getMessage() for r in caplog.records if r.levelname == "WARNING" and r.name == "deerflow.persistence.migrations._helpers"]
assert helper_warnings == [msg], f"expected only the token_usage_by_model drift, got: {helper_warnings!r}"
finally:
await engine.dispose()
# ---------------------------------------------------------------------------
# No false positives: a column a revision finds already present in the model's
# own shape must stay quiet on both dialects. The model spells types and
# defaults generically (``sa.String``, ``sa.BigInteger``, ``sa.DateTime``,
# ``server_default="0"``, ``sa.false()``) while reflection reports the
# dialect's spelling (``VARCHAR``, ``BIGINT``, Postgres ``TIMESTAMP``, ``'0'``),
# so the comparator must render the model side through the dialect first.
# ---------------------------------------------------------------------------
async def _drift_engine(tmp_path: Path, backend: str):
if backend == "sqlite":
return create_async_engine(_url(tmp_path)), ""
uri = os.environ.get("TEST_POSTGRES_URI")
if not uri:
pytest.skip("requires TEST_POSTGRES_URI (real Postgres drift reflection)")
schema = f"drift_{uuid.uuid4().hex}"
engine = create_async_engine(asyncpg_test_url(uri), connect_args=build_asyncpg_connect_args(schema))
async with engine.begin() as conn:
await conn.execute(sa.text(f'CREATE SCHEMA "{schema}"'))
return engine, schema
def _seed_at_revision(revision: str, *, drop_version_row: bool):
async def seed(engine, schema: str) -> None:
await asyncio.to_thread(_upgrade, _get_alembic_config(engine, postgres_schema=schema), revision)
if drop_version_row:
async with engine.begin() as conn:
await conn.execute(sa.text("DROP TABLE alembic_version"))
return seed
async def _seed_model_schema_without_version_row(engine, schema: str) -> None:
# The empty branch commits ``create_all`` before it stamps head; a process
# that dies in between leaves exactly this, and the next start takes the
# legacy branch over columns every revision finds already present.
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def _seed_model_schema_with_text_token_usage(engine, schema: str) -> None:
await _seed_model_schema_without_version_row(engine, schema)
async with engine.begin() as conn:
await conn.execute(sa.text("ALTER TABLE runs DROP COLUMN token_usage_by_model"))
await conn.execute(sa.text("ALTER TABLE runs ADD COLUMN token_usage_by_model TEXT NOT NULL DEFAULT '{}'"))
@asyncio_test
@pytest.mark.parametrize("backend", ["sqlite", "postgres"])
@pytest.mark.parametrize(
("seed", "expected_drift"),
[
pytest.param(_seed_at_revision(BASELINE, drop_version_row=True), [], id="pre_alembic_baseline"),
# 0025 re-runs 0023's ``safe_add_column`` for ``runs.change_seq``.
pytest.param(_seed_at_revision("0022_scheduled_occurrence_seq", drop_version_row=False), [], id="upgrade_across_change_seq_repair"),
pytest.param(_seed_model_schema_without_version_row, [], id="model_schema_without_version_row"),
pytest.param(_seed_model_schema_with_text_token_usage, ["runs.token_usage_by_model"], id="real_drift_only"),
],
)
async def test_bootstrap_warns_only_on_columns_that_really_drift(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
backend: str,
seed,
expected_drift: list[str],
) -> None:
engine, schema = await _drift_engine(tmp_path, backend)
try:
await seed(engine, schema)
with caplog.at_level("WARNING", logger="deerflow.persistence.migrations._helpers"):
await bootstrap_schema(engine, backend=backend, postgres_schema=schema)
assert await _alembic_version(engine) == HEAD
warned = [r.getMessage() for r in caplog.records if r.levelname == "WARNING" and r.name == "deerflow.persistence.migrations._helpers"]
assert [m.removeprefix("safe_add_column: ").split(" ", 1)[0] for m in warned] == expected_drift, f"unexpected drift warnings: {warned!r}"
finally:
if schema:
async with engine.begin() as conn:
await conn.execute(sa.text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
await engine.dispose()
# ---------------------------------------------------------------------------
# _type_equivalent unit tests: pin the JSON/JSONB equivalence so Postgres
# dialect synonyms stay quiet, and pin the TEXT/JSON divergence so the
@ -397,6 +492,101 @@ def test_type_equivalent_returns_true_on_missing_info() -> None:
assert _type_equivalent("", "JSON") is True
def test_type_equivalent_renders_model_types_through_the_dialect() -> None:
"""The model's generic types meet reflection's dialect types on equal terms."""
from sqlalchemy.dialects import postgresql, sqlite
from deerflow.persistence.migrations._helpers import _type_equivalent
sqlite_dialect = sqlite.dialect()
postgres_dialect = postgresql.dialect()
# Reflected (actual) on the left, model (desired) on the right.
assert _type_equivalent(sa.VARCHAR(length=128), sa.String(length=128), sqlite_dialect) is True
assert _type_equivalent(sa.BIGINT(), sa.BigInteger(), sqlite_dialect) is True
assert _type_equivalent(sqlite.DATETIME(), sa.DateTime(timezone=True), sqlite_dialect) is True
assert _type_equivalent(sa.VARCHAR(length=64), sa.String(length=64), postgres_dialect) is True
assert _type_equivalent(sa.BIGINT(), sa.BigInteger(), postgres_dialect) is True
assert _type_equivalent(postgresql.TIMESTAMP(timezone=True), sa.DateTime(timezone=True), postgres_dialect) is True
assert _type_equivalent(postgresql.JSONB(), sa.JSON(), postgres_dialect) is True
# Precision is a parameter like length; the time-zone suffix is not.
assert _type_equivalent(postgresql.TIMESTAMP(timezone=True, precision=3), sa.DateTime(timezone=True), postgres_dialect) is True
assert _type_equivalent(postgresql.TIMESTAMP(timezone=False, precision=3), sa.DateTime(timezone=True), postgres_dialect) is False
# Rendering both sides must not hide a real mismatch.
assert _type_equivalent(sa.TEXT(), sa.JSON(), sqlite_dialect) is False
assert _type_equivalent(sa.TEXT(), sa.JSON(), postgres_dialect) is False
assert _type_equivalent(postgresql.TIMESTAMP(timezone=False), sa.DateTime(timezone=True), postgres_dialect) is False
assert _type_equivalent(sa.INTEGER(), sa.BigInteger(), postgres_dialect) is False
# SQLite reflects an unrecognized declared type as NullType, which no
# dialect can render: missing info, not drift.
assert _type_equivalent(sa.types.NullType(), sa.String(length=64), sqlite_dialect) is True
def test_normalize_default_renders_model_defaults_like_reflection() -> None:
"""``server_default="0"`` / ``sa.false()`` compare equal to how each dialect reflects them."""
from sqlalchemy.dialects import postgresql, sqlite
sqlite_dialect = sqlite.dialect()
postgres_dialect = postgresql.dialect()
string_zero = sa.Column("c", sa.Integer(), server_default="0").server_default
false_default = sa.Column("c", sa.Boolean(), server_default=sa.false()).server_default
# SQLite reflects ``DEFAULT '0'`` quoted; Postgres reflects it bare on an
# integer column and as ``'0'::bigint`` on a bigint one.
assert _normalize_default(string_zero, sqlite_dialect) == _normalize_default("'0'", sqlite_dialect) == "0"
assert _normalize_default(string_zero, postgres_dialect) == _normalize_default("0", postgres_dialect) == "0"
assert _normalize_default("'0'::bigint", postgres_dialect) == "0"
assert _normalize_default(false_default, sqlite_dialect) == _normalize_default("0", sqlite_dialect)
assert _normalize_default(false_default, postgres_dialect) == _normalize_default("false", postgres_dialect)
assert _normalize_default(sa.text("'{}'")) == _normalize_default("'{}'::jsonb") == "{}"
# A different default still differs.
assert _normalize_default(string_zero, sqlite_dialect) != _normalize_default("'1'", sqlite_dialect)
assert _normalize_default(false_default, postgres_dialect) != _normalize_default("true", postgres_dialect)
# An explicit empty-string default is a default, not the absence of one.
empty_string = sa.Column("c", sa.Text(), server_default="").server_default
assert _normalize_default(empty_string, sqlite_dialect) == _normalize_default("''", sqlite_dialect) == ""
assert _normalize_default("''::text", postgres_dialect) == ""
assert _normalize_default(None) is None
@asyncio_test
@pytest.mark.parametrize("backend", ["sqlite", "postgres"])
async def test_empty_string_default_stays_distinct_from_no_default(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
backend: str,
) -> None:
"""``DEFAULT ''`` and no default drift from each other, through real reflection."""
from deerflow.persistence.migrations._helpers import _check_column_drift
engine, schema = await _drift_engine(tmp_path, backend)
try:
async with engine.begin() as conn:
await conn.execute(sa.text("CREATE TABLE drift_defaults (empty_default TEXT DEFAULT '', no_default TEXT)"))
def check(sync_conn) -> None:
inspector = sa.inspect(sync_conn)
columns = {column["name"]: column for column in inspector.get_columns("drift_defaults")}
_check_column_drift("drift_defaults", sa.Column("no_default", sa.Text(), server_default=""), columns["no_default"], inspector.dialect)
_check_column_drift("drift_defaults", sa.Column("empty_default", sa.Text()), columns["empty_default"], inspector.dialect)
# Matching empty-string defaults stay quiet.
_check_column_drift("drift_defaults", sa.Column("empty_default", sa.Text(), server_default=""), columns["empty_default"], inspector.dialect)
with caplog.at_level("WARNING", logger="deerflow.persistence.migrations._helpers"):
async with engine.connect() as conn:
await conn.run_sync(check)
warned = [r.getMessage() for r in caplog.records if r.levelname == "WARNING" and r.name == "deerflow.persistence.migrations._helpers"]
assert len(warned) == 2, f"expected exactly the two mismatched defaults, got: {warned!r}"
assert "drift_defaults.no_default" in warned[0] and "server_default actual=None desired=''" in warned[0], warned[0]
assert "drift_defaults.empty_default" in warned[1] and "server_default actual='' desired=None" in warned[1], warned[1]
finally:
if schema:
async with engine.begin() as conn:
await conn.execute(sa.text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
await engine.dispose()
# ---------------------------------------------------------------------------
# Branch 4: versioned DB
# ---------------------------------------------------------------------------