Files
AgentLab/backend/tests/test_sql_parameter_integrity.py
T
ThearchyHelios 2f90f0eb3a 口径台账:固定执行、验证审阅发布与增量重导入
- QueryCore 固定执行(K09):按已发布的不可变口径跑一次,交回类型化标量、发布/manifest 引用与逐查询证据
- 依赖闭包(K08):按选中的指标算出要拷的取数节点、要的入口字段,接进 derive / compose / metrics
- SQL 模板参数完整性(K01):试跑、组合问数、派生图、运行时取数共用一套语义,缺参数在渲染前就拒
- 受限字段变更服务(K03)与口径领域边界(K01):改动打在整图深拷贝上,身份与证据归属不可改
- 版本化受控模板绑定(K04):业务口径按显式绑定落到 SQL,对不上就报 unsupported / needs_update
- 验证记录(K05)与最小审阅:样例匹配不等于正式批准,审批人只从服务端身份取
- 发布依据清单(K07):发布时按不可变图物化内容寻址清单,正式用途按它判
- 增量重导入(K11)与 B/L/R 三方合并、受限自然语言修改提案(K12):只产候选,落图仍走既有写路径
- 接口与前端:口径草稿页接上字段变更、绑定状态、验证与重导入,新增 caliber-drafts / studio 检查脚本

验证:前端 tsc -b 通过。
2026-11-10 15:05:18 +08:00

634 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SQL 模板参数完整性(开工任务书 v3 · K01,§4.4):ledger/params 一套语义,各入口同一结果。
- 认模板里的参数(引号里的字 / 裸写 / 注释里)、未声明的字段、缺值的字段(含声明成可选、又没有缺省值的);写在表名列名
里、认不出写法的照实拒(单元一层,函数返回机器码);
- 组合式问数拷进来的取数节点:值在拷的时候渲染进 SQL(只渲染这一次),节点上写一份与**这条渲染好的 args** 绑定的
参数契约:跑一次真实的问数运行,断言**实际发给数据库的那条 SQL** 就带着值里的 {{ }} 文本(字面量,不再被解释);
- 契约与它注解的语句绑定哈希:复制走、改过 SQL 之后契约作废,那个节点回退成普通模板(正常渲染、正常判参数,带 {{ }}
的语句绝不原样发出去);
- 派生图(口径探针)同样:入口参数值里的 {{ }} 是字面量,各入口同值同语义;
- 缺参数(声明成可选、没有缺省值;开放入口里显式传了空串)、未声明参数:**零 SQL**——数一下 run_query 被调了几次,就是 0;
显式空串算没给,作者声明的 `default=""` 才算声明式省略(两条都有真实运行反例);
- 查询快照记源模板哈希、规范化输入哈希、参数契约与写在哪儿,承载方式照实写 inline_text(不假装驱动绑定);
- 对账图里「这次的取数」放的是执行过的语句:运行时的模板渲染不再碰它。
数据是虚构店铺库(tests/ledger_samples.py)。
"""
from __future__ import annotations
import hashlib
import pytest
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import Artifact
from app.engine.runner import run_manager
from app.ledger import compose, derive, params
from app.ledger import match as ledger_match
from app.look.calibers import CaliberKind
from tests import ledger_samples as S
TEMPLATE = S.TEMPLATE
@pytest.fixture(autouse=True)
async def engine_up(tmp_path):
await run_manager.setup()
await S.fresh_source(tmp_path / "shop.db")
yield
await ledger_match.drain()
await run_manager.shutdown()
@pytest.fixture
def executed(monkeypatch) -> list[str]:
"""这一次测试里真正发给数据库的语句(内联渲染之后的那一条),按先后。"""
from app.tools import datasource
seen: list[str] = []
real = datasource.run_query
async def spy(source, sql, **kw):
seen.append(sql)
return await real(source, sql, **kw)
monkeypatch.setattr(datasource, "run_query", spy)
return seen
async def query_snapshots(run_id: str) -> list[dict]:
"""这次运行落下的查询快照内容(按工件 id)。"""
from app.core.artifact_store import load
from app.data.catalog import QUERY_SNAPSHOT_KIND
async with SessionLocal() as session:
rows = (await session.execute(select(Artifact.id).where(
Artifact.run_id == run_id, Artifact.kind == QUERY_SNAPSHOT_KIND))).scalars().all()
return [payload for payload in (load(rid) for rid in rows) if isinstance(payload, dict)]
# ==========================================================================
# 认得模板里的参数(单元一层:机器码 + 写在哪儿)
# ==========================================================================
def test_parameters_are_read_where_they_are_written():
slots = params.slots_of("SELECT * FROM t WHERE d >= '{{ input.start }}' AND n > {{ input.n }} "
"AND r = '{{ input.region | trim }}' -- {{ input.note }}")
assert [(s.field, s.context, s.filters) for s in slots] == [
("start", "quoted", ()), ("n", "bare", ()), ("region", "quoted", ("trim",)), ("note", "comment", ())]
@pytest.mark.parametrize(("sql", "words"), [
('SELECT * FROM "{{ input.table }}"', "表名或列名"),
("SELECT {{ vars.pick }} FROM t", "运行时才有的变量"),
("SELECT {{ file_text(input.f) }} FROM t", "附件"),
("SELECT * FROM t WHERE a = '{{ input.x | upper }}'", "upper"),
("SELECT {{ input }} FROM t", "认不出"),
])
def test_unreadable_parameter_writings_are_refused(sql, words):
problems = params.check_template(sql, fields={"x": {"name": "x"}}, values={"x": "v"})
assert [p.code for p in problems] == [params.UNSUPPORTED] and words in problems[0].message
def test_undeclared_and_missing_are_two_different_findings():
fields = {"start": {"name": "start", "required": True}}
problems = params.check_template("SELECT '{{ input.start }}', '{{ input.nope }}'", fields=fields, values={},
where="口径「周销售」的入口")
assert [(p.code, p.name) for p in problems] == [(params.MISSING, "start"), (params.UNDECLARED, "nope")]
assert "(undeclared_parameter)" in problems[1].message and "nope" in problems[1].message
assert "不能当空串用(missing_parameter)" in problems[0].message
# 模板和声明对得上(还没到判值的那一步):读一版口径时只判这一层
assert params.check_template("SELECT '{{ input.start }}'", fields=fields) == []
def test_an_optional_parameter_without_a_default_is_missing_not_empty():
fields = {"store": {"name": "store"}}
[problem] = params.check_template("SELECT * FROM t WHERE s = '{{ input.store }}'", fields=fields, values={})
assert problem.code == params.MISSING and problem.name == "store"
# 声明了缺省值(含 `""`:作者写的就是空值不过滤)就不算缺
assert params.check_template("SELECT * FROM t WHERE s = '{{ input.store }}'",
fields={"store": {"name": "store", "default": ""}}, values={}) == []
assert params.check_template("SELECT * FROM t WHERE s = '{{ input.store }}'",
fields={"store": {"name": "store", "default": "all"}}, values={}) == []
@pytest.mark.parametrize(("sql", "value", "code", "words"), [
("SELECT * FROM t WHERE s = '{{ input.x }}'", "O'Brien", params.LITERAL, "值里不能有引号"),
("SELECT * FROM t WHERE s = '{{ input.x }}'", "a\\b", params.LITERAL, "反斜杠"),
("SELECT * FROM t WHERE n > {{ input.x }}", "1 OR 1=1", params.LITERAL, "只能是数"),
("SELECT * FROM t -- {{ input.x }}", "*/ DROP TABLE t", params.LITERAL, "注释"),
])
def test_values_the_inline_adapter_cannot_carry_are_refused(sql, value, code, words):
fields = {"x": {"name": "x"}}
[problem] = params.check_template(sql, fields=fields, values={"x": value})
assert problem.code == code and words in problem.message
def test_one_field_with_two_slots_reports_one_problem():
"""同一个字段写了两处槽位(引号里一处、裸写一处):承载不了只报一条,不重复报。"""
[problem] = params.check_template("SELECT '{{ input.x }}' AS a, {{ input.x }} AS b",
fields={"x": {"name": "x"}}, values={"x": "O'Brien"})
assert (problem.code, problem.name) == (params.LITERAL, "x")
@pytest.mark.parametrize(("sql", "fields", "dynamic"), [
("SELECT '{{ input.store }}'", ("store",), ()),
("SELECT '{{ input['store'] }}'", ("store",), ()),
('SELECT {{ input["store"] }}', ("store",), ()),
("SELECT {{ input[0] }}", ("0",), ()),
("SELECT {{ input.rows[0].name }}", ("rows",), ()),
("SELECT '{{ input['store'] | trim }}'", ("store",), ()),
("SELECT {{ file_text(input['附件']) }}", ("附件",), ()),
("SELECT {{ input }}", (), ("input",)),
("SELECT '{{ input['a.b'] }}'", (), ("input['a.b']",)),
("SELECT {{ input[a[b]] }}", (), ("input[a[b]]",)),
("SELECT {{ input x }}", (), ("input x",)),
("SELECT {{ inputs.x }}, {{ input_value }}", (), ()),
("SELECT {{ vars.region }}, {{ nodes.q.text }}", (), ()),
])
def test_input_references_follow_the_same_path_walk_as_resolve_path(sql, fields, dynamic):
"""运行时认的入口引用和 engine/expressions.resolve_path 一个走法:点号与合法下标都算字段引用(取第一层),
认不出的写法(会渲染成空串)单独交出来——vars./nodes. 不归这一趟管。"""
assert params.input_refs(sql) == fields
assert params.dynamic_input_writings(sql) == dynamic
assert params.mentions_input(sql) is bool(fields or dynamic)
def test_declared_types_are_normalized_without_looking_at_the_value():
assert params.field_type({"type": "integer"}) == params.field_type({"type": "int"}) == "integer"
assert params.field_type({}) == params.field_type({"type": "nonsense"}) == "string"
assert params.value_kind(3) == "number" and params.value_kind("3") == "text" and params.value_kind(None) == "null"
# ==========================================================================
# 组合式问数:值只渲染一次(E03)
# ==========================================================================
async def composed_answer_graph(workflow_id: str, inputs: dict) -> dict:
"""把口径拷进问数据的图(同 tests/test_ledger_compose.py 的做法,这里直接走 compose):args 是字面语句 + 参数契约。"""
from app.ledger import compose
async with SessionLocal() as session:
kind = await CaliberKind.load(session, compose=True)
made = compose.Composer(list(kind.options)).compose(
{"op": "use_caliber", "id": "c1", "workflow_id": workflow_id, "version": 1, "metrics": ["gmv"],
"inputs": inputs}, taken=set())
return S.chain(S.node("start", "input", fields=[{"name": "question"}]), *made.nodes,
S.node("write", "report", instructions="写周销售"),
S.node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}]))
async def test_a_value_that_looks_like_a_template_stays_literal_in_the_executed_sql(monkeypatch, executed):
"""E03:参数值里的 {{ }} 是数据,不被二次解释。
这里挑的是**在答数图里取得出值**的那一种写法({{ vars.question }} 指向入口字段):万一运行时又渲染一遍,值会被换成
问题的原文,SQL 就被数据改掉了——正是这条反例要拦住的那条路。"""
wf = await S.publish()
graph = await composed_answer_graph(wf, {"start": "{{ vars.question }}", "end": "2026-09-08"})
S.script(monkeypatch, "本周销售额 [[m:gmv]]。")
run = await run_manager.start(graph=graph, input_payload={"question": "九月第一周卖了多少"}, origin_kind="chat")
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
[sql] = executed
assert sql == TEMPLATE.replace("{{ input.start }}", "{{ vars.question }}").replace("{{ input.end }}", "2026-09-08")
assert "'{{ vars.question }}'" in sql and "九月第一周卖了多少" not in sql
async def test_a_value_naming_a_variable_that_does_not_exist_runs_as_a_literal(monkeypatch, executed):
"""反例的另一半:值里写的是图里根本没有的变量({{ vars.region }})——照样按字面量跑,不判成「没人产出的引用」。
画布上那条变量校验(engine/variables)会把节点配置里带 {{ }} 的字当成活引用;组合式问数拷进来的取数节点是服务端
渲染好的**数据**,它不该被那条规则拦下——否则「值里恰好有几个花括号」就等于这张图跑不了。"""
wf = await S.publish()
graph = await composed_answer_graph(wf, {"start": "{{ vars.region }}", "end": "2026-09-08"})
S.script(monkeypatch, "本周销售额 [[m:gmv]]。")
run = await run_manager.start(graph=graph, input_payload={"question": "九月第一周卖了多少"}, origin_kind="chat")
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
[sql] = executed
assert "'{{ vars.region }}'" in sql and "'2026-09-08'" in sql
async def test_a_copied_node_edited_back_into_a_template_renders_normally(monkeypatch, executed):
"""复制一个拷进来的取数节点、把 SQL 改回普通模板:契约作废,回退正常渲染——只渲染一次,不发带 {{ }} 的语句。"""
wf = await S.publish()
graph = await composed_answer_graph(wf, {"start": "2026-09-01", "end": "2026-09-08"})
fetch = next(n for n in graph["nodes"] if n["id"] == "c1_fetch")
assert params.literal_keys(fetch["data"]["config"], "tool") == ("args",)
fetch["data"]["config"]["args"]["sql"] += " AND '{{ input.question }}' <> ''"
assert params.literal_keys(fetch["data"]["config"], "tool") == ()
S.script(monkeypatch, "本周销售额 [[m:gmv]]。")
run = await run_manager.start(graph=graph, input_payload={"question": "九月第一周卖了多少"}, origin_kind="chat")
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
[sql] = executed
assert "'九月第一周卖了多少'" in sql and "{{" not in sql
async def test_a_copied_node_with_an_incomplete_edit_sends_no_sql(monkeypatch, executed):
"""复制后改过的 SQL 引用了入口声明过、这次没给的参数:运行时按同一套参数完整性拒,零 SQL。
契约在这里也帮不上忙(哈希对不上就当没写):走的是普通模板那条路——渲染前判缺参数,绝不把带 {{ }} 的语句发出去。"""
wf = await S.publish()
graph = await composed_answer_graph(wf, {"start": "2026-09-01", "end": "2026-09-08"})
start = next(n for n in graph["nodes"] if n["id"] == "start")
start["data"]["config"]["fields"].append({"name": "store"}) # 可选、没有缺省值
fetch = next(n for n in graph["nodes"] if n["id"] == "c1_fetch")
fetch["data"]["config"]["args"]["sql"] += " AND region = '{{ input.store }}'"
assert params.literal_keys(fetch["data"]["config"], "tool") == ()
S.script(monkeypatch, "本周销售额 [[m:gmv]]。")
run = await run_manager.start(graph=graph, input_payload={"question": "九月第一周卖了多少"}, origin_kind="chat")
row = await S.wait(run.id)
assert row.status == "failed" and "store" in row.error and "missing_parameter" in row.error, row.error
assert executed == []
async def test_the_probe_path_renders_a_template_like_value_once_too(executed):
"""派生图(口径探针、试跑走的就是它)同值同语义:入口值里的 {{ }} 也是字面量。"""
wf = await S.publish()
async with SessionLocal() as session:
pinned = await derive.load_pinned(session, wf, 1, "card")
run = await run_manager.start(graph=derive.caliber_probe(pinned).graph,
input_payload={"start": "{{ vars.region }}", "end": "2026-09-08"})
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
[sql] = executed
assert "'{{ vars.region }}'" in sql and "'2026-09-08'" in sql
async def test_the_query_snapshot_records_the_template_hash_types_and_no_fake_binding(monkeypatch, executed):
"""快照记源模板哈希、规范化输入哈希、参数契约与写在哪儿;承载方式照实写内联(不假装驱动绑定)。"""
wf = await S.publish()
graph = await composed_answer_graph(wf, {"start": "2026-09-01", "end": "2026-09-08"})
S.script(monkeypatch, "本周销售额 [[m:gmv]]。")
run = await run_manager.start(graph=graph, input_payload={"question": "九月第一周卖了多少"}, origin_kind="chat")
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
[payload] = await query_snapshots(run.id)
material = payload["parameters"]
assert material["adapter"] == params.ADAPTER == "inline_text"
assert material["binding"] == "values_in_statement"
# 源模板正文不在节点上(engine/variables 会扫节点配置里的 {{ }}):记哈希 + 那一版取数节点的固定引用,
# 模板本体按引用从不可变版本快照还原,哈希核对
assert material["template_hash"] == hashlib.sha256(TEMPLATE.encode("utf-8")).hexdigest()
assert len(material["inputs_hash"]) == 64 and "template" not in material
assert [(p["name"], p["kind"], p["declared"], p["slot"]) for p in material["parameters"]] == [
("start", "text", "string", "quoted"), ("end", "text", "string", "quoted")]
# 契约跟着材料一起记;绑定值不另存一份:它就是内联进实际语句里的那些字,实际语句本身在快照里
assert [d["name"] for d in material["declared"]] == ["start", "end", "store"]
assert set(material) == {"adapter", "binding", "template_hash", "inputs_hash", "declared", "parameters",
"source"}
assert payload["sql"] == TEMPLATE.replace("{{ input.start }}", "2026-09-01").replace("{{ input.end }}",
"2026-09-08")
assert material["source"]["node_id"] == "fetch" and material["source"]["workflow_version"] == 1
# ==========================================================================
# 缺参数、未声明参数:零 SQL(E04)
# ==========================================================================
def optional_field_graph(sql: str, fields: list[dict]) -> dict:
graph = S.caliber_graph(sql=sql)
graph["nodes"][0]["data"]["config"]["fields"] = fields
return graph
async def test_a_missing_optional_parameter_sends_no_sql(executed):
"""可选、没有缺省值的参数这次没给:不发 SQL,运行说清是哪个参数(以前渲染成空串照发)。"""
sql = ("SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND region = '{{ input.store }}' "
"AND paid_at >= '{{ input.start }}' AND paid_at < '{{ input.end }}'")
graph = optional_field_graph(sql, [{"name": "start", "required": True}, {"name": "end", "required": True},
{"name": "store"}])
wf = await S.publish(graph=graph)
async with SessionLocal() as session:
pinned = await derive.load_pinned(session, wf, 1, "card")
run = await run_manager.start(graph=derive.caliber_probe(pinned).graph,
input_payload={"start": "2026-09-01", "end": "2026-09-08"})
row = await S.wait(run.id)
assert row.status == "failed" and "store" in row.error and "missing_parameter" in row.error, row.error
assert executed == [] and await query_snapshots(run.id) == []
async def test_a_plain_canvas_query_with_a_missing_optional_parameter_sends_no_sql(executed):
"""普通画布上的一条取数:参数声明成可选、没有缺省值、这次没给——不发 SQL(以前渲染成空串照发)。"""
graph = S.chain(S.node("start", "input", fields=[{"name": "start", "required": True},
{"name": "store"}]),
S.node("q", "tool", tool=S.tool(),
args={"sql": "SELECT COUNT(*) AS n FROM orders WHERE region = '{{ input.store }}' "
"AND paid_at >= '{{ input.start }}'"}),
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
run = await run_manager.start(graph=graph, input_payload={"start": "2026-09-01"})
row = await S.wait(run.id)
assert row.status == "failed" and "store" in row.error and "missing_parameter" in row.error, row.error
assert executed == [] and await query_snapshots(run.id) == []
async def test_an_undeclared_parameter_cannot_be_composed_or_derived():
"""模板引用了入口没声明的字段:读这一版(派生、组合)时就拒,机器码 undeclared_parameter。"""
graph = optional_field_graph("SELECT SUM(amount) AS gmv FROM orders WHERE region = '{{ input.nope }}'",
[{"name": "start", "required": True}, {"name": "end", "required": True}])
wf = await S.publish(graph=graph)
async with SessionLocal() as session:
with pytest.raises(derive.NotDerivable) as caught:
await derive.load_pinned(session, wf, 1, "card")
assert caught.value.code == derive.UNDECLARED_PARAMETER and "nope" in caught.value.message
assert "undeclared_parameter" in caught.value.message
kind = await CaliberKind.load(session, compose=True)
[option] = [o for o in kind.options if o.workflow_id == wf]
assert not option.composable and "undeclared_parameter" in option.reason
async def test_a_query_referencing_a_parameter_nobody_declares_sends_no_sql(executed):
"""取数 SQL 引用了这次运行没有的参数:不发 SQL,说清是哪个参数。
入口一个字段都没声明时(开放入口)engine/variables 不会拦这种引用——运行到取数这一步由参数完整性拦,以前渲染成
空串照发。入口声明了字段的图更早就会被画布校验拦下(那是另一道,见下一条)。"""
graph = S.chain(S.node("start", "input"),
S.node("q", "tool", tool=S.tool(),
args={"sql": "SELECT COUNT(*) AS n FROM orders WHERE region = '{{ input.store }}'"}),
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
run = await run_manager.start(graph=graph, input_payload={"question": "有多少单"})
row = await S.wait(run.id)
assert row.status == "failed" and "store" in row.error and "undeclared_parameter" in row.error, row.error
assert executed == [] and await query_snapshots(run.id) == []
async def test_a_value_that_the_run_provides_is_enough_even_without_a_declared_field(executed):
"""入口没声明字段、值由发起时传进来(开放入口):照常发出 SQL——判断看的是「这次有没有值」,不是只认声明。
这是画布上很常见的写法(入口节点只写「开始」,参数由发起时传),拦掉它会把跑得好的图判成参数不完整。"""
graph = S.chain(S.node("start", "input"),
S.node("q", "tool", tool=S.tool(),
args={"sql": "SELECT COUNT(*) AS n FROM orders WHERE paid_at >= '{{ input.start }}'"}),
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
run = await run_manager.start(graph=graph, input_payload={"start": "2026-09-01"})
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
assert executed == ["SELECT COUNT(*) AS n FROM orders WHERE paid_at >= '2026-09-01'"]
async def test_an_open_entry_with_an_explicit_empty_string_sends_no_sql(executed):
"""开放入口(入口节点一个字段都没声明、键由发起时传)传来空串:算没给,不发 SQL。
空串不是值,是省略——和入口收值(params.provided)同一套,也和「声明了 default="" 才算声明式省略」同一套。以前只认
None,这种图会渲染成 `AND region = ''` 发出去;作者要表达「空值不过滤」,得把字段声明出来并写 `default=""`(下一条用例)。"""
graph = S.chain(S.node("start", "input"),
S.node("q", "tool", tool=S.tool(),
args={"sql": "SELECT COUNT(*) AS n FROM orders WHERE region = '{{ input.store }}'"}),
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
run = await run_manager.start(graph=graph, input_payload={"store": ""})
row = await S.wait(run.id)
assert row.status == "failed" and "store" in row.error and "missing_parameter" in row.error, row.error
assert executed == [] and await query_snapshots(run.id) == []
async def test_a_declared_empty_default_still_runs_in_an_open_entry(executed):
"""作者把字段声明出来、写 `default=""`:那是声明的省略规则,照跑,空串不是「没给」。"""
graph = S.chain(S.node("start", "input", fields=[{"name": "store", "default": ""}]),
S.node("q", "tool", tool=S.tool(),
args={"sql": "SELECT COUNT(*) AS n FROM orders WHERE ('{{ input.store }}' = '' "
"OR region = '{{ input.store }}')"}),
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
run = await run_manager.start(graph=graph, input_payload={})
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
[sql] = executed
assert "{{" not in sql and "('' = ''" in sql
async def test_an_executed_statement_carried_into_a_graph_is_not_templated_again(executed):
"""对账里「这次的取数」:放进去的是执行过的语句,运行时不再当模板解释一遍。
语句文本里挑了在答数图里取得出值的写法({{ vars.question }}):真的被再渲染一次的话,这条 SQL 就变成别的语句了。"""
text = "SELECT SUM(amount) AS gmv FROM orders WHERE region = '{{ vars.question }}' AND id > 1"
block = derive.ours_block(text, S.tool())
assert params.mode_of(block.nodes[0]["data"]["config"]) == params.LITERAL_ARGS
graph = S.chain(S.node("start", "input", fields=[{"name": "question"}]),
block.nodes[0],
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.ours_q1.text }}"}]))
run = await run_manager.start(graph=graph, input_payload={"question": "九月第一周"})
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
assert executed == [text]
async def test_a_bracket_reference_without_a_value_sends_no_sql(executed):
"""`{{ input['store'] }}`(下标写法、SQL 里还嵌着引号)没给值:一样不发 SQL。
运行时的兜底以前只认 `{{ input.字段 }}`,这种写法认不出来 → 渲染成 `''`,发出一条 `AND region = ''`。"""
graph = S.chain(S.node("start", "input", fields=[{"name": "store"}]),
S.node("q", "tool", tool=S.tool(),
args={"sql": "SELECT COUNT(*) AS n FROM orders WHERE region = '{{ input['store'] }}'"}),
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
run = await run_manager.start(graph=graph, input_payload={"store": ""})
row = await S.wait(run.id)
assert row.status == "failed" and "store" in row.error and "missing_parameter" in row.error, row.error
assert executed == [] and await query_snapshots(run.id) == []
async def test_a_bracket_reference_with_a_value_renders_and_runs(executed):
"""`{{ input['store'] }}` 正常给值:照常渲染进 SQL 发出去(不是拒绝一切下标写法)。"""
graph = S.chain(S.node("start", "input", fields=[{"name": "store"}]),
S.node("q", "tool", tool=S.tool(),
args={"sql": "SELECT COUNT(*) AS n FROM orders WHERE region = '{{ input['store'] }}'"}),
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
run = await run_manager.start(graph=graph, input_payload={"store": "east"})
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
assert executed == ["SELECT COUNT(*) AS n FROM orders WHERE region = 'east'"]
async def test_a_bracket_reference_uses_the_declared_default(executed):
"""`{{ input['store'] }}` 配声明缺省值:没给值时按入口那套补上,渲染进去的是缺省值。"""
graph = S.chain(S.node("start", "input", fields=[{"name": "store", "default": "east"}]),
S.node("q", "tool", tool=S.tool(),
args={"sql": 'SELECT COUNT(*) AS n FROM orders WHERE region = \'{{ input["store"] }}\''}),
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
run = await run_manager.start(graph=graph, input_payload={})
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
assert executed == ["SELECT COUNT(*) AS n FROM orders WHERE region = 'east'"]
@pytest.mark.parametrize("sql", ["SELECT COUNT(*) AS n FROM orders WHERE region = '{{ input }}'",
"SELECT COUNT(*) AS n FROM orders WHERE region = '{{ input['a.b'] }}'",
"SELECT COUNT(*) AS n FROM orders WHERE region = '{{ input[a[b]] }}'"])
async def test_an_unreadable_input_writing_sends_no_sql(executed, sql):
"""认不出取哪个字段的入口写法(光秃秃的 `{{ input }}`、下标里带点/套中括号):明确拒,零 SQL。
这些写法 resolve_path 取不到值 → 渲染成空串;当「没引用参数」放过去就等于发出一条条件被抹掉的查询。"""
graph = S.chain(S.node("start", "input", fields=[{"name": "question"}]),
S.node("q", "tool", tool=S.tool(), args={"sql": sql}),
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
run = await run_manager.start(graph=graph, input_payload={"question": "有多少单"})
row = await S.wait(run.id)
assert row.status == "failed" and "unsupported_parameter" in row.error, row.error
assert "看不出取的是哪个字段" in row.error
assert executed == [] and await query_snapshots(run.id) == []
async def test_vars_and_nodes_are_left_alone_in_a_canvas_query(executed):
"""普通模板照旧:取数 SQL 引用 vars./nodes. 不受这一趟影响(照常渲染后发出去)。"""
graph = S.chain(S.node("start", "input"),
S.node("pick", "transform", mode="expression", expression="'east'", assign_to="region"),
S.node("q", "tool", tool=S.tool(),
args={"sql": "SELECT COUNT(*) AS n FROM orders WHERE region = '{{ vars.region }}'"}),
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
run = await run_manager.start(graph=graph, input_payload={})
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
assert executed == ["SELECT COUNT(*) AS n FROM orders WHERE region = 'east'"]
# ==========================================================================
# 可复用的接口(试跑等入口接线用)
# ==========================================================================
def test_resolve_prefers_explicit_values_then_declared_defaults():
"""共享层定这一次的值:显式给的(含 0——0 是值)优先,否则用声明的缺省值(含 0 与空串);没缺省值的照旧缺。"""
fields = [{"name": "region", "default": "east"}, {"name": "min", "default": 0},
{"name": "note", "default": ""}, {"name": "start"}]
assert params.resolve(fields, {"start": "2026-09-01"}) == {
"region": "east", "min": 0, "note": "", "start": "2026-09-01"}
assert params.resolve(fields, {"region": "west", "min": 5, "note": "x", "start": "s"}) == {
"region": "west", "min": 5, "note": "x", "start": "s"}
# 显式空串算没给:回到声明的缺省值;没声明缺省值的(start)不只是空串问题——它压根没值,不在这份里
assert params.resolve(fields, {"region": "", "min": "", "note": "", "start": ""}) == {
"region": "east", "min": 0, "note": ""}
def test_plan_renders_the_declared_default_instead_of_an_empty_string():
"""声明了缺省值、调用方没给:渲染进去的必须是那个缺省值(以前 plan 拿原始 values 渲染,声明了却渲染成空)。"""
template = ("SELECT * FROM orders WHERE region = '{{ input.region }}' AND amount > {{ input.min }} "
"AND note = '{{ input.note }}'")
fields = [{"name": "region", "default": "east"}, {"name": "min", "default": 0}, {"name": "note", "default": ""}]
out = params.plan(template, fields=fields, values={})
assert "'east'" in out.statement and "amount > 0" in out.statement and "note = ''" in out.statement
assert "{{" not in out.statement
# 检查、渲染、材料里的输入哈希用的是同一份 resolved values:显式给同样的值 → 同一份材料
same = params.plan(template, fields=fields, values={"region": "east", "min": 0, "note": ""})
assert same.material["inputs_hash"] == out.material["inputs_hash"]
assert [p["kind"] for p in out.material["parameters"]] == ["text", "number", "text"]
# 覆盖声明:显式给的值优先
override = params.plan(template, fields=fields, values={"region": "west", "min": 10, "note": "x"})
assert "'west'" in override.statement and "amount > 10" in override.statement
assert override.material["inputs_hash"] != out.material["inputs_hash"]
@pytest.mark.parametrize("values", [{}, {"region": ""}, {"region": None}])
def test_plan_without_a_declared_default_still_reports_missing(values):
"""没有声明的缺省值:没给、给了空串、给了 None 都是缺参数(空串不是值)。"""
with pytest.raises(params.ParameterRefused) as caught:
params.plan("SELECT * FROM orders WHERE region = '{{ input.region }}'",
fields=[{"name": "region"}], values=values)
assert caught.value.code == params.MISSING and caught.value.problems[0].name == "region"
def test_plan_renders_once_and_carries_the_snapshot_material():
out = params.plan(TEMPLATE, fields=[{"name": "start", "required": True, "type": "date"},
{"name": "end", "required": True, "type": "date"}],
values={"start": "{{ vars.x }}", "end": "2026-09-08"})
assert "'{{ vars.x }}'" in out.statement
assert out.material["parameters"][0]["declared"] == "date"
with pytest.raises(params.ParameterRefused) as caught:
params.plan(TEMPLATE, fields=[{"name": "start", "required": True}, {"name": "end", "required": True}],
values={"start": "2026-09-01"})
assert caught.value.code == params.MISSING and "end" in caught.value.message
# ==========================================================================
# 只算选中的指标(K08 S2):只选 A 时 B 那条 SQL 不进图,也不要求它的参数
# ==========================================================================
#: buyer 只有 B 那条查询用:只选 A 时这次不必给
FIELDS_AB = [{"name": "start", "required": True}, {"name": "end", "required": True},
{"name": "buyer", "required": True}]
SQL_A = ("SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' "
"AND paid_at >= '{{ input.start }}' AND paid_at < '{{ input.end }}'")
SQL_B = SQL_A + " AND region = '{{ input.buyer }}'"
def two_metric_graph(*, on_missing: bool = True) -> dict:
"""一张卡两个指标、两条各自独立的查询(B 多要一个 buyer)。"""
card: dict = {"caliber": "双指标", "caliber_version": "v1",
"metrics": [{"id": "gmv", "name": "销售额", "expression": "cell(nodes.fetch_a, 0, 'gmv')"},
{"id": "n", "name": "单量", "expression": "cell(nodes.fetch_b, 0, 'n')"}]}
if on_missing:
card["on_missing"] = "null"
return S.chain(S.node("start", "input", fields=FIELDS_AB),
S.node("fetch_a", "tool", tool=S.tool(), args={"sql": SQL_A}),
S.node("fetch_b", "tool", tool=S.tool(), args={"sql": SQL_B}),
S.node("card", "metrics", **card),
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}]))
def shared_graph() -> dict:
"""两个指标读同一条取数(共享 SQL)。"""
return S.chain(S.node("start", "input", fields=FIELDS_AB[:2]),
S.node("fetch", "tool", tool=S.tool(), args={"sql": SQL_A}),
S.node("card", "metrics", caliber="共享取数", caliber_version="v1",
metrics=[{"id": "gmv", "name": "销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"},
{"id": "n", "name": "单量", "expression": "cell(nodes.fetch, 0, 'n')"}]),
S.node("out", "output"))
async def composed_graph(workflow_id: str, *, metrics: list[str] | None = None) -> tuple[dict, "compose.Composed"]:
"""照 test_ledger_compose 的做法:把口径拷进问数据的图,交回 (图, 这次选用)。"""
async with SessionLocal() as session:
kind = await CaliberKind.load(session, compose=True)
made = compose.Composer(list(kind.options)).compose(
{"op": "use_caliber", "id": "c1", "workflow_id": workflow_id, "version": 1, "card": "card",
"metrics": metrics or ["gmv"], "inputs": {"start": "2026-09-01", "end": "2026-09-08"}}, taken=set())
return S.chain(S.node("start", "input", fields=[{"name": "question"}]), *made.nodes,
S.node("write", "report", instructions="写周销售"),
S.node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}])), made
def rendered_a() -> str:
return SQL_A.replace("{{ input.start }}", "2026-09-01").replace("{{ input.end }}", "2026-09-08")
async def test_only_the_selected_metric_s_sql_is_executed(monkeypatch, executed):
"""组合式问数只选 gmv:B 那条查询不进图,实际发给数据库的只有 A 这一条(B 的参数这次不用给)。"""
wf = await S.publish(graph=two_metric_graph())
graph, made = await composed_graph(wf)
assert [n["id"] for n in graph["nodes"] if n["type"] == "tool"] == ["c1_fetch_a"]
assert made.result()["selection"]["metric_ids"] == ["gmv"]
S.script(monkeypatch, "本周销售额 [[m:gmv]]。")
run = await run_manager.start(graph=graph, input_payload={"question": "九月第一周卖了多少"}, origin_kind="chat")
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
assert executed == [rendered_a()] # 一条:B 的零条
async def test_a_shared_query_still_runs_and_declares_the_metric_it_used_to_compute(monkeypatch, executed):
"""A 和 B 共用一条 SQL:选 A 那条照样执行(一次),结果里如实声明它还在算 B。"""
wf = await S.publish(graph=shared_graph())
graph, made = await composed_graph(wf, metrics=["gmv"])
[query] = made.closure.queries
assert query.computes_unselected == ("n",) and made.closure.query_ids == (f"{wf}:fetch",)
S.script(monkeypatch, "本周销售额 [[m:gmv]]。")
run = await run_manager.start(graph=graph, input_payload={"question": "九月第一周卖了多少"}, origin_kind="chat")
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
assert executed == [rendered_a()]
async def test_the_probe_of_a_selection_carries_only_the_selected_query(executed):
"""派生探针按同一份选择裁:入口表单收窄到这次用的(不必给 buyer),拷出来的只有 A 那条查询。"""
wf = await S.publish(graph=two_metric_graph())
async with SessionLocal() as session:
pinned = await derive.load_pinned(session, wf, 1, "card", metric_ids=["gmv"])
probe = derive.caliber_probe(pinned)
assert [n["id"] for n in probe.graph["nodes"]] == [derive.ENTRY_ID, "fetch_a", "card", derive.EXIT_ID]
assert probe.graph["nodes"][0]["data"]["config"]["fields"] == FIELDS_AB[:2]
run = await run_manager.start(graph=probe.graph, input_payload={"start": "2026-09-01", "end": "2026-09-08"})
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
assert executed == [rendered_a()]