# GuanETL AI 开发参考

这份参考只保留 AI 真正常用、且最容易写错的部分。

## 先读什么

写 ETL 前，先按这个顺序看：

1. `etl/etl.go`
2. `etl/*.sql`
3. 必要时 `etl/meta.json`
4. 缺上下文时，用 `guancli` 查 ETL / ds / 字段，不要猜

## 不可违反的规则

```go
import . "guanetl/internal/framework"

func DefineETL() []Node
```

- 不要改点导入和 `DefineETL()` 签名。
- 返回值必须是叶子节点数组，通常是一个或多个 `OUTPUT_DATASET`。
- 节点 ID 必须唯一，格式必须是 `id_数字`。
- 下游节点必须写在上游节点之后。
- 只改 `etl/` 目录，不要手改 `_base_etl.json`、`_input.json`、`_exported.json`。
- 对 `edit` 导入出来的 ETL，优先局部修改，尽量保留现有节点顺序和文件命名。

## 节点选择顺序

优先用专用节点，最后才用 SQL：

1. `BasicInputDataset`
2. `BasicSelectColumns`
3. `BasicFilterRows`
4. `BasicJoinData`
5. `BasicGroupBy`
6. `BasicCalculator`
7. `BasicRemoveDuplicates`
8. `BasicAppendRows`
9. `BasicSqlScript`

特别注意：

- 等值 JOIN 不要写成 SQL，优先 `JOIN_DATA`。
- 只是筛选、选列、聚合、去重时，不要默认上 SQL。
- SQL 很长或有很多 CTE 时，优先拆成多个节点。

## 输入字段 schema 放哪里

输入字段 schema 以 `BasicInputDataset(..., []Field{...})` 为主。

```go
orders := BasicInputDataset("id_1001", "订单", "ds_orders", []Field{
    BasicField("user_id", FieldTypeSTRING),
    BasicField("amount", FieldTypeDOUBLE),
    BasicField("order_date", FieldTypeDATE),
}, Position{X: 100, Y: 100})
```

- `guanetl export` 会把这里的字段带到最终 JSON。
- `meta.json` 主要保留 ETL 名称等元信息。
- 字段相关报错时，优先检查 `[]Field`，不要先怀疑 `meta.json`。

## ODS 展示格式字符串先解析再声明数值

`guancli ds get` 只能告诉你字段声明类型；ODS / mock / 平台导出表可能把 UI 展示值原样存成 `STRING`，例如 `"13小时31分"`、`"27.69%"`、`"90"`。写 DWD 前先执行 `guancli ds preview <dsId>` 看样本；如果 preview 提示 STRING 样本像展示格式，不能在 `BasicInputDataset` 里直接把该字段声明成 `DOUBLE` / `LONG` 后聚合。

`guanetl export` 会通过数据集详情接口校验输入字段 schema：服务端字段是 `STRING` 时，`BasicInputDataset` 不能把它声明成 `DOUBLE` / `LONG` / `DATE` 等非 STRING 类型。

推荐先用 `BasicCalculator` 或 SQL 显式解析成新的数值列，再让下游聚合引用新列：

```go
calc := BasicCalculator("id_1002", "解析展示格式度量", inputA, []Formula{
    {
        Name: "下单转化率_数值",
        Type: FieldTypeDOUBLE,
        Expr: "CAST(REPLACE([下单转化率], \"%\", \"\") AS DOUBLE) / 100",
        Key:  "formula_order_rate_value",
    },
    {
        Name: "营业时长_分钟",
        Type: FieldTypeDOUBLE,
        Expr: "CAST(IF(REGEXP_EXTRACT([营业时长], \"([0-9.]+)小时\", 1) = \"\", \"0\", REGEXP_EXTRACT([营业时长], \"([0-9.]+)小时\", 1)) AS DOUBLE) * 60 + CAST(IF(REGEXP_EXTRACT([营业时长], \"([0-9.]+)分\", 1) = \"\", \"0\", REGEXP_EXTRACT([营业时长], \"([0-9.]+)分\", 1)) AS DOUBLE)",
        Key:  "formula_open_minutes",
    },
    {
        Name: "店铺分_数值",
        Type: FieldTypeDOUBLE,
        Expr: "CAST([店铺分] AS DOUBLE)",
        Key:  "formula_store_score_value",
    },
}, Position{X: 320, Y: 100})
```

- 百分比字符串先去 `%`，再确认业务语义是 `27.69` 还是 `0.2769`。
- 中文时长要统一成明确单位，例如分钟或秒。
- 纯数字字符串可能是编码 / ID；确认不是维度编码后再 cast。

## 字段 name / alias / showName 规则

后端 ETL 的展示名语义主要来自字段 `alias`，有些接口或前端上下文会叫 `displayName` / `showName`。

- 新版 ETL 默认使用输入字段别名：如果数据集字段有 `alias`，进入 ETL 算子后的列名通常是 `alias`；没有 `alias` 时才是原始 `name`。
- 输入字段别名的事实源是数据集字段元数据；可用 `guands dataset fields <dsId>` 回读，用 `guands dataset alias <dsId> --fd-id <fdId> --alias "展示名"` 更新。`BasicInputDataset(..., []Field{...})` 中的字段 schema 主要用于导入/导出和本地 lint，不会把一个未设置过数据集 alias 的字段强制变成展示名列。
- 本地校验按 `alias -> displayName/showName/title -> name` 解析有效字段名；同一来源下两个字段解析成相同有效名会被视为歧义，建模前应先改名或明确上游输出。
- 不要把 JOIN 键类型不一致直接断言为零匹配。STRING 与 LONG/DOUBLE 等数值类型 JOIN 时，Spark 可能做隐式数值 coercion：纯数字字符串可能匹配，`"001"` 可能折叠后匹配数值 `1`，非数字值可能无法匹配，超长 ID 可能丢失精度。业务标识键应在 JOIN 前显式统一为 STRING，并用 preview 验证真实匹配率。
- `preview` / `save` 会从数据集元数据补齐 `fdId -> alias` 的运行时映射，并把 JOIN predicate 中的输入 alias 归一化为 raw name；本地 `BasicInputDataset` 声明与服务端不一致时，以服务端字段身份为准，且不会修改工作区中的原始 `_exported.json`。当前服务端仍不支持把“设置过 alias 的数据集计算字段”直接选作 JOIN 输出列，应输出其原始依赖列或先在 ETL 中生成普通列。
- aggregation/window 计算字段不是行级字段，不能直接作为 JOIN 键；请先在 ETL 中生成普通计算列。`preview` / `save` 会在提交远端请求前拦截该用法。
- SQL 节点使用上游表 `input1`、`input2` 的当前列名。字段有中文展示名时，SQL 里应写展示名并用反引号，例如 ``SELECT `门店ID` FROM input1``。
- `SELECT_COLUMNS`、`FILTER_ROWS`、`REMOVE_DUPLICATES`、`GROUP_BY` 等直接列名算子，也以当前上游列名为准。
- 底层数据集读取和数据集元数据仍保留原始 `name`，所以 `guancli` / `guands` 输出字段时要同时关注 `name` 和 `alias`。
- 不确定时先执行 `guanetl lint`；它会提示疑似把 raw name 写进新版 ETL 算子的情况。

## 最常用函数

### 输入 / 输出

```go
BasicInputDataset(id, name, inputDsID string, fields []Field, position Position)
BasicOutputDataset(id, name string, source Node, outputDsName string, position Position)
BasicOutputDatasetInDir(id, name string, source Node, outputDsName, parentDirId string, position Position)
```

**新建输出节点必须用 `BasicOutputDatasetInDir`** 并传入 `guanetl create --output-parent-dir` 确认过的 DATA_SET 目录 id；`save` 会拒绝没有目录的新输出（服务端对空目录不报错而是静默落到根目录，不符合目录管理规范）。无目录版本的 `BasicOutputDataset` 只用于 `edit` 回读已物化输出时的往返兼容（目录已固化在服务端 dataSource 中），不要在新增输出时使用。目录 ID 可通过 `guancli ds tree` 获取，不存在时用 `guanetl mkdir --type DATA_SET` 显式创建。

新建 ETL 时有两类目录 id，不能混用：

- `guanetl create --parent-dir` 使用 ETL 目录树 id，可通过 `guancli etl tree` 获取。
- `BasicOutputDatasetInDir(..., parentDirId, ...)` / `guanetl create --output-parent-dir` 使用 DATA_SET 目录树 id，可通过 `guancli ds tree` 获取。
- 同名目录在两棵树中的 id 通常不同；需要成对创建时优先使用 `guanetl mkdir-pair`。
- `create` / `save` 默认会校验目录 id 类型，确认 id 正确但账号无法读取目录树时，可加 `--skip-dir-check` 跳过。

### 选列

```go
BasicSelectColumns(id, name string, source Node, columns []ColumnSetting, position Position)
```

```go
[]ColumnSetting{
    BasicColumnSetting("user_id"),
    {Name: "amount", NewName: "pay_amount"},
}
```

### 筛选

```go
BasicFilterRows(id, name string, source Node, combineType CombineType, conditions []FilterCondition, position Position)
```

- `combineType` 用常量 `CombineTypeAND` / `CombineTypeOR`
- 操作符用 `FilterOp*` 常量：`FilterOpEQ` `FilterOpNE` `FilterOpLT` `FilterOpLE` `FilterOpGT` `FilterOpGE` `FilterOpIN` `FilterOpBT` `FilterOpCONTAINS` `FilterOpNOTCONTAINS` `FilterOpSTARTSWITH` `FilterOpNOTSTARTSWITH` `FilterOpENDSWITH` `FilterOpNOTENDSWITH`
- 当前 `BasicFilterRows` 不支持 `IS_NULL` / `NOT_NULL`；null 过滤请改用 SQL 节点（如 ``WHERE `列名` IS NULL`` / ``IS NOT NULL``）。

### 等值 JOIN

```go
BasicJoinData(id, name string, leftSource, rightSource Node, joinType JoinType, joinColumns []JoinColumnPair, outputColumns []JoinOutputColumn, position Position)
```

- `joinType` 用常量 `JoinTypeINNER` `JoinTypeLEFTOUTER` `JoinTypeRIGHTOUTER` `JoinTypeFULLOUTER`

### 聚合

```go
BasicGroupBy(id, name string, source Node, groupByColumns []GroupByColumn, aggregationColumns []AggregationColumn, position Position)
```

- 聚合类型用常量 `AggrTypeSUM` `AggrTypeCOUNT` `AggrTypeCOUNTDISTINCT` `AggrTypeMIN` `AggrTypeMAX` `AggrTypeAVG` `AggrTypeFIRSTNOTNULL`

### 计算列

```go
BasicCalculator(id, name string, source Node, formulas []Formula, position Position)
```

`Formula` 必须写完整四个字段：

```go
Formula{
    Name: "订单金额等级",
    Type: FieldTypeSTRING,
    Expr: "IF([金额] >= 1000, \"大额\", \"普通\")",
    Key:  "formula_amount_level",
}
```

- `Name` 是新增列名。
- `Type` 是新增列类型，用 `FieldType*` 常量：`FieldTypeINT` `FieldTypeDOUBLE` `FieldTypeSTRING` `FieldTypeTIMESTAMP` `FieldTypeLONG` `FieldTypeSHORT` `FieldTypeFLOAT` `FieldTypeDATE` `FieldTypeBOOL` `FieldTypeDECIMAL` `FieldTypeARRAY`。
- `Expr` 是服务端公式表达式，字段引用必须写成 `[字段名]`。
- `Key` 在同一个 `CALCULATOR` 节点内必须唯一；建议用稳定字符串，不要留空。

`Expr` 规则：

- 字段名遵循“字段 name / alias / showName 规则”：上游字段有 `alias` 时，公式里通常写展示名，例如 `[门店ID]`，不要写 raw name `[store_id]`。
- 字符串字面量用双引号，例如 `"大额"`。
- 数值、比较、逻辑可以按 Spark SQL 表达式写，例如 `[金额] * 1.13`、`[数量] > 0 AND [金额] > 0`。
- 空值判断优先用 `IS NULL` / `IS NOT NULL` 或 `COALESCE`，不要写 `= NULL`。
- 多个公式之间尽量不要互相依赖；如果需要依赖前一个新增列，拆成两个 `BasicCalculator` 节点更稳。

常用函数（按 Spark SQL 表达式执行，函数名大小写不敏感；这里列 AI 写 ETL 时最常用、最稳的集合）：

| 类别 | 函数 / 写法 | 示例 |
|---|---|---|
| 文本截取 | `LEFT` `RIGHT` `SUBSTRING` | `LEFT([门店编码], 2)` |
| 文本处理 | `CONCAT` `UPPER` `LOWER` `TRIM` `LENGTH` `REPLACE` | `CONCAT([省份], "-", [城市])` |
| 数值处理 | `ABS` `ROUND` `CEIL` `FLOOR` | `ROUND([金额], 2)` |
| 空值处理 | `COALESCE` | `COALESCE([金额], 0)` |
| 条件判断 | `IF`、`CASE WHEN ... THEN ... ELSE ... END` | `IF([金额] > 0, "有效", "无效")` |
| 日期时间 | `CURRENT_DATE` `CURRENT_TIMESTAMP` `DATE_FORMAT` | `DATE_FORMAT([下单日期], "yyyy-MM")` |

示例：

```go
calc := BasicCalculator("id_1002", "新增计算列", inputA, []Formula{
    {
        Name: "含税金额",
        Type: FieldTypeDOUBLE,
        Expr: "ROUND([金额] * 1.13, 2)",
        Key:  "formula_tax_amount",
    },
    {
        Name: "门店前缀",
        Type: FieldTypeSTRING,
        Expr: "LEFT([门店ID], 2)",
        Key:  "formula_store_prefix",
    },
}, Position{X: 320, Y: 100})
```

### SQL

```go
BasicSqlScript(id, name string, sources []Node, sql string, position Position)
```

推荐把 SQL 放到独立文件：

```go
node := BasicSqlScript(
    "id_1005",
    "复杂转换",
    []Node{inputA, inputB},
    ReadSQLFile("node_1005.sql"),
    Position{X: 900, Y: 100},
)
```

SQL 规则：

- 上游节点在 SQL 中用 `input1`、`input2` 这类名字引用。
- 特殊列名、中文列名、带空格列名，以及 `AS` 输出别名中的非 ASCII 标识符，都必须用反引号。
- 输入数据集字段存在 `alias` / `displayName` / `showName` 时，优先使用展示名而不是 raw name。
- 新增 SQL 节点时，要同步新增 `.sql` 文件。

## 推荐骨架

```go
package main

import . "guanetl/internal/framework"

func DefineETL() []Node {
    inputA := BasicInputDataset("id_1001", "输入A", "ds_a", []Field{
        BasicField("id", FieldTypeSTRING),
    }, Position{X: 100, Y: 100})

    transformed := BasicFilterRows(
        "id_1002",
        "筛选A",
        inputA,
        CombineTypeAND,
        []FilterCondition{},
        Position{X: 320, Y: 100},
    )

    output := BasicOutputDataset("id_1003", "输出", transformed, "结果数据集", Position{X: 560, Y: 100})
    return []Node{output}
}
```

### 多输出 ETL

`DefineETL()` 可以返回多个叶子输出节点。适合从同一批输入派生多张下游表，例如同时产出明细表和汇总表。

```go
package main

import . "guanetl/internal/framework"

func DefineETL() []Node {
    orders := BasicInputDataset("id_1001", "订单", "ds_orders", []Field{
        BasicField("门店", FieldTypeSTRING),
        BasicField("订单号", FieldTypeSTRING),
        BasicField("销售额", FieldTypeDOUBLE),
    }, Position{X: 100, Y: 120})

    detail := BasicSelectColumns("id_1002", "订单明细", orders, []ColumnSetting{
        {Name: "门店"},
        {Name: "订单号"},
        {Name: "销售额"},
    }, Position{X: 340, Y: 40})

    summary := BasicGroupBy(
        "id_1003",
        "门店汇总",
        orders,
        []GroupByColumn{
            BasicGroupByColumn("门店", FieldTypeSTRING),
        },
        []AggregationColumn{
            BasicAggregationColumnWithAlias("销售额", FieldTypeDOUBLE, AggrTypeSUM, "销售额合计"),
        },
        Position{X: 340, Y: 200},
    )

    outDetail := BasicOutputDatasetInDir("id_1004", "输出明细", detail, "订单明细表", "ds_dir_id", Position{X: 600, Y: 40})
    outSummary := BasicOutputDatasetInDir("id_1005", "输出汇总", summary, "门店销售汇总表", "ds_dir_id", Position{X: 600, Y: 200})

    return []Node{outDetail, outSummary}
}
```

- 每个输出都要有独立的 `OUTPUT_DATASET` 节点 id 和独立的 `outputDsName`。
- 多输出共享同一个 ETL 的 `save`、`run` 和调度配置；任何一个输出分支变更后，都应重新验收本 ETL 的全部输出。
- 如果多个输出需要独立调度、独立发布、独立回滚，或希望隔离某个输出分支的变更影响，应拆成多个单输出 ETL。

## 每次改完都检查

1. `DefineETL()` 返回的是叶子节点吗？
2. 新节点 ID 唯一吗？格式对吗？
3. 节点顺序符合依赖吗？
4. `JOIN_DATA` / `GROUP_BY` / `FILTER_ROWS` 的枚举值合法吗？
5. 新增 SQL 文件了吗？文件名和代码引用一致吗？
6. 输入字段 schema 足够支撑后续节点吗？
7. `BasicCalculator` 的每个 `Formula` 都显式填写 `Type` 了吗？缺失会在 `export` 后提示 warning，并可能导致下游原生 `GROUP_BY` / `JOIN_DATA` 静态检查出现类型不匹配。

## 验证顺序

始终按这个顺序：

1. `guanetl export --dir <work_dir>`
2. 需要看结果时：`guanetl preview <node_id> --dir <work_dir>`；保存前要求非空时追加 `--require-nonempty`，零行会返回错误并阻止命令链继续执行
3. 保存前先看影响：`guanetl save --dir <work_dir> --dry-run`
4. 确认无误后：`guanetl save --dir <work_dir>`

如果 `export` 没过，不要直接 `save`。

## save 输出数据集冲突

`save` 会先拉取服务端当前 ETL，再把本地 `_exported.json` 合并进去。修改已保存 ETL 时，输出节点必须复用服务端已有输出数据集绑定，否则 direct-save 可能把它当成“创建新输出数据集”，触发“输出数据集目录中存在同名文件”。

新建 ETL 首次保存时，服务端 edit API 可能返回 ETL 不存在。若当前本地 base 与工作区 ETL ID 一致且尚无 actions，CLI 会将其识别为正常首次保存回退并输出 `save.first_save_fallback`；其他 edit 故障使用 `save.edit_fallback` warning，不应静默当作首次保存。

当本地输出节点 id 被重新生成，但 `outputDsName + parentDirId` 与一个服务端已绑定输出唯一匹配时，`save` 会在请求副本中自动恢复原节点 id 和 dsId，并在影响报告中输出 `save.output_binding_reconciled`。源 `_exported.json` 不会被改写。跨目录、双方重名歧义、删除旧输出或无法唯一匹配时不会自动协调。

- 再次修改已保存 ETL，优先重新执行 `guanetl edit <etl_id> --dir <新目录>`，不要长期复用旧工作目录。
- 只追加输出列时，可以原地 `save`：新增列，不改名、不删已有列、不改已有列类型，并保持原 `OUTPUT_DATASET` 节点 id 和 `outputDsName`；但这只保证输出身份稳定，不保证旧 DATAFLOW 数据集的字段目录已同步。对新增并透传到输出的 `BasicCalculator` 字段，必须执行 `guanetl run <etlId> --wait`：命令会按输出数据集独立等待本轮物化，并同时验证 metadata 与实际 preview 返回字段；任一目标输出尚未完成或字段缺失都会报错。
- 修改已有输出 schema 时，保持原 `OUTPUT_DATASET` 节点 id；改列名、删列、改已有列类型、换输入数据集或重接输出链路都可能影响下游绑定，保存前必须重新 `preview` 并评估下游。
- 如果用 `guands dataset rename` 改过 ETL 输出数据集名称，必须同步 `etl.go` 中 `BasicOutputDataset(..., outputDsName, ...)` 或 `BasicOutputDatasetInDir(..., outputDsName, ...)` 的名称。
- 不要为了绕过同名错误手动删除旧输出数据集；旧输出通常仍被 ETL 依赖。
- 如果只是在保留旧输出的同时增加新输出，使用新的节点 id 和不同的 `outputDsName` 或 `parentDirId` 即可。
- 如果确实要移除已绑定输出并创建新输出数据集，必须使用新的节点 id、`outputDsName` 或 `parentDirId`，并显式加 `--allow-output-replacement`。该选项不会迁移下游 dsId 引用，调用方必须自行完成依赖迁移。
- `save` 如果在 direct-save 前提示输出绑定风险，先修本地 `etl.go` / `meta.json`，再重新 `export -> preview -> save`。
- `save --dry-run` 只生成保存影响报告，不调用 direct-save；需要机器可读结果时加 `--format json`。报告里出现阻断级输出绑定风险时，必须先修本地定义，或在确认主动替换且已规划下游迁移后显式使用 `--allow-output-replacement`。

## Appendix: Framework Surface

这部分只列 AI 写 ETL 时最值得记住的公开函数和合法枚举。
源码真相在 runner framework 中，但不要把源码实现细节当成日常写法。

### 推荐优先使用的 Basic 函数

```go
BasicInputDataset(id, name, inputDsID string, fields []Field, position Position)
BasicOutputDataset(id, name string, source Node, outputDsName string, position Position)
BasicOutputDatasetInDir(id, name string, source Node, outputDsName, parentDirId string, position Position)
BasicSelectColumns(id, name string, source Node, columns []ColumnSetting, position Position)
BasicFilterRows(id, name string, source Node, combineType CombineType, conditions []FilterCondition, position Position)
BasicJoinData(id, name string, leftSource, rightSource Node, joinType JoinType, joinColumns []JoinColumnPair, outputColumns []JoinOutputColumn, position Position)
BasicGroupBy(id, name string, source Node, groupByColumns []GroupByColumn, aggregationColumns []AggregationColumn, position Position)
BasicCalculator(id, name string, source Node, formulas []Formula, position Position)
BasicRemoveDuplicates(id, name string, source Node, columnNames []string, position Position)
BasicAppendRows(id, name string, sources []Node, unionType UnionType, schemaSource string, position Position)
BasicSqlScript(id, name string, sources []Node, sql string, position Position)
```

### 只有需要完整配置时再用的 New 函数

```go
NewInputDataset(id, name string, config InputDatasetConfig)
NewOutputDataset(id, name string, source Node, config OutputDatasetConfig)
NewSelectColumns(id, name string, source Node, config SelectColumnsConfig)
NewFilterRows(id, name string, source Node, config FilterRowsConfig)
NewJoinData(id, name string, leftSource, rightSource Node, config JoinDataConfig)
NewGroupBy(id, name string, source Node, config GroupByConfig)
NewCalculator(id, name string, source Node, config CalculatorConfig)
NewRemoveDuplicates(id, name string, source Node, config RemoveDuplicatesConfig)
NewAppendRows(id, name string, sources []Node, config AppendRowsConfig)
NewSqlScript(id, name string, sources []Node, config SqlScriptConfig)
```

默认优先 `Basic*`，除非你明确需要手动控制更底层配置。

### 高频辅助函数

```go
BasicField(name string, fieldType FieldType)
BasicColumnSetting(name string)
BasicJoinColumnPair(leftColumn, rightColumn string)
BasicJoinOutputFromLeft(columnName string)
BasicJoinOutputFromRight(columnName string)
BasicJoinOutputFromLeftWithAlias(columnName, alias string)
BasicJoinOutputFromRightWithAlias(columnName, alias string)
BasicGroupByColumn(name string, columnType FieldType)
BasicGroupByColumnWithAlias(name string, columnType FieldType, newName string)
BasicAggregationColumn(name string, columnType FieldType, aggregationType AggregationType)
BasicAggregationColumnWithAlias(name string, columnType FieldType, aggregationType AggregationType, newName string)
BasicFilterCondition(columnName string, operator FilterOperator, filterValues []FilterValue)
BasicFilterValue(value string)
BasicFilterColumnValue(columnName string)
ReadSQLFile(filename string)
```

### Formula 结构

```go
type Formula struct {
    Name string    `json:"name"`
    Type FieldType `json:"type"`
    Expr string    `json:"expr"`
    Key  string    `json:"key"`
}
```

### 枚举常量（优先使用常量）

JOIN 类型、筛选组合、操作符、聚合类型、UNION 类型、字段/公式类型都是命名类型，
框架提供同名常量，命名规则是 `<类型前缀> + 去掉下划线的值`（`COUNT_DISTINCT` → `AggrTypeCOUNTDISTINCT`）。
**优先写常量**：常量名拼错会在 `export` 求值脚本时直接报 `undefined`，而字符串字面量拼错要等到图校验阶段才发现。
字符串字面量（`"INNER"`）仍然可用（存量脚本无需修改，`guanetl lint` 会提示改用常量）；
把 `string` 变量传给枚举参数需要显式转换，例如 `JoinType(v)`。

| 枚举 | 常量 | 说明 |
|---|---|---|
| 筛选组合 `CombineType` | `CombineTypeAND` `CombineTypeOR` | |
| 筛选操作符 `FilterOperator` | `FilterOpEQ` `FilterOpNE` `FilterOpLT` `FilterOpLE` `FilterOpGT` `FilterOpGE` `FilterOpIN` `FilterOpBT` `FilterOpCONTAINS` `FilterOpNOTCONTAINS` `FilterOpSTARTSWITH` `FilterOpNOTSTARTSWITH` `FilterOpENDSWITH` `FilterOpNOTENDSWITH` | `FilterOpISNULL` / `FilterOpNOTNULL` 当前后端 FILTER_ROWS 不支持，null 过滤请用 `BasicSqlScript` |
| 筛选值类型 `FilterValueType` | `FilterValueTypeVALUE` `FilterValueTypeCOLUMN` | 通常经 `BasicFilterValue` / `BasicFilterColumnValue` 间接使用 |
| JOIN 类型 `JoinType` | `JoinTypeINNER` `JoinTypeLEFTOUTER` `JoinTypeRIGHTOUTER` `JoinTypeFULLOUTER` | 服务端把 FULL_OUTER 记为 OUTER，框架自动转换 |
| 聚合类型 `AggregationType` | `AggrTypeSUM` `AggrTypeCOUNT` `AggrTypeCOUNTDISTINCT` `AggrTypeMIN` `AggrTypeMAX` `AggrTypeAVG` `AggrTypeFIRSTNOTNULL` | 服务端记为 SUM/CNT/CNT_DISTINCT/MIN/MAX/AVG/NUL，框架自动转换 |
| APPEND_ROWS `UnionType` | `UnionTypeINCLUDESHARED` `UnionTypeINCLUDEALL` `UnionTypeINCLUDEFROM` | |
| 字段/公式类型 `FieldType` | `FieldTypeINT` `FieldTypeDOUBLE` `FieldTypeSTRING` `FieldTypeTIMESTAMP` `FieldTypeLONG` `FieldTypeSHORT` `FieldTypeFLOAT` `FieldTypeDATE` `FieldTypeBOOL` `FieldTypeDECIMAL` `FieldTypeARRAY` | `Formula.Type` 只能取这些值；`BasicInputDataset` 的字段类型来自服务端，可能超出该列表，按 `guancli ds get` 的结果照写即可 |

`BasicAppendRows` 会按列名对齐各输入源。参与拼接的同名列类型必须一致；例如一侧为 `DATE`、另一侧为 `LONG` 会在 `export` 阶段报错。遇到冲突时，先用 `BasicCalculator` / `BasicSelectColumns` 把各源字段统一类型或移除不用的冲突列，再执行行拼接。
