"""SQL 里的列名对照数据源结构(sql_unknown_columns):助手自查(api/copilot.py)和表结构变化的影响面 (data/catalog_impact.py:已发布模板写死的 SQL 换上新结构再跑一遍,说得出「这条 SQL 现在会报列不存在」)共用这一份。 以前它在 api/copilot.py 里,影响面要用就得从 data 包反过来导入接口层;挪到这里,copilot 那边只改 import。 只做有把握的判断,宁可漏判不可误判:助手自查会把图打回去让模型改,影响面会把一条变化升为「要处理」。CTE、FROM 里的 子查询、引号括起来的标识符、结构里找不到的表、结构没探查过或者探查被截断的,一律不判。 """ from __future__ import annotations import re from typing import Any #: @ 开头的是变量(@region、@@session),: 开头的是参数,. 后面的是限定名 _SQL_IDENT = re.compile(r"(? str: """带引号、名字里有非 ASCII 字符的标识符(中文别名 AS "月份"、AS `曝光量`)连引号一起换成空格。 这项核对只认 ASCII 的列名,中文名本来就不判;不抹掉的话,SQL 里一出现带引号的标识符就整条放弃核对,照「结果列 起中文别名」写的 SQL 会把猜错的英文列名一起放过去。英文的带引号标识符("StatDate")大小写、空格都可能,照旧不判。""" from app.data.guard import _GENERIC, _tokens out = [] for token in _tokens(sql, _GENERIC): if token.kind == "ident" and not token.text.isascii(): out.append(" " * len(token.text)) else: out.append(token.text) return "".join(out) def sql_unknown_columns(sql: str, source: Any) -> tuple[list[str], list[str]] | None: """SQL 里用到、数据源结构里查不到的列:(查不到的列, 这几张表的全部列)。判断不了、或者都查得到时返回 None。""" from app.data import introspect as _introspect from app.data.guard import _blank_quoted, first_verb, strip_comments cache = getattr(source, "schema_cache", None) or {} if not cache.get("tables") or cache.get("failed") or first_verb(sql) != "select": return None text = _blank_unicode_identifiers(re.sub(r"\{\{.*?\}\}", " 0 ", strip_comments(sql), flags=re.S)) if '"' in text.replace("''", "") and _blank_quoted(text) != _blank_quoted(text, identifiers=False): return None # 引号括起来的标识符:大小写、空格都可能,不猜 text = _blank_quoted(text) if re.search(r"\bwith\b", text, re.I) or re.search(r"\b(?:from|join)\s*\(", text, re.I) or "`" in text \ or "[" in text: return None # [方括号] 标识符、数组下标:同样不猜 tables: dict[str, dict[str, Any]] = {} # 表名、别名(小写)→ 表结构 spans: list[tuple[int, int]] = [] for m in _FROM_ITEMS.finditer(text): spans.append(m.span(1)) for item in m.group(1).split(","): parsed = _TABLE_ITEM.match(item) if not parsed: return None name, alias = parsed.group(1), parsed.group(2) meta = _introspect.find_table(source, name) if meta is None: return None # 结构里找不到的表(视图、同义词、截断了的结构)不判 tables[name.lower()] = tables[name.rsplit(".", 1)[-1].lower()] = meta if alias and alias.lower() not in _SQL_WORDS: tables[alias.lower()] = meta if not tables: return None known = list(dict.fromkeys(c["name"] for meta in {id(m): m for m in tables.values()}.values() for c in meta.get("columns") or [])) columns = {c.lower() for c in known} # 「SELECT created_at FROM」里 created_at 前面是关键字,它是列,不是别名 aliases = {a.lower() for a in _ALIAS_AS.findall(text)} | { m.group(2).lower() for m in _ALIAS_BARE.finditer(text) if m.group(1).lower() not in _OPERAND_BEFORE} | { a.lower() for a in _WINDOW_NAME.findall(text)} names = {m.start(1) for pattern in (_NAME_AFTER, _DATEPART_ARG) for m in pattern.finditer(text)} unknown: list[str] = [] for m in _QUALIFIED.finditer(text): if any(a <= m.start() < b for a, b in spans): continue meta = tables.get(m.group(1).lower()) col = m.group(2) if meta is not None and col != "*" and col.lower() not in {c["name"].lower() for c in meta.get("columns") or []}: unknown.append(f"{m.group(1)}.{col}") for m in _SQL_IDENT.finditer(text): word = m.group(1) low = word.lower() if any(a <= m.start() < b for a, b in spans) or low in _SQL_WORDS or low in columns or low in aliases \ or low in tables or m.start() in names or re.match(r"\s*[.(]", text[m.end():]) \ or text[m.end():m.end() + 1] == "'": # N'…'、X'…'、_utf8mb4'…':字面量的前缀 continue unknown.append(word) unknown = list(dict.fromkeys(unknown)) return (unknown, known) if unknown else None __all__ = ["sql_unknown_columns"]