From 72de33c03d9c5c2641d3b50dfa2c6e5347e3ebab Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Thu, 17 Sep 2026 11:50:16 +0800 Subject: [PATCH] =?UTF-8?q?schema=20=E5=8F=91=E7=8E=B0=E6=94=B9=E6=88=90?= =?UTF-8?q?=E7=9C=8B=E4=BA=8B=E5=AE=9E=EF=BC=8C=E4=B8=8D=E5=86=8D=E9=9D=A0?= =?UTF-8?q?=E9=BB=91=E5=90=8D=E5=8D=95=E7=8C=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 起因是一个合理的质疑:这套 schema 处理是不是只对那一个 Oracle 有效。查证下来 "schema" 在四家里根本不是同一个东西——MySQL 的 schema 就是 database (SHOW schemas),Oracle 的 schema 就是 user(all_users),PG 是库下面的 命名空间(pg_namespace),SQLite 只有 main。SQLAlchemy 抹平了调用方式, 没抹平语义。 ## 黑名单方案实测不可用 原来靠一张硬编码的系统 schema 名单过滤。在真实 Oracle 上:94 个用户过滤完还剩 75 个(anonymous、C##OGGADMIN、infa96_domain、xs$null…),人在里面根本找不到 那两个业务 schema。名单再怎么补也补不完——这是在猜,而不是在看。 改成看事实:统计每个 schema 下有多少表/视图,有东西的才留下并按数量排序。 两条路径覆盖全部支持的库——information_schema 是 SQL 标准(MySQL/PG/SQL Server 都有),Oracle 不遵循这条标准,走它自己的 all_tables/all_views。不用"逐个 schema 调 get_table_names"的通用写法:94 个 schema 就是 94 次往返。 黑名单保留但降级为辅助:系统 schema 的对象数往往很大(SYS 有 1871 个), 光靠排序压不下去。 实测:94 个用户 → 2.3 秒收敛到 ANALYTICS、STAGING 两个业务 schema。 ## schema 填错时给可选项,而不是抛驱动异常 大小写、拼写、或者根本不知道该填什么,都是高频事故。原来直接把 OperationalError 抛出去,等于让人自己猜。现在先看连接本身好不好:能列出候选 就说明只是 schema 不对,这种情况返回空结构 + 候选列表,摘要里直接写 "该账号名下没有对象,数据可能在这些 schema:ANALYTICS、STAGING"。 ## 覆盖 SQLite 没有 information_schema,统计路径安静降级(实测);没有 schema 概念时 qualified 不硬拼前缀,有 schema 时必须带(Oracle 上少了就是 ORA-00942)。 新增一条用例守着"加新数据库类型时别忘了补统计路径",漏了就会退化回黑名单猜测。 MySQL / PostgreSQL 手上没有实例,走的是 information_schema 标准路径, 代码路径与 Oracle 共用,但未实测——这点在 README 里会标明。 --- backend/app/data/introspect.py | 76 ++++++++++++++++++++++++++++---- backend/tests/test_datasource.py | 68 ++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 9 deletions(-) diff --git a/backend/app/data/introspect.py b/backend/app/data/introspect.py index 63dc05b4..4cd50b7a 100644 --- a/backend/app/data/introspect.py +++ b/backend/app/data/introspect.py @@ -39,23 +39,71 @@ _SYSTEM_SCHEMAS = frozenset({ }) +# 统计每个 schema 下有多少对象。两条路径覆盖全部支持的库: +# information_schema 是 SQL 标准(MySQL / PostgreSQL / SQL Server 都有), +# Oracle 不遵循这条标准,用它自己的 all_tables / all_views 数据字典。 +# +# 不用"逐个 schema 调 get_table_names"的通用写法:这个库有 94 个 schema, +# 那样是 94 次往返,几十秒起步。 +_SCHEMA_STATS_SQL = { + "oracle": """ + SELECT owner AS s, COUNT(*) AS n FROM ( + SELECT owner FROM all_tables + UNION ALL SELECT owner FROM all_views + ) GROUP BY owner ORDER BY COUNT(*) DESC + """, + "_standard": """ + SELECT table_schema AS s, COUNT(*) AS n + FROM information_schema.tables + GROUP BY table_schema ORDER BY COUNT(*) DESC + """, +} + + +async def schema_stats(source: Any) -> list[tuple[str, int]]: + """每个 schema 有多少表/视图,多的在前。拿不到就返回空表让调用方降级。""" + from sqlalchemy import text as _text + + kind = (source.kind or "").lower() + if kind == "sqlite": + return [] # SQLite 只有 main,没有可选项 + sql = _SCHEMA_STATS_SQL.get(kind, _SCHEMA_STATS_SQL["_standard"]) + engine = await engines.get(source) + try: + async with engine.connect() as conn: + rows = (await conn.execute(_text(sql))).fetchall() + return [(str(r[0]), int(r[1])) for r in rows if r[0]] + except Exception: # noqa: BLE001 - 方言不认就算了,降级到纯名字列表 + return [] + + async def list_schemas(source: Any) -> list[str]: - """列出可访问的非系统 schema。 + """列出**可能装着业务数据**的 schema,最有可能的排在最前。 企业环境里只读账号名下往往一张表都没有——数据在别的 schema,靠跨库授权或 - synonym 访问。直接返回"0 张表"等于把人晾在那儿,得告诉他数据可能在哪。 + synonym 访问。直接返回"0 张表"等于把人晾在那儿。 + + 但光列出名字也不够用:实测一个 Oracle 实例有 94 个用户,靠黑名单过滤完 + 还剩 75 个(anonymous、C##OGGADMIN、xs$null…),人在里面找不到那两个 + 业务 schema。所以主要依据不是"猜哪些是系统的",而是"哪些里面真有东西"—— + 黑名单只用来排掉已知的系统 schema(它们对象数往往还很多,光靠排序压不下去)。 """ engine = await engines.get(source) - def _collect(sync_conn: Any) -> list[str]: + def _names(sync_conn: Any) -> list[str]: try: - names = sa_inspect(sync_conn).get_schema_names() + return list(sa_inspect(sync_conn).get_schema_names()) except Exception: # noqa: BLE001 - 有些方言不支持 return [] - return [n for n in names if n.lower() not in _SYSTEM_SCHEMAS] + + stats = await schema_stats(source) + if stats: + # 有统计就只留真有对象的,并按对象数降序——业务库自然浮到前面 + return [s for s, n in stats if n > 0 and s.lower() not in _SYSTEM_SCHEMAS] async with engine.connect() as conn: - return await conn.run_sync(_collect) + names = await conn.run_sync(_names) + return [n for n in names if n.lower() not in _SYSTEM_SCHEMAS] def _target_schema(source: Any, explicit: str | None) -> str | None: @@ -116,8 +164,18 @@ async def introspect(source: Any, *, schema: str | None = None) -> dict[str, Any } return {"tables": tables, "truncated": truncated, "total": len(names) + len(views)} - async with engine.connect() as conn: - payload = await conn.run_sync(_collect) + try: + async with engine.connect() as conn: + payload = await conn.run_sync(_collect) + except Exception as exc: # noqa: BLE001 + # schema 名填错是高频事故(大小写、拼写、或者根本不知道该填什么), + # 直接把驱动异常抛出去等于让人自己去猜。先看看连接本身好不好: + # 能列出候选说明只是 schema 不对,这种情况给出可选项比报错有用得多。 + candidates = await list_schemas(source) + if not candidates: + raise + payload = {"tables": {}, "truncated": False, "total": 0, + "error": f"{type(exc).__name__}: {exc}"} payload["synced_at"] = datetime.now(timezone.utc).isoformat() payload["schema"] = target @@ -125,7 +183,7 @@ async def introspect(source: Any, *, schema: str | None = None) -> dict[str, Any # 一张表都没探到时,别只回一个空壳——多半是数据在别的 schema 里, # 把候选列出来,用户才知道下一步该填什么 if not payload["tables"]: - payload["available_schemas"] = await list_schemas(source) + payload.setdefault("available_schemas", await list_schemas(source)) return payload diff --git a/backend/tests/test_datasource.py b/backend/tests/test_datasource.py index 34612297..901fbf6e 100644 --- a/backend/tests/test_datasource.py +++ b/backend/tests/test_datasource.py @@ -157,3 +157,71 @@ def test_oracle_accepts_database_as_service_name(): def test_unknown_kind_is_rejected(): with pytest.raises(ValueError, match="不支持"): build_url(_src("mongodb")) + + +# -------------------------------------------------------------------------- +# schema 发现:各家的 "schema" 根本不是同一个东西 +# MySQL schema == database(SHOW schemas) +# Oracle schema == user(all_users) +# PG database > schema(pg_namespace) +# SQLite 只有 main + attached +# 所以这组用例守的是"不管哪一家,探不到东西时都要给出可走的下一步"。 +# -------------------------------------------------------------------------- + +async def test_sqlite_degrades_without_information_schema(source): + """SQLite 没有 information_schema,统计路径要安静地降级而不是报错。""" + from app.data.introspect import list_schemas, schema_stats + + assert await schema_stats(source) == [] + assert isinstance(await list_schemas(source), list) + + +async def test_qualified_name_omits_schema_when_absent(source): + """没有 schema 概念时不要硬拼前缀,否则生成的 SQL 反而错。""" + source.schema_cache = await introspect(source) + assert source.schema_cache["tables"]["orders"]["qualified"] == "orders" + + +async def test_qualified_name_includes_schema_when_given(source): + """有 schema 时必须带前缀——Oracle 上少了它就是 ORA-00942。""" + cache = await introspect(source, schema="main") + assert cache["tables"]["orders"]["qualified"] == "main.orders" + + +async def test_bad_schema_returns_candidates_instead_of_raising(source, monkeypatch): + """schema 填错是高频事故(大小写、拼写),要给可选项而不是抛驱动异常。""" + from app.data import introspect as mod + + async def fake_list(_source): + return ["ANALYTICS", "STAGING"] + + monkeypatch.setattr(mod, "list_schemas", fake_list) + cache = await mod.introspect(source, schema="根本不存在") + assert cache["tables"] == {} + assert cache["available_schemas"] == ["ANALYTICS", "STAGING"] + + +async def test_summary_points_at_candidate_schemas(source, monkeypatch): + """摘要要把候选说给 Copilot 听,否则它只会对着空结构编表名。""" + from app.data import introspect as mod + + async def fake_list(_source): + return ["ANALYTICS", "STAGING"] + + monkeypatch.setattr(mod, "list_schemas", fake_list) + source.schema_cache = await mod.introspect(source, schema="根本不存在") + text = summary(source) + assert "ANALYTICS" in text and "该账号名下没有对象" in text + + +def test_schema_stats_sql_covers_every_supported_kind(): + """新增数据库类型时别忘了这里——漏了就退化成黑名单猜测。""" + from app.data.engine import SUPPORTED_KINDS + from app.data.introspect import _SCHEMA_STATS_SQL + + # sqlite 显式短路,其余要么有专用 SQL,要么落到 information_schema 标准路径 + assert "oracle" in _SCHEMA_STATS_SQL + assert "_standard" in _SCHEMA_STATS_SQL + for kind in SUPPORTED_KINDS: + assert kind in _SCHEMA_STATS_SQL or kind in ("sqlite", "mysql", "mariadb", + "postgres", "postgresql")