2026-10-24 更新:#150/#151 的旧复现已在 Launcher 0.1.39 通过复测,缺失关联键校验拒绝,输入 LIMIT 5 实际生效。当前 7 项执行及 1 项预期拒绝见独立实操记录;以下保留 0.1.30 历史素材,不作为当前版本的缺陷结论。
2026-10-21 更正编排:此前将用户所说的“QM 中 JOIN 两个 TM”误写成了多个 QM 的 Compose 组合。以下保留旧稿与真实证据,迁入高级篇,不作为基础篇第十四章。已有 #150/#151 仍是独立成立的 Compose 问题。
前面的 TM 围绕账单或明细这张事实表,映射客户、曲目、专辑等维度,也能描述第十二章那样的雪花式关联。这样,一个 QM 就能按这些维度分析相应的业务数据。
但现实分析经常涉及多个事实:例如按客户对照销售与回款,或核对账单金额与明细金额。这些事实各有自己的粒度、日期和指标定义,往往需要先按共同的业务键汇总,再关联比较;直接把原始记录连起来,反而可能放大金额。
因此,除了 TM 中预先声明的维度关联,还需要组合多个 QM 的查询。本章通过 Compose 完成:先列出客户与账单,再把账单按客户汇总后关联,最后核对两个事实结果。每个输入仍使用公开的语义字段,由引擎统一校验、编译和执行。
已有账单 QM 可以统计有账单的客户。要把当年没有账单的客户也列出来,需要一个完整客户目录作为左侧。
在原练习目录和会话中发送:
为 Chinook 增加客户目录语义模型,校验并加载。先不查询。
Codex 读取建模 Skill,检查 Customer 表,新增一对文件;原账单和明细模型不用修改。
export const model = {
name: 'ChinookCustomerModel',
caption: 'Chinook 客户目录',
description: '一行一位客户,包括查询期间没有账单的客户;姓名仅用于展示,关联使用客户 ID。',
tableName: 'Customer',
idColumn: 'CustomerId',
properties: [
{ column: 'CustomerId', name: 'customerId', caption: '客户 ID', type: 'INTEGER' },
{
column: 'FirstName', name: 'customerName', caption: '客户姓名', type: 'STRING',
formulaDef: { builder: (alias) => `${alias}.FirstName || ' ' || ${alias}.LastName` },
description: 'FirstName 与 LastName 拼接;不能作为客户唯一键。'
},
{ column: 'Country', name: 'customerCountry', caption: '客户国家', type: 'STRING',
description: '客户地址国家,不是交易账单国家。' }
]
};
const customer = loadTableModel('ChinookCustomerModel');
export const queryModel = {
name: 'ChinookCustomerQueryModel',
caption: 'Chinook 客户目录',
description: '客户完整目录,一行一客户;与账单或其聚合结果按客户 ID 关联。目录本身不继承某次账单查询的期间。',
loader: 'v2',
model: customer,
columnGroups: [
{ caption: '客户', items: [
{ ref: customer.customerId },
{ ref: customer.customerName },
{ ref: customer.customerCountry }
] }
],
accesses: []
};
这次八个建模文件校验、加载通过。Codex 实时读取三个 QM 的说明,确认以下字段;Markdown 由引擎根据已加载代码自动生成,不用提前编写。
| QM | 本章使用的字段 |
|---|---|
ChinookCustomerQueryModel |
customerId、customerName,一行一客户 |
ChinookInvoiceQueryModel |
customerId、invoiceId、invoiceDate、invoiceAmount,一行一账单 |
ChinookInvoiceLineQueryModel |
invoice$customerId、invoice$invoiceDate、lineAmount、salesQuantity,一行一明细 |
三个查询面都使用客户 ID 关联,姓名只用于展示。客户目录的实时说明样本供阅读核对,查询时仍由 AI 实时获取。
确认模型可用后,再发送:
列出客户及其 2012 年的账单,没有账单的客户也保留。
Codex 使用查询 Skill,构造下面的 Compose 脚本:
const customers = dsl({
model: 'ChinookCustomerQueryModel',
columns: ['customerId as customer_id', 'customerName as customer_name']
});
const bills = dsl({
model: 'ChinookInvoiceQueryModel',
columns: ['customerId as bill_customer_id', 'invoiceId as invoice_id', 'invoiceAmount as bill_amount'],
slice: [{ field: 'invoiceDate', op: '[)', value: ['2012-01-01', '2013-01-01'] }]
});
const joined = customers.join(bills, 'left', [
{ left: 'customer_id', op: '=', right: 'bill_customer_id' }
]);
return { plans: joined.query({
columns: ['customer_id', 'customer_name', 'invoice_id', 'bill_amount'],
orderBy: ['customer_id', 'invoice_id'],
limit: 200
}) };
dsl(...) 定义输入查询计划;join 指定连接类型和两侧输出键;最后的 query(...) 选择结果列并排序。各输入保留自己的语义定义,由引擎统一执行。
这里用不同别名 customer_id、bill_customer_id 区分左右两侧。JOIN 引用输入计划输出的名字,不直接引用物理表名。连接键必须已被对应输入选出。
日期条件放在账单输入中,先取得 2012 年账单,再 LEFT JOIN 客户目录。因此没有当年账单的客户仍会保留;把右侧日期条件移到连接后的筛选中,可能会把这些客户重新筛掉。
本次按 compose validate → preview → execute 执行,结果节选如下:
| 客户 ID | 客户 | 账单 ID | 金额 |
|---|---|---|---|
| 1 | Luís Gonçalves | 316 | 1.98 |
| 1 | Luís Gonçalves | 327 | 13.86 |
| 8 | Daan Peeters | — | — |
结果共 95 行:83 张账单,加上 12 位当年没有账单的客户。59 位客户并不意味着关联后只能有 59 行——一位客户有两张账单,就会出现两行。
未匹配行的账单字段本次没有出现在 JSON 中,表中以“—”表示。它代表这次关联没有匹配账单,不能把空字段说成一张金额为零的账单。
将 left 改为 inner,只保留匹配到的记录,本次为 83 行、47 位客户。两种结果的账单金额合计都是 477.53。
继续发送:
改成每位客户一行,统计 2012 年的账单金额和张数,没有账单的也保留。
客户目录不变,将右侧 bills 改为按客户汇总:
const bills = dsl({
model: 'ChinookInvoiceQueryModel',
columns: ['customerId as bill_customer_id', 'sum(invoiceAmount) as total_amount', 'count(invoiceId) as bill_count'],
slice: [{ field: 'invoiceDate', op: '[)', value: ['2012-01-01', '2013-01-01'] }],
groupBy: ['customerId']
});
仍按 customer_id = bill_customer_id 做 LEFT JOIN,最终选择 customer_id、customer_name、total_amount、bill_count。完整脚本见聚合后关联示例。
这次右侧已经是一客户一行,结果也就变成 59 行:
| 客户 ID | 客户 | 账单金额 | 账单数 |
|---|---|---|---|
| 1 | Luís Gonçalves | 15.84 | 2 |
| 2 | Leonie Köhler | 0.99 | 1 |
| 8 | Daan Peeters | — | — |
47 位客户有账单,12 位未匹配,金额合计仍为 477.53,账单数合计 83。如果业务要把“没有交易”显示为 0,应先明确这个展示约定;不要把来源缺失也一起填成零。
两种查询的区别就在连接前的粒度:
客户(一客户一行) JOIN 账单(一账单一行) → 账单记录 + 未匹配客户
客户(一客户一行) JOIN 账单汇总(一客户一行) → 每客户一行
再发送:
按客户核对 2012 年的账单金额和明细金额。
账单与明细是两个事实粒度。如果直接按客户 ID 关联,一位客户的每张账单都会匹配他的多条明细。SQL 语法没问题,金额却会被反复累计。
本次专门做了错误粒度的对照:客户 1 有 2 张账单、16 条明细,两边各自合计都是 15.84。直接按客户关联产生 32 行,再累计账单金额就成了 253.44,明细金额也变成 31.68。JOIN 只是认真地把你要求的组合全部连上了。
正确做法是先把两侧都汇总到客户粒度,再关联:
const bills = dsl({
model: 'ChinookInvoiceQueryModel',
columns: ['customerId as bill_customer_id', 'sum(invoiceAmount) as total_amount', 'count(invoiceId) as bill_count'],
slice: [{ field: 'invoiceDate', op: '[)', value: ['2012-01-01', '2013-01-01'] }],
groupBy: ['customerId']
});
const lines = dsl({
model: 'ChinookInvoiceLineQueryModel',
columns: ['invoice$customerId as line_customer_id', 'sum(lineAmount) as line_amount', 'sum(salesQuantity) as quantity'],
slice: [{ field: 'invoice$invoiceDate', op: '[)', value: ['2012-01-01', '2013-01-01'] }],
groupBy: ['invoice$customerId']
});
return { plans: bills.join(lines, 'inner', [
{ left: 'bill_customer_id', op: '=', right: 'line_customer_id' }
]).query({
columns: ['bill_customer_id', 'total_amount', 'bill_count', 'line_amount', 'quantity'],
orderBy: ['bill_customer_id'], limit: 100
}) };
这次得到 47 行,每位客户两侧金额一致;两列各自合计都是 477.53,账单数 83,销售数量 447。没有将账单金额与明细金额相加,因为它们是在核对同一笔交易的两种表示。
“先聚合再 JOIN”也不是万能去重术。两侧要采用相同客户键、期间和业务范围,并确保每个连接键只有一行。如果一侧按“客户+国家”分组,却只按客户连接,仍可能重复。
读者重点看:每侧一行代表什么,连接键是否唯一,要不要保留未匹配记录,金额是否在关联后放大。 原生 JOIN 不会替人决定这些业务含义。
排序和分页放在最终结果。本次最终每页 5 位的前两页均已核对;没有账单的期间做 LEFT JOIN,仍返回 59 位客户,账单指标为空。
各输入还要使用同一可信调用身份,各自落实权限。本次另用临时权限测试模型核对:右侧只允许 USA 时,仅 13 位客户有账单金额,合计 127.98;客户目录仍有 59 位,因为这个目录本身公开。拒绝访问的 QM、隐藏字段均在 Compose 校验时拒绝,未继续执行。真实身份传递沿用第十三章,不由脚本自己指定。
本章用 Launcher 0.1.30、CLI 0.1.25 实操。10 个 Compose 用例的结果已与只读原数据、Decimal 独立核对,其中一项专门演示错误粒度;另有 2 项权限预期拒绝;旧账单、明细和月汇总查询通过回归。
当前版本还有两处限制:不存在的 JOIN 输出键可能通过校验;输入计划的 limit 本次未生效。 因此不能将校验成功当作连接键已验证,也不能靠输入 limit 决定参与连接的数据。这里用明确的 slice 限定业务范围,只在最终结果分页。两项复现单独记录,不计为通过;错误连接键未执行。
完整脚本、结果、版本与复跑步骤见第十四章实操记录。
下一章讲完善语义模型后,怎样确认已有问题仍能答对。