feat(persistence): PR6 T5.11 — ORM workspace_id nullable=False

Flip the 4 business ORM models (ThreadMetaRow, RunRow, FeedbackRow,
RunEventRow) to ``workspace_id: Mapped[str]`` with ``nullable=False``.
PR5's alembic 0003 already enforces NOT NULL at the DB layer; this
aligns the ORM-driven ``create_all()`` path (dev / tests) with the same
invariant so a new install ends up at the post-0003 schema without
running alembic.

Test fallout absorbed:

- `tests/conftest.py` autouse seed now produces a fully consistent
  pair: user row (default_workspace_id = test-workspace-autouse) plus
  the workspace itself. The PR5 backfill script's "users without
  default_workspace_id" query no longer picks the fixture up. Insert
  order is user → workspace → UPDATE user, walking around the chicken-
  and-egg FK between `workspaces.owner_id` and `users.default_workspace_id`.
- `tests/test_backfill_workspace_id.py` adds a file-scoped autouse
  fixture that temporarily flips `column.nullable = True` for the four
  business tables (production correctness comes from alembic 0003;
  the script's own job is exactly to fill rows between 0002 and 0003
  so its tests need that transient state to be representable). Its
  `_init_engine` also deletes the autouse seed rows to match the
  "fresh DB" model the tests assume.
- `test_thread_meta_workspace_filter::test_create_workspace_none_bypasses`
  renamed to `test_create_workspace_none_rejected_by_orm` and asserts
  the new IntegrityError on explicit None — write paths can no longer
  bypass workspace scope.
- 5 `test_workspace_context` tests + the auth-middleware reset test
  get `@pytest.mark.no_auto_workspace` so they keep testing the
  unset-contextvar path.
- `test_workspace_repo::test_list_by_user_bypass_returns_all` switches
  to membership assertions instead of strict equality since the
  autouse fixture surfaces under `user_id=None`.

3214 passed, 30 skipped; the remaining 17 are the documented
pre-existing caplog ordering flakes (all pass in isolation).
This commit is contained in:
1445043649
2026-05-13 18:06:53 +08:00
parent c5c66ccbfc
commit 87ea715c2a
10 changed files with 85 additions and 19 deletions
@@ -22,11 +22,11 @@ class FeedbackRow(Base):
run_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="关联的运行 IDruns.run_id") run_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="关联的运行 IDruns.run_id")
thread_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="关联的会话 IDthreads_meta.thread_id") thread_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="关联的会话 IDthreads_meta.thread_id")
user_id: Mapped[str | None] = mapped_column(String(64), index=True, comment="反馈作者;为 NULL 表示历史无主数据") user_id: Mapped[str | None] = mapped_column(String(64), index=True, comment="反馈作者;为 NULL 表示历史无主数据")
workspace_id: Mapped[str | None] = mapped_column( workspace_id: Mapped[str] = mapped_column(
String(36), String(36),
ForeignKey("workspaces.id", ondelete="CASCADE"), ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=True, nullable=False,
comment="所属 workspacePR5 期间 nullable(回填中),PR5 0003 迁移后改 NOT NULL", comment="所属 workspacePR5 引入时 nullable 用于回填;alembic 0003 + PR6 仓储接入完成后 NOT NULL",
) )
message_id: Mapped[str | None] = mapped_column(String(64), comment="可选的 RunEventStore 事件 ID;为 NULL 表示针对整次运行而非单条消息") message_id: Mapped[str | None] = mapped_column(String(64), comment="可选的 RunEventStore 事件 ID;为 NULL 表示针对整次运行而非单条消息")
rating: Mapped[int] = mapped_column(nullable=False, comment="评分:+1 点赞,-1 点踩") rating: Mapped[int] = mapped_column(nullable=False, comment="评分:+1 点赞,-1 点踩")
@@ -22,11 +22,11 @@ class RunEventRow(Base):
index=True, index=True,
comment="会话所有者;为 NULL 表示鉴权引入之前的历史数据,新写入由 auth 中间件填充,启动期 orphan 迁移会回填存量", comment="会话所有者;为 NULL 表示鉴权引入之前的历史数据,新写入由 auth 中间件填充,启动期 orphan 迁移会回填存量",
) )
workspace_id: Mapped[str | None] = mapped_column( workspace_id: Mapped[str] = mapped_column(
String(36), String(36),
ForeignKey("workspaces.id", ondelete="CASCADE"), ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=True, nullable=False,
comment="所属 workspacePR5 期间 nullable(回填中),PR5 0003 迁移后改 NOT NULL", comment="所属 workspacePR5 引入时 nullable 用于回填;alembic 0003 + PR6 仓储接入完成后 NOT NULL",
) )
event_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="事件子类型(具体含义由 category 决定,如 ai_message_chunk、tool_call、run_started") event_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="事件子类型(具体含义由 category 决定,如 ai_message_chunk、tool_call、run_started")
category: Mapped[str] = mapped_column(String(16), nullable=False, comment='事件大类:"message" 消息 / "trace" 追踪 / "lifecycle" 生命周期') category: Mapped[str] = mapped_column(String(16), nullable=False, comment='事件大类:"message" 消息 / "trace" 追踪 / "lifecycle" 生命周期')
@@ -17,11 +17,11 @@ class RunRow(Base):
thread_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="所属会话 IDthreads_meta.thread_id") thread_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="所属会话 IDthreads_meta.thread_id")
assistant_id: Mapped[str | None] = mapped_column(String(128), comment="使用的 Assistant ID(自定义智能体名);为 NULL 表示默认 lead agent") assistant_id: Mapped[str | None] = mapped_column(String(128), comment="使用的 Assistant ID(自定义智能体名);为 NULL 表示默认 lead agent")
user_id: Mapped[str | None] = mapped_column(String(64), index=True, comment="发起本次运行的用户 ID") user_id: Mapped[str | None] = mapped_column(String(64), index=True, comment="发起本次运行的用户 ID")
workspace_id: Mapped[str | None] = mapped_column( workspace_id: Mapped[str] = mapped_column(
String(36), String(36),
ForeignKey("workspaces.id", ondelete="CASCADE"), ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=True, nullable=False,
comment="所属 workspacePR5 期间 nullable(回填中),PR5 0003 迁移后改 NOT NULL", comment="所属 workspacePR5 引入时 nullable 用于回填;alembic 0003 + PR6 仓储接入完成后 NOT NULL",
) )
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(
String(20), String(20),
@@ -16,11 +16,11 @@ class ThreadMetaRow(Base):
thread_id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="会话主键(LangGraph thread_id") thread_id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="会话主键(LangGraph thread_id")
assistant_id: Mapped[str | None] = mapped_column(String(128), index=True, comment="关联的 Assistant ID(自定义智能体名);为 NULL 表示默认 lead agent") assistant_id: Mapped[str | None] = mapped_column(String(128), index=True, comment="关联的 Assistant ID(自定义智能体名);为 NULL 表示默认 lead agent")
user_id: Mapped[str | None] = mapped_column(String(64), index=True, comment="会话所有者;为 NULL 表示历史无主数据") user_id: Mapped[str | None] = mapped_column(String(64), index=True, comment="会话所有者;为 NULL 表示历史无主数据")
workspace_id: Mapped[str | None] = mapped_column( workspace_id: Mapped[str] = mapped_column(
String(36), String(36),
ForeignKey("workspaces.id", ondelete="CASCADE"), ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=True, nullable=False,
comment="所属 workspacePR5 期间 nullable(回填中),PR5 0003 迁移后改 NOT NULL", comment="所属 workspacePR5 引入时 nullable 用于回填;alembic 0003 + PR6 仓储接入完成后 NOT NULL",
) )
display_name: Mapped[str | None] = mapped_column(String(256), comment="会话显示名(自动生成的标题或用户手改)") display_name: Mapped[str | None] = mapped_column(String(256), comment="会话显示名(自动生成的标题或用户手改)")
status: Mapped[str] = mapped_column(String(20), default="idle", comment='会话状态:"idle" 空闲 / "busy" 正在产出') status: Mapped[str] = mapped_column(String(20), default="idle", comment='会话状态:"idle" 空闲 / "busy" 正在产出')
+9 -1
View File
@@ -66,7 +66,7 @@ sys.modules["deerflow.subagents.executor"] = _executor_mock
def _register_test_seed_listener() -> None: def _register_test_seed_listener() -> None:
"""Attach an after_create hook that seeds the autouse user + workspace.""" """Attach an after_create hook that seeds the autouse user + workspace."""
try: try:
from sqlalchemy import event from sqlalchemy import event, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.dialects.sqlite import insert as sqlite_insert
@@ -86,6 +86,13 @@ def _register_test_seed_listener() -> None:
dialect = connection.dialect.name dialect = connection.dialect.name
now = datetime.now(UTC) now = datetime.now(UTC)
# Seed both rows with a consistent ``default_workspace_id`` so the
# PR5 backfill script (which scans ``users.default_workspace_id IS
# NULL``) does not pick up the test fixtures as candidates.
# Insert user first with NULL default_workspace_id (chicken-and-egg
# with workspaces.owner_id FK), then workspace, then UPDATE the user
# row to point at the workspace so the PR5 backfill script does not
# pick up the autouse user as a candidate.
user_values = { user_values = {
"id": "test-user-autouse", "id": "test-user-autouse",
"email": "test-user-autouse@local", "email": "test-user-autouse@local",
@@ -119,6 +126,7 @@ def _register_test_seed_listener() -> None:
connection.execute(user_stmt) connection.execute(user_stmt)
connection.execute(ws_stmt) connection.execute(ws_stmt)
connection.execute(update(UserRow.__table__).where(UserRow.__table__.c.id == "test-user-autouse").where(UserRow.__table__.c.default_workspace_id.is_(None)).values(default_workspace_id="test-workspace-autouse"))
event.listen(Base.metadata, "after_create", _seed) event.listen(Base.metadata, "after_create", _seed)
@@ -101,6 +101,7 @@ def test_public_path_skips_workspace_check() -> None:
assert res.status_code == 200 assert res.status_code == 200
@pytest.mark.no_auto_workspace
def test_workspace_contextvar_resets_between_requests() -> None: def test_workspace_contextvar_resets_between_requests() -> None:
"""After dispatch returns the contextvar must be clear (no leak across requests). """After dispatch returns the contextvar must be clear (no leak across requests).
+40 -1
View File
@@ -40,12 +40,51 @@ def anyio_backend() -> str:
return "asyncio" return "asyncio"
_BUSINESS_ROWS = (ThreadMetaRow, RunRow, FeedbackRow, RunEventRow)
@pytest.fixture(autouse=True)
def _relax_workspace_id_nullable():
"""Simulate alembic 0002 (pre-backfill) state during these tests.
PR6 T5.11 flipped ``workspace_id`` to ``nullable=False`` on the four
business ORM models — production correctness comes from alembic 0003.
The backfill script's job is precisely to fill the rows that were
inserted between 0002 (column added, nullable) and 0003 (NOT NULL),
so tests for it must be able to insert NULL rows. We mutate
``column.nullable`` for the four tables before ``create_all`` runs,
then restore on teardown so other tests see the production shape.
"""
saved: list[tuple] = []
for model in _BUSINESS_ROWS:
col = model.__table__.c.workspace_id
saved.append((col, col.nullable))
col.nullable = True
try:
yield
finally:
for col, original in saved:
col.nullable = original
async def _init_engine(tmp_path): async def _init_engine(tmp_path):
from sqlalchemy import delete
from deerflow.persistence.engine import get_session_factory, init_engine from deerflow.persistence.engine import get_session_factory, init_engine
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}" url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)) await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
return get_session_factory() sf = get_session_factory()
# The PR6 conftest seeds an autouse user + workspace so business-row FKs
# resolve in the wider test suite. Backfill tests model "fresh DB needs
# backfill" semantics, so wipe those rows here. Order: clear the FK
# pointer first, then the rows.
async with sf() as session:
await session.execute(delete(WorkspaceMembershipRow))
await session.execute(delete(WorkspaceRow).where(WorkspaceRow.id == "test-workspace-autouse"))
await session.execute(delete(UserRow).where(UserRow.id == "test-user-autouse"))
await session.commit()
return sf
async def _close(): async def _close():
@@ -96,11 +96,18 @@ class TestCreateWorkspace:
await _cleanup() await _cleanup()
@pytest.mark.anyio @pytest.mark.anyio
async def test_create_workspace_none_bypasses(self, tmp_path): async def test_create_workspace_none_rejected_by_orm(self, tmp_path):
"""Explicit None creates an orphan row (migration / CLI path).""" """After T5.11 (ORM nullable=False) explicit None creates fail at the DB layer.
Pre-PR6 the migration scripts relied on `workspace_id=None` to insert
orphan rows; that use is now restricted to **read** paths (filter
bypass). Writes must always carry a workspace.
"""
import sqlalchemy
repo = await _make_repo(tmp_path) repo = await _make_repo(tmp_path)
record = await repo.create("t1", workspace_id=None) with pytest.raises((sqlalchemy.exc.IntegrityError, sqlalchemy.exc.DBAPIError)):
assert record["workspace_id"] is None await repo.create("t1", workspace_id=None)
await _cleanup() await _cleanup()
+5
View File
@@ -28,11 +28,13 @@ from deerflow.runtime.workspace_context import (
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.no_auto_workspace
def test_default_is_none(): def test_default_is_none():
"""Before any set, contextvar returns None.""" """Before any set, contextvar returns None."""
assert get_current_workspace() is None assert get_current_workspace() is None
@pytest.mark.no_auto_workspace
def test_set_and_reset_roundtrip(): def test_set_and_reset_roundtrip():
"""set_current_workspace returns a token that reset restores.""" """set_current_workspace returns a token that reset restores."""
workspace = SimpleNamespace(id="ws-1", role="owner") workspace = SimpleNamespace(id="ws-1", role="owner")
@@ -44,6 +46,7 @@ def test_set_and_reset_roundtrip():
assert get_current_workspace() is None assert get_current_workspace() is None
@pytest.mark.no_auto_workspace
def test_require_current_workspace_raises_when_unset(): def test_require_current_workspace_raises_when_unset():
"""require_current_workspace raises RuntimeError if contextvar is unset.""" """require_current_workspace raises RuntimeError if contextvar is unset."""
assert get_current_workspace() is None assert get_current_workspace() is None
@@ -93,6 +96,7 @@ def test_default_workspace_id_is_default():
assert DEFAULT_WORKSPACE_ID == "default" assert DEFAULT_WORKSPACE_ID == "default"
@pytest.mark.no_auto_workspace
def test_effective_workspace_id_returns_default_when_no_workspace(): def test_effective_workspace_id_returns_default_when_no_workspace():
"""No workspace in context -> fallback to DEFAULT_WORKSPACE_ID.""" """No workspace in context -> fallback to DEFAULT_WORKSPACE_ID."""
assert get_effective_workspace_id() == "default" assert get_effective_workspace_id() == "default"
@@ -132,6 +136,7 @@ def test_resolve_auto_reads_from_contextvar():
reset_current_workspace(token) reset_current_workspace(token)
@pytest.mark.no_auto_workspace
def test_resolve_auto_raises_when_unset(): def test_resolve_auto_raises_when_unset():
assert get_current_workspace() is None assert get_current_workspace() is None
with pytest.raises(RuntimeError, match="workspace_id=AUTO but no workspace"): with pytest.raises(RuntimeError, match="workspace_id=AUTO but no workspace"):
+7 -1
View File
@@ -284,7 +284,13 @@ async def test_list_by_user_bypass_returns_all(tmp_path):
await repo.create(name="B", slug="all-b", owner_id="u-B") await repo.create(name="B", slug="all-b", owner_id="u-B")
workspaces = await repo.list_by_user(user_id=None) workspaces = await repo.list_by_user(user_id=None)
# PR6 conftest auto-seeds an "autouse-test" workspace via the
# ``Base.metadata.after_create`` hook so business-row FKs resolve.
# ``user_id=None`` bypasses the membership filter, so it surfaces
# alongside the two rows the test inserted — that is the intended
# "no filter" behaviour. Assert the inserted ones are present.
slugs = sorted(w["slug"] for w in workspaces) slugs = sorted(w["slug"] for w in workspaces)
assert slugs == ["all-a", "all-b"] assert "all-a" in slugs
assert "all-b" in slugs
finally: finally:
await _cleanup() await _cleanup()