合入数据目录阶段 1A:目录存储、接口、外键与命名推断、起草、冻结进快照、db_schema 带目录

This commit is contained in:
ThearchyHelios
2026-10-03 09:46:29 +08:00
18 changed files with 3847 additions and 17 deletions
+226
View File
@@ -0,0 +1,226 @@
"""数据目录接口:/api/datasources/{source_id}/catalog。
- GET /catalog 表清单(没有目录的表也列出来)
- GET /catalog/{table} 单表目录 + 表结构
- PUT /catalog/{table} 整份提交(乐观锁),改动过的项记为人工确认
- POST /catalog/{table}/review 单项审阅:确认、驳回、撤销审阅
- POST /catalog/draft 同步起草(注释、外键、命名推断,可选模型)
规则都在 data/catalog.py,这里只做取源、转换形状和把异常翻成状态码:版本不符 409,结构不合规、审阅路径
不对 422,源或表不存在 404。所有写操作记录请求头 X-Actor 的署名(自报、未认证)。
上传表格的源按当前快照的表结构回答(table_versions.resolve_source),和查询实际用的库是同一个版本。
"""
from __future__ import annotations
from typing import Any, Literal
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.runs import actor_of
from app.data import catalog, introspect, table_versions
from app.db.base import get_session
from app.db.models import DataSource
router = APIRouter(prefix="/api/datasources", tags=["catalog"])
class CatalogPutIn(BaseModel):
notes: dict[str, Any]
#: 读到的版本;这张表还没有目录时为 0
if_version: int = Field(ge=0)
class CatalogReviewIn(BaseModel):
#: grain、columns.amount.measure、relations.<编号>
path: str = Field(min_length=1, max_length=600)
action: Literal["confirm", "reject", "reset"]
if_version: int = Field(ge=0)
class CatalogDraftIn(BaseModel):
#: 要起草的表;不给或为空时按使用次数取前 20 张
tables: list[str] | None = Field(default=None, max_length=200)
#: 是否请助手的模型起草中文名、粒度、列的含义等
use_model: bool = False
async def _resolved(session: AsyncSession, source_id: str) -> tuple[DataSource, Any]:
"""(数据源记录, 用来读表结构的源):上传源解析成绑定当前快照的视图。"""
row = await session.get(DataSource, source_id)
if row is None:
raise HTTPException(404, "数据源不存在,可能已被删除")
try:
return row, await table_versions.resolve_source(session, row)
except table_versions.SnapshotMissing as e:
raise HTTPException(409, str(e)) from e
def _iso(value: Any) -> str | None:
return value.isoformat() if value is not None else None
def _item_value(notes: dict[str, Any], name: str) -> tuple[Any, str | None]:
item = notes.get(name)
if not isinstance(item, dict) or item.get("status") == "rejected":
return None, None
return item.get("value"), item.get("status")
def _summary_row(name: str, meta: dict[str, Any] | None, entry: catalog.CatalogEntry | None,
usage: int) -> dict[str, Any]:
notes = entry.notes if entry else {}
label, label_status = _item_value(notes, "label")
kind, _ = _item_value(notes, "kind")
relations = notes.get("relations") if isinstance(notes.get("relations"), list) else []
return {
"table_name": name,
"qualified": (meta or {}).get("qualified", name),
"is_view": bool((meta or {}).get("is_view")),
# 表结构里已经没有这张表(被删了、改名了),目录还留着:界面据此提示清理
"in_schema": meta is not None,
"label": label,
"label_status": label_status,
"kind": kind,
"counts": catalog.status_counts(notes),
"relations": sum(1 for r in relations if isinstance(r, dict) and r.get("status") != "rejected"),
"usage": usage,
"version": entry.version if entry else 0,
"updated_at": _iso(entry.updated_at) if entry else None,
"updated_by": entry.updated_by if entry else None,
}
def _structure(meta: dict[str, Any]) -> dict[str, Any]:
return {
"qualified": meta.get("qualified"),
"is_view": bool(meta.get("is_view")),
"comment": meta.get("comment"),
"columns": introspect.table_columns(meta),
"primary_key": list(meta.get("primary_key") or []),
# 升级前探查的缓存里没有这两项:为 null 表示「不知道」,和空列表(读过、没有)分开
"foreign_keys": meta.get("foreign_keys"),
"unique": meta.get("unique"),
}
async def _detail(session: AsyncSession, source: Any, table: str, entry: catalog.CatalogEntry | None,
usage: int | None = None) -> dict[str, Any]:
meta = ((source.schema_cache or {}).get("tables") or {}).get(table)
if usage is None:
usage = (await catalog.table_usage(session, source)).get(table, 0)
return {
"table_name": table,
"in_schema": meta is not None,
"notes": entry.notes if entry else {},
"version": entry.version if entry else 0,
"updated_at": _iso(entry.updated_at) if entry else None,
"updated_by": entry.updated_by if entry else None,
"structure": _structure(meta) if meta is not None else None,
"system_notes": catalog.system_notes_source(source),
"usage": usage,
}
async def _table_key(session: AsyncSession, source: Any, table: str) -> tuple[str, catalog.CatalogEntry | None]:
"""路径里的表名 → 目录的键。表结构里有的按表结构的写法;表结构里没有、但还留着目录的也认(便于清理)。"""
key = catalog.resolve_table_name(source.schema_cache, table)
entry = await catalog.read_entry(session, source.id, key or table)
if key is None and entry is None:
raise HTTPException(404, f"数据源中没有表 {table},可能已被删除或尚未探查结构")
return key or table, entry
def _conflict(e: catalog.CatalogConflict) -> HTTPException:
return HTTPException(409, str(e))
def _invalid(e: catalog.CatalogInvalid) -> HTTPException:
shown = ";".join(e.problems[:8]) + (f";另有 {len(e.problems) - 8} 处" if len(e.problems) > 8 else "")
return HTTPException(422, f"数据目录的格式不正确:{shown}")
@router.get("/{source_id}/catalog")
async def list_catalog(source_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""表清单:表结构里的每张表各一行(没有目录的也列),使用次数多的在前;目录还在、表已不在的排最后。"""
_, source = await _resolved(session, source_id)
cache = source.schema_cache or {}
tables = cache.get("tables") or {}
entries = await catalog.read_catalog(session, source.id)
usage = await catalog.table_usage(session, source)
order = {name: i for i, name in enumerate(tables)}
rows = [_summary_row(name, meta, entries.get(name), usage.get(name, 0)) for name, meta in tables.items()]
rows.sort(key=lambda r: (-r["usage"], order[r["table_name"]]))
rows += [_summary_row(name, None, entry, 0) for name, entry in sorted(entries.items()) if name not in tables]
return {
"tables": rows,
"system_notes": catalog.system_notes_source(source),
# 没有表结构时说明原因(尚未探查 / 探查失败),界面据此引导去探查
"schema_note": None if tables else introspect.why_empty(cache),
}
@router.get("/{source_id}/catalog/{table}")
async def get_catalog_table(source_id: str, table: str,
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
_, source = await _resolved(session, source_id)
key, entry = await _table_key(session, source, table)
return await _detail(session, source, key, entry)
@router.put("/{source_id}/catalog/{table}")
async def put_catalog_table(source_id: str, table: str, payload: CatalogPutIn,
x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""整份提交一张表的目录。改动过的项记为人工确认;版本不符 409。"""
_, source = await _resolved(session, source_id)
key, _ = await _table_key(session, source, table)
try:
entry = await catalog.save_human_edit(session, source.id, key, payload.notes, if_version=payload.if_version,
actor=actor_of(x_actor))
except catalog.CatalogConflict as e:
raise _conflict(e) from e
except catalog.CatalogInvalid as e:
raise _invalid(e) from e
return await _detail(session, source, key, entry)
@router.post("/{source_id}/catalog/draft")
async def draft_catalog(source_id: str, payload: CatalogDraftIn,
x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""同步起草。返回每张表新增、更新、删除了几项;模型用不了或某张表的模型起草失败,照实写在结果里。"""
_, source = await _resolved(session, source_id)
cache = source.schema_cache or {}
if not cache.get("tables"):
raise HTTPException(409, f"{introspect.why_empty(cache)},无法起草数据目录。请先在数据源卡片上点「探查结构」")
report = await catalog.draft_catalog(session, source, tables=payload.tables or None,
use_model=payload.use_model, actor=actor_of(x_actor))
rows = [{"table_name": r.table, "added": r.added, "updated": r.updated, "removed": r.removed,
"version": r.version, "error": r.error, "model_error": r.model_error} for r in report.tables]
return {
"tables": rows,
"total": {k: sum(r[k] for r in rows) for k in ("added", "updated", "removed")},
"model_used": report.model_used,
"model": report.model or None,
"model_error": report.model_error,
}
@router.post("/{source_id}/catalog/{table}/review")
async def review_catalog_item(source_id: str, table: str, payload: CatalogReviewIn,
x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""单项审阅。版本不符 409;路径不对、项不存在 422。"""
_, source = await _resolved(session, source_id)
key, _ = await _table_key(session, source, table)
try:
entry = await catalog.review_entry(session, source.id, key, payload.path, payload.action,
if_version=payload.if_version, actor=actor_of(x_actor))
except catalog.CatalogConflict as e:
raise _conflict(e) from e
except catalog.CatalogPathError as e:
raise HTTPException(422, str(e)) from e
return await _detail(session, source, key, entry)
+5 -1
View File
@@ -22,6 +22,7 @@ from app.api.runs import actor_of
from app.core.errors import AUTH, NETWORK, TIMEOUT, classify, explain, raw
from app.core.config import settings
from app.core.crypto import encrypt, mask
from app.data import catalog as data_catalog
from app.data import introspect as introspect_mod
from app.data import table_versions
from app.data.engine import (
@@ -805,10 +806,13 @@ async def get_schema(source_id: str, table: str | None = None,
raise HTTPException(409, str(e)) from e
if table:
meta = introspect_mod.find_table(row, table)
# 和 db_schema 工具同一个说法:有数据目录的表,字段清单后面附上目录
entries = await data_catalog.read_catalog(session, found.id)
return {
"table": table,
# 给模型看的那段文本照旧;界面画表格用下面的结构化字段
"detail": introspect_mod.describe_table(row, table),
"detail": introspect_mod.describe_table(row, table,
catalog={n: e.notes for n, e in entries.items()}),
"found": meta is not None,
"qualified": meta.get("qualified", table) if meta else None,
"kind": ("view" if meta.get("is_view") else "table") if meta else None,
File diff suppressed because it is too large Load Diff
+96 -5
View File
@@ -14,6 +14,7 @@
"""
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime, timezone
from typing import Any
@@ -118,8 +119,58 @@ def _target_schema(source: Any, explicit: str | None) -> str | None:
return str(configured) if configured else None
def _foreign_keys(raw: list[dict[str, Any]], target_schema: str | None,
default_schema: str | None) -> list[dict[str, Any]]:
"""反射出来的外键 → [{columns, to_table, to_columns}]。
to_table 和 schema_cache["tables"] 的键同一个口径:指向同一个 schema 的只写表名;跨 schema 的写
「schema.表名」(那张表不在这次探查的结果里,关系照记,图里是一个悬空的端点)。to_columns 可能为空
(SQLite 的 REFERENCES t 省略了列),由数据目录按被指向表的主键补。
"""
home = {s for s in (target_schema, default_schema) if s}
out: list[dict[str, Any]] = []
for fk in raw:
cols = [c for c in fk.get("constrained_columns") or [] if c]
to_table = fk.get("referred_table")
if not cols or not to_table:
continue
ref_schema = fk.get("referred_schema")
out.append({
"columns": cols,
"to_table": to_table if not ref_schema or ref_schema in home else f"{ref_schema}.{to_table}",
"to_columns": [c for c in fk.get("referred_columns") or [] if c],
})
return out
def _unique_groups(constraints: list[dict[str, Any]], indexes: list[dict[str, Any]],
pk: list[str]) -> list[list[str]]:
"""唯一约束和唯一索引的列组,去重,不含和主键相同的那组。
同一组唯一列在各家可能出现两次:MySQL 的 UNIQUE 约束本身就是唯一索引,PostgreSQL 的唯一约束背后也有
一个索引,Oracle 的主键背后还有一个唯一索引。表达式索引(列名里有 None)不算。
"""
seen = {tuple(c.lower() for c in pk)} if pk else set()
groups: list[list[str]] = []
candidates = [u.get("column_names") for u in constraints]
candidates += [ix.get("column_names") for ix in indexes if ix.get("unique")]
for cols in candidates:
if not cols or any(c is None for c in cols):
continue
key = tuple(str(c).lower() for c in cols)
if key in seen:
continue
seen.add(key)
groups.append([str(c) for c in cols])
return groups
async def introspect(source: Any, *, schema: str | None = None) -> dict[str, Any]:
"""探查整个库的结构,结果直接存进 DataSource.schema_cache。"""
"""探查整个库的结构,结果直接存进 DataSource.schema_cache。
每张表:列(名字、类型、可空、注释)、主键、表注释、是不是视图;读得到的话还有外键约束
(foreign_keys)和唯一约束 / 唯一索引(unique)——读不到的表不写这两个键,探查照常完成。
"""
engine = await engines.get(source)
target = _target_schema(source, schema)
@@ -151,9 +202,14 @@ async def introspect(source: Any, *, schema: str | None = None) -> dict[str, Any
failures: dict[str, str] = {}
def _multi(fn: Any, single: Any, names: list[str], *,
errors: dict[str, str] | None = None) -> dict[Any, Any]:
errors: dict[str, str] | None = None, ok: set[str] | None = None) -> dict[Any, Any]:
"""批量反射,失败时逐个对象重试。ok 给了的话,记下真正读到了的对象名:批量成功时是全部
(没有约束的表批量接口也会给空列表,或者干脆不给),逐个重试时只有没报错的那些。"""
try:
return dict(fn(schema=target_schema, filter_names=names, kind=ObjectKind.ANY))
got = dict(fn(schema=target_schema, filter_names=names, kind=ObjectKind.ANY))
if ok is not None:
ok.update(names)
return got
except NotImplementedError:
return {} # 表注释这类不是所有方言都支持
except Exception: # noqa: BLE001 - 批量取不到,逐个对象再试
@@ -162,6 +218,8 @@ async def introspect(source: Any, *, schema: str | None = None) -> dict[str, Any
for name in names:
try:
out[(target_schema, name)] = single(name, schema=target_schema)
if ok is not None:
ok.add(name)
except NotImplementedError:
break
except Exception as e: # noqa: BLE001 - 只跳过这一个对象
@@ -176,6 +234,20 @@ async def introspect(source: Any, *, schema: str | None = None) -> dict[str, Any
readable = [n for n in picked if columns_by.get((target_schema, n))]
pk_by = _multi(inspector.get_multi_pk_constraint, inspector.get_pk_constraint, readable)
comment_by = _multi(inspector.get_multi_table_comment, inspector.get_table_comment, readable)
# 外键和唯一约束给数据目录起草关系用(data/catalog.py)。放在列、主键、注释之后读:PostgreSQL 上一条
# 语句报错会让整个事务作废,先读的那几样不能被它连累。读不到的表不写这两个键(ok 集合里没有它),
# 下游据此分得清「读过、没有」和「不知道」——升级前探查的缓存里也没有这两个键。
fk_ok: set[str] = set()
uq_ok: set[str] = set()
ix_ok: set[str] = set()
fk_by = _multi(inspector.get_multi_foreign_keys, inspector.get_foreign_keys, readable, errors={}, ok=fk_ok)
uq_by = _multi(inspector.get_multi_unique_constraints, inspector.get_unique_constraints, readable,
errors={}, ok=uq_ok)
ix_by = _multi(inspector.get_multi_indexes, inspector.get_indexes, readable, errors={}, ok=ix_ok)
try:
default_schema = inspector.default_schema_name
except Exception: # noqa: BLE001 - 只用来判断外键是不是指向别的 schema
default_schema = None
tables: dict[str, Any] = {}
skipped: dict[str, str] = {}
@@ -207,6 +279,11 @@ async def introspect(source: Any, *, schema: str | None = None) -> dict[str, Any
"comment": comment,
"is_view": name in views,
}
if name in fk_ok:
tables[name]["foreign_keys"] = _foreign_keys(fk_by.get(key) or [], target_schema, default_schema)
if name in uq_ok:
tables[name]["unique"] = _unique_groups(uq_by.get(key) or [],
(ix_by.get(key) or []) if name in ix_ok else [], pk)
payload: dict[str, Any] = {"tables": tables, "truncated": truncated,
"total": len(names) + len(views)}
if failures and not tables:
@@ -345,8 +422,14 @@ def table_columns(meta: dict[str, Any]) -> list[dict[str, Any]]:
]
def describe_table(source: Any, table: str) -> str:
"""单表的完整字段说明。agent 调 db_schema 工具时返回这个。"""
def describe_table(source: Any, table: str, *, catalog: Mapping[str, Mapping[str, Any]] | None = None) -> str:
"""单表的完整字段说明。agent 调 db_schema 工具时返回这个。
catalog 是这个源的数据目录 {表名: notes}(data/catalog.py),给了且这张表有可给模型看的内容时,在字段
清单后面附上目录:中文名、粒度、业务主键、业务日期、有效记录条件、每列的含义 / 单位 / 度量类型 / 码值、
关联关系,推断的项标出来。导入表格的源,表和列的说明是系统按核对结果生成的:说明照旧拼在表名和列后面,
目录只追加说明没有覆盖的、或人工确认过的项(catalog.render_table_notes)。不给 catalog 时输出和以前一样。
"""
cache = source.schema_cache or {}
tables = cache.get("tables") or {}
# 模型可能传全名(ANALYTICS.V_TRIP_FACT),也可能只传表名,两种都认
@@ -381,6 +464,14 @@ def describe_table(source: Any, table: str) -> str:
marks.append(col["comment"])
suffix = f" {'、'.join(marks)}" if marks else ""
lines.append(f" {col['name']} {col['type']}{suffix}")
if catalog:
from app.data import catalog as catalog_mod
key = catalog_mod.resolve_table_name(cache, table)
block = catalog_mod.render_table_notes(catalog.get(key) if key else None, meta=meta,
system_notes=catalog_mod.system_notes_source(source))
if block:
lines.append(block)
return "\n".join(lines)
+31
View File
@@ -317,6 +317,37 @@ class SnapshotActivation(Base):
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
# --------------------------------------------------------------------------
# 数据目录(data/catalog.py)
# --------------------------------------------------------------------------
class CatalogNote(Base, TimestampMixin):
"""一张表的业务目录:中文名、粒度、业务主键、业务日期、列的含义 / 单位 / 度量类型 / 码值、表间关系。
只记数据事实,不放计算公式(公式在口径卡里)。notes 的结构和每一项的来源、状态见 data/catalog.py。
一表一行而不是一项一行:起草、审阅、冻结进表结构快照都以表为单位,版本号也按表走——两个人改
同一个源的不同表互不冲突,改同一张表时后提交的被乐观锁拦下。
挂外键、级联删除:目录是数据源的附属说明,源删掉了留着没有意义(和导入记录不同,后者要留作审计)。
运行用过的那一版目录已经随表结构快照冻结进证据,不靠这张表回溯。
"""
__tablename__ = "catalog_notes"
__table_args__ = (UniqueConstraint("source_id", "table_name"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
source_id: Mapped[str] = mapped_column(ForeignKey("data_sources.id", ondelete="CASCADE"), index=True)
# 与 schema_cache["tables"] 的键一致(不带 schema 前缀)
table_name: Mapped[str] = mapped_column(String(255))
notes: Mapped[dict[str, Any]] = mapped_column(default=dict)
# 从 1 起,每次内容有变化的写入加 1;写入方必须带上读到的版本(data/catalog.write_entry)
version: Mapped[int] = mapped_column(Integer, default=1)
# 最近一次写入的署名(请求头 X-Actor,自报、未认证);助手起草时可为空
updated_by: Mapped[str | None] = mapped_column(String(100), default=None)
class Setting(Base, TimestampMixin):
"""键值形式的用户设置,前端 Settings 页直接读写。"""
+2 -1
View File
@@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api import (
artifacts, conversations, copilot, datasources, evidence, governance, knowledge, runs,
artifacts, catalog, conversations, copilot, datasources, evidence, governance, knowledge, runs,
sandbox, settings as settings_api, source_versions, table_imports, tools, workflows,
)
from app.api.coded import CodedHTTPException, coded_handler
@@ -120,6 +120,7 @@ for router in (
# 按配方导入的 /api/datasources/imports/… 要在 datasources 的通配 /{source_id} 之前登记
table_imports.router,
source_versions.router,
catalog.router,
datasources.router,
):
app.include_router(router)
+51 -10
View File
@@ -21,6 +21,7 @@ import inspect
import json
import logging
import weakref
from collections.abc import Mapping
from pathlib import Path
from typing import Any
@@ -30,6 +31,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.events import EventType
from app.data import catalog as data_catalog
from app.data import introspect, table_versions
from app.data.engine import SnapshotTampered, masked_columns, query_timeout, run_query
from app.data.guard import QueryLimits, SqlRejected, is_write
@@ -154,6 +156,8 @@ RECIPE_QUERY_HINT = " 中文表名和列名请加双引号。查单个值时,
#: 封存事件 → schema_snapshot → 快照清单 → 各期导入清单 → 原件或清除记录,每一跳都按内容哈希
_FROZEN_SCHEMA_KEYS = ("schema", "synced_at", "truncated", "total", "import_mode", "import_manifests",
"snapshot_manifest")
#: 表结构快照里放数据目录的顶层键(_store_schema)。不来自 schema_cache:目录存在 catalog_notes 表里
CATALOG_SNAPSHOT_KEY = "catalog"
def _query_description(source: Any) -> str:
@@ -218,14 +222,31 @@ async def build_datasource_tools(
except table_versions.SnapshotMissing as e:
tools.extend(_unavailable_tool(row, prefix, str(e)) for prefix in prefixes)
continue
entries = await _catalog_of(row)
for prefix in prefixes:
if prefix == QUERY_PREFIX:
tools.append(_make_query_tool(source, ctx, fixed=fixed))
tools.append(_make_query_tool(source, ctx, fixed=fixed, catalog=entries))
else:
tools.append(_make_schema_tool(source, fixed=fixed))
tools.append(_make_schema_tool(source, fixed=fixed, catalog=entries))
return tools
async def _catalog_of(row: DataSource) -> dict[str, data_catalog.CatalogEntry]:
"""这个源的数据目录,建工具时读一次:db_schema 给模型看的和查询时冻结进表结构快照的是同一版。
读不到就当没有目录,工具照常可用——目录是附加说明,不能挡住查询。用单独的会话读:出错要回滚,
而回滚会让调用方会话里已经加载的对象(row)全部过期,之后再读属性就是异步环境里的懒加载。
"""
from app.db.base import SessionLocal
try:
async with SessionLocal() as own:
return await data_catalog.read_catalog(own, row.id)
except Exception: # noqa: BLE001
logger.exception("读取数据源 %s 的数据目录失败,本次不附目录", row.name)
return {}
# --------------------------------------------------------------------------
# 上传表格的版本
# --------------------------------------------------------------------------
@@ -414,10 +435,12 @@ def _unavailable_tool(row: DataSource, prefix: str, reason: str) -> StructuredTo
# --------------------------------------------------------------------------
def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False) -> StructuredTool:
def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False,
catalog: Mapping[str, data_catalog.CatalogEntry] | None = None) -> StructuredTool:
"""source 是手工源的 DataSource,或上传源绑定快照的 SourceView(字段同名)。
fixed:上传源的版本是这次运行固定的(找不到时的说法不同)。
catalog:建工具时读到的数据目录,随表结构快照冻结(_store_schema)。
"""
# 数据源在 options.query_timeout_s 里配了就用它,否则取缺省。数据库按它停下语句,
# 引擎按它告诉界面上限是多少(metadata["timeout_s"])
@@ -449,7 +472,7 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False) -> S
payload["source"] = source.name
# 表结构快照:这次查询时数据源的结构冻结下来,报告里写的表名、字段名按它核对。事后数据源
# 重新探查、改了结构,已经跑完的运行核对的还是当时那一份。上传源冻结的是绑定快照里那份
if schema := await _store_schema(source, ctx):
if schema := await _store_schema(source, ctx, catalog=catalog):
payload["schema_artifact"] = schema
# 查询快照进工件库:出具体系的数字回指要能下钻到"这个数是哪条 SQL 查出来的"
try:
@@ -465,9 +488,12 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False) -> S
extra["mask_columns"] = mask
if version := getattr(source, "snapshot_id", None):
extra["data_version"] = version
# 工件引用的 meta 记下源和 SQL 里用到的表:数据目录按它数每张表被运行查询过几次
# (catalog.table_usage),不用再逐个读工件内容。meta 不进内容哈希,快照本身一字不变
payload["artifact"] = await put_json(
{**payload, **extra} if extra else payload, kind="query_snapshot",
{**payload, **extra} if extra else payload, kind=data_catalog.QUERY_SNAPSHOT_KIND,
run_id=ctx.run_id or "", node_id=ctx.node_id or "",
meta=data_catalog.query_snapshot_meta(source, str(payload.get("sql") or sql)),
)
except Exception: # noqa: BLE001 - 存不下不影响查询本身
pass
@@ -485,7 +511,8 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False) -> S
)
async def _store_schema(source: Any, ctx: ToolContext) -> str | None:
async def _store_schema(source: Any, ctx: ToolContext, *,
catalog: Mapping[str, data_catalog.CatalogEntry] | None = None) -> str | None:
"""把数据源此刻的 schema_cache 存成 schema_snapshot 工件,返回工件 id。
上传源传进来的是绑定快照的视图,schema_cache 就是快照里冻结的那份:查的是哪一版,冻结的
@@ -493,6 +520,10 @@ async def _store_schema(source: Any, ctx: ToolContext) -> str | None:
内容寻址:结构没变的话,同一次运行里查多少次都是同一件,文件只有一份。没探查过结构
(或者探查失败、一张表都没有)返回 None:没有东西可以冻结,报告也就不核对表名。
数据目录一起冻结,放在顶层键 catalog 下:{表名: {version, notes(去掉驳回项)}},只收这份结构里有的、
有目录的表(catalog.frozen_catalog)。这次运行的模型看到的是哪一版目录,事后按快照就能回溯。已有字段
一个不动;没有目录的源不加这个键,快照和以前一字不差;目录不变(版本和内容都不变)则快照不变。
"""
cache = source.schema_cache or {}
tables = cache.get("tables")
@@ -503,15 +534,25 @@ async def _store_schema(source: Any, ctx: ToolContext) -> str | None:
content = {"source": source.name, "tables": tables,
**{k: cache[k] for k in _FROZEN_SCHEMA_KEYS if k in cache}}
meta: dict[str, Any] = {"source": source.name, "tables": len(tables)}
if frozen := data_catalog.frozen_catalog(catalog or {}, tables):
content[CATALOG_SNAPSHOT_KEY] = frozen
meta["catalog_tables"] = len(frozen)
try:
return await put_json(content, kind=SCHEMA_SNAPSHOT, run_id=ctx.run_id or "", node_id=ctx.node_id or "",
meta={"source": source.name, "tables": len(tables)})
meta=meta)
except Exception: # noqa: BLE001 - 存不下不影响查询本身,只是这次报告不核对表名
return None
def _make_schema_tool(source: Any, *, fixed: bool = False) -> StructuredTool:
"""source 同 _make_query_tool:上传源的表结构取绑定快照里冻结的那份。"""
def _make_schema_tool(source: Any, *, fixed: bool = False,
catalog: Mapping[str, data_catalog.CatalogEntry] | None = None) -> StructuredTool:
"""source 同 _make_query_tool:上传源的表结构取绑定快照里冻结的那份。
catalog:建工具时读到的数据目录,查单表时附在字段清单后面(introspect.describe_table)。
"""
notes = {name: entry.notes for name, entry in (catalog or {}).items()}
async def _run(table: str | list[str] | None = None) -> str:
if gone := _vanished(source, fixed):
return f"{DATA_UNAVAILABLE}{gone}"
@@ -520,7 +561,7 @@ def _make_schema_tool(source: Any, *, fixed: bool = False) -> StructuredTool:
# 而模型两种都会写。为此拒绝一次调用,纯属浪费一步
wanted = [t.strip() for one in wanted for t in str(one).split(",") if t.strip()]
if wanted:
return "\n\n".join(introspect.describe_table(source, t) for t in wanted)
return "\n\n".join(introspect.describe_table(source, t, catalog=notes) for t in wanted)
tables = introspect.table_names(source)
if not tables:
# 把实情交出去,而不是让它去点一个它点不到的按钮
+11
View File
@@ -41,3 +41,14 @@ def _prepare_schema():
def pytest_report_header(config) -> str:
return f"测试数据目录:{_TMP}"
@pytest.fixture(scope="session")
def scenic_db(tmp_path_factory) -> str:
"""合成的景区业务库(tests/fixtures/catalog/scenic.py):50 张表、真外键与只能靠命名推断的关系并存。
每个测试进程建一次,只读使用;要改库内容的测试自己调 scenic.build(tmp_path / "x.db") 另建一个。
"""
from tests.fixtures.catalog import scenic
return str(scenic.build(tmp_path_factory.mktemp("catalog") / "scenic.db"))
+16
View File
@@ -0,0 +1,16 @@
"""数据目录用的合成业务库夹具。
- scenic.py:一个虚构的「景区 + 园内门店」业务库(SQLite,50 张表 + 1 个视图),给数据目录的起草、关系图、
表清单测试用,阶段 1B(助手按任务挑表)也用它。表名、数据全部虚构,不对应任何真实部署。
用法::
from tests.fixtures.catalog import scenic
path = scenic.build(tmp_path / "scenic.db") # 写一个新库,返回路径;同样的参数造出同样的内容
scenic.FK_RELATIONS # 库里声明了外键约束的关系
scenic.NAME_RELATIONS # 没有外键约束、只能靠列名推断出来的关系
scenic.NOT_INFERRED # 看起来像关联、但命名推断必须放过的列(及原因)
conftest.py 里有个 scenic_db 夹具(每个测试进程建一次,只读使用),直接要这个参数就行。
"""
+30
View File
@@ -0,0 +1,30 @@
"""数据目录起草用的假模型:不发请求,按表名回固定的草稿。
model = FakeModel({"visits": {"label": "入园记录", "kind": "fact"}}, fail={"orders"})
await catalog.draft_with_model(model, source, ["visits"])
- 结构化输出(with_structured_output)返回自己;ainvoke 从提示词里认出这一批有哪些表(「表 <表名>」),
返回 {"tables": [{"table": 表名, **replies[表名]}, …]};
- fail 里的表所在的那一批抛异常,用来验证「一批失败只影响这一批」;
- calls 记下每次收到的提示词全文,用来核对交给模型的内容(只有结构,没有数据)。
"""
from __future__ import annotations
from typing import Any
class FakeModel:
def __init__(self, replies: dict[str, dict], fail: set[str] = frozenset()) -> None:
self.replies, self.fail, self.calls = replies, set(fail), []
def with_structured_output(self, schema: Any, **_: Any) -> "FakeModel":
assert schema["title"]
return self
async def ainvoke(self, messages: list[Any]) -> dict:
text = "\n".join(str(getattr(m, "content", m)) for m in messages)
self.calls.append(text)
names = [t for t in self.replies if f"表 {t}\n" in text or f"表 {t}(" in text]
if any(t in self.fail for t in names):
raise RuntimeError("网关超时")
return {"tables": [{"table": t, **self.replies[t]} for t in names]}
+284
View File
@@ -0,0 +1,284 @@
"""虚构的景区业务库:入园记录、渠道、票种、订单、园内门店、会员、营销、停车、旅行团……共 50 张表和 1 个视图。
为数据目录设计的几处特点:
- **真外键和只能靠命名推断的关系混在一起。** 核心交易表(订单、订单明细、入园记录的票种)声明了外键;
很多「看名字就知道连哪」的列(visits.gate_id、store_sales.productId、inventory_snapshots.storeID)没有约束,
只能按列名推断。三种命名都有:xxx_id、xxxId、xxxID;表名有复数、驼峰(memberTags)、y→ies(categories、agencies)。
- **关联表。** channel_visits 把渠道和入园记录连起来(复合主键,两端都是外键):入园记录本身不带渠道,
从 visits 到 channels 要经过它,正好是两跳的连接路径。group_visits 同理(旅行团与入园记录)。
- **命名推断必须放过的列**(NOT_INFERRED):自引用(employees.employee_id 是工号,不是指向自己)、
多个候选(guide 和 guides 两张表都在,tour_assignments.guide_id 不知道指哪张)、没有对应的表
(parking_records.lot_id、operation_logs.operator_id、categories.parentId)、类型对不上
(feedback_forms.store_id 是文字,stores.id 是整数)。
- **唯一约束 / 唯一索引。** orders.order_no、channels.channel_code 是列上的 UNIQUE;members.member_no、
visits.ticket_no 是单独建的唯一索引。
数据是固定种子生成的假数,量很小(入园记录 1500 行),只为让查询有结果;名称一律虚构。
"""
from __future__ import annotations
import random
import sqlite3
from pathlib import Path
DDL = """
CREATE TABLE parks (id INTEGER PRIMARY KEY, park_code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, city TEXT,
opened_on TEXT);
CREATE TABLE areas (id INTEGER PRIMARY KEY, park_id INTEGER NOT NULL, name TEXT NOT NULL);
CREATE TABLE gates (id INTEGER PRIMARY KEY, park_id INTEGER NOT NULL REFERENCES parks(id), gate_code TEXT NOT NULL,
name TEXT);
CREATE TABLE channels (id INTEGER PRIMARY KEY, channel_code TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
channel_type TEXT NOT NULL);
CREATE TABLE ticket_types (id INTEGER PRIMARY KEY, park_id INTEGER NOT NULL REFERENCES parks(id),
type_code TEXT NOT NULL, name TEXT NOT NULL, base_price REAL NOT NULL);
CREATE TABLE member_levels (id INTEGER PRIMARY KEY, level_name TEXT NOT NULL, discount_rate REAL);
CREATE TABLE members (id INTEGER PRIMARY KEY, member_no TEXT NOT NULL, name TEXT, member_level_id INTEGER,
joined_on TEXT, status INTEGER NOT NULL DEFAULT 1);
CREATE UNIQUE INDEX ux_members_member_no ON members(member_no);
CREATE TABLE "memberTags" (id INTEGER PRIMARY KEY, tag_name TEXT NOT NULL);
CREATE TABLE member_tag_links (id INTEGER PRIMARY KEY, member_id INTEGER NOT NULL REFERENCES members(id),
"memberTagId" INTEGER NOT NULL);
CREATE TABLE orders (id INTEGER PRIMARY KEY, order_no TEXT NOT NULL UNIQUE, member_id INTEGER REFERENCES members(id),
channel_id INTEGER NOT NULL REFERENCES channels(id), ordered_at TEXT NOT NULL,
total_amount REAL NOT NULL, status INTEGER NOT NULL);
CREATE TABLE order_items (id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL REFERENCES orders(id),
ticket_type_id INTEGER NOT NULL REFERENCES ticket_types(id), qty INTEGER NOT NULL,
unit_price REAL NOT NULL, amount REAL NOT NULL);
CREATE TABLE visits (id INTEGER PRIMARY KEY, ticket_no TEXT NOT NULL, park_id INTEGER NOT NULL REFERENCES parks(id),
gate_id INTEGER, ticket_type_id INTEGER NOT NULL REFERENCES ticket_types(id), member_id INTEGER,
visit_time TEXT NOT NULL, visitor_count INTEGER NOT NULL DEFAULT 1, status INTEGER NOT NULL);
CREATE UNIQUE INDEX ux_visits_ticket_no ON visits(ticket_no);
CREATE TABLE channel_visits (channel_id INTEGER NOT NULL REFERENCES channels(id),
visit_id INTEGER NOT NULL REFERENCES visits(id),
attributed_share REAL NOT NULL DEFAULT 1, PRIMARY KEY (channel_id, visit_id));
CREATE TABLE stores (id INTEGER PRIMARY KEY, store_code TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
park_id INTEGER NOT NULL REFERENCES parks(id), area_id INTEGER, opened_on TEXT);
CREATE TABLE categories (id INTEGER PRIMARY KEY, name TEXT NOT NULL, "parentId" INTEGER);
CREATE TABLE products (id INTEGER PRIMARY KEY, sku TEXT NOT NULL UNIQUE, name TEXT NOT NULL, category_id INTEGER,
list_price REAL);
CREATE TABLE store_sales (id INTEGER PRIMARY KEY, store_id INTEGER NOT NULL REFERENCES stores(id),
"productId" INTEGER NOT NULL, member_id INTEGER, sold_at TEXT NOT NULL, qty INTEGER NOT NULL,
amount REAL NOT NULL);
CREATE TABLE inventory_snapshots (id INTEGER PRIMARY KEY, "storeID" INTEGER NOT NULL, "productId" INTEGER NOT NULL,
snapshot_date TEXT NOT NULL, on_hand INTEGER NOT NULL);
CREATE TABLE campaigns (id INTEGER PRIMARY KEY, channel_id INTEGER REFERENCES channels(id), name TEXT NOT NULL,
starts_on TEXT, ends_on TEXT);
CREATE TABLE coupons (id INTEGER PRIMARY KEY, code TEXT NOT NULL UNIQUE, campaign_id INTEGER,
discount_amount REAL NOT NULL);
CREATE TABLE coupon_redemptions (id INTEGER PRIMARY KEY, coupon_id INTEGER NOT NULL REFERENCES coupons(id),
order_id INTEGER NOT NULL REFERENCES orders(id), redeemed_at TEXT NOT NULL);
CREATE TABLE payments (id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL REFERENCES orders(id),
pay_method TEXT NOT NULL, paid_amount REAL NOT NULL, paid_at TEXT NOT NULL);
CREATE TABLE refunds (id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL REFERENCES orders(id),
refund_amount REAL NOT NULL, reason_code TEXT, refunded_at TEXT NOT NULL);
CREATE TABLE reviews (id INTEGER PRIMARY KEY, visit_id INTEGER NOT NULL, score INTEGER NOT NULL, review_text TEXT,
created_at TEXT NOT NULL);
CREATE TABLE complaints (id INTEGER PRIMARY KEY, visit_id INTEGER, category_code TEXT NOT NULL,
status INTEGER NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE events (id INTEGER PRIMARY KEY, park_id INTEGER NOT NULL REFERENCES parks(id), name TEXT NOT NULL,
event_date TEXT NOT NULL);
CREATE TABLE event_bookings (id INTEGER PRIMARY KEY, event_id INTEGER NOT NULL, member_id INTEGER NOT NULL,
seats INTEGER NOT NULL, booked_at TEXT NOT NULL);
CREATE TABLE parking_lots (id INTEGER PRIMARY KEY, park_id INTEGER NOT NULL REFERENCES parks(id),
name TEXT NOT NULL, capacity INTEGER NOT NULL);
CREATE TABLE parking_records (id INTEGER PRIMARY KEY, lot_id INTEGER NOT NULL, plate_masked TEXT,
entered_at TEXT NOT NULL, exited_at TEXT, fee REAL);
CREATE TABLE weather_daily (park_id INTEGER NOT NULL, biz_date TEXT NOT NULL, weather TEXT, temp_high REAL,
temp_low REAL, PRIMARY KEY (park_id, biz_date));
CREATE TABLE holidays (biz_date TEXT PRIMARY KEY, holiday_name TEXT NOT NULL, is_holiday INTEGER NOT NULL);
CREATE TABLE dim_dates (date_key INTEGER PRIMARY KEY, biz_date TEXT NOT NULL UNIQUE, weekday INTEGER NOT NULL,
is_weekend INTEGER NOT NULL);
CREATE TABLE price_rules (id INTEGER PRIMARY KEY, ticket_type_id INTEGER NOT NULL REFERENCES ticket_types(id),
valid_from TEXT NOT NULL, valid_to TEXT, price REAL NOT NULL);
CREATE TABLE ticket_inventory (id INTEGER PRIMARY KEY, ticket_type_id INTEGER NOT NULL, biz_date TEXT NOT NULL,
quota INTEGER NOT NULL, sold INTEGER NOT NULL);
CREATE TABLE member_points_log (id INTEGER PRIMARY KEY, member_id INTEGER NOT NULL REFERENCES members(id),
change_points INTEGER NOT NULL, reason TEXT, created_at TEXT NOT NULL);
CREATE TABLE sms_logs (id INTEGER PRIMARY KEY, member_id INTEGER, template_code TEXT NOT NULL, sent_at TEXT NOT NULL);
CREATE TABLE suppliers (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE purchase_orders (id INTEGER PRIMARY KEY, supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
store_id INTEGER NOT NULL REFERENCES stores(id), ordered_at TEXT NOT NULL,
amount REAL NOT NULL);
CREATE TABLE purchase_order_lines (id INTEGER PRIMARY KEY, purchase_order_id INTEGER NOT NULL,
product_id INTEGER NOT NULL REFERENCES products(id), qty INTEGER NOT NULL,
cost REAL NOT NULL);
CREATE TABLE employees (id INTEGER PRIMARY KEY, employee_id TEXT NOT NULL, store_id INTEGER, name TEXT NOT NULL,
role TEXT);
CREATE TABLE agencies (id INTEGER PRIMARY KEY, channel_id INTEGER NOT NULL REFERENCES channels(id),
name TEXT NOT NULL);
CREATE TABLE tour_groups (id INTEGER PRIMARY KEY, agency_id INTEGER NOT NULL, group_size INTEGER NOT NULL,
visit_date TEXT NOT NULL);
CREATE TABLE group_visits (tour_group_id INTEGER NOT NULL REFERENCES tour_groups(id),
visit_id INTEGER NOT NULL REFERENCES visits(id), PRIMARY KEY (tour_group_id, visit_id));
CREATE TABLE guide (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE guides (id INTEGER PRIMARY KEY, name TEXT NOT NULL, licensed INTEGER);
CREATE TABLE tour_assignments (id INTEGER PRIMARY KEY, tour_group_id INTEGER NOT NULL, guide_id INTEGER NOT NULL,
assigned_on TEXT NOT NULL);
CREATE TABLE feedback_forms (id INTEGER PRIMARY KEY, store_id TEXT, submitted_at TEXT NOT NULL, rating INTEGER);
CREATE TABLE lost_and_found (id INTEGER PRIMARY KEY, park_id INTEGER NOT NULL, item_desc TEXT NOT NULL,
found_at TEXT NOT NULL);
CREATE TABLE operation_logs (id INTEGER PRIMARY KEY, operator_id INTEGER, action TEXT NOT NULL,
created_at TEXT NOT NULL);
CREATE TABLE sys_config (config_key TEXT PRIMARY KEY, config_value TEXT);
CREATE VIEW v_daily_visits AS
SELECT park_id, substr(visit_time, 1, 10) AS biz_date, SUM(visitor_count) AS visitors
FROM visits WHERE status = 1 GROUP BY park_id, substr(visit_time, 1, 10);
"""
#: 表的个数(不含视图)
TABLE_COUNT = 50
VIEWS = ("v_daily_visits",)
#: 声明了外键约束的关系:(本表, (本表列,), 被指向的表, (被指向的列,))
FK_RELATIONS = frozenset({
("gates", ("park_id",), "parks", ("id",)),
("ticket_types", ("park_id",), "parks", ("id",)),
("member_tag_links", ("member_id",), "members", ("id",)),
("orders", ("member_id",), "members", ("id",)),
("orders", ("channel_id",), "channels", ("id",)),
("order_items", ("order_id",), "orders", ("id",)),
("order_items", ("ticket_type_id",), "ticket_types", ("id",)),
("visits", ("park_id",), "parks", ("id",)),
("visits", ("ticket_type_id",), "ticket_types", ("id",)),
("channel_visits", ("channel_id",), "channels", ("id",)),
("channel_visits", ("visit_id",), "visits", ("id",)),
("stores", ("park_id",), "parks", ("id",)),
("store_sales", ("store_id",), "stores", ("id",)),
("campaigns", ("channel_id",), "channels", ("id",)),
("coupon_redemptions", ("coupon_id",), "coupons", ("id",)),
("coupon_redemptions", ("order_id",), "orders", ("id",)),
("payments", ("order_id",), "orders", ("id",)),
("refunds", ("order_id",), "orders", ("id",)),
("events", ("park_id",), "parks", ("id",)),
("parking_lots", ("park_id",), "parks", ("id",)),
("price_rules", ("ticket_type_id",), "ticket_types", ("id",)),
("member_points_log", ("member_id",), "members", ("id",)),
("purchase_orders", ("supplier_id",), "suppliers", ("id",)),
("purchase_orders", ("store_id",), "stores", ("id",)),
("purchase_order_lines", ("product_id",), "products", ("id",)),
("agencies", ("channel_id",), "channels", ("id",)),
("group_visits", ("tour_group_id",), "tour_groups", ("id",)),
("group_visits", ("visit_id",), "visits", ("id",)),
})
#: 没有外键约束、命名推断应当找出来的关系(被指向一端都是单列主键 id)
NAME_RELATIONS = frozenset({
("areas", ("park_id",), "parks", ("id",)),
("members", ("member_level_id",), "member_levels", ("id",)),
("member_tag_links", ("memberTagId",), "memberTags", ("id",)),
("visits", ("gate_id",), "gates", ("id",)),
("visits", ("member_id",), "members", ("id",)),
("stores", ("area_id",), "areas", ("id",)),
("products", ("category_id",), "categories", ("id",)),
("store_sales", ("productId",), "products", ("id",)),
("store_sales", ("member_id",), "members", ("id",)),
("inventory_snapshots", ("storeID",), "stores", ("id",)),
("inventory_snapshots", ("productId",), "products", ("id",)),
("coupons", ("campaign_id",), "campaigns", ("id",)),
("reviews", ("visit_id",), "visits", ("id",)),
("complaints", ("visit_id",), "visits", ("id",)),
("event_bookings", ("event_id",), "events", ("id",)),
("event_bookings", ("member_id",), "members", ("id",)),
("weather_daily", ("park_id",), "parks", ("id",)),
("ticket_inventory", ("ticket_type_id",), "ticket_types", ("id",)),
("sms_logs", ("member_id",), "members", ("id",)),
("purchase_order_lines", ("purchase_order_id",), "purchase_orders", ("id",)),
("employees", ("store_id",), "stores", ("id",)),
("tour_groups", ("agency_id",), "agencies", ("id",)),
("tour_assignments", ("tour_group_id",), "tour_groups", ("id",)),
("lost_and_found", ("park_id",), "parks", ("id",)),
# 视图没有约束,按列名同样推得出来:按日汇总的入园人数照样能按 park_id 连到景区
("v_daily_visits", ("park_id",), "parks", ("id",)),
})
#: 看起来像关联、命名推断必须放过的列:(表, 列) → 原因
NOT_INFERRED = {
("employees", "employee_id"): "自引用:工号,不是指向本表",
("tour_assignments", "guide_id"): "多个候选:guide 与 guides",
("parking_records", "lot_id"): "没有名为 lot / lots 的表",
("operation_logs", "operator_id"): "没有名为 operator / operators 的表",
("categories", "parentId"): "没有名为 parent / parents 的表",
("feedback_forms", "store_id"): "类型对不上:文字列指向整数主键",
}
#: 唯一约束 / 唯一索引:表 → 列组
UNIQUE = {
"parks": [["park_code"]], "channels": [["channel_code"]], "orders": [["order_no"]],
"members": [["member_no"]], "visits": [["ticket_no"]], "stores": [["store_code"]],
"products": [["sku"]], "coupons": [["code"]], "dim_dates": [["biz_date"]],
}
_PARKS = [(1, "P01", "青松谷景区", "甲市", "2015-05-01"), (2, "P02", "白鹭湾景区", "乙市", "2018-04-20"),
(3, "P03", "星河乐园", "甲市", "2021-10-01")]
_CHANNELS = [(1, "WEB", "官网", "线上直销"), (2, "MINI", "小程序", "线上直销"), (3, "BOX", "窗口售票", "线下"),
(4, "AGT", "旅行社分销", "分销"), (5, "OTA1", "第三方平台甲", "分销"), (6, "OTA2", "第三方平台乙", "分销")]
_LEVELS = [(1, "普通会员", 1.0), (2, "银卡会员", 0.95), (3, "金卡会员", 0.9), (4, "年卡会员", 0.0)]
def _day(rng: random.Random) -> str:
month = rng.choice((7, 8, 9))
return f"2026-{month:02d}-{rng.randint(1, 30 if month == 9 else 31):02d}"
def build(path: str | Path, *, seed: int = 20261003) -> Path:
"""在 path 写一个新的景区业务库并返回路径。path 已存在时先删掉:同样的种子造出同样的内容。"""
path = Path(path)
if path.exists():
path.unlink()
rng = random.Random(seed)
db = sqlite3.connect(path)
try:
db.executescript(DDL)
db.executemany("INSERT INTO parks VALUES (?,?,?,?,?)", _PARKS)
db.executemany("INSERT INTO areas VALUES (?,?,?)",
[(i, 1 + (i - 1) // 2, f"{'东西南北中'[i % 5]}片区") for i in range(1, 7)])
db.executemany("INSERT INTO gates VALUES (?,?,?,?)",
[(i, 1 + (i - 1) % 3, f"G{i:02d}", f"{i} 号闸口") for i in range(1, 9)])
db.executemany("INSERT INTO channels VALUES (?,?,?,?)", _CHANNELS)
types = [(i, 1 + (i - 1) // 3, f"T{i:02d}", name, price)
for i, (name, price) in enumerate([("成人票", 120.0), ("儿童票", 60.0), ("夜场票", 80.0)] * 3, 1)]
db.executemany("INSERT INTO ticket_types VALUES (?,?,?,?,?)", types)
db.executemany("INSERT INTO member_levels VALUES (?,?,?)", _LEVELS)
db.executemany("INSERT INTO members VALUES (?,?,?,?,?,?)",
[(i, f"M{i:05d}", f"会员{i}", rng.randint(1, 4), _day(rng), 1 if rng.random() > 0.05 else 0)
for i in range(1, 201)])
db.executemany('INSERT INTO "memberTags" VALUES (?,?)',
[(i, t) for i, t in enumerate(["亲子", "夜游", "摄影", "年卡", "团队"], 1)])
db.executemany("INSERT INTO member_tag_links VALUES (?,?,?)",
[(i, rng.randint(1, 200), rng.randint(1, 5)) for i in range(1, 301)])
orders, items = [], []
for i in range(1, 601):
qty = rng.randint(1, 4)
tid = rng.randint(1, 9)
amount = qty * types[tid - 1][4]
orders.append((i, f"OD{i:06d}", rng.choice([None, rng.randint(1, 200)]), rng.randint(1, 6),
f"{_day(rng)} {rng.randint(8, 20):02d}:{rng.randint(0, 59):02d}:00", amount,
1 if rng.random() > 0.08 else 2))
items.append((i, i, tid, qty, types[tid - 1][4], amount))
db.executemany("INSERT INTO orders VALUES (?,?,?,?,?,?,?)", orders)
db.executemany("INSERT INTO order_items VALUES (?,?,?,?,?,?)", items)
visits = [(i, f"TK{i:07d}", 1 + i % 3, rng.randint(1, 8), rng.randint(1, 9), rng.choice([None, rng.randint(1, 200)]),
f"{_day(rng)} {rng.randint(8, 21):02d}:{rng.randint(0, 59):02d}:00", rng.randint(1, 5),
1 if rng.random() > 0.04 else 0) for i in range(1, 1501)]
db.executemany("INSERT INTO visits VALUES (?,?,?,?,?,?,?,?,?)", visits)
db.executemany("INSERT INTO channel_visits VALUES (?,?,?)", [(rng.randint(1, 6), i, 1.0) for i in range(1, 1501)])
db.executemany("INSERT INTO stores VALUES (?,?,?,?,?,?)",
[(i, f"S{i:03d}", f"{i} 号门店", 1 + i % 3, 1 + i % 6, "2020-01-01") for i in range(1, 11)])
db.executemany("INSERT INTO categories VALUES (?,?,?)",
[(1, "餐饮", None), (2, "文创", None), (3, "饮品", 1), (4, "小吃", 1), (5, "纪念品", 2)])
db.executemany("INSERT INTO products VALUES (?,?,?,?,?)",
[(i, f"SKU{i:04d}", f"商品{i}", rng.randint(1, 5), float(rng.randint(5, 200))) for i in range(1, 41)])
db.executemany("INSERT INTO store_sales VALUES (?,?,?,?,?,?,?)",
[(i, rng.randint(1, 10), rng.randint(1, 40), rng.choice([None, rng.randint(1, 200)]),
f"{_day(rng)} 12:00:00", rng.randint(1, 3), float(rng.randint(5, 300))) for i in range(1, 801)])
db.executemany("INSERT INTO dim_dates VALUES (?,?,?,?)",
[(20260700 + d, f"2026-07-{d:02d}", d % 7, int(d % 7 in (5, 6))) for d in range(1, 32)])
db.executemany("INSERT INTO holidays VALUES (?,?,?)", [("2026-10-01", "国庆节", 1), ("2026-10-02", "国庆节", 1)])
db.executemany("INSERT INTO sys_config VALUES (?,?)", [("currency", "CNY"), ("timezone", "+08:00")])
db.executemany("INSERT INTO guide VALUES (?,?)", [(1, "旧导游档案")])
db.executemany("INSERT INTO guides VALUES (?,?,?)", [(1, "导游甲", 1), (2, "导游乙", 1)])
db.commit()
finally:
db.close()
return path
+199
View File
@@ -0,0 +1,199 @@
"""数据目录接口:/api/datasources/{id}/catalog 下的表清单、单表、整份提交、单项审阅、起草。
所有写操作记录请求头 X-Actor 的署名;版本不符一律 409,detail 说明这张表刚被修改过、请重新载入。
"""
from __future__ import annotations
import uuid
from urllib.parse import quote
import pytest
from httpx import ASGITransport, AsyncClient
from app.data import catalog
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource
from app.main import app as _fastapi_app
from tests.fixtures.catalog import scenic
from tests.fixtures.catalog.fake_model import FakeModel
ACTOR = {"X-Actor": quote("王敏")}
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=_fastapi_app), base_url="http://test") as c:
yield c
@pytest.fixture
async def source_id(client, scenic_db):
"""接上合成景区库并探查结构(走真实的探查接口)。"""
async with SessionLocal() as session:
row = DataSource(name=f"cat_{uuid.uuid4().hex[:10]}", kind="sqlite", database=scenic_db)
session.add(row)
await session.commit()
sid = row.id
r = await client.post(f"/api/datasources/{sid}/introspect")
assert r.status_code == 200, r.text
yield sid
await engines.invalidate(sid)
def _base(sid: str) -> str:
return f"/api/datasources/{sid}/catalog"
async def test_list_includes_tables_without_notes(client, source_id):
r = await client.get(_base(source_id))
assert r.status_code == 200
body = r.json()
rows = {t["table_name"]: t for t in body["tables"]}
assert len(rows) == scenic.TABLE_COUNT + len(scenic.VIEWS)
visits = rows["visits"]
assert visits["version"] == 0 and visits["label"] is None and visits["usage"] == 0
assert visits["counts"] == {"proposed": 0, "verified": 0, "confirmed": 0, "rejected": 0}
assert visits["relations"] == 0 and visits["updated_at"] is None
assert body["system_notes"] is False
async def test_draft_then_read_table(client, source_id):
r = await client.post(f"{_base(source_id)}/draft", json={"tables": ["visits", "nope"], "use_model": False},
headers=ACTOR)
assert r.status_code == 200, r.text
body = r.json()
by = {t["table_name"]: t for t in body["tables"]}
# visits:两条外键(景区、票种)+ 两条命名推断(闸口、会员)
assert by["visits"]["added"] == 4 and by["visits"]["version"] == 1 and by["visits"]["error"] is None
assert by["nope"]["error"] and body["model_used"] is False
assert body["total"] == {"added": 4, "updated": 0, "removed": 0}
r = await client.get(f"{_base(source_id)}/visits")
assert r.status_code == 200
detail = r.json()
assert detail["version"] == 1 and detail["updated_by"] == "王敏"
sources = sorted(rel["source"] for rel in detail["notes"]["relations"])
assert sources == ["fk", "fk", "name", "name"]
structure = detail["structure"]
assert structure["primary_key"] == ["id"]
assert {fk["to_table"] for fk in structure["foreign_keys"]} == {"parks", "ticket_types"}
assert ["ticket_no"] in structure["unique"]
assert any(c["name"] == "visit_time" for c in structure["columns"])
listed = {t["table_name"]: t for t in (await client.get(_base(source_id))).json()["tables"]}
assert listed["visits"]["counts"] == {"proposed": 2, "verified": 2, "confirmed": 0, "rejected": 0}
assert listed["visits"]["relations"] == 4 and listed["visits"]["version"] == 1
async def test_put_marks_edits_human_confirmed_and_bumps_version(client, source_id):
await client.post(f"{_base(source_id)}/draft", json={"tables": ["visits"]}, headers=ACTOR)
detail = (await client.get(f"{_base(source_id)}/visits")).json()
notes = detail["notes"]
notes["label"] = {"value": "入园记录"}
notes["grain"] = {"value": "每张门票每次检票一行", "source": "llm", "status": "proposed"}
r = await client.put(f"{_base(source_id)}/visits", json={"notes": notes, "if_version": detail["version"]},
headers={"X-Actor": quote("李雷")})
assert r.status_code == 200, r.text
saved = r.json()
assert saved["version"] == 2 and saved["updated_by"] == "李雷"
assert saved["notes"]["label"]["source"] == "human" and saved["notes"]["label"]["status"] == "confirmed"
assert saved["notes"]["grain"]["source"] == "human"
assert {r["source"] for r in saved["notes"]["relations"]} == {"fk", "name"} # 没动的保持原样
async def test_put_with_stale_version_conflicts(client, source_id):
await client.post(f"{_base(source_id)}/draft", json={"tables": ["visits"]}, headers=ACTOR)
r = await client.put(f"{_base(source_id)}/visits", json={"notes": {"label": {"value": "x"}}, "if_version": 0},
headers=ACTOR)
assert r.status_code == 409
assert "刚被修改过" in r.json()["detail"] and "重新载入" in r.json()["detail"]
async def test_put_rejects_malformed_notes(client, source_id):
r = await client.put(f"{_base(source_id)}/visits",
json={"notes": {"formula": {"value": "sum(amount)"}}, "if_version": 0}, headers=ACTOR)
assert r.status_code == 422
assert "formula" in r.json()["detail"]
async def test_review_confirm_reject_and_conflict(client, source_id):
await client.post(f"{_base(source_id)}/draft", json={"tables": ["visits"]}, headers=ACTOR)
detail = (await client.get(f"{_base(source_id)}/visits")).json()
gate = next(r for r in detail["notes"]["relations"] if r["columns"] == ["gate_id"])
r = await client.post(f"{_base(source_id)}/visits/review",
json={"path": f"relations.{gate['id']}", "action": "confirm", "if_version": 1},
headers={"X-Actor": quote("韩梅")})
assert r.status_code == 200, r.text
after = r.json()
assert after["version"] == 2 and after["updated_by"] == "韩梅"
assert next(x for x in after["notes"]["relations"] if x["id"] == gate["id"])["status"] == "confirmed"
stale = await client.post(f"{_base(source_id)}/visits/review",
json={"path": f"relations.{gate['id']}", "action": "reject", "if_version": 1})
assert stale.status_code == 409 and "重新载入" in stale.json()["detail"]
bad = await client.post(f"{_base(source_id)}/visits/review",
json={"path": "columns.visit_time.formula", "action": "confirm", "if_version": 2})
assert bad.status_code == 422
wrong = await client.post(f"{_base(source_id)}/visits/review",
json={"path": "grain", "action": "approve", "if_version": 2})
assert wrong.status_code == 422
async def test_unknown_table_and_source(client, source_id):
assert (await client.get(f"{_base(source_id)}/nope")).status_code == 404
assert (await client.get(_base("missing-source"))).status_code == 404
r = await client.put(f"{_base(source_id)}/nope", json={"notes": {}, "if_version": 0})
assert r.status_code == 404
async def test_draft_defaults_to_top_tables_and_model_via_settings(client, source_id, monkeypatch):
model = FakeModel({"visits": {"label": "入园记录", "kind": "fact"}})
async def fake_resolve(session):
return model, "fake-model"
async def usage(session, source):
return {"visits": 7}
monkeypatch.setattr(catalog, "resolve_draft_model", fake_resolve)
monkeypatch.setattr(catalog, "table_usage", usage)
r = await client.post(f"{_base(source_id)}/draft", json={"use_model": True}, headers=ACTOR)
assert r.status_code == 200, r.text
body = r.json()
assert len(body["tables"]) == 20 and body["tables"][0]["table_name"] == "visits"
assert body["model_used"] is True and body["model"] == "fake-model"
detail = (await client.get(f"{_base(source_id)}/visits")).json()
assert detail["notes"]["label"]["source"] == "llm" and detail["usage"] == 7
async def test_draft_reports_model_unavailable(client, source_id, monkeypatch):
async def unavailable(session):
raise catalog.CatalogModelUnavailable("未配置模型接入,无法由助手起草数据目录。请到「设置 → 模型接入」添加")
monkeypatch.setattr(catalog, "resolve_draft_model", unavailable)
r = await client.post(f"{_base(source_id)}/draft", json={"tables": ["visits"], "use_model": True})
assert r.status_code == 200
body = r.json()
assert body["model_used"] is False and "模型接入" in body["model_error"]
assert body["tables"][0]["added"] == 4 # 外键和命名推断照常写入
async def test_draft_without_schema_is_refused(client):
async with SessionLocal() as session:
row = DataSource(name=f"cat_{uuid.uuid4().hex[:10]}", kind="sqlite", database=":memory:")
session.add(row)
await session.commit()
sid = row.id
r = await client.post(f"{_base(sid)}/draft", json={})
assert r.status_code == 409 and "探查结构" in r.json()["detail"]
assert (await client.get(_base(sid))).json()["tables"] == []
async def test_schema_endpoint_detail_carries_catalog(client, source_id):
"""数据源页「表结构」里给模型看的那段文本和 db_schema 工具同一个说法,也附上目录。"""
before = (await client.get(f"/api/datasources/{source_id}/schema", params={"table": "visits"})).json()
assert catalog.NOTES_HEADING not in before["detail"]
await client.post(f"{_base(source_id)}/draft", json={"tables": ["visits"]}, headers=ACTOR)
after = (await client.get(f"/api/datasources/{source_id}/schema", params={"table": "visits"})).json()
assert catalog.NOTES_HEADING in after["detail"] and "gate_id → gates.id" in after["detail"]
assert after["detail"].startswith(before["detail"])
+375
View File
@@ -0,0 +1,375 @@
"""数据目录的起草:数据库注释、外键约束、命名推断、模型起草,以及并入已有目录。
这一阶段起草不发数据库查询(数据剖析归阶段 2),只看探查缓存里的结构。
"""
from __future__ import annotations
import json
import uuid
from types import SimpleNamespace
import pytest
from app.data import catalog
from app.data.catalog import make_item
from app.db.base import SessionLocal
from app.db.models import DataSource
from tests.fixtures.catalog.fake_model import FakeModel
def _col(name: str, type_: str = "INTEGER", comment: str | None = None, nullable: bool = True) -> dict:
return {"name": name, "type": type_, "nullable": nullable, "comment": comment}
def _table(*cols: dict, pk=("id",), fks=None, unique=None, comment=None) -> dict:
meta = {"qualified": "", "schema": None, "columns": list(cols), "primary_key": list(pk), "comment": comment,
"is_view": False}
if fks is not None:
meta["foreign_keys"] = fks
if unique is not None:
meta["unique"] = unique
return meta
def _source(tables: dict, **kw) -> SimpleNamespace:
for name, meta in tables.items():
meta["qualified"] = name
return SimpleNamespace(id=kw.pop("id", "src"), name="scenic", kind="sqlite", origin=kw.pop("origin", "manual"),
schema_cache={"tables": tables, **kw})
def _rels(notes: dict) -> set[tuple]:
return {(tuple(r["columns"]), r["to_table"], tuple(r["to_columns"]), r["source"], r["status"])
for r in notes.get("relations", [])}
# ---------------------------------------------------------------- 数据库注释
def test_comments_become_labels_or_meanings():
src = _source({"visits": _table(
_col("id"), _col("amount", "REAL", comment="实收金额"),
_col("status", comment="状态:1 表示有效,0 表示作废;作废记录不计入客流"),
comment="入园记录")})
draft = catalog.draft_structure(src, "visits")
assert draft.notes["label"] == make_item("入园记录", "comment")
assert draft.notes["columns"]["amount"]["label"] == make_item("实收金额", "comment")
assert draft.notes["columns"]["status"]["meaning"]["value"].startswith("状态:1 表示有效")
assert "label" not in draft.notes["columns"]["status"]
assert "comment" in draft.covered
def test_long_table_comment_goes_to_description():
src = _source({"visits": _table(_col("id"), comment="游客每次检票入园记一行,团队票按人数拆成多行")})
draft = catalog.draft_structure(src, "visits")
assert "label" not in draft.notes
assert draft.notes["description"]["value"].startswith("游客每次检票入园")
@pytest.mark.parametrize("kw", [{"origin": "upload"}, {"import_mode": "recipe"}])
def test_imported_table_comments_are_not_copied(kw):
"""导入表格的说明是按核对结果生成的(recipe_notes),数据目录不重复它们。"""
src = _source({"日报": _table(_col("日期", "TEXT", comment="格式 YYYY-MM-DD"), _col("客流", comment="单位:人次"),
pk=(), comment="粒度:每个日期")}, **kw)
assert catalog.system_notes_source(src)
draft = catalog.draft_structure(src, "日报")
assert "label" not in draft.notes and "description" not in draft.notes
assert "columns" not in draft.notes
# ---------------------------------------------------------------- 外键约束
def test_foreign_keys_become_verified_relations():
src = _source({
"visits": _table(_col("id"), _col("park_id"), fks=[
{"columns": ["park_id"], "to_table": "parks", "to_columns": ["id"]}]),
"parks": _table(_col("id"), fks=[]),
})
draft = catalog.draft_structure(src, "visits")
assert _rels(draft.notes) == {(("park_id",), "parks", ("id",), "fk", "verified")}
rel = draft.notes["relations"][0]
assert rel["cardinality"] == "many_to_one"
assert rel["id"] == catalog.relation_id("visits", ["park_id"], "parks", ["id"])
assert "fk" in draft.covered
def test_foreign_key_without_target_columns_points_at_primary_key_and_one_to_one():
src = _source({
"member_profiles": _table(_col("member_id"), _col("nickname", "TEXT"), pk=("member_id",),
fks=[{"columns": ["member_id"], "to_table": "members", "to_columns": []}]),
"members": _table(_col("id"), fks=[]),
})
rel = catalog.draft_structure(src, "member_profiles").notes["relations"][0]
assert rel["to_columns"] == ["id"] and rel["cardinality"] == "one_to_one"
def test_old_cache_without_foreign_keys_does_not_cover_fk():
"""旧缓存里没有 foreign_keys:不知道有没有外键,不能据此删掉已有的外键关系。"""
src = _source({"visits": _table(_col("id"), _col("park_id")), "parks": _table(_col("id"))})
draft = catalog.draft_structure(src, "visits")
assert "fk" not in draft.covered
# ---------------------------------------------------------------- 命名推断
def _infer(tables: dict, table: str) -> set[tuple]:
src = _source(tables)
return {(tuple(r["columns"]), r["to_table"], tuple(r["to_columns"]))
for r in catalog.infer_name_relations(src.schema_cache, table)}
@pytest.mark.parametrize("column, target", [
("channel_id", "channels"), # 复数
("category_id", "categories"), # y → ies
("storeId", "stores"), # 驼峰
("storeID", "stores"), # xxxID
("ticket_type_id", "ticket_types"), # 多段下划线
("memberTagId", "memberTags"), # 驼峰表名
("member_tag_id", "memberTags"), # 下划线列名对驼峰表名
("park_id", "park"), # 单数表名
("CHANNEL_ID", "CHANNELS"), # 大写(Oracle)
])
def test_name_inference_positive(column, target):
tables = {"facts": _table(_col("id"), _col(column)), target: _table(_col("id"))}
pk = "id"
assert _infer(tables, "facts") == {((column,), target, (pk,))}
@pytest.mark.parametrize("tables, why", [
({"employees": _table(_col("id"), _col("employee_id", "TEXT"))}, "自引用"),
({"facts": _table(_col("id"), _col("guide_id")), "guide": _table(_col("id")), "guides": _table(_col("id"))},
"多个候选"),
({"facts": _table(_col("id"), _col("lot_id")), "parking_lots": _table(_col("id"))}, "没有对应的表"),
({"facts": _table(_col("id"), _col("store_id", "VARCHAR(20)")), "stores": _table(_col("id"))}, "类型对不上"),
({"facts": _table(_col("id"), _col("day_id")), "days": _table(_col("park_id"), _col("d"), pk=("park_id", "d"))},
"被指向的表是复合主键"),
({"facts": _table(_col("id"), _col("tag_id")), "tags": _table(_col("name", "TEXT"), pk=())}, "被指向的表没有主键"),
({"facts": _table(_col("id"), _col("x_id")), "xs": _table(_col("id"))}, "词根太短"),
({"facts": _table(_col("id"), _col("paid")), "pas": _table(_col("id"))}, "不是 _id 结尾"),
({"facts": _table(_col("id"), _col("store_id"), fks=[{"columns": ["store_id"], "to_table": "shops",
"to_columns": ["id"]}]),
"stores": _table(_col("id")), "shops": _table(_col("id"))}, "已有外键约束的列"),
])
def test_name_inference_negative(tables, why):
assert _infer(tables, next(iter(tables))) == set(), why
def test_name_inference_one_to_one_when_column_is_own_key():
tables = {"member_profiles": _table(_col("member_id"), pk=("member_id",)), "members": _table(_col("id"))}
rel = catalog.infer_name_relations(_source(tables).schema_cache, "member_profiles")[0]
assert rel["cardinality"] == "one_to_one" and rel["source"] == "name" and rel["status"] == "proposed"
# ---------------------------------------------------------------- 模型起草
_VISITS_REPLY = {
"label": "入园记录", "grain": "每张门票每次检票一行", "kind": "fact", "keys": ["ticket_no"],
"business_date": {"column": "visit_time", "rule": "按检票时间计", "timezone": "+08:00"},
"columns": [
{"name": "visitor_count", "label": "入园人数", "meaning": "这次检票进园的人数", "unit": "人", "measure": "flow"},
{"name": "status", "label": "状态", "measure": "status"},
{"name": "ghost", "label": "不存在的列"}, # 丢掉
{"name": "ticket_no", "measure": "money"}, # 度量类型不在取值里:丢掉这一项
],
}
def _scenic_tables() -> dict:
return {
"visits": _table(_col("id"), _col("ticket_no", "TEXT"), _col("visit_time", "TEXT"), _col("visitor_count"),
_col("status"), fks=[]),
"channels": _table(_col("id"), _col("name", "TEXT"), fks=[]),
}
async def test_model_draft_maps_fields_and_drops_invalid_values():
src = _source(_scenic_tables())
model = FakeModel({"visits": _VISITS_REPLY, "channels": {"label": "渠道", "kind": "planet",
"keys": ["nope"]}})
got = await catalog.draft_with_model(model, src, ["visits", "channels"])
visits = got["visits"].notes
assert visits["label"] == make_item("入园记录", "llm")
assert visits["grain"]["value"] == "每张门票每次检票一行" and visits["kind"]["value"] == "fact"
assert visits["keys"]["value"] == ["ticket_no"]
assert visits["business_date"]["value"] == {"column": "visit_time", "rule": "按检票时间计", "timezone": "+08:00"}
assert visits["columns"]["visitor_count"]["unit"] == make_item("人", "llm")
assert visits["columns"]["status"]["measure"]["value"] == "status"
assert "ghost" not in visits["columns"] and "ticket_no" not in visits["columns"]
channels = got["channels"].notes
assert channels == {"label": make_item("渠道", "llm")} # 表类型、主键不合规的都丢掉
assert got["visits"].covered == frozenset({"llm"})
assert catalog.validate_notes(visits) == []
async def test_model_prompt_carries_structure_and_no_data():
src = _source(_scenic_tables())
model = FakeModel({"visits": _VISITS_REPLY})
await catalog.draft_with_model(model, src, ["visits"])
prompt = model.calls[0]
assert "表 visits" in prompt and "ticket_no" in prompt and "visit_time" in prompt
async def test_model_failure_only_affects_its_batch():
tables = {f"t{i:02d}": _table(_col("id"), *[_col(f"c{j}") for j in range(60)]) for i in range(4)}
src = _source(tables)
model = FakeModel({name: {"label": f"表{name}"} for name in tables}, fail={"t00"})
got = await catalog.draft_with_model(model, src, list(tables))
assert isinstance(got["t00"], str) and "网关超时" in got["t00"]
assert all(got[n].notes["label"]["value"] == f"表{n}" for n in ("t01", "t02", "t03"))
assert len(model.calls) >= 2 # 按表分批,不是一次全发
async def test_model_without_structured_output_falls_back_to_text():
"""有的网关不支持工具调用:结构化输出一调就报错,退回纯文本、从正文里取 JSON。"""
from langchain_core.messages import AIMessage
class TextOnly:
def with_structured_output(self, schema, **_):
raise NotImplementedError
async def ainvoke(self, messages):
return AIMessage(content='草稿如下:{"tables": [{"table": "visits", "label": "入园记录"}]}')
got = await catalog.draft_with_model(TextOnly(), _source(_scenic_tables()), ["visits"])
assert got["visits"].notes == {"label": make_item("入园记录", "llm")}
async def test_model_draft_respects_system_notes_of_imported_tables():
"""导入表格:表说明已经写了粒度、列说明写了单位,模型起草的粒度和单位不进目录,免得重复或矛盾。"""
src = _source({"日报": _table(_col("日期", "TEXT", comment="格式 YYYY-MM-DD"), _col("客流"), pk=(),
comment="粒度:每个日期")}, origin="upload")
model = FakeModel({"日报": {"label": "客流日报", "grain": "每天一行", "columns": [
{"name": "日期", "meaning": "统计日期", "unit": "天", "measure": "attribute"},
{"name": "客流", "meaning": "当天入园人次", "unit": "人次", "measure": "flow"}]}})
got = (await catalog.draft_with_model(model, src, ["日报"]))["日报"].notes
assert "grain" not in got and got["label"]["value"] == "客流日报"
assert "meaning" not in got["columns"]["日期"] and "unit" not in got["columns"]["日期"]
assert got["columns"]["日期"]["measure"]["value"] == "attribute"
assert got["columns"]["客流"]["unit"]["value"] == "人次" # 这一列没有系统说明,照常起草
assert "粒度:每个日期" in model.calls[0] # 系统说明交给模型作背景
# ---------------------------------------------------------------- 起草并写入
async def _db_source(tables: dict, **kw) -> DataSource:
for name, meta in tables.items():
meta["qualified"] = name
async with SessionLocal() as session:
row = DataSource(name=f"cat_{uuid.uuid4().hex[:10]}", kind="sqlite", database=":memory:",
schema_cache={"tables": tables}, **kw)
session.add(row)
await session.commit()
return row
async def test_draft_catalog_writes_and_is_idempotent():
tables = _scenic_tables()
tables["visits"]["foreign_keys"] = [{"columns": ["channel_id"], "to_table": "channels", "to_columns": ["id"]}]
tables["visits"]["columns"].append(_col("channel_id"))
tables["visits"]["columns"].append(_col("gate_id"))
tables["gates"] = _table(_col("id"), fks=[])
row = await _db_source(tables)
model = FakeModel({"visits": _VISITS_REPLY})
async with SessionLocal() as session:
report = await catalog.draft_catalog(session, row, tables=["visits"], use_model=True, model=model,
actor="王敏")
result = report.tables[0]
assert result.table == "visits" and result.error is None and result.version == 1
# 表级 5 项(中文名、粒度、表类型、业务主键、业务日期)+ 列级 6 项 + 外键和命名推断各一条关系
assert (result.added, result.updated, result.removed) == (13, 0, 0)
assert report.model_used and report.model_error is None
async with SessionLocal() as session:
entry = await catalog.read_entry(session, row.id, "visits")
assert entry.updated_by == "王敏"
assert _rels(entry.notes) == {(("channel_id",), "channels", ("id",), "fk", "verified"),
(("gate_id",), "gates", ("id",), "name", "proposed")}
again = await catalog.draft_catalog(session, row, tables=["visits"], use_model=True, model=model, actor=None)
assert (again.tables[0].added, again.tables[0].updated, again.tables[0].removed) == (0, 0, 0)
assert again.tables[0].version == 1 # 没有变化不升版本
async def test_redraft_keeps_confirmed_and_rejected_items():
tables = _scenic_tables()
tables["visits"]["columns"].append(_col("gate_id"))
tables["gates"] = _table(_col("id"), fks=[])
row = await _db_source(tables)
model = FakeModel({"visits": _VISITS_REPLY})
async with SessionLocal() as session:
await catalog.draft_catalog(session, row, tables=["visits"], use_model=True, model=model, actor=None)
entry = await catalog.read_entry(session, row.id, "visits")
rid = entry.notes["relations"][0]["id"]
entry = await catalog.review_entry(session, row.id, "visits", "grain", "confirm", if_version=entry.version,
actor="李雷")
entry = await catalog.review_entry(session, row.id, "visits", f"relations.{rid}", "reject",
if_version=entry.version, actor="李雷")
changed = FakeModel({"visits": {**_VISITS_REPLY, "grain": "每次入园一行", "label": "游客入园"}})
report = await catalog.draft_catalog(session, row, tables=["visits"], use_model=True, model=changed,
actor=None)
after = await catalog.read_entry(session, row.id, "visits")
assert after.notes["grain"]["value"] == "每张门票每次检票一行" and after.notes["grain"]["status"] == "confirmed"
assert after.notes["relations"][0]["status"] == "rejected" # 驳回的关系留着,下次起草不再提出
assert after.notes["label"]["value"] == "游客入园" # 同一来源、没确认过的项照常更新
assert report.tables[0].updated == 1
async def test_draft_catalog_model_unavailable_still_writes_other_sources(monkeypatch):
tables = _scenic_tables()
tables["visits"]["comment"] = "入园记录"
row = await _db_source(tables)
async def unavailable(session):
raise catalog.CatalogModelUnavailable("未配置模型接入,无法起草。请到「设置 → 模型接入」添加")
monkeypatch.setattr(catalog, "resolve_draft_model", unavailable)
async with SessionLocal() as session:
report = await catalog.draft_catalog(session, row, tables=["visits"], use_model=True, actor=None)
entry = await catalog.read_entry(session, row.id, "visits")
assert not report.model_used and "模型接入" in report.model_error
label = entry.notes["label"]
assert label["updated_at"] and {k: v for k, v in label.items() if k != "updated_at"} == make_item("入园记录",
"comment")
async def test_draft_catalog_model_failure_on_one_table(monkeypatch):
row = await _db_source(_scenic_tables())
model = FakeModel({"visits": _VISITS_REPLY, "channels": {"label": "渠道"}}, fail={"visits"})
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
async with SessionLocal() as session:
report = await catalog.draft_catalog(session, row, tables=["visits", "channels"], use_model=True,
model=model, actor=None)
by = {r.table: r for r in report.tables}
assert by["visits"].model_error and "网关超时" in by["visits"].model_error
assert by["channels"].model_error is None and by["channels"].added == 1
async def test_draft_catalog_defaults_to_most_used_tables(monkeypatch):
tables = {f"t{i:02d}": _table(_col("id"), fks=[]) for i in range(25)}
row = await _db_source(tables)
async def usage(session, source):
return {"t24": 9, "t03": 5, "t10": 5}
monkeypatch.setattr(catalog, "table_usage", usage)
async with SessionLocal() as session:
report = await catalog.draft_catalog(session, row, use_model=False, actor=None)
picked = [r.table for r in report.tables]
assert len(picked) == catalog.DRAFT_DEFAULT_TABLES == 20
assert picked[:3] == ["t24", "t03", "t10"] # 次数相同按表结构里的顺序
assert picked[3:6] == ["t00", "t01", "t02"]
async def test_draft_catalog_unknown_table_is_reported():
row = await _db_source(_scenic_tables())
async with SessionLocal() as session:
report = await catalog.draft_catalog(session, row, tables=["nope"], use_model=False, actor=None)
assert report.tables[0].error and "nope" in report.tables[0].error
def test_draft_prompt_constants_are_model_only():
"""提示词常量的名字带 _SYSTEM / _PROMPT:文案检查按名字把它们排除在界面文案之外。"""
assert catalog.CATALOG_DRAFT_SYSTEM and catalog.CATALOG_DRAFT_PROMPT
json.dumps(catalog.CATALOG_DRAFT_PROMPT_SCHEMA, ensure_ascii=False)
+183
View File
@@ -0,0 +1,183 @@
"""数据目录的合并、单项审阅和人工编辑规则(纯函数)。
- 起草结果并入已有目录:人工确认过、驳回过的项一律不动;同一来源的项可以更新;不同来源按可信程度
(人工 > 外键 > 数据剖析 > 数据库注释 > 模型起草、命名推断)决定谁留下;这一轮完整算过的来源,
已经没有的旧项删掉(外键约束被删了,原来那条「有确证」的关系不能留着)。
- 单项审阅:confirm / reject / reset,路径写法 grain、columns.amount.measure、relations.<编号>。
- 人工提交整份目录:改动过的项记为 human / confirmed,没动过的项保持原来的来源和状态。
"""
from __future__ import annotations
import pytest
from app.data import catalog
from app.data.catalog import make_item
AT = "2026-10-03T08:00:00+00:00"
def _rel(columns, to_table, to_columns, source="name", status=None, table="visits", **kw):
return {"id": catalog.relation_id(table, columns, to_table, to_columns), "columns": columns,
"to_table": to_table, "to_columns": to_columns, "cardinality": "many_to_one", "coverage": None,
"source": source, "status": status or catalog.initial_status(source), **kw}
# ---------------------------------------------------------------- 合并
def test_merge_adds_new_items_and_counts():
draft = {"label": make_item("入园记录", "llm"),
"columns": {"amount": {"unit": make_item("元", "llm")}},
"relations": [_rel(["gate_id"], "gates", ["id"])]}
merged, stats = catalog.merge_notes({}, draft, at=AT)
assert stats == catalog.MergeStats(added=3, updated=0, removed=0)
assert merged["label"]["value"] == "入园记录" and merged["label"]["updated_at"] == AT
assert merged["columns"]["amount"]["unit"]["status"] == "proposed"
assert merged["relations"][0]["source"] == "name"
def test_merge_never_overwrites_confirmed_or_rejected():
existing = {"label": make_item("入园明细", "human"),
"grain": make_item("每张票一行", "llm", "rejected"),
"relations": [_rel(["member_id"], "members", ["id"], status="rejected")]}
draft = {"label": make_item("入园记录", "llm"), "grain": make_item("每张票一行", "llm"),
"relations": [_rel(["member_id"], "members", ["id"])]}
merged, stats = catalog.merge_notes(existing, draft, covered={"llm", "name"}, at=AT)
assert merged == existing
assert stats == catalog.MergeStats(0, 0, 0)
def test_merge_same_source_updates_value():
existing = {"grain": make_item("每次入园一行", "llm", at="2026-09-01T00:00:00+00:00")}
merged, stats = catalog.merge_notes(existing, {"grain": make_item("每张门票一次检票一行", "llm")}, at=AT)
assert merged["grain"]["value"] == "每张门票一次检票一行" and merged["grain"]["updated_at"] == AT
assert stats.updated == 1
def test_merge_same_value_is_not_an_update():
existing = {"grain": make_item("每次入园一行", "llm", at="2026-09-01T00:00:00+00:00")}
merged, stats = catalog.merge_notes(existing, {"grain": make_item("每次入园一行", "llm")}, at=AT)
assert merged == existing and stats == catalog.MergeStats(0, 0, 0)
def test_merge_higher_rank_source_replaces_lower_but_not_the_reverse():
# 外键约束(有确证)顶掉同一条关系的命名推断
existing = {"relations": [_rel(["member_id"], "members", ["id"], source="name")]}
merged, stats = catalog.merge_notes(existing, {"relations": [_rel(["member_id"], "members", ["id"], source="fk")]},
at=AT)
assert merged["relations"][0]["source"] == "fk" and merged["relations"][0]["status"] == "verified"
assert stats.updated == 1
# 数据库注释比模型起草可信:模型起草不顶掉注释,注释可以顶掉模型起草
existing = {"label": make_item("入园记录", "comment")}
merged, _ = catalog.merge_notes(existing, {"label": make_item("游客入园", "llm")}, at=AT)
assert merged["label"]["source"] == "comment"
merged, _ = catalog.merge_notes({"label": make_item("游客入园", "llm")}, {"label": make_item("入园记录", "comment")},
at=AT)
assert merged["label"] == {**make_item("入园记录", "comment"), "updated_at": AT}
def test_merge_removes_stale_items_only_for_covered_sources():
stale_fk = _rel(["store_id"], "stores", ["id"], source="fk")
keep_name = _rel(["gate_id"], "gates", ["id"], source="name")
existing = {"relations": [stale_fk, keep_name], "label": make_item("入园记录", "llm")}
# 这一轮完整读过外键约束(covered 含 fk)而那条外键已经没了:删掉;命名推断和模型起草这一轮没算,留着
merged, stats = catalog.merge_notes(existing, {}, covered={"fk"}, at=AT)
assert merged["relations"] == [keep_name] and merged["label"]["value"] == "入园记录"
assert stats.removed == 1
def test_merge_keeps_column_entries_tidy():
existing = {"columns": {"amount": {"unit": make_item("元", "llm")}}}
merged, stats = catalog.merge_notes(existing, {}, covered={"llm"}, at=AT)
assert merged == {} and stats.removed == 1
# ---------------------------------------------------------------- 单项审阅
def _notes():
return {"grain": make_item("每次入园一行", "llm"),
"columns": {"amount": {"measure": make_item("flow", "llm")}},
"relations": [_rel(["park_id"], "parks", ["id"], source="fk"), _rel(["gate_id"], "gates", ["id"])]}
def test_review_confirm_reject_reset_on_each_kind_of_path():
notes = _notes()
rid = notes["relations"][1]["id"]
out = catalog.review_item(notes, "grain", "confirm", at=AT)
assert out["grain"]["status"] == "confirmed" and out["grain"]["source"] == "llm" and out["grain"]["updated_at"] == AT
out = catalog.review_item(out, "columns.amount.measure", "reject", at=AT)
assert out["columns"]["amount"]["measure"]["status"] == "rejected"
out = catalog.review_item(out, f"relations.{rid}", "confirm", at=AT)
assert out["relations"][1]["status"] == "confirmed"
# reset 回到来源的初始状态:外键是 verified,其余是 proposed
fk_id = notes["relations"][0]["id"]
out = catalog.review_item(catalog.review_item(out, f"relations.{fk_id}", "reject", at=AT),
f"relations.{fk_id}", "reset", at=AT)
assert out["relations"][0]["status"] == "verified"
out = catalog.review_item(out, "columns.amount.measure", "reset", at=AT)
assert out["columns"]["amount"]["measure"]["status"] == "proposed"
assert notes["grain"]["status"] == "proposed" # 不改入参
def test_review_reset_of_human_item_removes_it():
notes = {"label": make_item("入园记录", "human")}
assert catalog.review_item(notes, "label", "reset", at=AT) == {}
def test_review_column_names_may_contain_dots():
notes = {"columns": {"a.b": {"unit": make_item("元", "llm")}}}
out = catalog.review_item(notes, "columns.a.b.unit", "confirm", at=AT)
assert out["columns"]["a.b"]["unit"]["status"] == "confirmed"
@pytest.mark.parametrize("path", ["", "nope", "grain.extra", "columns.amount", "columns.amount.formula",
"columns.missing.measure", "relations.r000000000000", "label"])
def test_review_bad_paths(path):
with pytest.raises(catalog.CatalogPathError):
catalog.review_item(_notes(), path, "confirm", at=AT)
def test_review_bad_action():
with pytest.raises(catalog.CatalogPathError):
catalog.review_item(_notes(), "grain", "approve", at=AT)
# ---------------------------------------------------------------- 人工编辑
def test_human_edit_marks_changed_items_as_human_confirmed_and_keeps_the_rest():
existing = _notes()
submitted = {
"grain": {**existing["grain"], "value": "每张门票每次检票一行"}, # 改了值
"columns": {"amount": {"measure": existing["columns"]["amount"]["measure"], # 没动
"unit": {"value": "元"}}}, # 新填,没写来源和状态
"relations": existing["relations"][:1], # 删掉一条关系
"dedup": {"value": "同一票号只计一次", "source": "llm", "status": "verified"}, # 冒充别的来源、状态
}
out = catalog.apply_human_edit(existing, submitted, table="visits", at=AT)
assert out["grain"] == {"value": "每张门票每次检票一行", "source": "human", "status": "confirmed", "updated_at": AT}
assert out["columns"]["amount"]["measure"] == existing["columns"]["amount"]["measure"]
assert out["columns"]["amount"]["unit"] == {"value": "元", "source": "human", "status": "confirmed",
"updated_at": AT}
assert out["dedup"]["source"] == "human" and out["dedup"]["status"] == "confirmed"
assert len(out["relations"]) == 1 and out["relations"][0] == existing["relations"][0]
assert catalog.validate_notes(out) == []
def test_human_edit_status_only_change_keeps_source():
existing = _notes()
submitted = {**existing, "grain": {**existing["grain"], "status": "confirmed"}}
out = catalog.apply_human_edit(existing, submitted, table="visits", at=AT)
assert out["grain"]["source"] == "llm" and out["grain"]["status"] == "confirmed"
# 不能把推断改成「有确证」:verified 只来自外键约束和数据剖析
submitted = {**existing, "grain": {**existing["grain"], "status": "verified"}}
assert catalog.apply_human_edit(existing, submitted, table="visits", at=AT)["grain"]["status"] == "proposed"
def test_human_edit_relation_id_follows_its_ends():
existing = _notes()
moved = {**existing["relations"][1], "to_table": "parks", "to_columns": ["id"], "columns": ["gate_id"]}
out = catalog.apply_human_edit(existing, {"relations": [moved]}, table="visits", at=AT)
assert out["relations"][0]["id"] == catalog.relation_id("visits", ["gate_id"], "parks", ["id"])
assert out["relations"][0]["source"] == "human"
+187
View File
@@ -0,0 +1,187 @@
"""数据目录交给模型的文本、关系图与连接路径、冻结内容、使用次数。"""
from __future__ import annotations
import uuid
from app.data import catalog
from app.data.catalog import make_item
from app.db.base import SessionLocal
from app.db.models import Artifact, DataSource, Run
MARK = "(推断,未确认)"
def _rel(table, columns, to_table, to_columns, source="fk", status=None, cardinality="many_to_one"):
return {"id": catalog.relation_id(table, columns, to_table, to_columns), "columns": columns, "to_table": to_table,
"to_columns": to_columns, "cardinality": cardinality, "coverage": None, "source": source,
"status": status or catalog.initial_status(source)}
VISITS = {
"label": make_item("入园记录", "human"),
"grain": make_item("每张门票每次检票一行", "llm"),
"kind": make_item("fact", "llm", "confirmed"),
"keys": make_item(["ticket_no"], "human"),
"business_date": make_item({"column": "visit_time", "rule": "按检票时间计", "timezone": "+08:00"}, "human"),
"valid_filter": make_item("status = 1", "human"),
"dedup": make_item("同一票号只计一次", "llm", "rejected"),
"columns": {
"visitor_count": {"meaning": make_item("这次检票进园的人数", "human"), "unit": make_item("人", "llm"),
"measure": make_item("flow", "llm", "confirmed")},
"status": {"codes": make_item({"1": "有效", "0": "作废"}, "human")},
},
"relations": [_rel("visits", ["park_id"], "parks", ["id"]),
_rel("visits", ["gate_id"], "gates", ["id"], source="name"),
_rel("visits", ["member_id"], "members", ["id"], source="name", status="rejected")],
}
def test_full_render_shows_every_fact_and_marks_inferences():
text = catalog.render_table_notes(VISITS)
assert "中文名:入园记录" in text
assert f"粒度:每张门票每次检票一行{MARK}" in text
assert "表类型:事实表" in text and "业务主键:ticket_no" in text
assert "业务日期:visit_time" in text and "按检票时间计" in text and "+08:00" in text
assert "有效记录条件:status = 1" in text
assert "visitor_count:这次检票进园的人数" in text and f"单位:人{MARK}" in text and "流量" in text
assert "1=有效" in text and "0=作废" in text
assert "park_id → parks.id" in text and f"gate_id → gates.id(多对一){MARK}" in text
# 驳回的项模型看不到
assert "同一票号只计一次" not in text and "members" not in text
# 有确证、已确认的不标推断
assert "表类型:事实表(" not in text and "中文名:入园记录(" not in text
def test_render_of_empty_or_all_rejected_notes_is_empty():
assert catalog.render_table_notes({}) == ""
assert catalog.render_table_notes({"dedup": make_item("x", "llm", "rejected")}) == ""
def test_render_beside_system_notes_only_adds_what_they_do_not_cover():
"""导入表格:表和列已有按核对结果生成的说明。保留说明,只追加人工确认过的、或说明没有覆盖的项。"""
meta = {"comment": "粒度:每个日期。客流 等于 各入口之和(导入时已逐日核对)",
"columns": [{"name": "日期", "type": "TEXT", "comment": "格式 YYYY-MM-DD"},
{"name": "客流", "type": "INTEGER", "comment": None}]}
notes = {"grain": make_item("每天一行", "llm"), "label": make_item("客流日报", "llm"),
"description": make_item("人工补充的说明", "human"),
"columns": {"日期": {"meaning": make_item("统计日期", "llm"), "measure": make_item("attribute", "llm")},
"客流": {"unit": make_item("人次", "llm")}}}
text = catalog.render_table_notes(notes, meta=meta, system_notes=True)
assert "每天一行" not in text and "统计日期" not in text # 说明已经覆盖、又没人确认的:不追加
assert "客流日报" in text and "人工补充的说明" in text # 说明没有覆盖的、人工确认过的:追加
assert "属性" in text and "人次" in text
def test_compact_index_one_line_per_table():
cache = {"tables": {"visits": {"qualified": "visits", "columns": []},
"parks": {"qualified": "main.parks", "columns": []},
"gates": {"qualified": "gates", "columns": []}}}
text = catalog.render_table_index(cache, {"visits": VISITS, "parks": {"label": make_item("景区", "llm")}})
lines = text.splitlines()
assert len(lines) == 3
assert lines[0].startswith("visits|入园记录|每张门票每次检票一行") and lines[0].endswith(MARK)
assert lines[1].startswith("main.parks|景区") and MARK in lines[1]
assert lines[2] == "gates"
# ---------------------------------------------------------------- 关系图
NOTES = {
"visits": {"relations": [_rel("visits", ["park_id"], "parks", ["id"]),
_rel("visits", ["member_id"], "members", ["id"], source="name", status="rejected")]},
"channel_visits": {"relations": [_rel("channel_visits", ["channel_id"], "channels", ["id"]),
_rel("channel_visits", ["visit_id"], "visits", ["id"])]},
"orders": {"relations": [_rel("orders", ["channel_id"], "channels", ["id"]),
_rel("orders", ["member_id"], "members", ["id"])]},
}
def test_graph_adjacency_is_bidirectional_and_skips_rejected():
graph = catalog.relation_graph(NOTES)
assert {e.to_table for e in graph["visits"]} == {"parks", "channel_visits"}
back = next(e for e in graph["channels"] if e.to_table == "orders")
assert back.from_columns == ("id",) and back.to_columns == ("channel_id",) and back.cardinality == "one_to_many"
assert all(e.to_table != "members" for e in graph["visits"])
def test_join_paths_up_to_two_hops():
graph = catalog.relation_graph(NOTES)
paths = catalog.join_paths(graph, "visits", "channels")
assert [tuple(e.to_table for e in p) for p in paths] == [("channel_visits", "channels")]
assert paths[0][0].condition() == "visits.id = channel_visits.visit_id"
assert paths[0][1].condition() == "channel_visits.channel_id = channels.id"
assert catalog.join_paths(graph, "visits", "parks")[0][0].to_table == "parks"
# 三跳以上不给:visits → channel_visits → channels → orders
assert catalog.join_paths(graph, "visits", "orders") == []
# 驳回的关系不出现在路径里
assert catalog.join_paths(graph, "visits", "members") == []
def test_graph_can_fill_in_relations_from_structure():
"""没起草过目录的表,关系图可以按表结构现推(外键 + 命名推断),驳回过的照样排除。"""
cache = {"tables": {
"visits": {"columns": [{"name": "id", "type": "INTEGER"}, {"name": "gate_id", "type": "INTEGER"},
{"name": "member_id", "type": "INTEGER"}],
"primary_key": ["id"], "foreign_keys": []},
"gates": {"columns": [{"name": "id", "type": "INTEGER"}], "primary_key": ["id"], "foreign_keys": []},
"members": {"columns": [{"name": "id", "type": "INTEGER"}], "primary_key": ["id"], "foreign_keys": []},
}}
rejected = {"visits": {"relations": [_rel("visits", ["member_id"], "members", ["id"], source="name",
status="rejected")]}}
graph = catalog.relation_graph(rejected, schema_cache=cache)
assert {e.to_table for e in graph["visits"]} == {"gates"}
assert graph["gates"][0].status == "proposed"
# ---------------------------------------------------------------- 冻结
def test_frozen_catalog_strips_rejected_and_keeps_versions():
entries = {"visits": catalog.CatalogEntry("visits", VISITS, 4),
"gone": catalog.CatalogEntry("gone", {"label": make_item("x", "human")}, 2),
"empty": catalog.CatalogEntry("empty", {"dedup": make_item("x", "llm", "rejected")}, 1)}
frozen = catalog.frozen_catalog(entries, ["visits", "empty", "parks"])
assert set(frozen) == {"visits"} # 表结构里没有的、全被驳回的都不冻结
assert frozen["visits"]["version"] == 4
assert "dedup" not in frozen["visits"]["notes"]
assert len(frozen["visits"]["notes"]["relations"]) == 2
assert catalog.frozen_catalog({}, ["visits"]) == {}
# ---------------------------------------------------------------- 使用次数
async def test_table_usage_counts_run_queries_per_table(monkeypatch):
sid = uuid.uuid4().hex
name = f"use_{sid[:8]}"
cache = {"tables": {"visits": {"qualified": "main.visits"}, "parks": {"qualified": "main.parks"},
"明细": {"qualified": "main.明细"}}}
run_a, run_b = uuid.uuid4().hex, uuid.uuid4().hex
legacy = {sid[:8] + "legacy1": {"source": name, "sql": 'SELECT * FROM "Visits" v JOIN main.parks p ON 1=1'}}
monkeypatch.setattr(catalog, "_load_artifact", lambda aid: legacy.get(aid))
async with SessionLocal() as session:
session.add(DataSource(id=sid, name=name, kind="sqlite", database=":memory:", schema_cache=cache))
session.add_all([Run(id=run_a, graph={}), Run(id=run_b, graph={})])
session.add_all([
# 新写法:查询当时把源和表名记在工件引用的 meta 里
Artifact(id=sid[:8] + "q1", run_id=run_a, kind="query_snapshot",
meta={"source_id": sid, "source": name, "tables": ["visits"]}),
Artifact(id=sid[:8] + "q2", run_id=run_b, kind="query_snapshot",
meta={"source_id": sid, "source": name, "tables": ["visits", "明细"]}),
# 同一个查询快照被两次运行引用:各算一次
Artifact(id=sid[:8] + "q2", run_id=run_a, kind="query_snapshot",
meta={"source_id": sid, "source": name, "tables": ["visits", "明细"]}),
# 别的数据源、工具库试用(不属于任何运行)、别的工件:不算
Artifact(id=sid[:8] + "q3", run_id=run_a, kind="query_snapshot",
meta={"source_id": "other", "source": "other", "tables": ["visits"]}),
Artifact(id=sid[:8] + "q4", run_id="playground", kind="query_snapshot",
meta={"source_id": sid, "source": name, "tables": ["visits"]}),
Artifact(id=sid[:8] + "s1", run_id=run_a, kind="schema_snapshot", meta={"source": name}),
# 老的查询快照引用没有 meta:读工件内容里的源名和 SQL
Artifact(id=sid[:8] + "legacy1", run_id=run_b, kind="query_snapshot", meta={}),
])
await session.commit()
source = await session.get(DataSource, sid)
usage = await catalog.table_usage(session, source)
assert usage == {"visits": 4, "明细": 2, "parks": 1}
+185
View File
@@ -0,0 +1,185 @@
"""数据目录在运行时:db_schema 工具输出附上目录,查询时目录随表结构快照冻结进证据,查询快照记下用到的表。
- describe_table(db_schema 工具的输出)附上这张表的目录;推断的项标「推断,未确认」,驳回的不出现。
导入表格的源保留系统生成的说明,目录只追加说明没有覆盖的、或人工确认过的项。
- 表结构快照新增顶层键 catalog:{表: {version, notes(去掉驳回项)}}。没有目录的源一个字都不加;
目录不变则快照不变(内容寻址,哈希相同)。
"""
from __future__ import annotations
import json
import uuid
from types import SimpleNamespace
from app.core.artifact_store import canonical_json, content_hash, load
from app.data import catalog
from app.data.catalog import make_item
from app.data.engine import engines
from app.data.introspect import describe_table, introspect
from app.db.base import SessionLocal
from app.db.models import Artifact, DataSource
from app.tools.datasource import _store_schema, build_datasource_tools
from app.tools.registry import ToolContext
MARK = "(推断,未确认)"
def _rel(table, columns, to_table, to_columns, source="fk", status=None):
return {"id": catalog.relation_id(table, columns, to_table, to_columns), "columns": columns, "to_table": to_table,
"to_columns": to_columns, "cardinality": "many_to_one", "coverage": None, "source": source,
"status": status or catalog.initial_status(source)}
CACHE = {
"schema": "main", "synced_at": "2026-10-01T00:00:00+00:00", "truncated": False, "total": 2,
"tables": {
"visits": {"qualified": "visits", "schema": None, "is_view": False, "comment": None, "primary_key": ["id"],
"columns": [{"name": "id", "type": "INTEGER", "nullable": False, "comment": None},
{"name": "park_id", "type": "INTEGER", "nullable": False, "comment": None},
{"name": "visitor_count", "type": "INTEGER", "nullable": False, "comment": None},
{"name": "status", "type": "INTEGER", "nullable": False, "comment": None}]},
"parks": {"qualified": "parks", "schema": None, "is_view": False, "comment": None, "primary_key": ["id"],
"columns": [{"name": "id", "type": "INTEGER", "nullable": False, "comment": None}]},
},
}
NOTES = {
"label": make_item("入园记录", "human"),
"grain": make_item("每张门票每次检票一行", "llm"),
"keys": make_item(["id"], "human"),
"business_date": make_item({"column": "visit_time", "rule": "按检票时间计"}, "human"),
"valid_filter": make_item("status = 1", "llm"),
"dedup": make_item("按票号去重", "llm", "rejected"),
"columns": {"visitor_count": {"meaning": make_item("这次检票进园的人数", "llm"), "unit": make_item("人", "human"),
"measure": make_item("flow", "llm")},
"status": {"codes": make_item({"1": "有效", "0": "作废"}, "human")}},
"relations": [_rel("visits", ["park_id"], "parks", ["id"])],
}
def _source(cache=CACHE, **kw) -> SimpleNamespace:
return SimpleNamespace(id=kw.pop("id", "src-x"), name=kw.pop("name", "scenic"), kind="sqlite", options={},
readonly=True, description="", schema_cache=cache, origin=kw.pop("origin", "manual"), **kw)
# ---------------------------------------------------------------- describe_table
def test_describe_table_appends_catalog():
text = describe_table(_source(), "visits", catalog={"visits": NOTES})
head, _, block = text.partition(catalog.NOTES_HEADING)
assert head.startswith("表 visits") and " visitor_count INTEGER" in head # 原来的列清单照旧
assert "中文名:入园记录" in block and f"粒度:每张门票每次检票一行{MARK}" in block
assert "业务主键:id" in block and "业务日期:visit_time;规则:按检票时间计" in block
assert f"有效记录条件:status = 1{MARK}" in block
assert f"visitor_count:这次检票进园的人数{MARK};单位:人;度量类型:流量,可跨期加总{MARK}" in block
assert "status:码值:1=有效、0=作废" in block
assert "park_id → parks.id(多对一)" in block
assert "按票号去重" not in text
def test_describe_table_without_catalog_is_unchanged():
plain = describe_table(_source(), "visits")
assert describe_table(_source(), "visits", catalog={}) == plain
assert describe_table(_source(), "visits", catalog={"parks": NOTES}) == plain
assert catalog.NOTES_HEADING not in plain
# 全名、大小写不一致也对得上目录
assert catalog.NOTES_HEADING in describe_table(_source(), "MAIN.Visits", catalog={"visits": NOTES})
def test_describe_table_keeps_system_notes_of_imported_tables():
"""导入表格:说明是按核对结果生成的。保留说明,目录不重复它、不和它抢话。"""
cache = {"import_mode": "recipe", "tables": {"日报": {
"qualified": "日报", "comment": "粒度:每个日期。不要把各入口相加", "primary_key": [],
"columns": [{"name": "日期", "type": "TEXT", "nullable": False, "comment": "格式 YYYY-MM-DD"},
{"name": "客流", "type": "INTEGER", "nullable": True, "comment": "单位:人次"}]}}}
notes = {"label": make_item("客流日报", "llm"), "grain": make_item("每天一行", "llm"),
"columns": {"客流": {"unit": make_item("人", "llm"), "measure": make_item("flow", "llm")},
"日期": {"meaning": make_item("统计日期", "human")}}}
text = describe_table(_source(cache, origin="upload"), "日报", catalog={"日报": notes})
assert text.startswith("表 日报(粒度:每个日期。不要把各入口相加)")
assert "单位:人次" in text and "格式 YYYY-MM-DD" in text # 系统说明原样保留
assert "每天一行" not in text # 表说明已写粒度:推断的粒度不追加
assert "单位:人" + MARK not in text # 列说明已写单位:推断的单位不追加
assert "客流日报" in text and "流量" in text # 说明没有覆盖的照常追加
assert "日期:统计日期" in text # 人工确认过的追加
# ---------------------------------------------------------------- 冻结进表结构快照
def _entry(notes, version=1):
return {"visits": catalog.CatalogEntry("visits", notes, version)}
async def test_store_schema_freezes_catalog_and_is_content_addressed():
ctx = SimpleNamespace(run_id="", node_id="")
plain = await _store_schema(_source(), ctx)
before = {"source": "scenic", "tables": CACHE["tables"],
**{k: CACHE[k] for k in ("schema", "synced_at", "truncated", "total")}}
assert plain == content_hash(canonical_json(before)) # 没有目录:和以前一字不差
assert await _store_schema(_source(), ctx, catalog={}) == plain
first = await _store_schema(_source(), ctx, catalog=_entry(NOTES, 3))
frozen = load(first)
assert frozen["catalog"]["visits"]["version"] == 3
assert "dedup" not in frozen["catalog"]["visits"]["notes"] # 驳回的不冻结
assert frozen["tables"] == CACHE["tables"] # 已有字段的语义不变
# 目录不变:同一件工件
assert await _store_schema(_source(), ctx, catalog=_entry(json.loads(json.dumps(NOTES)), 3)) == first
# 目录变了(升了版本):换一件
assert await _store_schema(_source(), ctx, catalog=_entry(NOTES, 4)) != first
# 只有驳回项、或者表不在快照里:不加这个键
assert await _store_schema(_source(), ctx, catalog={
"visits": catalog.CatalogEntry("visits", {"dedup": make_item("x", "llm", "rejected")}, 1),
"gone": catalog.CatalogEntry("gone", {"label": make_item("x", "human")}, 1)}) == plain
def test_snapshot_readers_ignore_catalog_key():
"""读表结构快照的地方(证据台账的表结构条目、裁判摘录的口径行)只认自己的键,多一个 catalog 不影响。"""
from app.data.provenance import judge_lines
from app.engine.evidence import schema_entry_fields
tables = {"日报": {"qualified": "日报", "comment": "粒度:每个日期", "primary_key": [],
"columns": [{"name": "客流", "type": "INTEGER", "nullable": True, "comment": "单位:人次"}]}}
base = {"source": "flow", "tables": tables, "schema": "main"}
with_catalog = {**base, "catalog": {"日报": {"version": 2, "notes": {"label": make_item("客流日报", "human")}}}}
assert schema_entry_fields(with_catalog) == schema_entry_fields(base)
query = {"data_version": "snap1", "columns": ["客流"], "sql": "SELECT 客流 FROM 日报"}
lines = judge_lines(query, base, lambda _id: None, tables=["日报"], hidden=set())
assert lines and judge_lines(query, with_catalog, lambda _id: None, tables=["日报"], hidden=set()) == lines
# ---------------------------------------------------------------- 工具里串起来
async def test_tools_show_and_freeze_catalog_and_record_query_tables(scenic_db):
async with SessionLocal() as session:
row = DataSource(name=f"cat_{uuid.uuid4().hex[:10]}", kind="sqlite", database=scenic_db)
session.add(row)
await session.commit()
row.schema_cache = await introspect(row)
await session.commit()
await catalog.write_entry(session, row.id, "visits", {"label": make_item("入园记录", "human"),
"grain": make_item("每张门票每次检票一行", "llm")},
if_version=0, actor="王敏")
run_id = uuid.uuid4().hex
ctx = ToolContext(run_id=run_id, node_id="n1", data_versions=None)
tools = {t.name: t for t in await build_datasource_tools(
[f"db_query__{row.name}", f"db_schema__{row.name}"], ctx, session)}
try:
shown = await tools[f"db_schema__{row.name}"].coroutine(table="visits")
assert "中文名:入园记录" in shown and f"粒度:每张门票每次检票一行{MARK}" in shown
other = await tools[f"db_schema__{row.name}"].coroutine(table="parks")
assert catalog.NOTES_HEADING not in other
result = json.loads(await tools[f"db_query__{row.name}"].coroutine(
sql="SELECT v.id FROM visits v JOIN parks p ON p.id = v.park_id LIMIT 3"))
frozen = load(result["schema_artifact"])
assert frozen["catalog"] == {"visits": {"version": 1, "notes": {
"label": make_item("入园记录", "human"), "grain": make_item("每张门票每次检票一行", "llm")}}}
async with SessionLocal() as session:
ref = await session.get(Artifact, (result["artifact"], run_id))
assert ref.meta == {"source_id": row.id, "source": row.name, "tables": ["visits", "parks"]}
finally:
await engines.invalidate(row.id)
+147
View File
@@ -0,0 +1,147 @@
"""数据目录的存储:catalog_notes 表、版本号与乐观锁、结构校验。
目录是多人可改的长期资产(助手起草、人工确认、阶段 2 的数据剖析都往里写),所以每次写入必须带上
「我是基于第几版改的」:两个人同时打开同一张表各改各的,后提交的那个要被拦下来重新载入,不能悄悄
盖掉前一个人的确认。
"""
from __future__ import annotations
import uuid
import pytest
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.data import catalog
from app.db.base import SessionLocal
from app.db.models import CatalogNote, DataSource
async def _source() -> str:
async with SessionLocal() as session:
row = DataSource(name=f"cat_{uuid.uuid4().hex[:10]}", kind="sqlite", database=":memory:")
session.add(row)
await session.commit()
return row.id
def _label(value: str, source: str = "human", status: str = "confirmed") -> dict:
return {"value": value, "source": source, "status": status}
async def test_first_write_creates_version_one_and_records_actor():
sid = await _source()
async with SessionLocal() as session:
entry = await catalog.write_entry(session, sid, "visits", {"label": _label("入园记录")},
if_version=0, actor="王敏")
assert entry.version == 1
assert entry.updated_by == "王敏"
assert entry.notes["label"]["value"] == "入园记录"
async with SessionLocal() as session:
again = await catalog.read_entry(session, sid, "visits")
assert again is not None and again.version == 1 and again.updated_at is not None
async def test_each_write_bumps_version_and_stale_version_conflicts():
sid = await _source()
async with SessionLocal() as session:
await catalog.write_entry(session, sid, "visits", {"label": _label("入园记录")}, if_version=0, actor=None)
second = await catalog.write_entry(session, sid, "visits", {"label": _label("入园明细")},
if_version=1, actor="李雷")
assert second.version == 2
# 另一个人还拿着第 1 版:被拦下,内容不变
with pytest.raises(catalog.CatalogConflict):
await catalog.write_entry(session, sid, "visits", {"label": _label("别的名字")}, if_version=1, actor="韩梅")
# 以为这张表还没有目录(if_version=0),其实已经有了:同样是冲突
with pytest.raises(catalog.CatalogConflict):
await catalog.write_entry(session, sid, "visits", {"label": _label("别的名字")}, if_version=0, actor=None)
async with SessionLocal() as session:
entry = await catalog.read_entry(session, sid, "visits")
assert entry.version == 2 and entry.notes["label"]["value"] == "入园明细" and entry.updated_by == "李雷"
async def test_unchanged_notes_do_not_bump_version():
"""内容没变就不升版本:冻结进表结构快照的是 {版本, 内容},空写一次也升版本的话,快照哈希会无故变化。"""
sid = await _source()
notes = {"label": _label("渠道")}
async with SessionLocal() as session:
first = await catalog.write_entry(session, sid, "channels", notes, if_version=0, actor="甲")
same = await catalog.write_entry(session, sid, "channels", dict(notes), if_version=1, actor="乙")
assert same.version == first.version == 1
assert same.updated_by == "甲"
async def test_unique_per_source_and_table():
sid = await _source()
async with SessionLocal() as session:
session.add(CatalogNote(source_id=sid, table_name="visits", notes={}, version=1))
await session.commit()
session.add(CatalogNote(source_id=sid, table_name="visits", notes={}, version=1))
with pytest.raises(IntegrityError):
await session.commit()
async def test_deleting_source_cascades():
sid = await _source()
async with SessionLocal() as session:
await catalog.write_entry(session, sid, "visits", {"label": _label("入园记录")}, if_version=0, actor=None)
row = await session.get(DataSource, sid)
await session.delete(row)
await session.commit()
async with SessionLocal() as session:
left = (await session.execute(select(CatalogNote).where(CatalogNote.source_id == sid))).scalars().all()
assert left == []
async def test_read_catalog_lists_all_tables_of_a_source():
sid, other = await _source(), await _source()
async with SessionLocal() as session:
await catalog.write_entry(session, sid, "visits", {"label": _label("入园记录")}, if_version=0, actor=None)
await catalog.write_entry(session, sid, "stores", {"label": _label("门店")}, if_version=0, actor=None)
await catalog.write_entry(session, other, "visits", {"label": _label("别处")}, if_version=0, actor=None)
entries = await catalog.read_catalog(session, sid)
assert set(entries) == {"visits", "stores"}
assert entries["stores"].notes["label"]["value"] == "门店"
@pytest.mark.parametrize("notes, fragment", [
({"label": "入园记录"}, "中文名"), # 不是项
({"label": {"value": "x", "source": "guess", "status": "proposed"}}, "来源"), # 来源不在取值里
({"label": {"value": "x", "source": "llm", "status": "maybe"}}, "状态"),
({"kind": _label("table")}, "表类型"),
({"keys": _label("visit_id")}, "业务主键"), # 应为字符串列表
({"business_date": _label({"column": "visit_date", "rule": 1})}, "业务日期"),
({"columns": {"amount": {"measure": _label("sum")}}}, "列 amount 的度量类型"),
({"columns": {"status": {"codes": _label({"1": 2})}}}, "列 status 的码值"),
({"formula": _label("sum(amount)")}, "formula"), # 目录不放公式
({"relations": [{"id": "r1", "columns": ["a"], "to_table": "t", "to_columns": [],
"source": "fk", "status": "verified"}]}, "关联关系 r1"),
])
def test_validate_rejects_malformed_notes(notes, fragment):
problems = catalog.validate_notes(notes)
assert problems, notes
assert any(fragment in p for p in problems), problems
def test_validate_accepts_full_example():
notes = {
"label": _label("入园记录"), "description": _label("每次检票入园记一行", "llm", "proposed"),
"grain": _label("每次入园"), "keys": _label(["visit_id"]), "kind": _label("fact"),
"business_date": _label({"column": "visit_date", "rule": "按检票时间计", "timezone": "+08:00"}),
"valid_filter": _label("status = 1"), "dedup": _label("同一票号只计一次"),
"columns": {"amount": {"label": _label("实收金额"), "meaning": _label("扣除优惠后的金额"),
"unit": _label("元"), "measure": _label("flow"),
"codes": _label({"1": "有效"}, "comment", "proposed")}},
"relations": [{"id": catalog.relation_id("visits", ["store_id"], "stores", ["id"]), "columns": ["store_id"],
"to_table": "stores", "to_columns": ["id"], "cardinality": "many_to_one", "coverage": None,
"source": "fk", "status": "verified"}],
}
assert catalog.validate_notes(notes) == []
async def test_write_rejects_invalid_notes():
sid = await _source()
async with SessionLocal() as session:
with pytest.raises(catalog.CatalogInvalid) as e:
await catalog.write_entry(session, sid, "visits", {"label": "入园记录"}, if_version=0, actor=None)
assert e.value.problems
@@ -0,0 +1,119 @@
"""探查时补读外键约束和唯一约束 / 唯一索引,以及数据目录在合成大库上的起草效果。
数据目录的关系起草靠它:外键记成有确证的关系,唯一约束用来判断一对一。读不到(方言不支持、权限不够、
某个对象反射报错)就跳过那一项,不能毁掉整次探查;跳过的表不写这两个键,下游据此分得清「读过、没有」
和「不知道」(旧缓存里也没有这两个键)。
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from sqlalchemy.engine.reflection import Inspector
from app.data import catalog
from app.data.engine import engines
from app.data.introspect import introspect
from tests.fixtures.catalog import scenic
def _source(path: str, sid: str = "scenic-src") -> SimpleNamespace:
return SimpleNamespace(id=sid, name="scenic", kind="sqlite", database=path, host=None, port=None, username=None,
password=None, options={}, readonly=True, description="景区业务库", schema_cache={},
origin="manual")
@pytest.fixture
async def scenic_cache(scenic_db):
source = _source(scenic_db, "scenic-cache")
try:
cache = await introspect(source)
finally:
await engines.invalidate(source.id)
return cache
async def test_fixture_is_large_and_complete(scenic_cache):
tables = scenic_cache["tables"]
assert len(tables) == scenic.TABLE_COUNT + len(scenic.VIEWS) > 40
assert not scenic_cache.get("truncated") and not scenic_cache.get("skipped")
async def test_foreign_keys_are_read(scenic_cache):
found = {(name, tuple(fk["columns"]), fk["to_table"], tuple(fk["to_columns"]))
for name, meta in scenic_cache["tables"].items() for fk in meta["foreign_keys"]}
assert found == scenic.FK_RELATIONS
# 每张表、每个视图都读过:没有外键的是空列表,不是缺键
assert all(isinstance(m.get("foreign_keys"), list) for m in scenic_cache["tables"].values())
assert scenic_cache["tables"]["v_daily_visits"]["foreign_keys"] == []
async def test_unique_constraints_and_unique_indexes_are_read(scenic_cache):
tables = scenic_cache["tables"]
for name, groups in scenic.UNIQUE.items():
for group in groups:
assert group in tables[name]["unique"], (name, group)
# 主键不重复记成唯一约束;没有唯一约束的是空列表
assert ["id"] not in tables["orders"]["unique"]
assert tables["refunds"]["unique"] == []
async def test_constraint_reflection_failure_does_not_break_introspection(scenic_db, monkeypatch):
def boom(*args, **kwargs):
raise RuntimeError("permission denied for information_schema")
for name in ("get_multi_foreign_keys", "get_foreign_keys", "get_multi_unique_constraints",
"get_unique_constraints", "get_multi_indexes", "get_indexes"):
monkeypatch.setattr(Inspector, name, boom)
source = _source(scenic_db, "scenic-broken")
try:
cache = await introspect(source)
finally:
await engines.invalidate(source.id)
assert not cache.get("failed") and len(cache["tables"]) == scenic.TABLE_COUNT + len(scenic.VIEWS)
visits = cache["tables"]["visits"]
assert "foreign_keys" not in visits and "unique" not in visits
assert visits["primary_key"] == ["id"] and visits["columns"]
async def test_not_implemented_constraints_are_skipped(scenic_db, monkeypatch):
def unsupported(*args, **kwargs):
raise NotImplementedError
monkeypatch.setattr(Inspector, "get_multi_foreign_keys", unsupported)
monkeypatch.setattr(Inspector, "get_foreign_keys", unsupported)
source = _source(scenic_db, "scenic-nofk")
try:
cache = await introspect(source)
finally:
await engines.invalidate(source.id)
assert "foreign_keys" not in cache["tables"]["visits"]
assert isinstance(cache["tables"]["visits"]["unique"], list)
# ---------------------------------------------------------------- 在合成大库上起草
async def test_structure_draft_on_scenic_db(scenic_db, scenic_cache):
source = SimpleNamespace(**{**vars(_source(scenic_db)), "schema_cache": scenic_cache})
fk, name = set(), set()
for table in scenic_cache["tables"]:
for rel in catalog.draft_structure(source, table).notes.get("relations", []):
entry = (table, tuple(rel["columns"]), rel["to_table"], tuple(rel["to_columns"]))
(fk if rel["source"] == "fk" else name).add(entry)
assert rel["status"] == ("verified" if rel["source"] == "fk" else "proposed")
assert fk == scenic.FK_RELATIONS
assert name == scenic.NAME_RELATIONS
inferred_columns = {(t, c[0]) for t, c, _, _ in name}
assert not inferred_columns & set(scenic.NOT_INFERRED)
async def test_join_path_through_link_table(scenic_cache):
graph = catalog.relation_graph({}, schema_cache=scenic_cache)
paths = catalog.join_paths(graph, "visits", "channels")
via = [tuple(e.to_table for e in p) for p in paths]
assert ("channel_visits", "channels") in via
best = paths[0]
assert all(e.status == "verified" for e in best) # 两跳都是外键,排在推断的路径前面
assert [e.condition() for e in best] == ["visits.id = channel_visits.visit_id",
"channel_visits.channel_id = channels.id"]