Skip to content

Commit f39ea11

Browse files
MKY508claude
andcommitted
feat: 实现可视化表关系功能 (P1)
后端: - 新增 TableRelationship 数据库模型 - 新增 Schema API (获取表结构、CRUD 关系、自动检测建议) - ExecutionService 注入关系上下文到 AI 提示 - 限制最大注入 15 个关系,防止超过 token 限制 前端: - 安装 @xyflow/react 拖拽库 - 新增 TableNode 组件 (显示表和列) - 新增 SchemaSettings 组件 (React Flow 画布) - 设置页面新增"表关系"标签页 - ConnectionSettings 支持选择连接 功能: - 可视化显示数据库表结构 - 拖拽连线建立 JOIN 关系 - 自动检测 xxx_id 模式并建议关系 - AI 查询时自动使用预定义关系 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0c9f537 commit f39ea11

File tree

14 files changed

+1158
-11
lines changed

14 files changed

+1158
-11
lines changed

apps/api/app/api/v1/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from fastapi import APIRouter
44

5-
from app.api.v1 import auth, chat, connections, history, models, semantic, user_config
5+
from app.api.v1 import auth, chat, connections, history, models, schema, semantic, user_config
66

77
api_router = APIRouter()
88

@@ -13,4 +13,5 @@
1313
api_router.include_router(models.router, prefix="/config", tags=["模型配置"])
1414
api_router.include_router(connections.router, prefix="/config", tags=["数据库连接"])
1515
api_router.include_router(semantic.router, prefix="/config", tags=["语义层"])
16+
api_router.include_router(schema.router, tags=["表关系"])
1617
api_router.include_router(user_config.router, tags=["用户配置"])

apps/api/app/api/v1/schema.py

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
"""Schema 和表关系 API"""
2+
3+
import re
4+
from uuid import UUID
5+
6+
from fastapi import APIRouter, Depends, HTTPException, status
7+
from sqlalchemy import delete, select
8+
from sqlalchemy.ext.asyncio import AsyncSession
9+
10+
from app.api.deps import get_current_user
11+
from app.core import encryptor
12+
from app.db import get_db
13+
from app.db.tables import Connection, TableRelationship, User
14+
from app.models import (
15+
APIResponse,
16+
ColumnInfo,
17+
RelationshipSuggestion,
18+
SchemaInfo,
19+
TableInfo,
20+
TableRelationshipBatchCreate,
21+
TableRelationshipCreate,
22+
TableRelationshipResponse,
23+
TableRelationshipUpdate,
24+
)
25+
from app.services.database import create_database_manager
26+
27+
router = APIRouter(prefix="/schema", tags=["schema"])
28+
29+
30+
async def _get_connection(
31+
connection_id: UUID,
32+
user: User,
33+
db: AsyncSession,
34+
) -> Connection:
35+
"""获取并验证数据库连接"""
36+
result = await db.execute(
37+
select(Connection).where(
38+
Connection.id == connection_id,
39+
Connection.user_id == user.id,
40+
)
41+
)
42+
connection = result.scalar_one_or_none()
43+
if not connection:
44+
raise HTTPException(
45+
status_code=status.HTTP_404_NOT_FOUND,
46+
detail="数据库连接不存在",
47+
)
48+
return connection
49+
50+
51+
def _get_db_config(connection: Connection) -> dict:
52+
"""构建数据库配置"""
53+
password = None
54+
if connection.password_encrypted:
55+
try:
56+
password = encryptor.decrypt(connection.password_encrypted)
57+
except Exception:
58+
pass
59+
60+
return {
61+
"driver": connection.driver,
62+
"host": connection.host,
63+
"port": connection.port,
64+
"user": connection.username,
65+
"password": password,
66+
"database": connection.database_name,
67+
}
68+
69+
70+
def _detect_relationships(tables: list[TableInfo]) -> list[RelationshipSuggestion]:
71+
"""自动检测可能的表关系"""
72+
suggestions = []
73+
table_names = {t.name.lower(): t.name for t in tables}
74+
75+
for table in tables:
76+
for column in table.columns:
77+
col_lower = column.name.lower()
78+
79+
# 检测 xxx_id 模式
80+
if col_lower.endswith("_id") and col_lower != "id":
81+
# 提取可能的表名
82+
potential_table = col_lower[:-3] # 去掉 _id
83+
84+
# 尝试匹配表名(单数/复数)
85+
matched_table = None
86+
for variant in [
87+
potential_table,
88+
potential_table + "s",
89+
potential_table + "es",
90+
potential_table.rstrip("s"),
91+
]:
92+
if variant in table_names:
93+
matched_table = table_names[variant]
94+
break
95+
96+
if matched_table and matched_table != table.name:
97+
# 检查目标表是否有 id 列
98+
target_table_info = next(
99+
(t for t in tables if t.name == matched_table), None
100+
)
101+
if target_table_info:
102+
has_id = any(
103+
c.name.lower() == "id" for c in target_table_info.columns
104+
)
105+
if has_id:
106+
suggestions.append(
107+
RelationshipSuggestion(
108+
source_table=table.name,
109+
source_column=column.name,
110+
target_table=matched_table,
111+
target_column="id",
112+
confidence=0.9,
113+
reason=f"列名 {column.name} 匹配表 {matched_table}",
114+
)
115+
)
116+
117+
return suggestions
118+
119+
120+
@router.get("/{connection_id}", response_model=APIResponse[SchemaInfo])
121+
async def get_schema(
122+
connection_id: UUID,
123+
current_user: User = Depends(get_current_user),
124+
db: AsyncSession = Depends(get_db),
125+
):
126+
"""获取数据库 Schema 信息和关系建议"""
127+
connection = await _get_connection(connection_id, current_user, db)
128+
db_config = _get_db_config(connection)
129+
130+
try:
131+
db_manager = create_database_manager(db_config)
132+
schema_info = db_manager.get_schema_info()
133+
134+
# 解析 schema_info 字符串为结构化数据
135+
tables = _parse_schema_info(schema_info)
136+
137+
# 自动检测关系
138+
suggestions = _detect_relationships(tables)
139+
140+
return APIResponse.ok(
141+
data=SchemaInfo(tables=tables, suggestions=suggestions)
142+
)
143+
except Exception as e:
144+
raise HTTPException(
145+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
146+
detail=f"获取 Schema 失败: {str(e)}",
147+
)
148+
149+
150+
def _parse_schema_info(schema_info: str) -> list[TableInfo]:
151+
"""解析 schema_info 字符串为结构化数据"""
152+
tables = []
153+
current_table = None
154+
columns = []
155+
156+
for line in schema_info.strip().split("\n"):
157+
line = line.strip()
158+
if not line:
159+
continue
160+
161+
# 检测表名行 (格式: "表名 tablename:" 或 "tablename:")
162+
if line.endswith(":"):
163+
if current_table and columns:
164+
tables.append(TableInfo(name=current_table, columns=columns))
165+
# 提取表名
166+
table_match = re.match(r"(?:表\s+)?(\w+):", line)
167+
if table_match:
168+
current_table = table_match.group(1)
169+
columns = []
170+
elif current_table and line.startswith("-"):
171+
# 解析列信息 (格式: "- column_name (TYPE)")
172+
col_match = re.match(r"-\s*(\w+)\s*\(([^)]+)\)", line)
173+
if col_match:
174+
col_name = col_match.group(1)
175+
col_type = col_match.group(2)
176+
columns.append(
177+
ColumnInfo(
178+
name=col_name,
179+
data_type=col_type,
180+
is_primary_key=col_name.lower() == "id",
181+
is_foreign_key=col_name.lower().endswith("_id"),
182+
)
183+
)
184+
185+
# 添加最后一个表
186+
if current_table and columns:
187+
tables.append(TableInfo(name=current_table, columns=columns))
188+
189+
return tables
190+
191+
192+
@router.get(
193+
"/{connection_id}/relationships",
194+
response_model=APIResponse[list[TableRelationshipResponse]],
195+
)
196+
async def get_relationships(
197+
connection_id: UUID,
198+
current_user: User = Depends(get_current_user),
199+
db: AsyncSession = Depends(get_db),
200+
):
201+
"""获取已保存的表关系"""
202+
await _get_connection(connection_id, current_user, db)
203+
204+
result = await db.execute(
205+
select(TableRelationship).where(
206+
TableRelationship.connection_id == connection_id,
207+
TableRelationship.user_id == current_user.id,
208+
TableRelationship.is_active.is_(True),
209+
)
210+
)
211+
relationships = result.scalars().all()
212+
213+
return APIResponse.ok(
214+
data=[TableRelationshipResponse.model_validate(r) for r in relationships]
215+
)
216+
217+
218+
@router.post(
219+
"/{connection_id}/relationships",
220+
response_model=APIResponse[TableRelationshipResponse],
221+
)
222+
async def create_relationship(
223+
connection_id: UUID,
224+
data: TableRelationshipCreate,
225+
current_user: User = Depends(get_current_user),
226+
db: AsyncSession = Depends(get_db),
227+
):
228+
"""创建表关系"""
229+
await _get_connection(connection_id, current_user, db)
230+
231+
relationship = TableRelationship(
232+
user_id=current_user.id,
233+
connection_id=connection_id,
234+
**data.model_dump(),
235+
)
236+
db.add(relationship)
237+
await db.commit()
238+
await db.refresh(relationship)
239+
240+
return APIResponse.ok(
241+
data=TableRelationshipResponse.model_validate(relationship),
242+
message="关系创建成功",
243+
)
244+
245+
246+
@router.post(
247+
"/{connection_id}/relationships/batch",
248+
response_model=APIResponse[list[TableRelationshipResponse]],
249+
)
250+
async def create_relationships_batch(
251+
connection_id: UUID,
252+
data: TableRelationshipBatchCreate,
253+
current_user: User = Depends(get_current_user),
254+
db: AsyncSession = Depends(get_db),
255+
):
256+
"""批量创建表关系"""
257+
await _get_connection(connection_id, current_user, db)
258+
259+
relationships = []
260+
for rel_data in data.relationships:
261+
relationship = TableRelationship(
262+
user_id=current_user.id,
263+
connection_id=connection_id,
264+
**rel_data.model_dump(),
265+
)
266+
db.add(relationship)
267+
relationships.append(relationship)
268+
269+
await db.commit()
270+
271+
for rel in relationships:
272+
await db.refresh(rel)
273+
274+
return APIResponse.ok(
275+
data=[TableRelationshipResponse.model_validate(r) for r in relationships],
276+
message=f"成功创建 {len(relationships)} 个关系",
277+
)
278+
279+
280+
@router.put(
281+
"/relationships/{relationship_id}",
282+
response_model=APIResponse[TableRelationshipResponse],
283+
)
284+
async def update_relationship(
285+
relationship_id: UUID,
286+
data: TableRelationshipUpdate,
287+
current_user: User = Depends(get_current_user),
288+
db: AsyncSession = Depends(get_db),
289+
):
290+
"""更新表关系"""
291+
result = await db.execute(
292+
select(TableRelationship).where(
293+
TableRelationship.id == relationship_id,
294+
TableRelationship.user_id == current_user.id,
295+
)
296+
)
297+
relationship = result.scalar_one_or_none()
298+
299+
if not relationship:
300+
raise HTTPException(
301+
status_code=status.HTTP_404_NOT_FOUND,
302+
detail="关系不存在",
303+
)
304+
305+
update_data = data.model_dump(exclude_none=True)
306+
for key, value in update_data.items():
307+
setattr(relationship, key, value)
308+
309+
await db.commit()
310+
await db.refresh(relationship)
311+
312+
return APIResponse.ok(
313+
data=TableRelationshipResponse.model_validate(relationship),
314+
message="关系更新成功",
315+
)
316+
317+
318+
@router.delete("/relationships/{relationship_id}", response_model=APIResponse)
319+
async def delete_relationship(
320+
relationship_id: UUID,
321+
current_user: User = Depends(get_current_user),
322+
db: AsyncSession = Depends(get_db),
323+
):
324+
"""删除表关系"""
325+
result = await db.execute(
326+
delete(TableRelationship).where(
327+
TableRelationship.id == relationship_id,
328+
TableRelationship.user_id == current_user.id,
329+
)
330+
)
331+
332+
if result.rowcount == 0:
333+
raise HTTPException(
334+
status_code=status.HTTP_404_NOT_FOUND,
335+
detail="关系不存在",
336+
)
337+
338+
await db.commit()
339+
340+
return APIResponse.ok(message="关系删除成功")

apps/api/app/db/tables.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,32 @@ class SemanticTerm(Base, UUIDMixin, TimestampMixin):
170170
# 关系
171171
user: Mapped["User"] = relationship(back_populates="semantic_terms")
172172
connection: Mapped["Connection | None"] = relationship()
173+
174+
175+
class TableRelationship(Base, UUIDMixin, TimestampMixin):
176+
"""表关系定义 - 用于多表 JOIN"""
177+
178+
__tablename__ = "table_relationships"
179+
180+
user_id: Mapped[UUID] = mapped_column(
181+
PG_UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
182+
)
183+
connection_id: Mapped[UUID] = mapped_column(
184+
PG_UUID(as_uuid=True), ForeignKey("connections.id", ondelete="CASCADE"), nullable=False
185+
)
186+
source_table: Mapped[str] = mapped_column(String(100), nullable=False)
187+
source_column: Mapped[str] = mapped_column(String(100), nullable=False)
188+
target_table: Mapped[str] = mapped_column(String(100), nullable=False)
189+
target_column: Mapped[str] = mapped_column(String(100), nullable=False)
190+
relationship_type: Mapped[str] = mapped_column(
191+
String(10), default="1:N"
192+
) # 1:1, 1:N, N:1, N:M
193+
join_type: Mapped[str] = mapped_column(
194+
String(20), default="LEFT"
195+
) # LEFT, INNER, RIGHT, FULL
196+
description: Mapped[str | None] = mapped_column(Text)
197+
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
198+
199+
# 关系
200+
user: Mapped["User"] = relationship()
201+
connection: Mapped["Connection"] = relationship()

0 commit comments

Comments
 (0)