mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:21:58 +00:00
合入期 5 验收修复 M4:单个工作流接口的 run_count 与列表一致;触发历史的翻页游标改成 Z 结尾、放进地址不用编码,老写法照样认;db_schema 认写成一个字符串的 JSON 数组;双盲复算「规范化后不同的行」改成每个条件一行,两边相同的条件不再因换行位置不同被标成差异
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -42,6 +43,29 @@ def _iso(moment: datetime | None) -> str | None:
|
||||
return service.as_utc(moment).isoformat() if moment is not None else None
|
||||
|
||||
|
||||
#: 查询串把「+」解成空格之后,时间末尾的时区偏移:「…03.376320 00:00」
|
||||
_SPACED_OFFSET = re.compile(r"(.*\d) (\d{2}:?\d{2})")
|
||||
|
||||
|
||||
def _cursor(moment: datetime) -> str:
|
||||
"""触发历史的翻页游标:UTC 写成 Z 结尾、精确到微秒。写成 +00:00 时照接口说明原样拼进地址,「+」在查询串里会被
|
||||
解成空格,回 422(2026-10 验收);Z 结尾的放进地址不用编码,和运行列表的游标一个写法。"""
|
||||
return service.as_utc(moment).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _cursor_time(before: str) -> datetime:
|
||||
"""before → 时间:Z、+00:00 都认,也认「+」被查询串解成空格的「 00:00」(老的游标照原样拼进地址时就是这样)。
|
||||
不是时间的照样抛 ValueError。"""
|
||||
text = before.strip()
|
||||
try:
|
||||
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
spaced = _SPACED_OFFSET.fullmatch(text)
|
||||
if spaced is None:
|
||||
raise
|
||||
return datetime.fromisoformat(f"{spaced[1]}+{spaced[2]}")
|
||||
|
||||
|
||||
async def _body(request: Request) -> dict[str, Any]:
|
||||
try:
|
||||
data = await request.json()
|
||||
@@ -579,7 +603,8 @@ async def run_now(schedule_id: str, session: AsyncSession = Depends(get_session)
|
||||
@router.get("/{schedule_id}/fires")
|
||||
async def list_fires(schedule_id: str, limit: str | None = None, before: str | None = None,
|
||||
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
"""触发历史,新的在前:{items: [Fire], next_before}。翻页把 next_before 原样作为 before 传回来;没有更多时为空。"""
|
||||
"""触发历史,新的在前:{items: [Fire], next_before}。翻页把 next_before 原样作为 before 传回来(Z 结尾,拼进地址
|
||||
不用编码);没有更多时为空。"""
|
||||
await _get(session, schedule_id)
|
||||
size = 20
|
||||
if limit is not None:
|
||||
@@ -589,7 +614,7 @@ async def list_fires(schedule_id: str, limit: str | None = None, before: str | N
|
||||
stmt = select(ScheduleFire).where(ScheduleFire.schedule_id == schedule_id)
|
||||
if before is not None:
|
||||
try:
|
||||
cursor = datetime.fromisoformat(before.strip().replace("Z", "+00:00"))
|
||||
cursor = _cursor_time(before)
|
||||
except ValueError as e:
|
||||
raise _invalid(f"翻页位置需要是接口返回的 next_before,当前为「{before}」", "before") from e
|
||||
stmt = stmt.where(ScheduleFire.created_at < service.as_utc(cursor))
|
||||
@@ -597,7 +622,7 @@ async def list_fires(schedule_id: str, limit: str | None = None, before: str | N
|
||||
stmt.order_by(ScheduleFire.created_at.desc(), ScheduleFire.id.desc()).limit(size + 1))).scalars())
|
||||
items = rows[:size]
|
||||
return {"items": await _fire_outs(session, items),
|
||||
"next_before": _iso(items[-1].created_at) if len(rows) > size else None}
|
||||
"next_before": _cursor(items[-1].created_at) if len(rows) > size else None}
|
||||
|
||||
|
||||
@router.post("/{schedule_id}/webhook/test")
|
||||
|
||||
@@ -65,14 +65,28 @@ class WorkflowOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
async def _run_counts(session: AsyncSession, ids: list[str] | None = None) -> dict[str, int]:
|
||||
"""各工作流的运行次数 {工作流 id: 次数},ids 为 None 时算全部。列表和单个工作流的接口都按它算:以前只有列表
|
||||
计数,详情、保存、恢复的返回落在 WorkflowOut 的默认值 0 上,同一个工作流两处说的次数不一样。"""
|
||||
stmt = select(Run.workflow_id, func.count(Run.id)).group_by(Run.workflow_id)
|
||||
if ids is not None:
|
||||
stmt = stmt.where(Run.workflow_id.in_(ids))
|
||||
return dict((await session.execute(stmt)).all())
|
||||
|
||||
|
||||
async def _workflow_out(session: AsyncSession, workflow: Workflow) -> WorkflowOut:
|
||||
"""返回单个工作流的接口都走这里,run_count 和列表一致。"""
|
||||
item = WorkflowOut.model_validate(workflow)
|
||||
item.run_count = (await _run_counts(session, [workflow.id])).get(workflow.id, 0)
|
||||
return item
|
||||
|
||||
|
||||
@router.get("", response_model=list[WorkflowOut])
|
||||
async def list_workflows(session: AsyncSession = Depends(get_session)) -> list[WorkflowOut]:
|
||||
rows = list(
|
||||
(await session.execute(select(Workflow).order_by(Workflow.updated_at.desc()))).scalars()
|
||||
)
|
||||
counts = dict(
|
||||
(await session.execute(select(Run.workflow_id, func.count(Run.id)).group_by(Run.workflow_id))).all()
|
||||
)
|
||||
counts = await _run_counts(session)
|
||||
out: list[WorkflowOut] = []
|
||||
for row in rows:
|
||||
item = WorkflowOut.model_validate(row)
|
||||
@@ -84,7 +98,7 @@ async def list_workflows(session: AsyncSession = Depends(get_session)) -> list[W
|
||||
@router.post("", response_model=WorkflowOut, status_code=201)
|
||||
async def create_workflow(
|
||||
payload: WorkflowIn, session: AsyncSession = Depends(get_session)
|
||||
) -> Workflow:
|
||||
) -> WorkflowOut:
|
||||
workflow = Workflow(
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
@@ -99,23 +113,23 @@ async def create_workflow(
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(workflow)
|
||||
return workflow
|
||||
return await _workflow_out(session, workflow)
|
||||
|
||||
|
||||
@router.get("/{workflow_id}", response_model=WorkflowOut)
|
||||
async def get_workflow(
|
||||
workflow_id: str, session: AsyncSession = Depends(get_session)
|
||||
) -> Workflow:
|
||||
) -> WorkflowOut:
|
||||
workflow = await session.get(Workflow, workflow_id)
|
||||
if not workflow:
|
||||
raise HTTPException(404, _GONE)
|
||||
return workflow
|
||||
return await _workflow_out(session, workflow)
|
||||
|
||||
|
||||
@router.patch("/{workflow_id}", response_model=WorkflowOut)
|
||||
async def update_workflow(
|
||||
workflow_id: str, payload: WorkflowPatch, session: AsyncSession = Depends(get_session)
|
||||
) -> Workflow:
|
||||
) -> WorkflowOut:
|
||||
workflow = await session.get(Workflow, workflow_id)
|
||||
if not workflow:
|
||||
raise HTTPException(404, _GONE)
|
||||
@@ -146,7 +160,7 @@ async def update_workflow(
|
||||
workflow.status = "draft"
|
||||
await session.commit()
|
||||
await session.refresh(workflow)
|
||||
return workflow
|
||||
return await _workflow_out(session, workflow)
|
||||
|
||||
|
||||
@router.delete("/{workflow_id}", status_code=204)
|
||||
@@ -172,7 +186,7 @@ async def delete_workflow(
|
||||
@router.post("/{workflow_id}/duplicate", response_model=WorkflowOut, status_code=201)
|
||||
async def duplicate_workflow(
|
||||
workflow_id: str, session: AsyncSession = Depends(get_session)
|
||||
) -> Workflow:
|
||||
) -> WorkflowOut:
|
||||
source = await session.get(Workflow, workflow_id)
|
||||
if not source:
|
||||
raise HTTPException(404, _GONE)
|
||||
@@ -185,7 +199,7 @@ async def duplicate_workflow(
|
||||
session.add(copy)
|
||||
await session.commit()
|
||||
await session.refresh(copy)
|
||||
return copy
|
||||
return await _workflow_out(session, copy)
|
||||
|
||||
|
||||
class VersionOut(BaseModel):
|
||||
@@ -267,7 +281,7 @@ async def get_version(
|
||||
@router.post("/{workflow_id}/versions/{version}/restore", response_model=WorkflowOut)
|
||||
async def restore_version(
|
||||
workflow_id: str, version: int, session: AsyncSession = Depends(get_session)
|
||||
) -> Workflow:
|
||||
) -> WorkflowOut:
|
||||
workflow = await session.get(Workflow, workflow_id)
|
||||
if not workflow:
|
||||
raise HTTPException(404, _GONE)
|
||||
@@ -298,7 +312,7 @@ async def restore_version(
|
||||
workflow.status = "draft"
|
||||
await session.commit()
|
||||
await session.refresh(workflow)
|
||||
return workflow
|
||||
return await _workflow_out(session, workflow)
|
||||
|
||||
|
||||
class PublishIn(BaseModel):
|
||||
|
||||
@@ -149,8 +149,8 @@ WHERE / GROUP BY 里用列别名的),而且只说四类:
|
||||
|
||||
**兜底。** 规范形式不一致、一条具体说法都没有时,给一条 aspect="other" 的「两条 SQL 的写法不同,未能归纳成具体差异,
|
||||
请对照原文」;具体说法只覆盖了一部分时追加「另有未能归纳的差异,请对照原文」;fallback 为真。text_diff 给出规范化后
|
||||
不同的行(difflib 比两边 sqlglot pretty 的输出,最多 40 行);有一边没有规范形式(保真守卫拦下的)时给原文的逐行
|
||||
对照。
|
||||
不同的行(difflib 比两边 sqlglot pretty 的输出,WHERE、ON、HAVING 的合取式每个条件一行,不同的行前面带上标出所在子句的
|
||||
上级行,最多 40 行,见 _text_diff);有一边没有规范形式(保真守卫拦下的)时给原文的逐行对照。
|
||||
|
||||
仍做不到的(只给兜底说明,或者认不出等价):
|
||||
- 认不出的等价改写:要靠约束才成立的(主键不为空时的 COUNT(主键) 和 COUNT(*)、按主键关联又没用到的 LEFT JOIN、带
|
||||
@@ -5923,8 +5923,9 @@ class SqlDiff:
|
||||
equivalent:两边经过只保持语义的变换之后,规范形式一字不差(level 是哪一级的规范形式,见 _canonical_forms)。
|
||||
items:具体差异,等价时为空;不等价时一定不为空——归纳不出具体差异时是一条 aspect="other" 的兜底说明。
|
||||
fallback:用了兜底说明(一条具体说法都没有,或者具体说法只覆盖了一部分)。
|
||||
text_diff:规范化之后两条 SQL 不同的行(difflib 比两边 sqlglot pretty 的输出,「- 」开头是 A 的、「+ 」开头是
|
||||
B 的,最多 40 行),给界面高亮;等价时为空。"""
|
||||
text_diff:规范化之后两条 SQL 不同的行(difflib 比两边 sqlglot pretty 的输出,WHERE、ON、HAVING 的合取式每个条件
|
||||
一行,「- 」开头是 A 的、「+ 」开头是 B 的;不同的行前面带上它的各级上级行,「 」开头、两边相同,只为标出不同的行
|
||||
在哪个子句里;最多 40 行,见 _text_diff),给界面高亮;等价时为空。"""
|
||||
|
||||
equivalent: bool
|
||||
items: list[FacetDiff] = field(default_factory=list)
|
||||
@@ -5942,6 +5943,10 @@ _FALLBACK = "两条 SQL 的写法不同,未能归纳成具体差异,请对
|
||||
_FALLBACK_REST = "另有未能归纳的差异,请对照原文"
|
||||
#: text_diff 最多列几行
|
||||
_MAX_DIFF_LINES = 40
|
||||
#: 逐行比较时不看的、条件前面的 AND(见 _text_diff)
|
||||
_LEADING_AND = re.compile(r"^(\s*)AND ")
|
||||
#: 各方言「每个条件一行」的生成器类,按 sqlglot 的生成器类缓存(见 _line_generator)
|
||||
_LINE_GENERATORS: dict[type, type] = {}
|
||||
|
||||
_CJK_PUNCT = frozenset(",。;:、!?「」『』()《》【】…—")
|
||||
#: 聚合函数的说法
|
||||
@@ -7761,19 +7766,155 @@ def _by_bound(conds: list[Any]) -> list[Any]:
|
||||
|
||||
def _text_diff(a: _Form | None, b: _Form | None, read: str | None) -> tuple[str, ...]:
|
||||
"""规范化之后两条 SQL 不同的行:difflib 比两边 sqlglot pretty 的输出,「- 」是 A 的、「+ 」是 B 的。只有一边有这一级
|
||||
的规范形式时不比(只列一边的行读起来像另一边是空的),返回空的,调用方改给原文的逐行对照(见 _raw_diff)。"""
|
||||
import difflib
|
||||
的规范形式时不比(只列一边的行读起来像另一边是空的),返回空的,调用方改给原文的逐行对照(见 _raw_diff)。
|
||||
|
||||
def lines(form: _Form) -> list[str]:
|
||||
try:
|
||||
return form.tree.sql(dialect=read, pretty=True, comments=False).splitlines()
|
||||
except Exception: # noqa: BLE001
|
||||
return form.text.splitlines()
|
||||
两边按同一套排版写(_diff_lines):WHERE、ON、HAVING 的合取式每个条件一行。sqlglot 的 pretty 按宽度决定换不换行,
|
||||
两个条件放得下就排在一行、三个就拆开,同一个条件在两边落在不同的行上,就被当成「不同的行」(2026-10 验收:两边完全
|
||||
相同的日期区间被标成了差异,审核人会以为统计期也不一样)。比的时候不看条件前面的 AND:一边多出来的条件排在最前面
|
||||
时,另一边同一个条件只差在前面有没有 AND。列出来的仍是各自原样的行。只动排版,判不判等价、差异说明都不看这里。
|
||||
|
||||
不同的行前面带上它的各级上级行(_ancestors:排在前面、缩进比它浅的最近一行,一层层数到顶格),两边相同的那几行以
|
||||
「 」开头列出,只为标出不同的行在哪个子句里——哪个 WHERE、哪个 JOIN 的 ON、哪层子查询。只列不同的行时,只差一个
|
||||
关联条件的那一边只剩一行「AND 条件」,看不出是哪个 JOIN 的关联条件、还是筛选条件(2026-10 复核)。上级行在两边只差
|
||||
前面有没有 AND 时,列不带 AND 的写法(两边共同的部分)。上级行本身不同的,照样是「- 」「+ 」行。"""
|
||||
import difflib
|
||||
|
||||
if a is None or b is None:
|
||||
return ()
|
||||
out = [line for line in difflib.ndiff(lines(a), lines(b)) if line[:2] in ("- ", "+ ")]
|
||||
return tuple(out[:_MAX_DIFF_LINES])
|
||||
la, lb = _diff_lines(a, read), _diff_lines(b, read)
|
||||
ka, kb = [_LEADING_AND.sub(r"\1", x) for x in la], [_LEADING_AND.sub(r"\1", x) for x in lb]
|
||||
# (标记, A 的行号, B 的行号):ndiff 按顺序走完两边,「 」两边各进一行,「- 」「+ 」各进自己那边一行,「? 」是行内
|
||||
# 提示、不占行
|
||||
rows: list[tuple[str, int, int]] = []
|
||||
i = j = 0
|
||||
for line in difflib.ndiff(ka, kb):
|
||||
tag = line[:2]
|
||||
if tag == " ":
|
||||
rows.append((tag, i, j))
|
||||
i, j = i + 1, j + 1
|
||||
elif tag == "- ":
|
||||
rows.append((tag, i, -1))
|
||||
i += 1
|
||||
elif tag == "+ ":
|
||||
rows.append((tag, -1, j))
|
||||
j += 1
|
||||
need_a = _ancestors(la, [i for tag, i, _ in rows if tag == "- "])
|
||||
need_b = _ancestors(lb, [j for tag, _, j in rows if tag == "+ "])
|
||||
out: list[str] = []
|
||||
for tag, i, j in rows:
|
||||
if tag == "- ":
|
||||
out.append(f"- {la[i]}")
|
||||
elif tag == "+ ":
|
||||
out.append(f"+ {lb[j]}")
|
||||
elif i in need_a or j in need_b:
|
||||
out.append(f" {la[i] if la[i] == lb[j] else ka[i]}")
|
||||
out = out[:_MAX_DIFF_LINES]
|
||||
# 截到 40 行时末尾剩下的上级行后面已经没有不同的行,不留:上级行只跟着不同的行出现
|
||||
while out and out[-1].startswith(" "):
|
||||
out.pop()
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def _ancestors(lines: list[str], changed: list[int]) -> set[int]:
|
||||
"""changed 这些行的各级上级行的行号:排在前面、缩进比它浅的最近一行是它的上级,一层层往上数到顶格。sqlglot 的
|
||||
pretty 里子句的内容缩在子句名下面(WHERE 下的条件、JOIN … ON 下的关联条件、EXISTS( 下的子查询),所以数出来的就是
|
||||
它所在的各层子句。空白行不当上级。"""
|
||||
def depth(line: str) -> int:
|
||||
return len(line) - len(line.lstrip(" "))
|
||||
|
||||
parent = [-1] * len(lines)
|
||||
# 往上数时还可能当上级的行:从上往下缩进一行比一行深,比当前行深或一样深的都不是它的上级
|
||||
stack: list[int] = []
|
||||
for n, line in enumerate(lines):
|
||||
if not line.strip():
|
||||
continue
|
||||
while stack and depth(lines[stack[-1]]) >= depth(line):
|
||||
stack.pop()
|
||||
parent[n] = stack[-1] if stack else -1
|
||||
stack.append(n)
|
||||
out: set[int] = set()
|
||||
for n in changed:
|
||||
k = parent[n]
|
||||
# 已经在里面的,它往上的各级也已经在里面了
|
||||
while k >= 0 and k not in out:
|
||||
out.add(k)
|
||||
k = parent[k]
|
||||
return out
|
||||
|
||||
|
||||
def _diff_lines(form: _Form, read: str | None) -> list[str]:
|
||||
"""逐行对照用的写法:sqlglot pretty,WHERE、HAVING、ON 的合取式每个条件一行,ON 挂在 JOIN 那一行的末尾(见
|
||||
_line_generator)。生成不了就退回普通的 pretty,再不行用规范形式的原文。"""
|
||||
try:
|
||||
text = _line_generator(read).generate(form.tree)
|
||||
except Exception: # noqa: BLE001 - 只是给人看的排版,出错不能连累差异说明
|
||||
try:
|
||||
text = form.tree.sql(dialect=read, pretty=True, comments=False)
|
||||
except Exception: # noqa: BLE001
|
||||
text = form.text
|
||||
return [line.rstrip() for line in text.splitlines()]
|
||||
|
||||
|
||||
def _condition_root(node: Any) -> str | None:
|
||||
"""node 是 WHERE、HAVING 的整个条件("clause")、JOIN 的 ON 条件("on"),还是别处的(None)。"""
|
||||
from sqlglot import exp
|
||||
|
||||
parent = node.parent
|
||||
if isinstance(parent, (exp.Where, exp.Having)) and node.arg_key == "this":
|
||||
return "clause"
|
||||
if isinstance(parent, exp.Join) and node.arg_key == "on":
|
||||
return "on"
|
||||
return None
|
||||
|
||||
|
||||
def _line_generator(read: str | None) -> Any:
|
||||
"""这种方言的 sqlglot 生成器(pretty、不带注释),只改条件的排法:WHERE、HAVING、ON 的整个条件按 AND 拆开,每个条件
|
||||
一行,第二个起前面写 AND(各层子查询里的也一样),ON 挂在 JOIN 那一行的末尾。别的照 sqlglot 原样:条件本身太长时
|
||||
照样折行,两边同一个条件折得一样。
|
||||
|
||||
- 条件本身是 OR(XOR)连起来的一律加括号:和别的条件并列时本来就要加,单独一个时不加的话,同一个条件两边写法不同。
|
||||
条件外面多余的括号、AND 套 AND 的括号去掉(_conjuncts),不改意思。
|
||||
- ON 挂在 JOIN 那一行的末尾(表名、子查询的右括号后面),条件各占一行缩在下面:写在 ON 后面的话,第一个条件和后面
|
||||
的差在「ON」「AND」上,换个先后就对不上;缩进比 WHERE 的条件深一级,ON 里和 WHERE 里的同一个条件不会被当成同一
|
||||
行。ON 不单独占一行:那样两边 JOIN 的表不同时,两边都有的「ON」被当成相同的行,条件前面就没了 ON(2026-10 复核)。
|
||||
"""
|
||||
from sqlglot import exp
|
||||
from sqlglot.dialects.dialect import Dialect
|
||||
|
||||
dialect = Dialect.get_or_raise(read)
|
||||
base = dialect.generator_class
|
||||
cls = _LINE_GENERATORS.get(base)
|
||||
if cls is None:
|
||||
class _ConditionLines(base): # type: ignore[misc, valid-type]
|
||||
def sql(self, expression: Any, key: str | None = None, comment: bool = True) -> str:
|
||||
root = _condition_root(expression) if key is None and isinstance(expression, exp.Expr) else None
|
||||
conds = _conjuncts(expression) if root else []
|
||||
if not conds:
|
||||
return super().sql(expression, key, comment)
|
||||
lines = [self._one_condition(c) for c in conds]
|
||||
text = "\n".join([lines[0], *(f"AND {line}" for line in lines[1:])])
|
||||
# ON:join_sql 拼成「ON <条件>」并给条件的各行加一级缩进,开头换行就是条件都在 ON 下面;单独一行的 ON
|
||||
# 再由下面的 join_sql 挂到上一行末尾
|
||||
return "\n" + self.indent(text) if root == "on" else text
|
||||
|
||||
def join_sql(self, expression: Any) -> str:
|
||||
# sqlglot 拼出来是换行、缩进一级、「ON 」再换行(ON 单独一行,条件在下面):把这个 ON 接到上一行(表名、
|
||||
# 子查询的右括号)末尾。只认这一层的:子查询里、条件里套着的 JOIN 都缩得更深,对不上
|
||||
pad = " " * self.pad
|
||||
return super().join_sql(expression).replace(f"\n{pad}ON \n", " ON\n", 1)
|
||||
|
||||
def _one_condition(self, node: Any) -> str:
|
||||
# 只有一个条件时它就是整个条件,再走 self.sql 又会被认成整个条件、绕不出来:这一层直接用 sqlglot 原来的
|
||||
# 写法(里面各层子查询的条件照样经过上面的 sql)
|
||||
text = super().sql(node)
|
||||
if not isinstance(node, exp.Connector):
|
||||
return text
|
||||
if "\n" not in text:
|
||||
return f"({text})"
|
||||
return f"({self.seg(self.indent(text), sep='')}{self.seg(')', sep='')}"
|
||||
|
||||
cls = _LINE_GENERATORS[base] = _ConditionLines
|
||||
return cls(dialect=dialect, pretty=True, comments=False)
|
||||
|
||||
|
||||
def _raw_diff(a: str, b: str) -> tuple[str, ...]:
|
||||
|
||||
@@ -644,6 +644,27 @@ async def _store_schema(source: Any, ctx: ToolContext, *,
|
||||
return None
|
||||
|
||||
|
||||
def _schema_tables(table: str | list[str] | None) -> list[str]:
|
||||
"""db_schema 的 table 参数 → 要看的表名。"user, roles"、["user","roles"]、整个写成一个字符串的 JSON 数组
|
||||
'["user", "roles"]' 是同一个意思,模型三种都会写,为此拒绝一次调用纯属浪费一步。最后那种只按逗号拆就成了
|
||||
「没有 ["user"」「没有 "roles"]」,两张表明明都在(2026-10 验收:盲算、反方审稿的核查 agent 因此白白多调了几轮
|
||||
模型)。只认元素都是字符串的 JSON 数组,别的写法照旧按逗号拆。"""
|
||||
wanted = [table] if isinstance(table, str) else list(table or [])
|
||||
names: list[str] = []
|
||||
for one in wanted:
|
||||
text = str(one).strip()
|
||||
if text.startswith("[") and text.endswith("]"):
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except ValueError:
|
||||
parsed = None
|
||||
if isinstance(parsed, list) and all(isinstance(t, str) for t in parsed):
|
||||
names += parsed
|
||||
continue
|
||||
names.append(str(one))
|
||||
return [t.strip() for one in names for t in one.split(",") if t.strip()]
|
||||
|
||||
|
||||
def _make_schema_tool(source: Any, *, fixed: bool = False,
|
||||
catalog: Mapping[str, data_catalog.CatalogEntry] | None = None) -> StructuredTool:
|
||||
"""source 同 _make_query_tool:上传源的表结构取绑定快照里冻结的那份。
|
||||
@@ -655,10 +676,7 @@ def _make_schema_tool(source: Any, *, fixed: bool = False,
|
||||
async def _run(table: str | list[str] | None = None) -> str:
|
||||
if gone := _vanished(source, fixed):
|
||||
return f"{DATA_UNAVAILABLE}{gone}"
|
||||
wanted = [table] if isinstance(table, str) else list(table or [])
|
||||
# 逗号分隔也认:"user, roles" 和 ["user","roles"] 是同一个意思,
|
||||
# 而模型两种都会写。为此拒绝一次调用,纯属浪费一步
|
||||
wanted = [t.strip() for one in wanted for t in str(one).split(",") if t.strip()]
|
||||
wanted = _schema_tables(table)
|
||||
if wanted:
|
||||
return "\n\n".join(introspect.describe_table(source, t, catalog=notes) for t in wanted)
|
||||
tables = introspect.table_names(source)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""双盲复算审批卡的前端夹具(frontend/src/dev/fixtures/dual_check.json)要和服务端的真实输出对得上。
|
||||
|
||||
审批卡上「对照两条 SQL」的高亮、「只差一个方面会记入数据目录」的提示,都是照 explain_sql_diff 的输出画的。它的
|
||||
text_diff 是规范化之后的行(sqlglot pretty:列名带表名、条件并成一行),和原文的行对不上;夹具要是照原文手写,
|
||||
text_diff 是规范化之后的行(sqlglot pretty:列名带表名、每个条件一行),和原文的行对不上;夹具要是照原文手写,
|
||||
check-verify 在夹具上能过,真实数据上却一处都不高亮(2026-10 复核时发现)。所以这里按夹具里的两条 SQL 重新算一遍
|
||||
差异说明,逐个指标、逐个变体和夹具里写的比。sql_semantics 改了说法或规范形式,这里先变红,按下面的办法重新生成。
|
||||
|
||||
@@ -12,8 +12,9 @@ check-verify 在夹具上能过,真实数据上却一处都不高亮(2026-10
|
||||
**变体。** variants 换掉「销售额」的那一对 SQL,挑的是收尾版差异说明(只说时间列、多出来的筛选、聚合与去重、关联
|
||||
四类,别的只给兜底)下各有代表性的几种:按哪一列筛时间不同(time_column,两列都声明为日期类型,预告写业务日期)、
|
||||
只有一边按时间筛、一边多出来的筛选(排除无效记录的 filter_extra 预告写有效记录条件,指标自己范围的 filter_scope 不写)、
|
||||
差两个筛选条件、关联方式、聚合只差去重(dedup,预告写去重规则)、说不清的(fallback:同一列上的时间范围不同,只给兜底,
|
||||
审批卡默认展开逐行对照)、一边解析失败。
|
||||
差两个筛选条件、关联方式、只差一个关联条件(join_condition:LEFT JOIN 的 ON 里多一个条件,只给兜底,逐行对照里那一行
|
||||
条件前面带着它所在的 JOIN)、聚合只差去重(dedup,预告写去重规则)、说不清的(fallback:同一列上的时间范围不同,只给
|
||||
兜底,审批卡默认展开逐行对照)、一边解析失败。
|
||||
|
||||
**写目录的预告。** 服务端在摘要里另带每个指标的 catalog(采用原流程、采用盲算各会往数据目录里写什么,
|
||||
dual_check_facts.preview,约定之外另加的),审批卡照它说;老服务端没有时卡上按差异的形状有条件地说,两种说法都按
|
||||
@@ -197,6 +198,7 @@ REPRESENTATIVE = {
|
||||
"filter_extra": (["filter"], {"a": "valid_filter"}),
|
||||
"dedup": (["aggregate"], {"a": "dedup"}),
|
||||
"fallback": (["other"], {}),
|
||||
"join_condition": (["other"], {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -211,6 +213,9 @@ def test_variants_keep_their_representative_shapes():
|
||||
assert {side: said["item"] for side, said in swap["catalog"].items() if said["write"]} == writes, name
|
||||
fallback = fx["variants"]["fallback"]["diff"]
|
||||
assert fallback["fallback"] is True and fallback["text_diff"], fallback
|
||||
# 只差一个关联条件:那一行条件前面带着它所在的 JOIN(两边相同的上级行,审批卡淡色画),检查脚本拿它看上级行怎么画
|
||||
on = fx["variants"]["join_condition"]["diff"]["text_diff"]
|
||||
assert [line[:2] for line in on] == [" ", "- "] and on[0].startswith(" LEFT JOIN ") and on[0].endswith(" ON"), on
|
||||
|
||||
|
||||
def test_text_diff_is_not_the_raw_text():
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""触发历史的翻页游标(GET /api/schedules/{id}/fires 的 next_before)照原样拼进查询串也能翻页。
|
||||
|
||||
2026-10 验收:next_before 是「2026-10-06T14:37:03.376320+00:00」这样带 +00:00 的时间,照接口说明「原样传回」拼进
|
||||
地址(不做百分号编码),「+」在查询串里被解成空格,回 422「翻页位置需要是接口返回的 next_before」。界面用
|
||||
URLSearchParams 编码,不受影响;写脚本、做集成的照说明写就会撞上。现在 next_before 写成 Z 结尾(放进地址不用编码,
|
||||
和运行列表的游标一个写法),解析时 Z、+00:00、被解成空格的「 00:00」都认:老的游标、编码过的照样能用。
|
||||
|
||||
定时任务、工作流、触发记录都是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import Schedule, ScheduleFire, Workflow
|
||||
from app.main import app
|
||||
|
||||
UTC = timezone.utc
|
||||
#: 五次触发,记录时间各差一天、带微秒(游标得精确到微秒,翻页才不重不漏)
|
||||
CREATED = [datetime(2031, 3, 1 + i, 1, 0, 1, 250_000 + i, tzinfo=UTC) for i in range(5)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def schedule_id() -> str:
|
||||
async with SessionLocal() as session:
|
||||
wf = Workflow(name=f"灯塔周报-{uuid.uuid4().hex[:6]}", graph={"nodes": [], "edges": []})
|
||||
session.add(wf)
|
||||
await session.flush()
|
||||
row = Schedule(name="灯塔周报 · 每周", workflow_id=wf.id, workflow_name=wf.name, cron="0 9 * * 1",
|
||||
timezone="Asia/Shanghai", run_class="exploratory", input_template={}, enabled=False)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
for i, at in enumerate(CREATED):
|
||||
session.add(ScheduleFire(schedule_id=row.id, fire_at=at.replace(second=0, microsecond=0), kind="scheduled",
|
||||
status="skipped_overlap", reason=f"第 {i} 次", created_at=at))
|
||||
await session.commit()
|
||||
return row.id
|
||||
|
||||
|
||||
def _reasons(body: dict) -> list[str]:
|
||||
return [f["reason"] for f in body["items"]]
|
||||
|
||||
|
||||
async def test_next_before_needs_no_encoding(client, schedule_id):
|
||||
first = (await client.get(f"/api/schedules/{schedule_id}/fires?limit=2")).json()
|
||||
assert _reasons(first) == ["第 4 次", "第 3 次"]
|
||||
cursor = first["next_before"]
|
||||
# UTC 写成 Z 结尾、精确到微秒:没有「+」,放进地址不用编码
|
||||
assert cursor == "2031-03-04T01:00:01.250003Z"
|
||||
# 照原样拼进查询串(不做百分号编码)
|
||||
second = await client.get(f"/api/schedules/{schedule_id}/fires?limit=2&before={cursor}")
|
||||
assert second.status_code == 200, second.text
|
||||
assert _reasons(second.json()) == ["第 2 次", "第 1 次"]
|
||||
last = await client.get(f"/api/schedules/{schedule_id}/fires?limit=2&before={second.json()['next_before']}")
|
||||
assert last.status_code == 200 and _reasons(last.json()) == ["第 0 次"] and last.json()["next_before"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("written", [
|
||||
"2031-03-04T01:00:01.250003+00:00", # 老的游标照原样拼进地址:「+」被解成空格
|
||||
"2031-03-04T01:00:01.250003%2B00:00", # 老的游标编码过
|
||||
"2031-03-04T01:00:01.250003Z",
|
||||
"2031-03-04T09:00:01.250003%2B08:00", # 别的时区的同一时刻
|
||||
"2031-03-04T09:00:01.250003+08:00", # ……照原样拼进地址
|
||||
])
|
||||
async def test_every_way_the_cursor_arrives_reads_the_same_page(client, schedule_id, written):
|
||||
got = await client.get(f"/api/schedules/{schedule_id}/fires?limit=2&before={written}")
|
||||
assert got.status_code == 200, got.text
|
||||
assert _reasons(got.json()) == ["第 2 次", "第 1 次"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("written", ["昨天", "2031-03-04T01:00:01 昨天", "2031-03-04T01:00:01 00:00:00"])
|
||||
async def test_a_cursor_that_is_not_a_time_is_still_refused_in_chinese(client, schedule_id, written):
|
||||
got = await client.get(f"/api/schedules/{schedule_id}/fires", params={"before": written})
|
||||
assert got.status_code == 422 and got.json()["field"] == "before"
|
||||
assert got.json()["detail"].startswith("翻页位置需要是接口返回的 next_before")
|
||||
@@ -340,7 +340,9 @@ def check_shape(diff: SqlDiff) -> None:
|
||||
assert diff.items == [] and not diff.fallback and diff.text_diff == ()
|
||||
return
|
||||
assert diff.items and diff.text_diff and len(diff.text_diff) <= 40
|
||||
assert all(line[:2] in ("- ", "+ ") for line in diff.text_diff)
|
||||
# 「- 」是 A 的行、「+ 」是 B 的行;「 」开头的是两边相同、标出不同的行在哪个子句里的上级行,只跟着不同的行出现
|
||||
assert all(line[:2] in ("- ", "+ ", " ") for line in diff.text_diff)
|
||||
assert diff.text_diff[-1][:2] in ("- ", "+ ")
|
||||
if diff.fallback:
|
||||
assert diff.items[-1].aspect == "other" and diff.items[-1].text in (FALLBACK, FALLBACK_REST)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""explain_sql_diff 的 text_diff(「规范化后不同的行」):两边相同的条件不因为换行的位置不同被标成差异,不同的行看得出
|
||||
在哪个子句里。
|
||||
|
||||
2026-10 验收:双盲复算审批卡上,两边完全相同的日期区间被标成了差异——
|
||||
+ orders.paid_date >= '2026-09-28' AND orders.paid_date <= '2026-10-04'
|
||||
- orders.paid_date >= '2026-09-28'
|
||||
- AND orders.paid_date <= '2026-10-04'
|
||||
- AND orders.status <> 'refunded'
|
||||
sqlglot 的 pretty 按宽度决定换不换行:两个条件放得下就排在一行,三个就拆成三行,同一个条件在两边落在不同的行上。原文
|
||||
SQL 都是单行时原文栏一行都不标,这一栏是唯一逐行指出差在哪的地方,审核人会以为统计期也不一样。
|
||||
|
||||
现在逐行比之前两边按同一套排版写:WHERE、ON、HAVING 的合取式每个条件一行(嵌在子查询里的也一样),条件本身是 OR
|
||||
连起来的加上括号;比的时候不看条件前面的 AND(一边多出来的条件排在最前面时,另一边同一个条件只差在有没有 AND 上)。
|
||||
只动排版:判不判等价、差异说明(items)不变,sql_semantics、sql_equivalence 原有的测试照样全过。
|
||||
|
||||
复核(第 2 轮):ON 单独占一行时,只差一个关联条件的那一栏只剩一行「AND 条件」,看不出是哪个 JOIN 的关联条件、还是
|
||||
筛选条件;两边 JOIN 的表不同时,两边都有的那一行「ON」被当成相同的行吃掉,条件前面没了 ON。现在 ON 挂在 JOIN 那一行
|
||||
的末尾(条件缩在下面,比 WHERE 的条件深一级);不同的行前面带上它的各级上级行——「 」开头、两边相同、只为标出不同的
|
||||
行在哪个子句里(哪个 WHERE、哪个 JOIN 的 ON、哪层子查询),审批卡上淡色显示。
|
||||
|
||||
表名、列名、日期、取值都是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.data.sql_semantics import explain_sql_diff
|
||||
|
||||
LABELS = ("原流程", "盲算")
|
||||
#: 包裹表、仓库表、快递员表的结构(虚构):给了表结构才认列,同验收时双盲复算节点的调用
|
||||
SCHEMA = {"tables": {
|
||||
"parcels": {"columns": [{"name": "id", "type": "INTEGER"}, {"name": "fee", "type": "REAL"},
|
||||
{"name": "sent_on", "type": "TEXT"}, {"name": "state", "type": "TEXT"},
|
||||
{"name": "hub", "type": "TEXT"}, {"name": "depot_id", "type": "INTEGER"},
|
||||
{"name": "courier_id", "type": "INTEGER"}]},
|
||||
"depots": {"columns": [{"name": "id", "type": "INTEGER"}, {"name": "zone", "type": "TEXT"},
|
||||
{"name": "kind", "type": "TEXT"}]},
|
||||
"couriers": {"columns": [{"name": "id", "type": "INTEGER"}, {"name": "team", "type": "TEXT"},
|
||||
{"name": "active", "type": "INTEGER"}]},
|
||||
}}
|
||||
WEEK = "sent_on BETWEEN '2031-03-03' AND '2031-03-09'"
|
||||
#: 两个 LEFT JOIN,{d}、{c} 处各可再加一个关联条件
|
||||
TWO_JOINS = ("SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id{d} "
|
||||
"LEFT JOIN couriers c ON c.id = p.courier_id{c}")
|
||||
|
||||
|
||||
def diff(a: str, b: str):
|
||||
return explain_sql_diff(a, b, "sqlite", schema=SCHEMA, labels=LABELS)
|
||||
|
||||
|
||||
def depth(line: str) -> int:
|
||||
return len(line) - len(line.lstrip(" "))
|
||||
|
||||
|
||||
def test_the_same_date_range_is_not_marked_when_one_side_has_one_more_condition():
|
||||
"""验收时的形状:原流程比盲算多一个排除作废的条件,两边的日期区间一样——只标多出来的那一个条件,前面带上它所在的
|
||||
WHERE(两边相同的上下文行)。"""
|
||||
a = f"SELECT SUM(fee) AS week_fee FROM parcels WHERE {WEEK} AND state <> 'void'"
|
||||
b = f"SELECT SUM(fee) AS total_fee FROM parcels WHERE {WEEK}"
|
||||
got = diff(a, b)
|
||||
assert got.text_diff == (" WHERE", "- AND parcels.state <> 'void'")
|
||||
# 只动排版:判定和差异说明照旧
|
||||
assert not got.equivalent and not got.fallback and got.level == "optimizer"
|
||||
assert [(i.aspect, i.text) for i in got.items] == [("filter", "原流程排除了 state = 'void' 的行和 state 为空的行,盲算没有")]
|
||||
# 反过来比也一样,只是换到「+ 」那一边
|
||||
assert diff(b, a).text_diff == (" WHERE", "+ AND parcels.state <> 'void'")
|
||||
|
||||
|
||||
def test_an_extra_condition_sorted_first_does_not_drag_the_shared_ones_along():
|
||||
"""多出来的条件排在最前面:另一边同一个日期条件只差在前面有没有 AND,不算不同。"""
|
||||
a = f"SELECT SUM(fee) FROM parcels WHERE {WEEK} AND hub = 'north'"
|
||||
b = f"SELECT SUM(fee) FROM parcels WHERE {WEEK}"
|
||||
assert diff(a, b).text_diff == (" WHERE", "- parcels.hub = 'north'")
|
||||
assert diff(b, a).text_diff == (" WHERE", "+ parcels.hub = 'north'")
|
||||
|
||||
|
||||
def test_two_conditions_on_each_side_mark_only_the_one_that_differs():
|
||||
"""同一列上的区间只差结束日期(这种只给兜底说明,审核人全靠逐行对照):开始日期那一行两边一样,不标。"""
|
||||
a = f"SELECT SUM(fee) FROM parcels WHERE {WEEK}"
|
||||
b = "SELECT SUM(fee) FROM parcels WHERE sent_on BETWEEN '2031-03-03' AND '2031-03-10'"
|
||||
got = diff(a, b)
|
||||
assert got.fallback
|
||||
assert got.text_diff == (" WHERE", "- AND parcels.sent_on <= '2031-03-09'", "+ AND parcels.sent_on <= '2031-03-10'")
|
||||
|
||||
|
||||
def test_on_conditions_get_one_line_each_under_their_join():
|
||||
"""LEFT JOIN 的 ON 挂在 JOIN 那一行的末尾,条件各占一行缩在下面;多出来的那个条件单独标出来,前面带上它所在的
|
||||
JOIN(以前 ON 单独一行,标出来的只剩「AND depots.kind = 'cold'」,看不出是关联条件还是筛选条件)。"""
|
||||
a = "SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id AND d.kind = 'cold'"
|
||||
b = "SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id"
|
||||
assert diff(a, b).text_diff == (" LEFT JOIN depots AS depots ON", "- AND depots.kind = 'cold'")
|
||||
# 关联条件本身不同:只标那一行,JOIN 那一行两边一样,作为上下文列出
|
||||
c = "SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.zone = p.hub"
|
||||
assert diff(b, c).text_diff == (" LEFT JOIN depots AS depots ON", "- depots.id = parcels.depot_id",
|
||||
"+ depots.zone = parcels.hub")
|
||||
|
||||
|
||||
def test_two_left_joins_name_the_join_the_differing_condition_belongs_to():
|
||||
"""两个 LEFT JOIN 只差其中一个的关联条件:标出来的条件前面带上的是它自己的那个 JOIN,不是另一个,也不是 WHERE。"""
|
||||
plain = TWO_JOINS.format(d="", c="")
|
||||
on_courier = TWO_JOINS.format(d="", c=" AND c.active = 1")
|
||||
assert diff(on_courier, plain).text_diff == (" LEFT JOIN couriers AS couriers ON", "- AND couriers.active = 1")
|
||||
on_depot = TWO_JOINS.format(d=" AND d.kind = 'cold'", c="")
|
||||
assert diff(plain, on_depot).text_diff == (" LEFT JOIN depots AS depots ON", "+ AND depots.kind = 'cold'")
|
||||
|
||||
|
||||
def test_a_different_join_table_keeps_on_with_each_side():
|
||||
"""两边 LEFT JOIN 的表不同:每一栏都是带着 ON 的 JOIN 那一行、下面紧跟各自的关联条件(以前两边都有的那一行 ON 被
|
||||
当成相同的行吃掉,表名下面直接跟条件)。"""
|
||||
a = "SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id"
|
||||
b = "SELECT COUNT(*) FROM parcels p LEFT JOIN couriers c ON c.id = p.courier_id"
|
||||
assert diff(a, b).text_diff == ("- LEFT JOIN depots AS depots ON", "- depots.id = parcels.depot_id",
|
||||
"+ LEFT JOIN couriers AS couriers ON", "+ couriers.id = parcels.courier_id")
|
||||
|
||||
|
||||
def test_a_join_condition_never_floats_without_its_join():
|
||||
"""关联的对象换了写法(原流程关联一个子查询,盲算关联两张表):每个关联条件紧跟在它所在的、带着 ON 的那一行下面——
|
||||
子查询的右括号后面也带着 ON,不会出现条件上面是子查询的最后一行、或者上面是别的 JOIN 的情况。"""
|
||||
a = ("SELECT COUNT(*) FROM parcels p LEFT JOIN (SELECT depot_id AS k, SUM(fee) AS s FROM parcels GROUP BY depot_id) "
|
||||
"AS sub ON p.depot_id = sub.k")
|
||||
b = ("SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id "
|
||||
"LEFT JOIN couriers c ON c.id = p.courier_id")
|
||||
got = diff(a, b).text_diff
|
||||
assert [line for line in got if line.startswith("+ ")] == [
|
||||
"+ LEFT JOIN couriers AS couriers ON", "+ couriers.id = parcels.courier_id",
|
||||
"+ LEFT JOIN depots AS depots ON", "+ depots.id = parcels.depot_id"]
|
||||
original = [line for line in got if line.startswith("- ")]
|
||||
assert original[0] == "- LEFT JOIN (" and original[-2:] == ["- ) AS sub ON", "- parcels.depot_id = sub.c1"], got
|
||||
# 每一行都只在一边,没有两边相同的上下文行,也没有单独一行的 ON
|
||||
assert len(original) + 4 == len(got) and not any(line.strip() in ("ON", "- ON", "+ ON") for line in got)
|
||||
|
||||
|
||||
def test_the_same_condition_in_on_and_in_where_is_not_one_line():
|
||||
"""同一个条件一边写在 LEFT JOIN 的 ON 里、一边写在 WHERE 里(意思不同:写在 ON 里不筛掉左表的行):ON 里的条件比
|
||||
WHERE 的缩进深一级,不会被当成两边相同的行。"""
|
||||
a = "SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id AND p.hub = 'north'"
|
||||
b = "SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id WHERE p.hub = 'north'"
|
||||
assert diff(a, b).text_diff == (" LEFT JOIN depots AS depots ON", "+ WHERE", "- AND parcels.hub = 'north'",
|
||||
"+ parcels.hub = 'north'")
|
||||
|
||||
|
||||
def test_a_top_level_line_carries_no_context():
|
||||
"""不同的行本身顶格(FROM 换了表):没有上级行,不带上下文。"""
|
||||
assert diff("SELECT COUNT(*) FROM parcels", "SELECT COUNT(*) FROM depots").text_diff == (
|
||||
"- FROM parcels AS parcels", "+ FROM depots AS depots")
|
||||
|
||||
|
||||
def test_having_conditions_get_one_line_each_too():
|
||||
a = "SELECT hub, SUM(fee) FROM parcels GROUP BY hub HAVING SUM(fee) > 10 AND COUNT(*) > 2"
|
||||
b = "SELECT hub, SUM(fee) FROM parcels GROUP BY hub HAVING SUM(fee) > 10"
|
||||
assert diff(a, b).text_diff == (" HAVING", "- AND COUNT(*) > 2")
|
||||
|
||||
|
||||
def test_conditions_inside_a_subquery_get_one_line_each_too():
|
||||
"""子查询里的条件也是每个条件一行;上下文一层层带到顶格:外层的 WHERE、EXISTS 那一行、子查询的 WHERE。"""
|
||||
a = ("SELECT COUNT(*) FROM parcels p WHERE p.state <> 'void' "
|
||||
"AND EXISTS (SELECT 1 FROM depots d WHERE d.id = p.depot_id AND d.zone = 'east')")
|
||||
b = ("SELECT COUNT(*) FROM parcels p WHERE p.state <> 'void' "
|
||||
"AND EXISTS (SELECT 1 FROM depots d WHERE d.id = p.depot_id)")
|
||||
*context, line = diff(a, b).text_diff
|
||||
assert line.startswith("- AND ") and line.endswith(".zone = 'east'")
|
||||
assert [c.strip() for c in context] == ["WHERE", "EXISTS(", "WHERE"] and all(c.startswith(" ") for c in context)
|
||||
assert [depth(c[2:]) for c in context] == [0, 2, 4]
|
||||
|
||||
|
||||
def test_an_or_condition_reads_the_same_alone_and_among_others():
|
||||
"""OR 连起来的条件和别的条件并列时要加括号,单独一个时以前不加:同一个条件两边写法不同,就被标成了差异。现在条件
|
||||
本身是 OR 的一律加括号,两边写法一样;加括号不改意思。"""
|
||||
either = "(hub = 'north' OR hub = 'south')"
|
||||
a = f"SELECT COUNT(*) FROM parcels WHERE {either} AND state <> 'void'"
|
||||
b = f"SELECT COUNT(*) FROM parcels WHERE {either}"
|
||||
assert diff(a, b).text_diff == (" WHERE", "- parcels.state <> 'void'")
|
||||
c = "SELECT COUNT(*) FROM parcels WHERE (hub = 'north' OR hub = 'west') AND state <> 'void'"
|
||||
assert diff(a, c).text_diff == (" WHERE", "- AND (parcels.hub = 'north' OR parcels.hub = 'south')",
|
||||
"+ AND (parcels.hub = 'north' OR parcels.hub = 'west')")
|
||||
|
||||
|
||||
def test_context_cut_off_by_the_forty_line_cap_is_dropped():
|
||||
"""截到 40 行时,最后剩下的上级行后面的不同行被截掉了:这种上级行不留,最后一行总是不同的行。"""
|
||||
cols = 19
|
||||
a = ", ".join(f"SUM(fee * {i}) AS c{i}" for i in range(cols))
|
||||
b = ", ".join(f"SUM(fee * {i + 100}) AS c{i}" for i in range(cols))
|
||||
got = diff(f"SELECT {a} FROM parcels WHERE fee > 0 AND state <> 'void'",
|
||||
f"SELECT {b} FROM parcels WHERE fee > 0").text_diff
|
||||
# 上级行 SELECT、两边各 19 行的选择列共 39 行;第 40 行本该是 WHERE,它下面那一行被截掉了
|
||||
assert got[0] == " SELECT" and len(got) == 1 + 2 * cols
|
||||
assert got[-1][:2] in ("- ", "+ ") and " WHERE" not in got
|
||||
@@ -334,6 +334,52 @@ async def test_schema_tool_still_lists_everything_when_asked_for_nothing(source)
|
||||
assert "张表" in out and "orders" in out
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def depots(tmp_path):
|
||||
"""两张表的库(虚构的仓储):parcels、depots,结构已探查。"""
|
||||
from app.data import introspect
|
||||
|
||||
path = tmp_path / "depots.db"
|
||||
db = sqlite3.connect(path)
|
||||
db.executescript("CREATE TABLE parcels (id INTEGER PRIMARY KEY, depot_id INTEGER, fee REAL);"
|
||||
"CREATE TABLE depots (id INTEGER PRIMARY KEY, zone TEXT);")
|
||||
db.commit()
|
||||
db.close()
|
||||
src = SimpleNamespace(id="tool-args-depots-src", name="depots", kind="sqlite", database=str(path), host=None,
|
||||
port=None, username=None, password=None, options={}, readonly=True, description="测试库",
|
||||
schema_cache={})
|
||||
src.schema_cache = await introspect.introspect(src)
|
||||
return src
|
||||
|
||||
|
||||
@pytest.mark.parametrize("written", ['["parcels", "depots"]', '["parcels","depots"]', ' [ "parcels" , "depots" ] ',
|
||||
['["parcels", "depots"]'], ['["parcels"]', "depots"]])
|
||||
async def test_schema_tool_reads_a_json_array_written_as_one_string(depots, written) -> None:
|
||||
"""2026-10 验收:模型把 table 写成一整个字符串 '["orders", "stores"]'(参数说明里的 ["user","roles"] 引得它这么写),
|
||||
只按逗号拆就成了「没有 ["orders"」「没有 "stores"]」——两张都在,模型却白白多调了几轮。写成字符串的 JSON 数组
|
||||
和逗号分隔一样认。"""
|
||||
out = await _make_schema_tool(depots).coroutine(table=written)
|
||||
assert out.count("表 parcels") == 1 and out.count("表 depots") == 1, out
|
||||
assert "没有" not in out
|
||||
|
||||
|
||||
async def test_schema_tool_reads_a_one_table_json_array(depots) -> None:
|
||||
out = await _make_schema_tool(depots).coroutine(table='["parcels"]')
|
||||
assert out.startswith("表 parcels") and "depots" not in out and "没有" not in out
|
||||
# 空数组和不传一样:列出所有表
|
||||
assert "共 2 张表" in await _make_schema_tool(depots).coroutine(table="[]")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("written,missing", [
|
||||
("[parcels]", "[parcels]"), # 不是 JSON(名字没加引号):照旧整个当表名
|
||||
("['parcels']", "['parcels']"), # 单引号也不是 JSON
|
||||
("[1, 2]", "[1"), # 数组里不是表名:照旧按逗号拆
|
||||
])
|
||||
async def test_schema_tool_leaves_other_bracketed_text_as_before(depots, written, missing) -> None:
|
||||
out = await _make_schema_tool(depots).coroutine(table=written)
|
||||
assert f"中没有 {missing}。" in out
|
||||
|
||||
|
||||
def test_copilot_is_told_not_to_pin_max_steps() -> None:
|
||||
"""Copilot 把 max_steps: 8 写进了生成的图,后端默认值根本轮不上。"""
|
||||
from app.api.copilot import NODE_REFERENCE
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""核查 agent(engine/verify_agent.py)的 db_schema 认「整个写成一个字符串的 JSON 数组」的表名。
|
||||
|
||||
2026-10 验收:盲算、反方审稿的模型把 table 写成 '["orders", "stores"]'(一个字符串),工具只按逗号拆,回「中没有
|
||||
["orders"」「中没有 "stores"]」,两张表明明都在;模型照原样重试、再失败,到第三轮才改成不传 table,白白多调了几轮
|
||||
模型。现在第一次调用就拿到两张表的结构(tools/datasource.py 的 _schema_tables),模型看到的工具结果、时间线上的
|
||||
tool.end 都是表结构。
|
||||
|
||||
模型是 MockChatModel 剧本,数据源是现造的 SQLite 文件,表名、数字都是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
|
||||
from app.core.events import EventType
|
||||
from app.data import introspect
|
||||
from app.data.engine import engines
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import DataSource
|
||||
from app.engine import verify_agent
|
||||
from app.engine.verify_agent import run_verify_agent
|
||||
from app.providers.factory import ModelSpec
|
||||
from app.providers.mock_model import MockChatModel
|
||||
from app.tools.registry import Versions
|
||||
|
||||
SCHEMA = {"type": "object", "properties": {"value": {"type": "number", "description": "包裹运费合计"}},
|
||||
"required": ["value"]}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def depots(tmp_path):
|
||||
"""两张表:包裹(运费 12、8、5)和仓库。返回源名。"""
|
||||
path = tmp_path / f"{uuid.uuid4().hex[:8]}.db"
|
||||
db = sqlite3.connect(path)
|
||||
db.executescript("CREATE TABLE parcels (id INTEGER PRIMARY KEY, depot_id INTEGER, fee INTEGER);"
|
||||
"CREATE TABLE depots (id INTEGER PRIMARY KEY, zone TEXT);"
|
||||
"INSERT INTO parcels VALUES (1, 1, 12), (2, 1, 8), (3, 2, 5);"
|
||||
"INSERT INTO depots VALUES (1, '东区'), (2, '西区');")
|
||||
db.commit()
|
||||
db.close()
|
||||
name = f"depots_{uuid.uuid4().hex[:8]}"
|
||||
async with SessionLocal() as session:
|
||||
row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
row.schema_cache = await introspect.introspect(row)
|
||||
await session.commit()
|
||||
source_id = row.id
|
||||
yield name
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
async def test_a_json_array_written_as_one_string_gets_both_tables_at_once(monkeypatch, depots):
|
||||
seen: list[list] = []
|
||||
steps = [[(f"db_schema__{depots}", {"table": '["parcels", "depots"]'}, "s1")],
|
||||
[(f"db_query__{depots}", {"sql": "SELECT SUM(fee) AS total FROM parcels"}, "c1")]]
|
||||
|
||||
def decide(model, messages):
|
||||
seen.append(list(messages))
|
||||
if model.response_format:
|
||||
return AIMessage(content=json.dumps({"value": {"alias": "Q1", "row": 0, "column": "total"},
|
||||
"rationale": "运费合计见 [[see:Q1]]。"}, ensure_ascii=False))
|
||||
if not steps:
|
||||
return AIMessage(content="查完了。")
|
||||
return AIMessage(content="查一下。", tool_calls=[{"name": n, "args": a, "id": i} for n, a, i in steps.pop(0)])
|
||||
|
||||
async def fake_model(session, spec):
|
||||
return MockChatModel(model_name="mock-fast"), "mock-fast"
|
||||
|
||||
monkeypatch.setattr(MockChatModel, "_decide", decide)
|
||||
monkeypatch.setattr(verify_agent, "get_chat_model", fake_model)
|
||||
events: list[tuple] = []
|
||||
out = await run_verify_agent(spec=ModelSpec(model="mock-fast"), system="核对包裹运费。", task="算一遍包裹运费合计。",
|
||||
sources=[depots], output_schema=SCHEMA, data_versions=Versions.CURRENT,
|
||||
emit=lambda kind, **data: events.append((kind, data)), run_id="run-m4", node_id="dual")
|
||||
assert out.error is None and out.result["value"] == 25
|
||||
# 模型在循环里看到的那次工具结果:两张表的结构,没有「中没有」
|
||||
[answer] = [str(m.content) for messages in seen for m in messages
|
||||
if isinstance(m, ToolMessage) and m.tool_call_id == "s1"][:1]
|
||||
assert "表 parcels" in answer and "表 depots" in answer and "没有" not in answer
|
||||
# 时间线上那一条 tool.end 也是表结构
|
||||
[end] = [d for kind, d in events if kind == EventType.TOOL_END and d.get("call_id") == "s1"]
|
||||
assert "表 parcels" in end["preview"] and "没有" not in end["preview"]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""单个工作流接口的 run_count 和列表接口按同一个函数算。
|
||||
|
||||
2026-10 验收:GET /api/workflows/{id} 的 run_count 恒为 0,同一时刻列表接口(GET /api/workflows)里这个工作流是
|
||||
2 次、运行列表按 workflow_id 筛出来也是 2 条——只有列表接口按 Run.workflow_id 计数,详情、保存(PATCH)、恢复旧版
|
||||
直接返回数据库对象,WorkflowOut.run_count 落在默认值 0 上。现在凡是返回工作流的接口都按同一个函数算,次数和列表一致。
|
||||
|
||||
工作流名、图、运行都是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import Run
|
||||
from app.main import app
|
||||
|
||||
GRAPH = {
|
||||
"nodes": [
|
||||
{"id": "start", "type": "input", "position": {"x": 0, "y": 0},
|
||||
"data": {"label": "输入", "config": {"fields": [{"name": "week"}]}}},
|
||||
{"id": "out", "type": "output", "position": {"x": 300, "y": 0},
|
||||
"data": {"label": "成果", "config": {"fields": [{"name": "r", "value": "{{ input.week }}"}]}}},
|
||||
],
|
||||
"edges": [{"source": "start", "target": "out"}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
async def _add_runs(workflow_id: str | None, n: int, status: str = "succeeded") -> None:
|
||||
async with SessionLocal() as session:
|
||||
for _ in range(n):
|
||||
session.add(Run(workflow_id=workflow_id, workflow_name="灯塔周报", graph=GRAPH, input={}, status=status))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _listed(client, workflow_id: str) -> int:
|
||||
[row] = [w for w in (await client.get("/api/workflows")).json() if w["id"] == workflow_id]
|
||||
return row["run_count"]
|
||||
|
||||
|
||||
async def test_the_single_workflow_counts_its_runs_like_the_list(client):
|
||||
made = (await client.post("/api/workflows", json={"name": "灯塔周报", "graph": GRAPH})).json()
|
||||
other = (await client.post("/api/workflows", json={"name": "潮汐日报", "graph": GRAPH})).json()
|
||||
await _add_runs(made["id"], 3)
|
||||
await _add_runs(made["id"], 1, status="failed")
|
||||
# 别的工作流、没挂在工作流上的运行不算进来
|
||||
await _add_runs(other["id"], 2)
|
||||
await _add_runs(None, 1)
|
||||
|
||||
assert await _listed(client, made["id"]) == 4
|
||||
got = await client.get(f"/api/workflows/{made['id']}")
|
||||
assert got.status_code == 200 and got.json()["run_count"] == 4
|
||||
assert (await client.get(f"/api/workflows/{other['id']}")).json()["run_count"] == await _listed(client, other["id"]) == 2
|
||||
|
||||
|
||||
async def test_save_and_restore_answer_with_the_same_count(client):
|
||||
"""保存、恢复旧版的返回也是这个工作流:界面拿它替换手里的那一份,次数不能变回 0。"""
|
||||
made = (await client.post("/api/workflows", json={"name": "灯塔周报", "graph": GRAPH})).json()
|
||||
await _add_runs(made["id"], 2)
|
||||
|
||||
renamed = await client.patch(f"/api/workflows/{made['id']}", json={"name": "灯塔周报(新)"})
|
||||
assert renamed.status_code == 200 and renamed.json()["run_count"] == 2
|
||||
changed = {**GRAPH, "nodes": [*GRAPH["nodes"][:1], {**GRAPH["nodes"][1], "position": {"x": 320, "y": 0}}]}
|
||||
saved = await client.patch(f"/api/workflows/{made['id']}", json={"graph": changed, "note": "挪了一下"})
|
||||
assert saved.status_code == 200 and saved.json()["version"] == 2 and saved.json()["run_count"] == 2
|
||||
restored = await client.post(f"/api/workflows/{made['id']}/versions/1/restore")
|
||||
assert restored.status_code == 200 and restored.json()["run_count"] == 2
|
||||
assert await _listed(client, made["id"]) == 2
|
||||
|
||||
|
||||
async def test_new_and_copied_workflows_have_no_runs_yet(client):
|
||||
"""新建、复制出来的是新工作流,还没有运行;复制不带上原来那个的次数。"""
|
||||
made = (await client.post("/api/workflows", json={"name": "灯塔周报", "graph": GRAPH})).json()
|
||||
assert made["run_count"] == 0
|
||||
await _add_runs(made["id"], 3)
|
||||
copy = await client.post(f"/api/workflows/{made['id']}/duplicate")
|
||||
assert copy.status_code == 201 and copy.json()["run_count"] == 0
|
||||
assert (await client.get(f"/api/workflows/{made['id']}")).json()["run_count"] == 3
|
||||
@@ -247,8 +247,11 @@
|
||||
],
|
||||
"fallback": false,
|
||||
"text_diff": [
|
||||
"- orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-08'",
|
||||
"+ orders.created_at >= '2026-09-01' AND orders.created_at < '2026-09-08'"
|
||||
" WHERE",
|
||||
"- orders.paid_at >= '2026-09-01'",
|
||||
"+ orders.created_at >= '2026-09-01'",
|
||||
"- AND orders.paid_at < '2026-09-08'",
|
||||
"+ AND orders.created_at < '2026-09-08'"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -309,12 +312,15 @@
|
||||
],
|
||||
"fallback": true,
|
||||
"text_diff": [
|
||||
" WHERE",
|
||||
"- EXISTS(",
|
||||
"+ refunds.order_id IN (",
|
||||
" SELECT",
|
||||
"- 1",
|
||||
"+ \"orders@1\".id",
|
||||
"- WHERE",
|
||||
"- \"orders@1\".id = refunds.order_id AND \"orders@1\".status <> 'test'"
|
||||
"- \"orders@1\".id = refunds.order_id",
|
||||
"- AND \"orders@1\".status <> 'test'"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -580,8 +586,11 @@
|
||||
],
|
||||
"fallback": false,
|
||||
"text_diff": [
|
||||
"- orders.created_at >= '2026-09-01' AND orders.created_at < '2026-09-08'",
|
||||
"+ orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-08'"
|
||||
" WHERE",
|
||||
"- orders.created_at >= '2026-09-01'",
|
||||
"+ orders.paid_at >= '2026-09-01'",
|
||||
"- AND orders.created_at < '2026-09-08'",
|
||||
"+ AND orders.paid_at < '2026-09-08'"
|
||||
]
|
||||
},
|
||||
"catalog": {
|
||||
@@ -621,7 +630,8 @@
|
||||
"fallback": false,
|
||||
"text_diff": [
|
||||
"- WHERE",
|
||||
"- orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-08'"
|
||||
"- orders.paid_at >= '2026-09-01'",
|
||||
"- AND orders.paid_at < '2026-09-08'"
|
||||
]
|
||||
},
|
||||
"catalog": {
|
||||
@@ -718,7 +728,8 @@
|
||||
"fallback": false,
|
||||
"text_diff": [
|
||||
"- WHERE",
|
||||
"- orders.channel = 'web' AND orders.status <> 'refunded'"
|
||||
"- orders.channel = 'web'",
|
||||
"- AND orders.status <> 'refunded'"
|
||||
]
|
||||
},
|
||||
"catalog": {
|
||||
@@ -748,10 +759,10 @@
|
||||
"fallback": false,
|
||||
"text_diff": [
|
||||
"- FROM orders AS orders",
|
||||
"- LEFT JOIN refunds AS refunds",
|
||||
"- LEFT JOIN refunds AS refunds ON",
|
||||
"+ FROM orders AS orders, refunds AS refunds",
|
||||
"+ WHERE",
|
||||
"- ON orders.id = refunds.order_id",
|
||||
"- orders.id = refunds.order_id",
|
||||
"+ orders.id = refunds.order_id"
|
||||
]
|
||||
},
|
||||
@@ -766,6 +777,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"join_condition": {
|
||||
"sql": [
|
||||
"SELECT COUNT(*) AS lines\nFROM orders o\nLEFT JOIN refunds r ON r.order_id = o.id\n AND r.amount > 0",
|
||||
"SELECT COUNT(*) AS lines\nFROM orders o\nLEFT JOIN refunds r ON r.order_id = o.id"
|
||||
],
|
||||
"diff": {
|
||||
"equivalent": false,
|
||||
"items": [
|
||||
{
|
||||
"aspect": "other",
|
||||
"text": "两条 SQL 的写法不同,未能归纳成具体差异,请对照原文"
|
||||
}
|
||||
],
|
||||
"fallback": true,
|
||||
"text_diff": [
|
||||
" LEFT JOIN refunds AS refunds ON",
|
||||
"- AND refunds.amount > 0"
|
||||
]
|
||||
},
|
||||
"catalog": {
|
||||
"a": {
|
||||
"write": false,
|
||||
"reason": "指标「销售额」两边的 SQL 不止差一个方面,或有未能归纳的差异,无法确定审核人认可的是哪一处,不写入数据目录"
|
||||
},
|
||||
"b": {
|
||||
"write": false,
|
||||
"reason": "指标「销售额」两边的 SQL 不止差一个方面,或有未能归纳的差异,无法确定审核人认可的是哪一处,不写入数据目录"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dedup": {
|
||||
"sql": [
|
||||
"SELECT COUNT(DISTINCT user_id) AS buyers\nFROM orders",
|
||||
@@ -781,6 +822,7 @@
|
||||
],
|
||||
"fallback": false,
|
||||
"text_diff": [
|
||||
" SELECT",
|
||||
"- COUNT(DISTINCT orders.user_id)",
|
||||
"+ COUNT(orders.user_id)"
|
||||
]
|
||||
@@ -814,8 +856,9 @@
|
||||
],
|
||||
"fallback": true,
|
||||
"text_diff": [
|
||||
"- orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-08'",
|
||||
"+ orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-09'"
|
||||
" WHERE",
|
||||
"- AND orders.paid_at < '2026-09-08'",
|
||||
"+ AND orders.paid_at < '2026-09-09'"
|
||||
]
|
||||
},
|
||||
"catalog": {
|
||||
@@ -873,6 +916,7 @@
|
||||
],
|
||||
"fallback": false,
|
||||
"text_diff": [
|
||||
" WHERE",
|
||||
"- AND orders.paid_at >= '2026-09-01'",
|
||||
"+ AND orders.created_at >= '2026-09-01'",
|
||||
"- AND orders.paid_at < '2026-09-08'",
|
||||
@@ -915,6 +959,7 @@
|
||||
],
|
||||
"fallback": false,
|
||||
"text_diff": [
|
||||
" SELECT",
|
||||
"- COUNT(DISTINCT orders.id)",
|
||||
"+ COUNT(orders.id)"
|
||||
]
|
||||
@@ -948,8 +993,8 @@
|
||||
],
|
||||
"fallback": false,
|
||||
"text_diff": [
|
||||
"- orders.id = refunds.order_id AND orders.status <> 'cancelled'",
|
||||
"+ orders.id = refunds.order_id"
|
||||
" WHERE",
|
||||
"- AND orders.status <> 'cancelled'"
|
||||
]
|
||||
},
|
||||
"catalog": {
|
||||
|
||||
@@ -3865,6 +3865,8 @@ export const DUAL_CHECK_TEXT = {
|
||||
source: (s: string) => `数据源 ${s}`,
|
||||
inputs: '输入取值',
|
||||
textDiff: '规范化后不同的行',
|
||||
/** 不同的行前面带着淡色的上级行(服务端 text_diff 里「 」开头的行)时,接在 textDiff 后面,也是那几行的悬停说明 */
|
||||
textDiffContext: '淡色的行两边相同,标出所在的子句',
|
||||
textDiffNone: '这一边没有不同的行',
|
||||
rationale: '盲算的说明',
|
||||
/** 理由文档里有没有出处的数字时,理由下面那一句提醒 */
|
||||
|
||||
@@ -696,7 +696,7 @@ const MAX_LCS_CELLS = 250_000
|
||||
/**
|
||||
* 两段原文按行比(不看空白、大小写和行尾的逗号分号),返回两边各自不在最长公共子序列里的行号。
|
||||
*
|
||||
* 不拿 text_diff 去对原文:服务端的 text_diff 是规范化之后的行(列名带表名、条件并成一行),和原文的行几乎从来
|
||||
* 不拿 text_diff 去对原文:服务端的 text_diff 是规范化之后的行(列名带表名、每个条件一行),和原文的行几乎从来
|
||||
* 对不上。一行都对不上(写法完全不同、各写成一行)时返回 null:整段都标出来没有信息量,差在哪看规范化后的行
|
||||
*/
|
||||
function lineDiff(a: string[], b: string[]): { a: Set<number>; b: Set<number> } | null {
|
||||
@@ -727,6 +727,36 @@ function lineDiff(a: string[], b: string[]): { a: Set<number>; b: Set<number> }
|
||||
/** SQL 原文按行切(去掉末尾的空白行) */
|
||||
const linesOf = (sql: unknown): string[] | null => (hasSql(sql) ? (sql as string).replace(/\s+$/, '').split('\n') : null)
|
||||
|
||||
/** 规范化后的一行:context 是服务端带上的上级行(「 」开头、两边相同),只为标出不同的行在哪个子句里,不高亮 */
|
||||
interface NormLine { text: string; context: boolean }
|
||||
|
||||
/** 行首的空格数:服务端的规范化写法用空格缩进,子句的内容缩在子句名下面 */
|
||||
const depthOf = (line: string): number => line.length - line.trimStart().length
|
||||
|
||||
/**
|
||||
* text_diff 里一栏的行:这一边(「- 」原流程、「+ 」盲算)不同的行,加上它们用得着的上级行。服务端在不同的行前面带上
|
||||
* 各级上级行(「 」开头:条件在哪个 WHERE、哪个 JOIN 的 ON、哪层子查询里),两边共用一份;这里只留这一边的不同行
|
||||
* 往上数用得着的——排在前面、缩进比它浅的最近一行,一层层往上——另一边才用得着的不放进这一栏(一边多出一个 WHERE 时,
|
||||
* 另一边关联条件上面那一行 JOIN 和它无关)。这一边没有不同的行时一行都不留,栏里照实说没有
|
||||
*/
|
||||
function normSide(lines: string[], mark: '- ' | '+ '): NormLine[] {
|
||||
const own = lines.filter((l) => l.startsWith(mark) || l.startsWith(' '))
|
||||
.map((l) => ({ text: l.slice(2), context: l.startsWith(' ') }))
|
||||
if (!own.some((l) => !l.context)) return []
|
||||
const keep = new Set<number>()
|
||||
own.forEach((line, i) => {
|
||||
if (line.context) return
|
||||
let depth = depthOf(line.text)
|
||||
for (let k = i - 1; k >= 0 && depth > 0; k--) {
|
||||
const above = own[k]
|
||||
if (!above.text.trim() || depthOf(above.text) >= depth) continue
|
||||
if (above.context) keep.add(k)
|
||||
depth = depthOf(above.text)
|
||||
}
|
||||
})
|
||||
return own.filter((line, i) => !line.context || keep.has(i))
|
||||
}
|
||||
|
||||
function valueText(v: unknown): string {
|
||||
if (v == null) return NONE
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v.toLocaleString('en-US', { maximumFractionDigits: 6 }) : String(v)
|
||||
@@ -750,17 +780,16 @@ const CODE_BOX = 'overflow-y-auto whitespace-pre-wrap rounded border bg-panel px
|
||||
/**
|
||||
* 对照两条 SQL:先列每个输入两边各取到多少;再把不同的输入的两条 SQL 原文左右并排(卡片窄于 32rem 时上下排,按卡片
|
||||
* 自己的宽度算,画布右栏里也是上下排),两段原文按行比出的不同行高亮;最后是规范化后不同的行(服务端的 text_diff),
|
||||
* 同样分原流程、盲算两栏,每行高亮——原文格式差得太远、按行比不出东西时,差在哪就看这一段
|
||||
* 同样分原流程、盲算两栏,不同的行高亮,前面淡色的是它所在子句的那几行(见 normSide)——原文格式差得太远、按行比不出
|
||||
* 东西时,差在哪就看这一段
|
||||
*/
|
||||
function Compare({ id, metric }: { id: string; metric: DualCheckMetric }) {
|
||||
const pairs = useMemo(() => pairsOf(metric), [metric])
|
||||
const norm = useMemo(() => {
|
||||
const lines = (Array.isArray(metric.diff?.text_diff) ? metric.diff.text_diff : []).filter((l): l is string => typeof l === 'string')
|
||||
return {
|
||||
original: lines.filter((l) => l.startsWith('- ')).map((l) => l.slice(2)),
|
||||
blind: lines.filter((l) => l.startsWith('+ ')).map((l) => l.slice(2)),
|
||||
}
|
||||
return { original: normSide(lines, '- '), blind: normSide(lines, '+ ') }
|
||||
}, [metric])
|
||||
const withContext = norm.original.some((l) => l.context) || norm.blind.some((l) => l.context)
|
||||
const differing = pairs.filter((p) => p.differs)
|
||||
return (
|
||||
<div id={id} className="mt-1.5 min-w-0 space-y-2" data-dual-compare="">
|
||||
@@ -784,7 +813,9 @@ function Compare({ id, metric }: { id: string; metric: DualCheckMetric }) {
|
||||
{(differing.length ? differing : pairs).map((p) => <PairSql key={p.path} pair={p} />)}
|
||||
{(norm.original.length > 0 || norm.blind.length > 0) && (
|
||||
<div data-dual-text-diff="">
|
||||
<div className="mb-0.5 text-2xs text-faint">{T.textDiff}</div>
|
||||
<div className="mb-0.5 text-2xs text-faint">
|
||||
{T.textDiff}{withContext && <span data-dual-text-diff-hint="">{` · ${T.textDiffContext}`}</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 @lg:grid-cols-2">
|
||||
<NormPane side="original" lines={norm.original} />
|
||||
<NormPane side="blind" lines={norm.blind} />
|
||||
@@ -866,8 +897,11 @@ function SqlPane({ side, label, lines, marks }: { side: 'original' | 'blind'; la
|
||||
)
|
||||
}
|
||||
|
||||
/** 规范化后不同的行的一栏:这一边的「- 」(原流程)或「+ 」(盲算)行,每行高亮;这一边一行都没有时照实说 */
|
||||
function NormPane({ side, lines }: { side: 'original' | 'blind'; lines: string[] }) {
|
||||
/**
|
||||
* 规范化后不同的行的一栏:这一边的「- 」(原流程)或「+ 」(盲算)行,每行高亮;前面带着的上级行(两边相同,标出在
|
||||
* 哪个子句里)不高亮、用淡一级的字色,同原文栏里没标的行;这一边一行都没有时照实说
|
||||
*/
|
||||
function NormPane({ side, lines }: { side: 'original' | 'blind'; lines: NormLine[] }) {
|
||||
const label = side === 'original' ? DUAL_CHECK_TEXT.original : DUAL_CHECK_TEXT.blind
|
||||
return (
|
||||
<figure className="m-0 min-w-0" data-dual-norm={side}>
|
||||
@@ -875,11 +909,17 @@ function NormPane({ side, lines }: { side: 'original' | 'blind'; lines: string[]
|
||||
<div className={clsx('max-h-48', CODE_BOX, lines.length ? 'mono' : 'font-sans text-faint')} tabIndex={0} role="group"
|
||||
aria-label={`${T.textDiff} · ${label}`}>
|
||||
{lines.length
|
||||
? lines.map((line, i) => (
|
||||
<mark key={i} className="mb-px block rounded-sm px-1 last:mb-0" style={MARK} data-diff-line={side === 'original' ? 'del' : 'add'}>
|
||||
{line || ' '}
|
||||
</mark>
|
||||
))
|
||||
? lines.map((line, i) => (line.context
|
||||
? (
|
||||
<span key={i} className="mb-px block px-1 text-dim last:mb-0" title={T.textDiffContext} data-diff-context="">
|
||||
{line.text || ' '}
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<mark key={i} className="mb-px block rounded-sm px-1 last:mb-0" style={MARK} data-diff-line={side === 'original' ? 'del' : 'add'}>
|
||||
{line.text || ' '}
|
||||
</mark>
|
||||
)))
|
||||
: <span className="px-1">{T.textDiffNone}</span>}
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
@@ -3656,7 +3656,10 @@ export interface DualCheckDiff {
|
||||
items: { aspect: string; text: string }[]
|
||||
/** 用了兜底说明(一条具体说法都没有,或者具体说法只覆盖了一部分) */
|
||||
fallback: boolean
|
||||
/** 规范化之后两条 SQL 不同的行:「- 」开头是原流程的,「+ 」开头是盲算的 */
|
||||
/**
|
||||
* 规范化之后两条 SQL 不同的行:「- 」开头是原流程的,「+ 」开头是盲算的;「 」开头的是两边相同的上级行,只为标出
|
||||
* 不同的行在哪个子句里(老工件里没有)
|
||||
*/
|
||||
text_diff: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -578,12 +578,15 @@ async function reply(state, method, path, url, body) {
|
||||
return { json: { fire: f, run_id: 'run-new-1' } }
|
||||
}
|
||||
if (method === 'GET' && rest === '/fires') {
|
||||
// 游标照服务端的写法:最后一条的记录时间,UTC 写成 Z 结尾;legacyCursor 时写成老服务端的 +00:00(「+」不编码
|
||||
// 放进查询串会被解成空格,页面得照样翻得过去)
|
||||
const cursor = (f) => (state.legacyCursor ? f.created_at.replace(/Z$/, '+00:00') : f.created_at)
|
||||
const limit = Number(url.searchParams.get('limit') ?? 20)
|
||||
const before = url.searchParams.get('before')
|
||||
const start = before ? state.fires.findIndex((f) => f.fire_at === before) + 1 : 0
|
||||
const start = before ? state.fires.findIndex((f) => cursor(f) === before) + 1 : 0
|
||||
const items = state.fires.slice(start, start + limit)
|
||||
const more = start + limit < state.fires.length
|
||||
return { json: { items, next_before: more ? items.at(-1).fire_at : null } }
|
||||
return { json: { items, next_before: more ? cursor(items.at(-1)) : null } }
|
||||
}
|
||||
if (method === 'POST' && rest === '/webhook/test') return { json: state.webhookTest ?? WEBHOOK_TEST_OK }
|
||||
}
|
||||
@@ -1439,6 +1442,19 @@ await section('详情:触发历史分页', async () => {
|
||||
.some((x) => x.key === 'POST /schedules/preview'))
|
||||
check('没有页面报错', !errors.length, errors.join(' | '))
|
||||
|
||||
// 老服务端给的游标带 +00:00:页面照样编码成 %2B 传回去,第二页接得上、不重复
|
||||
const legacy = await open('/schedules/s-weekly?tab=history', { state: freshState({ legacyCursor: true }) })
|
||||
await legacy.page.locator('[data-fire-id]').first().waitFor()
|
||||
await legacy.page.locator('[data-fire-more]').click()
|
||||
await until(async () => (await legacy.page.locator('[data-fire-id]').count()) === 25)
|
||||
const old = legacy.sent.filter((s) => s.key === 'GET /schedules/s-weekly/fires').at(-1)
|
||||
const want = makeFires()[19].created_at.replace(/Z$/, '+00:00')
|
||||
check('老服务端的 +00:00 游标:编码后原样传回(「+」写成 %2B)', (old?.search ?? '').includes('%2B')
|
||||
&& new URLSearchParams(old?.search ?? '').get('before') === want, old?.search ?? '')
|
||||
check('……接在后面,共 25 条、没有重复的行', new Set(await legacy.page.locator('[data-fire-id]')
|
||||
.evaluateAll((els) => els.map((e) => e.getAttribute('data-fire-id')))).size === 25)
|
||||
check('……没有页面报错', !legacy.errors.length, legacy.errors.join(' | '))
|
||||
|
||||
const none = freshState({ fires: [] })
|
||||
const e = await open('/schedules/s-stock?tab=history', { state: none })
|
||||
await e.page.locator('[data-fire-empty]').waitFor()
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// - 审批卡:两路对不上时,卡上要先按摘要画出来(完整对照还在路上时不能一片空白),再按工件补全;具体差异逐条列,
|
||||
// 只有兜底说明(或只有解析失败)时写明「未能归纳成具体差异」并默认展开对照;对照两条 SQL 时左右并排(窄栏上下排),
|
||||
// 原文按行比出的不同行高亮,规范化后不同的行(text_diff)分两栏高亮——夹具里的差异说明是服务端的真实输出
|
||||
// (backend/tests/test_dual_check_fixture.py 核对),规范化的行和原文对不上,高亮不能指望两边逐行相等;
|
||||
// (backend/tests/test_dual_check_fixture.py 核对),规范化的行和原文对不上,高亮不能指望两边逐行相等;不同的行
|
||||
// 前面带着标出所在子句的上级行(两边相同、不高亮,只放进用得着它的那一栏),只差一个关联条件时看得出是哪个 JOIN 的;
|
||||
// 「记入数据目录」不能说得比服务端宽:服务端在摘要里预告了采用哪一边写什么(payload.summary[].catalog)时照它说,
|
||||
// 两边都不写就不提,当前选的那一句加重;同一次裁决里别的指标按当前的选择落到同一张表的同一项上时,照服务端写目录前
|
||||
// 的归拢说——业务日期、去重规则说法不同的改说都不记,有效记录条件说合成一句;老服务端没有预告时只在可能写的形状下
|
||||
@@ -297,7 +298,7 @@ await section('审批卡:并排对照与高亮(服务端真实的差异输
|
||||
const blind = gmv.locator('[data-dual-sql="blind"]')
|
||||
check('两条 SQL 都在,各标是哪一边', (await text(orig)).includes('paid_at') && (await text(blind)).includes('created_at')
|
||||
&& (await text(gmv.locator('[data-dual-pair]'))).includes('原流程的 SQL') && (await text(gmv.locator('[data-dual-pair]'))).includes('盲算的 SQL'))
|
||||
// 夹具的 text_diff 是 explain_sql_diff 的真实输出:规范化后列名带表名、两个条件并成一行,和原文的任何一行都对不上
|
||||
// 夹具的 text_diff 是 explain_sql_diff 的真实输出:规范化后列名带表名、每个条件一行,和原文的任何一行都对不上
|
||||
const real = sidesOf(fx.card.metrics[0].diff)
|
||||
const rawLines = new Set(fx.card.metrics[0].original.inputs[0].sql.split('\n').map((l) => l.trim()))
|
||||
check('(夹具)text_diff 是规范化后的行,和原文逐行对不上', real.del.length > 0 && !real.del.some((l) => rawLines.has(l)), real.del.join(' | '))
|
||||
@@ -309,6 +310,16 @@ await section('审批卡:并排对照与高亮(服务端真实的差异输
|
||||
check('……盲算一栏是「+ 」行', marks.normAdd.join('|') === real.add.join('|'), marks.normAdd.join('|'))
|
||||
const hl = await gmv.locator('[data-dual-norm] [data-diff-line]').first().evaluate((el) => getComputedStyle(el).backgroundColor)
|
||||
check('……高亮行有底色', !!hl && hl !== 'rgba(0, 0, 0, 0)' && hl !== 'transparent', hl)
|
||||
// 不同的行前面带着两边相同的上级行(这里是 WHERE:两边不同的条件都在 WHERE 里):两栏各在自己的不同行前面列一遍,
|
||||
// 不高亮、不算进高亮的行;标题后面说明淡色的行是什么
|
||||
const normRows = await gmv.evaluate((el) => ['original', 'blind'].map((s) => [...el.querySelectorAll(
|
||||
`[data-dual-norm="${s}"] [data-diff-context], [data-dual-norm="${s}"] [data-diff-line]`)]
|
||||
.map((n) => (n.hasAttribute('data-diff-context') ? `(${n.textContent.trim()})` : n.textContent.trim()))))
|
||||
check('……两边相同的上级行(WHERE)列在两栏的不同行前面', normRows.every((rows, n) => rows[0] === '(WHERE)'
|
||||
&& rows.slice(1).join('|') === (n ? real.add : real.del).join('|')), JSON.stringify(normRows))
|
||||
const ctxStyle = await gmv.locator('[data-dual-norm] [data-diff-context]').first().evaluate((el) => [el.tagName, getComputedStyle(el).backgroundColor])
|
||||
check('……上级行不是高亮的 <mark>、没有底色', ctxStyle[0] === 'SPAN' && (ctxStyle[1] === 'rgba(0, 0, 0, 0)' || ctxStyle[1] === 'transparent'), ctxStyle.join(' '))
|
||||
check('……标题后面说明淡色的行两边相同、标出所在的子句', (await text(gmv.locator('[data-dual-text-diff-hint]'))).includes('淡色的行两边相同,标出所在的子句'))
|
||||
const o = await orig.boundingBox()
|
||||
const b = await blind.boundingBox()
|
||||
check('宽栏里左右并排:同一行、盲算在右边', !!o && !!b && Math.abs(o.y - b.y) < 2 && b.x >= o.x + o.width - 1, JSON.stringify({ o, b }))
|
||||
@@ -351,6 +362,54 @@ await section('审批卡:并排对照与高亮(服务端真实的差异输
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
await section('审批卡:规范化后的行带上所在的子句(只差一个关联条件)', async () => {
|
||||
// 2026-10 复核:ON 单独一行时,只差一个关联条件的那一栏只剩「AND 条件」,看不出是哪个 JOIN 的关联条件、还是筛选条件;
|
||||
// 两边 JOIN 的表不同时,两边都有的那一行 ON 被当成相同的行吃掉。现在 ON 挂在 JOIN 那一行的末尾,条件前面带着它所在的
|
||||
// JOIN(两边相同的上级行,淡一级的字色、不高亮);盲算一边没有不同的行,照实说没有,不把只有原流程用得着的那一行
|
||||
// JOIN 画进去。夹具的 join_condition 是服务端对这一对 SQL 的真实输出(test_dual_check_fixture 核对)
|
||||
const td = fx.variants.join_condition.diff.text_diff
|
||||
check('(夹具)只差一个关联条件:一行带 ON 的上级行加一行不同的行', td.length === 2 && td[0] === ' LEFT JOIN refunds AS refunds ON'
|
||||
&& td[1].startsWith('- '), td.join(' | '))
|
||||
for (const theme of ['dark', 'light']) {
|
||||
const { page, errors } = await preview('dual-check-approval', { artifact: cardWith('join_condition'), theme })
|
||||
await page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
const gmv = metric(page, 'gmv')
|
||||
// 只给兜底说明时对照默认展开
|
||||
const toggle = gmv.locator('[data-dual-compare-toggle]')
|
||||
if ((await toggle.getAttribute('aria-expanded')) !== 'true') await toggle.click()
|
||||
const rows = await gmv.locator('[data-dual-norm="original"]').evaluate((el) => [...el.querySelectorAll('[data-diff-context], [data-diff-line]')]
|
||||
.map((n) => ({ text: n.textContent, context: n.hasAttribute('data-diff-context'), tag: n.tagName })))
|
||||
check(`${theme}:原流程一栏先是带 ON 的 JOIN 那一行(上级行),下面是缩进的那一个关联条件(高亮)`, rows.length === 2
|
||||
&& rows[0].context && rows[0].tag === 'SPAN' && rows[0].text === 'LEFT JOIN refunds AS refunds ON'
|
||||
&& !rows[1].context && rows[1].tag === 'MARK' && rows[1].text === ` ${td[1].slice(2).trim()}`, JSON.stringify(rows))
|
||||
const other = gmv.locator('[data-dual-norm="blind"]')
|
||||
check(`${theme}:盲算一栏照实说没有不同的行,不画只有原流程用得着的上级行`, (await text(other)).includes('这一边没有不同的行')
|
||||
&& await other.locator('[data-diff-context], [data-diff-line]').count() === 0, await text(other))
|
||||
// 读得清:上级行的字色叠在代码框的底色上
|
||||
const c = await gmv.locator('[data-dual-norm="original"] [data-diff-context]').evaluate((el) => {
|
||||
let bg = 'rgba(0, 0, 0, 0)'
|
||||
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
|
||||
const v = getComputedStyle(n).backgroundColor
|
||||
if (v !== 'rgba(0, 0, 0, 0)' && v !== 'transparent') { bg = v; break }
|
||||
}
|
||||
return { fg: getComputedStyle(el).color, bg, base: getComputedStyle(document.body).backgroundColor, title: el.title }
|
||||
})
|
||||
const ratio = contrast(c.fg, c.bg, c.base)
|
||||
check(`${theme}:上级行的字对比度 ≥ 4.5`, ratio >= 4.5, `${ratio.toFixed(2)}(${c.fg} on ${c.bg})`)
|
||||
check(`${theme}:悬停上级行说明它两边相同、标出所在的子句`, c.title === '淡色的行两边相同,标出所在的子句', c.title)
|
||||
await shot(page, `approval-join-context-${theme}`)
|
||||
check(`${theme}:页面没有报错`, errors.length === 0, errors.join(';'))
|
||||
}
|
||||
// 390px:标题后面的说明和带 ON 的 JOIN 那一行折行显示,不横向溢出
|
||||
const narrow = await preview('dual-check-approval', { artifact: cardWith('join_condition'), width: 390, height: 844 })
|
||||
await narrow.page.waitForSelector('[data-dual-check][data-state="ready"]')
|
||||
const nt = metric(narrow.page, 'gmv').locator('[data-dual-compare-toggle]')
|
||||
if ((await nt.getAttribute('aria-expanded')) !== 'true') await nt.click()
|
||||
await narrow.page.waitForSelector('[data-dual-text-diff-hint]')
|
||||
const w = await narrow.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
check('390px:带着上级行和说明也不横向溢出', w <= 0, `${w}px`)
|
||||
})
|
||||
|
||||
/**
|
||||
* 把「销售额」换成 variants 里的一对 SQL 及其真实的差异说明(取值照旧不同),别的指标原样;diff 给了就用给的
|
||||
* (拼几个输入的、兜底的原话)。peer 给了就把「退款率」对不上的那个输入同样换成 peers 里的一对 SQL
|
||||
@@ -462,6 +521,18 @@ await section('审批卡:没有预告时,「记入数据目录」只说必
|
||||
if (!real.add.length) {
|
||||
check('……一边没有不同的行时照实说', (await text(gmv.locator('[data-dual-norm="blind"]'))).includes('没有不同的行'))
|
||||
}
|
||||
// 规范化后每个条件一行(2026-10 验收:两个条件排在一行、三个拆成三行,两边相同的日期条件因为换行的位置不同被
|
||||
// 标成了差异)。只差结束日期时,两边一样的开始日期不标,只标结束日期那一行
|
||||
if (name === 'fallback') {
|
||||
const marked = [...m.normDel, ...m.normAdd]
|
||||
check('……两边相同的条件(开始日期)不标,只标不同的那一行', m.normDel.length === 1 && m.normAdd.length === 1
|
||||
&& !marked.some((l) => l.includes(">= '2026-09-01'"))
|
||||
&& marked.every((l) => l.includes("orders.paid_at < '2026-09-")), marked.join(' ‖ '))
|
||||
}
|
||||
if (name === 'two_filters') {
|
||||
check('……一边多出的两个条件各占一行', m.normDel.includes("orders.channel = 'web'")
|
||||
&& m.normDel.includes("AND orders.status <> 'refunded'"), m.normDel.join(' | '))
|
||||
}
|
||||
}
|
||||
check(`${what}:页面没有报错`, errors.length === 0, errors.join(';'))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user