From 839a9a5f7bfb302575a9f96ba8ef8bdc50d20ca2 Mon Sep 17 00:00:00 2001 From: tangkikodo Date: Fri, 3 Jul 2026 16:07:13 +0800 Subject: [PATCH] feat(voyager): add Hide Reverse Relationships toggle for ER diagram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new display toggle that hides ONETOMANY reverse-mirror edges in the ER diagram, keeping only MANYTOONE (FK-holder side) and MANYTOMANY edges. Eliminates the visual duplication produced by SQLModel `back_populates` bidirectional relationships (each entity pair went from 2 edges to 1). Backend: - `ErDiagramDotBuilder.__init__` accepts `hide_reverse_relationships: bool` - `_add_relationship_link` early-returns on `direction == 'ONETOMANY'` when the flag is on; MANYTOONE and MANYTOMANY are preserved unchanged - `ErDiagramPayload` and `ErDiagramSubgraphPayload` get the new field (default `False`, backward compatible) - `VoyagerContext` transparently passes the field through to the builder Frontend: - `store.js` adds `state.filter.hideReverseRelationships` field, `toggleHideReverseRelationships(val, onGenerate)` action, and threads the field into both payload builders (`buildErDiagramPayload` and `buildErDiagramSubgraphPayload`) - `vue-main.js` reads the persisted state from `localStorage` on init and registers the toggle action - `index.html` renders a `` in the ER-diagram display options panel (keyboard-accessible by default; only shown in ER-diagram mode) - Subgraph (spec 005) follows the filter automatically because `filter_to_neighborhood` consumes the already-filtered `self.links` Spec kit artifacts under `specs/007-voyager-er-pure-fk/` (spec, plan, research, data-model, contracts, quickstart, tasks) — feature was renamed from "Pure Foreign Key" to "Hide Reverse Relationships" mid-spec after discovering the original premise (independent FK-column edges) doesn't match the current implementation; directory name retained as a filesystem identifier, decoupled from the UI label. Tests: 10 new pytest cases cover filter off/on, M2M preservation, unirectional MANYTOONE/ONETOMANY, SchemaNode.fields invariance (FR-007), endpoint contract + backward compat, subgraph follow-through, and self-referential back_populates. Full suite: 41 passed, 0 regressions. Co-Authored-By: Claude Opus 4.7 --- .specify/feature.json | 2 +- .specify/integrations/claude.manifest.json | 2 +- .../checklists/requirements.md | 36 ++ .../contracts/er-diagram-payload-extension.md | 115 ++++++ .../contracts/frontend-toggle.md | 160 +++++++++ .../contracts/hide-reverse-filter.md | 139 ++++++++ specs/007-voyager-er-pure-fk/data-model.md | 246 +++++++++++++ specs/007-voyager-er-pure-fk/plan.md | 113 ++++++ specs/007-voyager-er-pure-fk/quickstart.md | 237 +++++++++++++ specs/007-voyager-er-pure-fk/research.md | 143 ++++++++ specs/007-voyager-er-pure-fk/spec.md | 135 +++++++ specs/007-voyager-er-pure-fk/tasks.md | 225 ++++++++++++ src/nexusx/voyager/create_voyager.py | 2 + src/nexusx/voyager/er_diagram_dot.py | 8 + src/nexusx/voyager/voyager_context.py | 2 + src/nexusx/voyager/web/index.html | 10 + src/nexusx/voyager/web/store.js | 21 ++ src/nexusx/voyager/web/vue-main.js | 3 + tests/test_voyager_hide_reverse.py | 328 ++++++++++++++++++ 19 files changed, 1925 insertions(+), 2 deletions(-) create mode 100644 specs/007-voyager-er-pure-fk/checklists/requirements.md create mode 100644 specs/007-voyager-er-pure-fk/contracts/er-diagram-payload-extension.md create mode 100644 specs/007-voyager-er-pure-fk/contracts/frontend-toggle.md create mode 100644 specs/007-voyager-er-pure-fk/contracts/hide-reverse-filter.md create mode 100644 specs/007-voyager-er-pure-fk/data-model.md create mode 100644 specs/007-voyager-er-pure-fk/plan.md create mode 100644 specs/007-voyager-er-pure-fk/quickstart.md create mode 100644 specs/007-voyager-er-pure-fk/research.md create mode 100644 specs/007-voyager-er-pure-fk/spec.md create mode 100644 specs/007-voyager-er-pure-fk/tasks.md create mode 100644 tests/test_voyager_hide_reverse.py diff --git a/.specify/feature.json b/.specify/feature.json index 58edaf5..3bafa66 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/006-voyager-about-tab" + "feature_directory": "specs/007-voyager-er-pure-fk" } diff --git a/.specify/integrations/claude.manifest.json b/.specify/integrations/claude.manifest.json index f2e01a6..9ce113e 100644 --- a/.specify/integrations/claude.manifest.json +++ b/.specify/integrations/claude.manifest.json @@ -1,7 +1,7 @@ { "integration": "claude", "version": "0.11.3", - "installed_at": "2026-06-20T02:31:44.756825+00:00", + "installed_at": "2026-07-03T06:11:58.021218+00:00", "files": { ".claude/skills/speckit-analyze/SKILL.md": "0c6d0525b97b523851a3ea8fc0efdcf477dfa65bef8c4d976b6da2f220f32c86", ".claude/skills/speckit-clarify/SKILL.md": "eb8907c57e9af2c54952370a45e3fb66cb21a64dc7b3d361615bab914c13becf", diff --git a/specs/007-voyager-er-pure-fk/checklists/requirements.md b/specs/007-voyager-er-pure-fk/checklists/requirements.md new file mode 100644 index 0000000..0ea56df --- /dev/null +++ b/specs/007-voyager-er-pure-fk/checklists/requirements.md @@ -0,0 +1,36 @@ +# 规格质量检查清单:Voyager ER 图 —— Hide Reverse Relationships 连线模式 + +**用途**:在进入 plan 阶段前,对 spec.md 的完整性与质量做一次校验 +**创建日期**:2026-07-03 +**对应功能**:[spec.md](../spec.md) + +## 内容质量 + +- [x] 不包含实现细节(语言、框架、API、文件路径)——仅必要的现有命名引用作为上下文锚点 +- [x] 聚焦用户价值与业务需求 +- [x] 面向非技术干系人可读 +- [x] 所有必填章节已填写 + +## 需求完整性 + +- [x] spec.md 中无残留的 `[NEEDS CLARIFICATION]` 标记 +- [x] 所有功能需求(FR-001 ~ FR-014)可测试、无歧义 +- [x] 成功标准(SC-001 ~ SC-005)可衡量 +- [x] 成功标准未夹带实现细节(无框架/语言/数据库/工具名) +- [x] 用户故事的验收场景全部已定义(Story 1 八条、Story 2 五条) +- [x] 边界情况已识别(10 条) +- [x] 功能范围有清晰边界(仅裁剪 ER-diagram 模式下的连线,不干涉字段展示/侧边栏/其他模式) +- [x] 依赖与假设已在"假设"区块列出 + +## 功能就绪度 + +- [x] 每条功能需求都有对应的验收场景支撑 +- [x] 用户故事覆盖主要使用流程(首次勾选、持久化、与其他选项正交) +- [x] 功能可达成"成功标准"中描述的可衡量结果 +- [x] 实现细节未泄漏到规格中(连线的"如何区分 FK vs Relationship"留给 plan 阶段) + +## 备注 + +- Phase 0 八步访谈区块已省略:本特性是 Voyager 工具自身的 UI 增强,不涉及 nexusx 业务建模(无业务实体、聚合根、Service 切分、DB 选型)。处理方式与上一同源特性 `006-voyager-about-tab` 一致。 +- 关键决策点(连线裁剪范围、默认状态、持久化策略)已在 "澄清记录 → Session 2026-07-03" 中以 Q&A 形式固化,spec 主体不再重复 NEEDS CLARIFICATION。 +- 本清单所有项已通过;spec 可直接进入 `/speckit-clarify`(如需追加澄清)或 `/speckit-plan`(开始设计实现方案)。 diff --git a/specs/007-voyager-er-pure-fk/contracts/er-diagram-payload-extension.md b/specs/007-voyager-er-pure-fk/contracts/er-diagram-payload-extension.md new file mode 100644 index 0000000..7595682 --- /dev/null +++ b/specs/007-voyager-er-pure-fk/contracts/er-diagram-payload-extension.md @@ -0,0 +1,115 @@ +# 契约:ErDiagramPayload / ErDiagramSubgraphPayload 字段扩展 + +**功能**:[spec.md](../spec.md) · **数据模型**:[data-model.md](../data-model.md) · **过滤契约**:[hide-reverse-filter.md](./hide-reverse-filter.md) + +**位置**:`src/nexusx/voyager/create_voyager.py`(`ErDiagramPayload` 第 64 行、`ErDiagramSubgraphPayload` 第 72 行) + +--- + +## 字段定义 + +### `ErDiagramPayload`(`POST /er-diagram` 请求体) + +新增字段: + +```python +class ErDiagramPayload(PydanticModel): + # ... 现有字段保持不变 + show_module: bool = True + better_cluster_display: bool = False + show_methods: bool = True + hide_reverse_relationships: bool = False # 新增(本期) +``` + +### `ErDiagramSubgraphPayload`(`POST /er-diagram-subgraph` 请求体,spec 005) + +新增同名字段: + +```python +class ErDiagramSubgraphPayload(PydanticModel): + """Spec 005 — request body for POST /er-diagram-subgraph. + + Same rendering fields as :class:`ErDiagramPayload`, plus the required + schema_name. + """ + schema_name: str + # ... 现有渲染字段保持不变(与 ErDiagramPayload 对齐) + show_module: bool = True + better_cluster_display: bool = False + show_methods: bool = True + hide_reverse_relationships: bool = False # 新增(本期) +``` + +--- + +## 请求示例 + +### 主图请求 + +```http +POST /er-diagram HTTP/1.1 +Content-Type: application/json + +{ + "schema": "demo.enterprise_voyager", + "show_module": true, + "better_cluster_display": false, + "show_methods": true, + "hide_reverse_relationships": true +} +``` + +### 子图请求(spec 005) + +```http +POST /er-diagram-subgraph HTTP/1.1 +Content-Type: application/json + +{ + "schema": "demo.enterprise_voyager", + "schema_name": "demo.enterprise_voyager.models.Post", + "show_module": true, + "better_cluster_display": false, + "show_methods": true, + "hide_reverse_relationships": true +} +``` + +--- + +## 校验规则 + +- **类型**:`bool`,Pydantic 自动校验。非 bool 值(如 `"true"` 字符串、`1` 整数)按 Pydantic 默认行为转换;转换失败返回 `422 Unprocessable Entity`,与现有 bool 字段(`show_module` 等)一致。 +- **必填性**:可选(默认 `False`)。老客户端不传该字段时行为与现状完全一致(向后兼容)。 +- **取值语义**: + - `false`(默认):行为与现状完全一致——所有方向(MANYTOONE / ONETOMANY / MANYTOMANY)的 relationship 连线都生成。 + - `true`:进入 Pure FK 模式——`_add_relationship_link` 跳过 `direction == 'ONETOMANY'` 的 relationship,详见 [hide-reverse-filter.md](./hide-reverse-filter.md)。 + +--- + +## 响应 shape + +**完全不变**——`/er-diagram` 仍返回 `{dot: str, links: [...], schemas: {...}}`,`/er-diagram-subgraph` 仍返回 spec 005 定义的子图响应 shape。`hide_reverse_relationships: true` 时唯一可观察的差异是 `dot` 字符串和 `links` 数组中 ONETOMANY 方向的边缺失。 + +--- + +## 向后兼容性 + +- **老客户端**(包括已发布的 voyager 前端缓存版本、第三方调用者)不传该字段时,Pydantic 默认 `False`,行为完全一致。 +- **service worker 缓存**(`web/sw.js`):本期不修改缓存键策略;如果 sw 把请求体作为缓存键的一部分(需在实现时确认),新增字段会导致老缓存失效、新缓存重新填充——属于一次性开销,无需特殊处理。 +- **路由处理函数**:`@router.post("/er-diagram")` 与 `@router.post("/er-diagram-subgraph")` 的处理逻辑**无需修改**——FastAPI 自动把请求体映射到 `voyager_context.get_er_diagram(payload)` 等方法,新增字段通过 `payload.get("hide_reverse_relationships", False)` 在 `voyager_context.py` 内透传到 `ErDiagramDotBuilder` 构造函数。 + +--- + +## 与现有 toggle 字段的对齐表 + +| 字段 | localStorage key | 默认值 | 作用 | +|------|-----------------|--------|------| +| `show_module` | `show_module_cluster` | `True` | 按模块聚类显示 | +| `better_cluster_display` | `better_cluster_display` | `False` | 改进的聚类显示 | +| `show_methods` | (仅会话内) | `True` | 显示实体方法 | +| `brief` | `brief_mode` | `False` | 简短模式(仅 tag → schema) | +| `pydantic_resolve_meta` | `pydantic_resolve_meta` | `False` | 显示 Pydantic resolve 元数据 | +| **`hide_reverse_relationships`**(本期新增) | **`hide_reverse_relationships`** | **`False`** | **隐藏 ONETOMANY 反向镜像** | + +新增字段在命名、默认值、localStorage 持久化模式上与现有字段完全对齐。 diff --git a/specs/007-voyager-er-pure-fk/contracts/frontend-toggle.md b/specs/007-voyager-er-pure-fk/contracts/frontend-toggle.md new file mode 100644 index 0000000..7ac6399 --- /dev/null +++ b/specs/007-voyager-er-pure-fk/contracts/frontend-toggle.md @@ -0,0 +1,160 @@ +# 契约:前端 store + UI 集成 + +**功能**:[spec.md](../spec.md) · **数据模型**:[data-model.md](../data-model.md) · **Payload 契约**:[er-diagram-payload-extension.md](./er-diagram-payload-extension.md) + +**位置**: +- `src/nexusx/voyager/web/store.js`(state + toggle 函数) +- `src/nexusx/voyager/web/vue-main.js`(初始化 + fetch 透传) +- `src/nexusx/voyager/web/component/schema-code-display.js`(显示选项面板 checkbox) + +--- + +## store.js 变更 + +### 新增 state 字段 + +在 `state.filter` 对象内新增: + +```javascript +state.filter = { + // ... 现有字段保持不变 + hideReverseRelationships: false, // 新增(本期) +} +``` + +初始值 `false`,与 spec FR-002"默认未勾选"一致。 + +### 新增 toggle 函数 + +紧邻现有 `toggleBetterClusterDisplay`(约第 467-475 行)之后新增: + +```javascript +/** + * Toggle hide reverse relationships mode. + * When enabled, only MANYTOONE and MANYTOMANY direction relationship edges + * are rendered (FK-holder side preserved); ONETOMANY reverse mirrors hidden. + * Persists to localStorage; calls onGenerate to trigger graph re-render. + * @param {boolean} val - New value + * @param {Function} onGenerate - Callback to regenerate graph + */ +toggleHideReverseRelationships(val, onGenerate) { + state.filter.hideReverseRelationships = val + try { + localStorage.setItem("hide_reverse_relationships", JSON.stringify(val)) + } catch (e) { + console.warn("Failed to save hide_reverse_relationships to localStorage", e) + } + onGenerate() +}, +``` + +签名、错误降级(`console.warn` 不阻塞)、`onGenerate()` 调用模式与现有 `toggleBetterClusterDisplay` / `toggleBrief` / `togglePydanticResolveMeta` 完全一致。 + +--- + +## vue-main.js 变更 + +### 初始化时读取 localStorage + +在现有 `loadToggleState("pydantic_resolve_meta", ...)`(约第 55 行)之后新增: + +```javascript +store.state.filter.hideReverseRelationships = loadToggleState( + "hide_reverse_relationships", + false, +) +``` + +`loadToggleState(key, defaultValue)` 是项目内已有的辅助函数(与 `show_module_cluster` / `better_cluster_display` / `brief_mode` / `pydantic_resolve_meta` 复用同一函数),自动处理 JSON 解析失败、隐私模式禁用 localStorage 等降级场景(spec FR-011 / Story 2 验收场景 4-5)。 + +### fetch `/er-diagram` 时透传字段 + +约第 189-200 行,现有 `fetch("er-diagram", { body: JSON.stringify({...}) })` 的 body 内新增一行: + +```javascript +const res = await fetch("er-diagram", { + // ... method / headers 不变 + body: JSON.stringify({ + // ... 现有字段保持不变 + schema: store.state.graph.schema, + show_module: store.state.filter.showModule, + better_cluster_display: store.state.filter.betterClusterDisplay, + show_methods: store.state.modeControl.showMethodsEnabled, + // ... 其他现有字段 + hide_reverse_relationships: store.state.filter.hideReverseRelationships, // 新增(本期) + }), +}) +``` + +### fetch `/er-diagram-subgraph` 时透传字段 + +`/er-diagram-subgraph` 请求体由 `src/nexusx/voyager/web/store.js::buildErDiagramSubgraphPayload(schemaName)` 构造(spec 005 引入,约第 623-632 行)——这是该端点请求体的**唯一构造点**,被 `store.js::fetchRelatedEntities`(约第 638 行)调用、由 `component/related-entities-display.js` 触发。 + +在本函数返回对象内新增字段: + +```javascript +buildErDiagramSubgraphPayload(schemaName) { + return { + schema_name: schemaName, + show_fields: state.filter.showFields, + show_module: state.filter.showModule, + better_cluster_display: state.filter.showModule && state.filter.betterClusterDisplay, + edge_minlen: state.filter.edgeMinlen, + show_methods: state.filter.showMethods, + hide_reverse_relationships: state.filter.hideReverseRelationships, // 新增(本期) + } +}, +``` + +**为什么直接透传而不做条件包装**:与 `better_cluster_display` 不同(其在 subgraph 中受 `state.filter.showModule && ...` 条件包装,因为 cluster display 依赖 module 聚类),`hide_reverse_relationships` 是独立的渲染配置、无前置依赖,直接透传即可——与 spec FR-007"子图跟随主图渲染配置"原则一致。 + +--- + +## 显示选项面板变更(component/schema-code-display.js 或同等位置) + +### 新增 Quasar checkbox + +紧邻现有 `better cluster display` / `brief mode` / `pydantic resolve meta` toggle 之后新增: + +```html + +``` + +### 仅在 ER-diagram 模式下可见 + +`schema-code-display.js` 已有按 `store.state.mode === 'er-diagram'` 条件渲染面板的逻辑(spec FR-001)——新增 checkbox 沿用同一条件,无需新增判断。 + +### 键盘可达性(spec FR-004) + +Quasar `q-checkbox` 默认键盘可达(Tab 聚焦、Space 切换),与现有 `q-checkbox` 一致,无需额外配置。 + +### 选项位置(plan 阶段不固化) + +spec FR-001 仅要求"与现有显示选项位于同一交互区域",具体顺序(出现在 brief mode 之前还是之后)、分组(独立还是合并到"relationship display"子组)属于 UI 实现细节,由 tasks 阶段决定。建议放在 `better cluster display` 之后、`brief mode` 之前(语义上"裁剪连线"接近"聚类显示")。 + +--- + +## 与项目内现有 toggle 的对齐表 + +| Toggle | state.filter 字段 | localStorage key | toggle 函数 | Payload 字段 | +|--------|------------------|------------------|-------------|--------------| +| Show Module Cluster | `showModule` | `show_module_cluster` | `toggleShowModule` | `show_module` | +| Better Cluster Display | `betterClusterDisplay` | `better_cluster_display` | `toggleBetterClusterDisplay` | `better_cluster_display` | +| Brief Mode | `brief` | `brief_mode` | `toggleBrief` | `brief` | +| Pydantic Resolve Meta | `pydanticResolveMetaEnabled`* | `pydantic_resolve_meta` | `togglePydanticResolveMeta` | `show_pydantic_resolve_meta` | +| **Hide Reverse Relationships**(本期) | **`hideReverseRelationships`** | **`hide_reverse_relationships`** | **`toggleHideReverseRelationships`** | **`hide_reverse_relationships`** | + +*`pydanticResolveMetaEnabled` 在 `state.modeControl` 而非 `state.filter`,因为它影响"是否生成 Pydantic resolve 元数据"而非"如何渲染图"。本期 Pure FK 模式属于"如何渲染图"范畴,归 `state.filter`,与 cluster display / brief mode 同侧。 + +--- + +## 不变量 + +1. **URL 不含状态**:toggle 函数不修改 URL 参数(spec FR-012);分享 URL 时接收方按自己 localStorage 偏好渲染。 +2. **不破坏现有 toggle**:Pure FK 与 cluster display / brief mode / show fields / pydantic resolve meta 等正交(spec FR-013)——各 toggle 独立写入 localStorage、独立透传到请求体、后端独立处理。 +3. **localStorage 不可用降级**:`loadToggleState` 与 `localStorage.setItem` 的 try/catch 已覆盖隐私模式、配额满、JSON 解析失败等场景(spec FR-011 / Story 2 验收场景 4-5)。 +4. **勾选即生效**:`toggleHideReverseRelationships` 调用 `onGenerate()` 触发 ER 图重新生成(spec FR-003),与现有 toggle 一致——不需要用户额外点击"应用"或"刷新"。 diff --git a/specs/007-voyager-er-pure-fk/contracts/hide-reverse-filter.md b/specs/007-voyager-er-pure-fk/contracts/hide-reverse-filter.md new file mode 100644 index 0000000..a14693b --- /dev/null +++ b/specs/007-voyager-er-pure-fk/contracts/hide-reverse-filter.md @@ -0,0 +1,139 @@ +# 契约:`_add_relationship_link` 早退过滤逻辑 + +**功能**:[spec.md](../spec.md) · **数据模型**:[data-model.md](../data-model.md) · **Payload 契约**:[er-diagram-payload-extension.md](./er-diagram-payload-extension.md) + +**位置**:`src/nexusx/voyager/er_diagram_dot.py::ErDiagramDotBuilder._add_relationship_link`(第 199-237 行) + +--- + +## 现状(修改前) + +```python +def _add_relationship_link( + self, + entity_kls: type, + rel_info: RelationshipInfo, +) -> None: + """Add a Link for a single relationship.""" + if not _is_model_like_target(rel_info.target_entity): + return + + source_name = full_class_name(entity_kls) + target_name = full_class_name(rel_info.target_entity) + + # Ensure target node exists + self._add_to_node_set(rel_info.target_entity) + + # Build label with cardinality + cardinality = f'1 {ARROW} N' if rel_info.is_list else f'1 {ARROW} 1' + label = f'{rel_info.name}\n{cardinality}' + + # Build source anchor from relationship name field + source_anchor = f'{source_name}::f{rel_info.name}' + + # Check for duplicates + biz = rel_info.name + pair = (source_anchor, self._generate_node_head(target_name), biz) + if pair in self.link_set: + return + self.link_set.add(pair) + + self.links.append(Link( + source=source_anchor, + source_origin=source_name, + target=self._generate_node_head(target_name), + target_origin=target_name, + type='schema', + label=label, + style='solid', + loader_fullname=None, + )) +``` + +--- + +## 修改后 + +在 `_is_model_like_target` 早退之后、`source_name = full_class_name(...)` 之前,**新增一行 direction 早退判定**: + +```python +def _add_relationship_link( + self, + entity_kls: type, + rel_info: RelationshipInfo, +) -> None: + """Add a Link for a single relationship.""" + if not _is_model_like_target(rel_info.target_entity): + return + + # Spec 007: Hide Reverse Relationships mode — skip ONETOMANY reverse mirrors. + # MANYTOONE (FK holder side) and MANYTOMANY (through table) are preserved. + if self.hide_reverse_relationships and rel_info.direction == 'ONETOMANY': + return + + source_name = full_class_name(entity_kls) + # ... 后续逻辑完全不变 +``` + +--- + +## 判定逻辑 + +| `hide_reverse_relationships` | `rel_info.direction` | 行为 | +|------------------------------|----------------------|------| +| `False`(默认) | 任意 | 与现状一致——所有方向 relationship 都生成 Link | +| `True` | `'MANYTOONE'` | **保留**——生成 Link(持有 FK 一侧) | +| `True` | `'ONETOMANY'` | **隐藏**——早退、不生成 Link(被引用实体的反向镜像) | +| `True` | `'MANYTOMANY'` | **保留**——生成 Link(M2M 不在 back_populates 反向冗余范围) | +| `True` | 其他/异常值 | **保留**——不匹配 `'ONETOMANY'`,按"MANYTOONE/MANYTOMANY 同等保留"处理 | + +--- + +## 不变量(Pure FK 模式开启时仍成立) + +1. **`self.rel_name_set` 记录全部 relationship**:`rel_name_set` 在 `analysis()` 第 121-125 行独立构建(与 `_add_relationship_link` 解耦),不受 Pure FK 早退影响——`_get_entity_fields` 仍能渲染完整字段表(含 ONETOMANY 方向 relationship 字段),Fields tab 内容不变(spec FR-007)。 +2. **`self.node_set` 包含全部实体节点**:`_add_to_node_set` 在早退之前未被调用,但实体节点本身在 `analysis()` 第 127-130 行的独立循环中已经被全部加入——Pure FK 模式不隐藏任何实体节点,只裁剪连线(spec FR-007)。 +3. **`self.link_set`(dedup 集合)一致性**:被早退的 ONETOMANY relationship 不入 `link_set`,与"该 Link 不存在"语义一致。 +4. **`filter_to_neighborhood`(spec 005 子图)自动继承**:在 `analysis()` 之后调用、消费已过滤的 `self.links`,子图天然跟随 Pure FK 裁剪,无需新增逻辑。 + +--- + +## 关键边界情况 + +### 自引用双向关系 + +`Tree.parent = Relationship(back_populates="children")`(MANYTOONE)+ `Tree.children = Relationship(back_populates="parent")`(ONETOMANY): + +- Pure FK 关闭:两条 Link 都生成(自环呈现为 2 条自连线) +- Pure FK 开启:只生成 `Tree::fparent → Tree::PK`,自环呈现为单条 MANYTOONE 自连线 + +### 单向 ONETOMANY(无 `back_populates`) + +仅在 `User` 上定义 `User.posts = Relationship(...)`,`Post` 上无反向: + +- Pure FK 关闭:生成 `User::fposts → Post::PK` Link +- Pure FK 开启:早退、不生成 Link(`Post` 与 `User` 之间无连线)——spec Story 1 验收场景 5 + +### 单向 MANYTOONE(无 `back_populates`) + +仅在 `Post` 上定义 `Post.author = Relationship(...)`(指向 `User`),`User` 上无反向: + +- Pure FK 关闭:生成 `Post::fauthor → User::PK` Link +- Pure FK 开启:保留(MANYTOONE 不被过滤) + +### M2M 关系(`secondary="..."`) + +`Post.tags = Relationship(secondary="post_tag", back_populates="posts")` + `Tag.posts = Relationship(secondary="post_tag", back_populates="tags")`: + +- Pure FK 关闭:两条 Link 都生成 +- Pure FK 开启:两条 Link 都保留(`direction == 'MANYTOMANY'` 不匹配 `'ONETOMANY'`)——spec FR-006 + +### 复合外键 / 多列 FK + +Pure FK 模式不引入新行为——只要 relationship 存在且方向是 MANYTOONE,Link 照常生成。是否实际为复合 FK 不影响过滤规则。 + +### `rel_info.direction` 为非常规值 + +理论不应发生(SQLAlchemy `inspect()` 严格返回 `MANYTOONE` / `ONETOMANY` / `MANYTOMANY`)。若发生:不匹配 `'ONETOMANY'`,按"保留"处理(保守偏向"宁可不画也不要画错"的反向——这里的"画错"指漏画 MANYTOONE/M2M,与 spec 边界情况"无法判定 direction 时按 ONETOMANY 处理即隐藏"略有出入)。 + +**取舍说明**:spec 边界情况写"无法判定 direction 时按 ONETOMANY 处理",但代码现实是 `direction` 字段一定是三个明确字符串之一(不会是 None 或空串)。本期实现采用字面字符串比较 `rel_info.direction == 'ONETOMANY'`——若未来 SQLAlchemy 行为变化导致 `direction` 取值集合扩展,需要重新审视这一默认值。tests 用例 5(详见 quickstart.md)覆盖此边界。 diff --git a/specs/007-voyager-er-pure-fk/data-model.md b/specs/007-voyager-er-pure-fk/data-model.md new file mode 100644 index 0000000..3b8dd9c --- /dev/null +++ b/specs/007-voyager-er-pure-fk/data-model.md @@ -0,0 +1,246 @@ +# Phase 1 — 数据模型:Voyager Hide Reverse Relationships 连线模式 + +**功能**:[spec.md](./spec.md) · **计划**:[plan.md](./plan.md) · **调研**:[research.md](./research.md) + +--- + +## 1. 数据通路总览 + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ 前端(浏览器) │ +│ │ +│ ┌────────────────────┐ ┌──────────────────────────────────────────┐ │ +│ │ 显示选项面板 │───▶│ store.js │ │ +│ │ [✓] Hide Reverse │ │ state.filter.hideReverseRelationships │ │ +│ │ Relationships │ │ toggleHideReverseRelationships(val, │ │ +│ └────────────────────┘ │ onGenerate) │ │ +│ │ └─▶ 写 localStorage │ │ +│ │ └─▶ onGenerate() │ │ +│ └──────────────┬───────────────────────────┘ │ +│ │ │ +│ vue-main.js 初始化时: │ │ +│ loadToggleState("hide_reverse_ │ │ +│ relationships", false) │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ fetch POST /er-diagram │ │ +│ │ body: { │ │ +│ │ ..., │ │ +│ │ hide_reverse_relationships: , │ │ +│ │ } │ │ +│ └──────────────┬───────────────────────────┘ │ +└───────────────────────────────────────────┼──────────────────────────────┘ + │ HTTP + ▼ +┌──────────────────────────────────────────────────────────────────────────┐ +│ 后端(FastAPI) │ +│ │ +│ create_voyager.py │ +│ @router.post("/er-diagram") │ +│ payload: ErDiagramPayload ◀── 新增 hide_reverse_relationships 字段 │ +│ │ +│ voyager_context.py │ +│ get_er_diagram(payload) │ +│ └─▶ ErDiagramDotBuilder( │ +│ er_manager, │ +│ show_module=payload["show_module"], │ +│ better_cluster_display=payload["better_cluster_display"], │ +│ show_methods=payload["show_methods"], │ +│ hide_reverse_relationships=payload["hide_reverse_ │ +│ relationships"], ◀── 新增透传 │ +│ ) │ +│ │ +│ er_diagram_dot.py │ +│ ErDiagramDotBuilder.__init__ ◀── 新增 hide_reverse_relationships 参数 │ +│ ErDiagramDotBuilder.analysis() │ +│ └─▶ for entity_kls, rels in all_relationships.items(): │ +│ for _rel_name, rel_info in rels.items(): │ +│ self._add_relationship_link(entity_kls, rel_info) │ +│ │ │ +│ ▼ │ +│ ErDiagramDotBuilder._add_relationship_link │ +│ if not _is_model_like_target(rel_info.target_entity): return │ +│ if self.hide_reverse_relationships and │ +│ rel_info.direction == 'ONETOMANY': return ◀── 新增早退 │ +│ # 后续逻辑不变(dedup、构造 Link、append) │ +│ │ +│ 最终:self.links 只含 MANYTOONE + MANYTOMANY 方向 Link │ +│ self.rel_name_set 仍含全部 relationship(供 Fields tab 字段渲染) │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +**关键不变量**: +- `Link` 数据结构 shape 不变(不新增字段)。 +- `SchemaNode.fields` shape 不变(Pure FK 模式只裁剪连线、不裁剪字段展示)。 +- `self.rel_name_set` 在 Pure FK 模式下仍记录**全部** relationship(包括被过滤掉的 ONETOMANY),供 `_get_entity_fields` 渲染实体字段表使用——Fields tab 仍展示完整字段列表(含 ONETOMANY 方向 relationship 字段,符合 spec FR-007)。 +- `self.link_set`(dedup 集合)只记录实际生成的 Link 对应键,因此 Pure FK 模式下 ONETOMANY 对应的键不入集合(与"该 Link 不存在"一致)。 +- `filter_to_neighborhood`(spec 005)在 `analysis()` 之后调用、消费 `self.links`,自动继承过滤结果——子图跟随裁剪无需新增逻辑。 + +--- + +## 2. 后端数据契约扩展 + +### 2.1 `ErDiagramDotBuilder.__init__` 新增参数 + +`src/nexusx/voyager/er_diagram_dot.py` 第 87-110 行: + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `hide_reverse_relationships` | `bool` | `False` | True 时 `analysis()` 内 `_add_relationship_link` 跳过 `direction == 'ONETOMANY'` 的 relationship;False 时行为与现状完全一致 | + +构造函数体内新增 `self.hide_reverse_relationships = hide_reverse_relationships`。 + +### 2.2 `_add_relationship_link` 新增早退判定 + +`src/nexusx/voyager/er_diagram_dot.py` 第 199-237 行函数体,在现有 `if not _is_model_like_target(rel_info.target_entity): return` 之后、`source_name = full_class_name(entity_kls)` 之前,新增: + +```python +if self.hide_reverse_relationships and rel_info.direction == 'ONETOMANY': + return +``` + +**为什么用字符串字面量 `'ONETOMANY'`**:`RelationshipInfo.direction` 字段类型为 `str`(非 Enum),取值字符串由 SQLAlchemy `inspect()` 在 `loader/registry.py::_inspect_relationships` 中赋值(如 `direction="MANYTOONE"` 等,第 90 行附近的 import)。直接比较字符串与现有代码风格一致;若未来引入 Enum 可同步迁移。 + +### 2.3 `ErDiagramPayload`、`ErDiagramSubgraphPayload` 字段扩展 + +`src/nexusx/voyager/create_voyager.py`: + +**`ErDiagramPayload`(第 64 行)新增字段**: + +```python +class ErDiagramPayload(PydanticModel): + # ... 现有字段 + show_module: bool = True + better_cluster_display: bool = False + # ... 其他 + show_methods: bool = True + hide_reverse_relationships: bool = False # 新增 +``` + +**`ErDiagramSubgraphPayload`(第 72 行)新增同名字段**: + +```python +class ErDiagramSubgraphPayload(PydanticModel): + # ... 现有字段(与 ErDiagramPayload 对齐) + show_methods: bool = True + hide_reverse_relationships: bool = False # 新增 +``` + +### 2.4 `voyager_context.py` 4 处构造点透传 + +在 `voyager_context.py` 第 108、128、171-175、229-233 行附近的 4 处 `ErDiagramDotBuilder(...)` 构造调用,每处新增一行: + +```python +hide_reverse_relationships=payload.get("hide_reverse_relationships", False), +``` + +**为什么用 `payload.get(...)` 而非 `payload.hide_reverse_relationships`**:与现有 `better_cluster_display=payload.get("better_cluster_display", False)` 模式一致(第 171-175 行);同时提供默认值,对老客户端(不传该字段)向后兼容。 + +--- + +## 3. 前端状态字段扩展 + +### 3.1 `store.js` 新增 state 字段 + +`src/nexusx/voyager/web/store.js` 中 `state.filter` 对象新增字段: + +```javascript +state.filter = { + // ... 现有字段 + betterClusterDisplay: false, + showModule: false, + brief: false, + // ... 其他 + hideReverseRelationships: false, // 新增 +} +``` + +### 3.2 `store.js` 新增 toggle 函数 + +紧邻现有 `toggleBetterClusterDisplay`(第 467-475 行附近)之后新增: + +```javascript +/** + * Toggle hide reverse relationships (Hide Reverse Relationships mode). + * When enabled, only MANYTOONE and MANYTOMANY direction relationship + * edges are rendered; ONETOMANY reverse mirrors are hidden. + * @param {boolean} val - New value + * @param {Function} onGenerate - Callback to regenerate graph + */ +toggleHideReverseRelationships(val, onGenerate) { + state.filter.hideReverseRelationships = val + try { + localStorage.setItem("hide_reverse_relationships", JSON.stringify(val)) + } catch (e) { + console.warn("Failed to save hide_reverse_relationships to localStorage", e) + } + onGenerate() +}, +``` + +签名、错误降级(`console.warn` 不阻塞)、`onGenerate()` 调用模式均与现有 toggle 函数逐字对齐。 + +### 3.3 `vue-main.js` 初始化时读取 localStorage + +`src/nexusx/voyager/web/vue-main.js` 第 55-65 行附近(现有 `loadToggleState("pydantic_resolve_meta", ...)` 之后)新增: + +```javascript +store.state.filter.hideReverseRelationships = loadToggleState("hide_reverse_relationships", false) +``` + +### 3.4 `vue-main.js` 调用 `fetch /er-diagram` 时透传字段 + +在 `vue-main.js` 第 189 行附近(现有 `fetch("er-diagram", { body: JSON.stringify({...}) })`),请求体新增字段: + +```javascript +const res = await fetch("er-diagram", { + // ... + body: JSON.stringify({ + // ... 现有字段 + show_methods: store.state.modeControl.showMethodsEnabled, + hide_reverse_relationships: store.state.filter.hideReverseRelationships, // 新增 + }), +}) +``` + +`/er-diagram-subgraph` 请求体同理(如果 vue-main.js 直接构造;若由 `related-entities-display.js` 构造则在该组件内透传)。 + +### 3.5 显示选项面板新增 checkbox + +`src/nexusx/voyager/web/component/schema-code-display.js`(或对应显示选项面板所在组件)的模板,紧邻现有 `better cluster display` / `brief mode` toggle 之后新增 Quasar `q-checkbox`: + +```html + +``` + +具体位置(顺序、分组)属于 UI 实现细节,本数据模型不固化;只需满足 spec FR-001"与现有显示选项位于同一交互区域"。 + +--- + +## 4. 不受 Pure FK 模式影响的数据通路 + +以下数据通路在 Pure FK 模式开启/关闭时**完全不变**: + +- **`/source`(源码)、`/vscode-link`(IDE 跳转)、`/docstring`(spec 006)**:与 ER 图渲染独立,不受 Pure FK 影响。 +- **`SchemaNode.fields`(字段表)**:`_get_entity_fields` 仍消费 `self.rel_name_set`(记录全部 relationship 名),Fields tab 仍展示完整字段列表。 +- **`Web/schema`(schema 元数据)端点**:不经 `ErDiagramDotBuilder` 流程,不受影响。 +- **`/use-case-diagram` 等其他模式端点**:Pure FK 仅作用于 ER-diagram 模式(spec 假设区块固化),其他模式不传 `hide_reverse_relationships` 字段。 + +--- + +## 5. 边界情况数据契约 + +| 边界情况 | 数据行为 | +|---------|---------| +| 老客户端不传 `hide_reverse_relationships` 字段 | Pydantic 默认 `False`,行为与现状一致 | +| 字段值为非 bool(如 `"true"` 字符串) | Pydantic 自动转换(与现有 bool 字段一致);转换失败返回 422 | +| `rel_info.direction` 为非常规值(如 None) | 不匹配 `'ONETOMANY'`,按"MANYTOONE/MANYTOMANY 同等保留"处理(不会被过滤) | +| `localStorage.hide_reverse_relationships` 为非 JSON 字符串 | `loadToggleState` 已有 try/catch 降级(spec 边界情况已覆盖),默认 false | +| 自引用双向关系(`Tree.parent` ↔ `Tree.children`) | `parent` MANYTOONE 保留、`children` ONETOMANY 隐藏;自环呈现为单条 MANYTOONE 自连线 | +| M2M 关系(`secondary="..."`) | SQLAlchemy `direction` 为 `MANYTOMANY`,不匹配 `'ONETOMANY'`,双方向都保留 | +| 单向 ONETOMANY(无 `back_populates`) | 仍按 `direction == 'ONETOMANY'` 隐藏,符合 spec 验收场景 5 | diff --git a/specs/007-voyager-er-pure-fk/plan.md b/specs/007-voyager-er-pure-fk/plan.md new file mode 100644 index 0000000..8224a89 --- /dev/null +++ b/specs/007-voyager-er-pure-fk/plan.md @@ -0,0 +1,113 @@ +# Implementation Plan:Voyager ER 图 —— Hide Reverse Relationships 连线模式 + +**Branch**: `007-voyager-er-pure-fk` | **Date**: 2026-07-03 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `/specs/007-voyager-er-pure-fk/spec.md` + +**Note**: 本计划由 `/speckit-plan` 产出,所有产物使用中文撰写(项目级约定)。功能分支名 `007-voyager-er-pure-fk` 为历史标识(specify 阶段命名),UI 可见 label 已在 clarify 阶段(Q5)改名为 "Hide Reverse Relationships";分支/目录名保留不变,与 UI label 解耦。 + +## Summary + +为 Voyager ER 图新增 "Hide Reverse Relationships" 显示选项,勾选后只保留 MANYTOONE 方向与 MANYTOMANY 方向的 relationship 连线、隐藏 ONETOMANY 反向镜像,消除 `back_populates` 双向关系产生的视觉重复(每对双向关联实体之间的连线从 2 条降为 1 条)。勾选状态默认关闭、写入 localStorage 持久化、与其他显示选项正交;作用范围包括主画布与 Related Entities 子图(spec 005 已建立"子图跟随主图渲染配置"原则)。 + +后端实现路径:在 `er_diagram_dot.py::_add_relationship_link` 中按 `RelationshipInfo.direction` 字段(SQLAlchemy `inspect()` 已提供,无需新增反射逻辑)早退过滤 ONETOMANY;`ErDiagramDotBuilder.__init__` 新增 `hide_reverse_relationships: bool = False` 参数;前端通过现有 `/er-diagram`、`/er-diagram-subgraph` 请求体(`ErDiagramPayload`、`ErDiagramSubgraphPayload`)新增同名字段把 UI 状态传到后端。不改造连线锚点或 label 生成方式、不引入新前端依赖、不破坏现有端点契约 shape(新增可选 bool 字段属兼容性变更)。 + +## Technical Context + +- **Language/Version**: Python 3.10+(后端);Vanilla JS + Vue 3 + Quasar 2(前端,**无构建工具链**,第三方库以 `.min.js` 形式 vendored 在 `src/nexusx/voyager/web/`) +- **Primary Dependencies**: FastAPI 0.135+(后端 HTTP)、Quasar / Vue 3(前端 UI,已 vendored);本期**不引入新依赖**——`RelationshipInfo.direction` 字段已由 SQLAlchemy `inspect()` 在 `loader/registry.py::_inspect_relationships` 中自动反射 +- **Storage**: 浏览器 localStorage(key: `hide_reverse_relationships`,沿用项目内 `better_cluster_display` / `show_module_cluster` / `brief_mode` / `pydantic_resolve_meta` 等偏好持久化模式);后端无持久化 +- **Testing**: `pytest`(后端 `_add_relationship_link` 按 direction 过滤逻辑 + `/er-diagram` / `/er-diagram-subgraph` 端点契约扩展);前端无自动化测试基线,依赖 `quickstart.md` 的人工验证流程 +- **Target Platform**: 开发机(Linux/macOS/Windows)启动 FastAPI 进程,浏览器访问 `http://localhost:`;不做 SSR +- **Project Type**: library(nexusx)+ 内嵌 web 服务子模块(voyager) +- **Performance Goals**: 过滤逻辑(按 direction 早退)开销可忽略——`analysis()` 复杂度从 O(relationships) 不变,只是每次迭代多一次 `if rel_info.direction == 'ONETOMANY'` 比较;前端 toggle 切换触发的 ER 图重新生成与现有 toggle(cluster display / brief mode)一致 +- **Constraints**: + - **不引入前端构建工具链**——所有前端依赖必须能以 `