mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-08-08 16:25:58 +08:00
Compare commits
13 Commits
6cc5bece8d
...
3bf5a3493c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bf5a3493c | ||
|
|
4d37555dd8 | ||
|
|
0af0eaecf6 | ||
|
|
60848ef341 | ||
|
|
0f94d02786 | ||
|
|
67d919947f | ||
|
|
db1aca7221 | ||
|
|
fc4d14bce0 | ||
|
|
9c47f28221 | ||
|
|
9f1603ed2f | ||
|
|
9be9667779 | ||
|
|
2d508c8ac0 | ||
|
|
73db45cabf |
189
skills/data-cloud-bdt-expert/SKILL.md
Normal file
189
skills/data-cloud-bdt-expert/SKILL.md
Normal file
@ -0,0 +1,189 @@
|
||||
---
|
||||
name: data-cloud-bdt-expert
|
||||
description: "Explain and investigate an existing Salesforce Data Cloud Batch Data Transform (BDT) JSON definition. Use when the user provides or references a BDT JSON export and asks to understand, summarize, walk through, trace lineage in, or debug that specific BDT — for example, 'explain this BDT', 'what does this batch data transform do', 'where does field X come from in this BDT', 'which nodes feed OUTPUT5', or 'what does this computeRelative formula mean'. Accepts a path to a BDT JSON file or pasted JSON content. Does not author, edit, run, or fetch BDTs from an org."
|
||||
allowed-tools: Bash Read Write
|
||||
---
|
||||
|
||||
## 1. When to Use This Skill
|
||||
|
||||
Use this skill when the user asks you to understand a Salesforce Data Cloud **Batch Data Transform (BDT)** JSON export. The BDT's JSON is a dependency graph of 3 to 100+ nodes — loads, joins, filters, formulas, window functions, aggregates, and outputs — that together describe how data flows from source DMOs/DLOs to target DMOs/DLOs.
|
||||
|
||||
Trigger on prompts like:
|
||||
|
||||
- "Explain this BDT." / "What does this BDT do?"
|
||||
- "Walk me through this batch data transform."
|
||||
- "Summarize this BDT JSON."
|
||||
- "Which nodes feed OUTPUT5?"
|
||||
- "Where does field `ssot__ProductAmount__c` come from in this BDT?"
|
||||
- "Trace the lineage of `TotalAmount__c`."
|
||||
- "What does this computeRelative formula mean?" / "Why would this field be zero?"
|
||||
- "What DMOs does this BDT read from?" / "What does this filter do?"
|
||||
|
||||
The skill accepts both shapes a BDT can appear in: the **editor export** (what customers download from the BDT viewer — `{version, nodes, ui}`) and the **Connect API create payload** (what developers POST when authoring via the API — with outer wrapper `{name, label, type, dataSpaceName, definition: {...}}`). See `references/bdt-reference.md` for details. Developers and customers alike can use the skill to understand a BDT — validation is out of scope for v1 but planned as a follow-up.
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
- **Python 3.9+** on `PATH`. The parser script uses only the standard library — no `pip install` required.
|
||||
- A **BDT JSON** — either a file path the user gives you, content the user pastes into the chat, or a file attachment you can read locally.
|
||||
|
||||
Do NOT assume the user has Salesforce CLI, `gh`, `jq`, Node, or any Salesforce org auth. The skill runs entirely locally against a static JSON file.
|
||||
|
||||
## 3. Input Handling
|
||||
|
||||
Decision tree for getting the BDT JSON into a local file the script can read:
|
||||
|
||||
1. **User gives a file path** (absolute or relative) → use it directly.
|
||||
2. **User pastes JSON inline** → write it to `/tmp/bdt-<epoch_seconds>.json` with the Write tool, then use that path.
|
||||
3. **User attaches a file** → the path is in the attachment metadata; use it.
|
||||
4. **No BDT provided yet** → ask: *"Please paste the BDT JSON, give me a file path, or attach the export."*
|
||||
|
||||
Before any further work, validate by running `python bdt_analyze.py summary <path>`. If it exits 3 with "Invalid JSON", tell the user:
|
||||
|
||||
> "This file doesn't appear to be valid JSON. Could you re-export from the BDT viewer and try again? The exact error was: \<paste stderr\>."
|
||||
|
||||
### 3.1 Multi-definition payloads
|
||||
|
||||
**Multi-definition payloads.** If the user provides a Connect API payload with a `definitions[]` array containing multiple entries, first run `python bdt_analyze.py definitions <path>` to list them. Then offer the user a choice — *"The payload has 3 definitions: MyTransform, OtherTransform, ThirdTransform. Which do you want me to explain?"* — and invoke subsequent subcommands with `--definition N` (0-indexed). For example: `python bdt_analyze.py summary <path> --definition 1`. All analysis subcommands (summary, sources, outputs, stages, nodes, node, lineage, field-trace, formula) accept the `--definition` flag. Editor exports and single-definition payloads can be explained directly without this step; running `definitions` on them still succeeds and shows one row, so it is safe to run on any input if you are unsure.
|
||||
|
||||
## 4. Explanation Flow (Job A — Progressive Disclosure)
|
||||
|
||||
The default flow for "explain this BDT":
|
||||
|
||||
**Step 1.** Run `python bdt_analyze.py summary <path>` and render the **Executive Summary (Mode A)** in plain English to the user. Target ≤ 200 words. Name every source DMO/DLO and every output target. Identify at least one business-domain cue from the DMO names (e.g., *"looks like a sales-orders pipeline"*).
|
||||
|
||||
**Step 2.** Offer the four explanation modes:
|
||||
|
||||
> Want to go deeper? I can give you:
|
||||
>
|
||||
> - **(A) Executive summary** — reprint of what I just gave you.
|
||||
> - **(B) Layered breakdown** — per-output lineage + per-stage mini-explanations.
|
||||
> - **(C) Node-by-node walkthrough** — every node in topological order.
|
||||
> - **(D) Business-intent read** — what this BDT is *trying to do*, in business language (marked as inference).
|
||||
|
||||
**Step 3.** Based on the user's pick:
|
||||
|
||||
- **Mode A** — reprint the summary; no further script calls.
|
||||
- **Mode B** — run `python bdt_analyze.py stages <path>`, then for each sink run `python bdt_analyze.py lineage <path> <sink>`. Narrate: one section per output (its lineage), then a per-stage narration using the topo order.
|
||||
- **Mode C** — run `python bdt_analyze.py nodes <path>` (add `--limit 50` if the BDT is large). Walk through each node in topo order, translating the digest into plain English. Consult `references/bdt-node-catalog.md` before narrating any action type the conversation hasn't already covered.
|
||||
- **Mode D** — narrate the inferred business purpose from the `summary` digest + DMO/DLO names + output structure. **Always prefix with** *"Based on the structure, this appears to…"*. Explicit uncertainty marker is mandatory.
|
||||
|
||||
**Override rule.** If the user's first message names a mode (e.g., *"walk me through every node"* = Mode C), skip Step 2 entirely and go straight to the chosen mode.
|
||||
|
||||
## 5. Q&A Flow (Job B — Investigation)
|
||||
|
||||
When the user asks a follow-up question, route it to the right subcommand using this table. **Always narrate the output in plain English — never dump raw script output to the user.**
|
||||
|
||||
| User question pattern | Subcommand to run |
|
||||
|---|---|
|
||||
| "What does node X do?" / "Explain X" (L2) | `node <path> X` |
|
||||
| "Which nodes feed X?" / "Upstream of X?" (L1) | `lineage <path> X` |
|
||||
| "Where does field F come from?" (L3) | `field-trace <path> F` |
|
||||
| "What DMOs/DLOs does this read from?" (L4) | `sources <path>` |
|
||||
| "What fields does OUTPUT N produce?" (L5) | `node <path> OUTPUT_N` |
|
||||
| "What does this formula mean?" (I1) | `formula <path> X` |
|
||||
| "Why would F be null/zero/unexpected?" (I2) | `field-trace <path> F` to locate the defining node(s), **then** `formula <path> <defining-node>` on the formula or computeRelative node that computes the field (not the output mapping — pick the earliest formula/computeRelative node in the field-trace output). |
|
||||
| "What does this filter do?" (I3) | `node <path> X` |
|
||||
|
||||
After running the subcommand(s), translate the output into plain English. For I1/I2 you **must** consult `references/bdt-function-catalog.md` (and `references/bdt-window-functions.md` if it's a `computeRelative` node) before narrating.
|
||||
|
||||
## 6. Output Style Guidance
|
||||
|
||||
- **Plain language first, technical detail second.** Lead with what the BDT or node does in business terms; only then mention the action type / parameter shape.
|
||||
- **Use UI labels when available.** Say *"Join Users (JOIN0)"* rather than just *"JOIN0"* whenever `ui.nodes[X].label` exists.
|
||||
- **Prefer field purpose over expression.** *"Product amount, zeroed except on the first ranked product per order"* rather than echoing the `case when ... end`.
|
||||
- **Explicit uncertainty markers** on Mode D and I2 answers: *"Based on the structure, this appears to…"*.
|
||||
- **Never fabricate lineage or definitions.** If `field-trace` returns no match, say the field isn't defined in this BDT. Don't invent a source.
|
||||
- **Redact Salesforce record IDs in narration** (15/18-char IDs starting `0`) unless the user asks about them specifically. This keeps conversations shareable.
|
||||
- **Don't fabricate features that aren't in the JSON.** If the user asks about scheduling, run history, or streaming behavior and those aren't in the JSON, say so plainly.
|
||||
- **Don't dump raw JSON.** If the user wants to see raw params, run `node --json <path> <name>` and *then* explain what's interesting in the result.
|
||||
|
||||
## 7. Reference Consultation (mandatory)
|
||||
|
||||
- **Always-loaded:** `references/bdt-reference.md` — the top-level overview. Assume this is in context every conversation.
|
||||
- **Consult `references/bdt-node-catalog.md`** via the Read tool **before**:
|
||||
- Explaining any node type you haven't already explained in this conversation.
|
||||
- Narrating any of: `bucket`, `flatten`, `flattenJson`, `split`, `extractGrains`, `extractTable`, `appendV2`, `multidefinitionMerge`, `cdpPredict`, `extension`, `extensionFunction`, `jsonAggregate`, `typeCast`, `formatDate` — these are rarer action types where the parameter shape matters.
|
||||
- **Consult `references/bdt-function-catalog.md`** **before** interpreting any `formulaExpression` (I1 or I2). Look up each function named in the expression.
|
||||
- **Consult `references/bdt-window-functions.md`** **whenever** you narrate a `computeRelative` node.
|
||||
- **Cite the source when narrating deep detail.** Good: *"Per the node catalog, `computeRelative` runs a window function over the rows partitioned by `<partitionBy>` and ordered by `<orderBy>`."* Bad: asserting SQL semantics with no grounding.
|
||||
- **If a node type or function isn't in any of the reference files**, narrate what the JSON shows (action name + pretty-printed parameters) and **explicitly flag that the item is not in the documentation materials shipped with this skill**. Do not guess at semantics.
|
||||
|
||||
## 8. Error Handling Guidance (what to tell the user)
|
||||
|
||||
| Script outcome | What to say |
|
||||
|---|---|
|
||||
| Exit 3: `Invalid JSON at line X` | "This file isn't valid JSON at line X. Could you re-export from the BDT viewer?" Then paste the exact error. |
|
||||
| Exit 3: `Expected top-level 'nodes' object` | "This doesn't look like a BDT export — a BDT has a top-level `nodes` object. Is this the right file?" |
|
||||
| Exit 3: `Cycle or broken reference detected` | "This BDT has a dependency cycle or references a non-existent node. You'll need to edit the BDT in the Data Cloud viewer to fix the circular dependency or broken reference — re-exporting won't help because the cycle exists in the BDT definition itself. The problematic nodes are: \<list from stderr\>." |
|
||||
| Exit 2: `No node named 'X'` | "I don't see a node named `X` in this BDT. Did you mean one of: \<list of available names the script printed on stderr\>?" |
|
||||
| Exit 2: `Field 'F' is not defined by any node` | "That field isn't defined anywhere in this BDT. Closest matches from the fields I can see: \<list from stderr\>. Is it possibly a field from the source DMO that's not pulled in?" |
|
||||
| User's BDT uses an action type not in the catalog | "This BDT uses `<action>`, which isn't in my reference material. Here's what its parameters look like: \<raw params\>. I can describe the graph structure around it, but I can't vouch for the exact semantics of this action type." |
|
||||
|
||||
## 9. Non-Goals (explicit "Do not" directives)
|
||||
|
||||
**Do not** author, create, or edit BDTs. If the user asks to modify a BDT, explain that this skill only explains BDTs and point them at the BDT viewer in Data Cloud.
|
||||
|
||||
**Do not** run, schedule, or trigger BDTs. Execution happens in Data Cloud; this skill works on the JSON export only.
|
||||
|
||||
**Do not** fetch BDTs from a live org. v1 accepts only a user-provided file path or pasted JSON. If the user asks, say that live-org fetch is planned for v2.
|
||||
|
||||
**Do not** validate BDTs — don't claim a BDT is "correct" or "will run successfully". Validation (required-field checks, enum checks, dangling-ref detection) is planned as a follow-up v1.1 and is explicitly out of scope. If the user asks "is my BDT valid?", reply with *"This skill explains BDTs but doesn't validate them; validation is planned for a follow-up release. Want me to explain what the BDT does so you can spot issues manually?"*
|
||||
|
||||
**Do not** guess at node-type or function semantics not covered by the reference materials. Say "this isn't in the reference material shipped with this skill" and surface the raw parameters.
|
||||
|
||||
**Do not** invent business intent. Mode D and I2 answers must be prefixed with *"Based on the structure, this appears to…"* — never claim business purpose as fact.
|
||||
|
||||
**Do not** echo Salesforce record IDs verbatim (15/18-char IDs) in narration unless the user asks specifically about an ID.
|
||||
|
||||
**Do not** output raw Python script output to the user — always narrate.
|
||||
|
||||
**Do not** modify the BDT JSON file on disk. This skill is read-only.
|
||||
|
||||
**Do not** visualize the BDT as a graph image or diagram. Skill output is text only.
|
||||
|
||||
**Do not** compare multiple BDTs. This is a v1 limitation (single-BDT focus). If asked, suggest running the skill on each BDT separately.
|
||||
|
||||
## 10. Troubleshooting
|
||||
|
||||
Common issues and how to address them:
|
||||
|
||||
| Issue | Solution |
|
||||
|---|---|
|
||||
| `python: command not found` or `python3: command not found` | Ask the user to install Python 3.9+. Point them at https://www.python.org/downloads/. The skill's parser uses only the standard library, so `pip install` is **not** needed. |
|
||||
| `ModuleNotFoundError: No module named 'bdt_analyze'` | The script path is wrong. The script lives at `scripts/bdt_analyze.py` inside the skill directory. Run it by absolute path (the Bash tool invocation should include the full path from the repo root). |
|
||||
| BDT file path contains spaces (e.g. `"default B2C (Prod).json"`) | Quote the path when calling the script: `python bdt_analyze.py summary "default B2C (Prod).json"`. |
|
||||
| Script output is very large for a big BDT (Mode C) | Use `--limit 50` on the `nodes` subcommand, or recommend Mode B (layered) instead of Mode C. |
|
||||
| Field trace returns no match despite the field clearly appearing in the BDT | The field may only appear qualified (e.g., `SalesOrder.ssot__Id__c`). Try the qualified form. If still no match, the field may be passed through from a source DMO but not explicitly defined in the BDT — in that case field-trace cannot locate it; narrate "this field originates from the load node's source DMO" instead. Do NOT attempt to trace it by stripping the `__c` suffix — that would risk matching a different field with a similar name. |
|
||||
| User says output "doesn't match what I see in BDT viewer" | Verify the version in the JSON header matches the org's current release. The canonical schema reference in `references/bdt-node-catalog.md` may predate the user's BDT, so older/newer BDTs may use variants not yet documented. Flag as "version drift" and narrate best-effort. |
|
||||
|
||||
## 11. Example Interactions
|
||||
|
||||
**Example 1 — "Explain this BDT" on a small DMO-to-DMO BDT.**
|
||||
|
||||
User: *"Explain this BDT."* (attaches `minimal_dmo_to_dmo.json`)
|
||||
|
||||
Skill:
|
||||
1. Runs `summary` → gets: 3 nodes (1 load, 1 formula, 1 output), reads from `ssot__Account__dlm`, writes to `Account_Upper__dlm`.
|
||||
2. Narrates in plain English:
|
||||
> This BDT takes accounts from the `ssot__Account__dlm` DMO, uppercases the account name, and writes the result to `Account_Upper__dlm`. Three nodes: **Account** (load), **Uppercase Name** (formula), **Account Upper** (output).
|
||||
3. Offers modes: *"Want the layered breakdown, node-by-node walkthrough, or business-intent read?"*
|
||||
|
||||
**Example 2 — Field lineage on a larger BDT.**
|
||||
|
||||
User: *"Where does `TotalAmount__c` come from in this BDT?"* (after already loading `window_and_aggregate.json`)
|
||||
|
||||
Skill:
|
||||
1. Runs `field-trace window_and_aggregate.json TotalAmount__c`.
|
||||
2. Receives: defined by `AGG_BY_ACCOUNT` (action `aggregate`), specifically as `SUM` over `ssot__GrandTotalAmount__c`, grouping by `ssot__AccountId__c`. Upstream chain: `LOAD_ORDERS → RANK_ORDERS → AGG_BY_ACCOUNT`.
|
||||
3. Consults `references/bdt-node-catalog.md` for the `aggregate` section.
|
||||
4. Narrates:
|
||||
> `TotalAmount__c` is produced by the **Totals per account (`AGG_BY_ACCOUNT`)** aggregate node. It's a `SUM` of `ssot__GrandTotalAmount__c`, grouped by `ssot__AccountId__c` (one row per account). The amount values come from the original Sales Orders DMO via `LOAD_ORDERS`. The `RANK_ORDERS` node between them adds a per-account order rank but doesn't alter the amount.
|
||||
|
||||
## 12. Next Steps
|
||||
|
||||
After completing an explanation or Q&A, if the conversation seems to be wrapping up, offer:
|
||||
|
||||
- "Want to trace another field or explain another node?"
|
||||
- "Need this BDT's summary in a different format (e.g., as a bulleted list for a doc)?"
|
||||
- "If you want to edit or run this BDT, use the BDT viewer in Data Cloud — this skill explains BDTs but doesn't author or execute them."
|
||||
- If the user found a bug or limitation, point them at the [afv-library GitHub issues](https://github.com/forcedotcom/afv-library/issues) to report it.
|
||||
@ -0,0 +1,77 @@
|
||||
{
|
||||
"version": "66.0",
|
||||
"nodes": {
|
||||
"LOAD_WEB_ORDERS": {
|
||||
"action": "load",
|
||||
"sources": [],
|
||||
"parameters": {
|
||||
"dataset": {"name": "WebOrders__dlo", "type": "dataLakeObject"},
|
||||
"fields": ["OrderId__c", "Amount__c", "ChannelOrderKey__c"],
|
||||
"sampleDetails": {"type": "TopN", "sortBy": []}
|
||||
}
|
||||
},
|
||||
"LOAD_STORE_ORDERS": {
|
||||
"action": "load",
|
||||
"sources": [],
|
||||
"parameters": {
|
||||
"dataset": {"name": "StoreOrders__dlo", "type": "dataLakeObject"},
|
||||
"fields": ["OrderId__c", "Amount__c", "ChannelOrderKey__c"],
|
||||
"sampleDetails": {"type": "TopN", "sortBy": []}
|
||||
}
|
||||
},
|
||||
"APPEND_ALL_ORDERS": {
|
||||
"action": "appendV2",
|
||||
"sources": ["LOAD_WEB_ORDERS", "LOAD_STORE_ORDERS"],
|
||||
"parameters": {
|
||||
"allowImplicitDisjointSchema": false,
|
||||
"fieldMappings": [
|
||||
{"targetField": "OrderId__c", "sources": [{"node": "LOAD_WEB_ORDERS", "field": "OrderId__c"}, {"node": "LOAD_STORE_ORDERS", "field": "OrderId__c"}]},
|
||||
{"targetField": "Amount__c", "sources": [{"node": "LOAD_WEB_ORDERS", "field": "Amount__c"}, {"node": "LOAD_STORE_ORDERS", "field": "Amount__c"}]},
|
||||
{"targetField": "ChannelOrderKey__c", "sources": [{"node": "LOAD_WEB_ORDERS", "field": "ChannelOrderKey__c"}, {"node": "LOAD_STORE_ORDERS", "field": "ChannelOrderKey__c"}]}
|
||||
]
|
||||
}
|
||||
},
|
||||
"SPLIT_CHANNEL_KEY": {
|
||||
"action": "split",
|
||||
"sources": ["APPEND_ALL_ORDERS"],
|
||||
"parameters": {
|
||||
"sourceField": "ChannelOrderKey__c",
|
||||
"delimiter": "-",
|
||||
"targetFields": [
|
||||
{"name": "Channel__c", "label": "Channel"},
|
||||
{"name": "OrderNumber__c", "label": "Order Number"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"OUTPUT_ORDERS": {
|
||||
"action": "outputD360",
|
||||
"sources": ["SPLIT_CHANNEL_KEY"],
|
||||
"parameters": {
|
||||
"name": "OrdersWithChannel__dlm",
|
||||
"type": "dataModelObject",
|
||||
"writeMode": "OVERWRITE",
|
||||
"fieldsMappings": [
|
||||
{"sourceField": "OrderId__c", "targetField": "OrderId__c"},
|
||||
{"sourceField": "Amount__c", "targetField": "Amount__c"},
|
||||
{"sourceField": "Channel__c", "targetField": "Channel__c"},
|
||||
{"sourceField": "OrderNumber__c", "targetField": "OrderNumber__c"}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"nodes": {
|
||||
"LOAD_WEB_ORDERS": {"label": "Web Orders", "type": "LOAD_DATASET", "top": 100, "left": 100},
|
||||
"LOAD_STORE_ORDERS": {"label": "Store Orders", "type": "LOAD_DATASET", "top": 260, "left": 100},
|
||||
"APPEND_ALL_ORDERS": {"label": "Union", "type": "APPEND", "top": 180, "left": 260},
|
||||
"SPLIT_CHANNEL_KEY": {"label": "Split channel key", "type": "SPLIT", "top": 180, "left": 420},
|
||||
"OUTPUT_ORDERS": {"label": "Orders + channel", "type": "OUTPUT", "top": 180, "left": 580}
|
||||
},
|
||||
"connectors": [
|
||||
{"source": "LOAD_WEB_ORDERS", "target": "APPEND_ALL_ORDERS"},
|
||||
{"source": "LOAD_STORE_ORDERS", "target": "APPEND_ALL_ORDERS"},
|
||||
{"source": "APPEND_ALL_ORDERS", "target": "SPLIT_CHANNEL_KEY"},
|
||||
{"source": "SPLIT_CHANNEL_KEY", "target": "OUTPUT_ORDERS"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
{
|
||||
"version": "66.0",
|
||||
"nodes": {
|
||||
"LOAD_ORDERS": {
|
||||
"action": "load",
|
||||
"sources": [],
|
||||
"parameters": {
|
||||
"dataset": {"name": "ssot__SalesOrder__dlm", "type": "dataModelObject"},
|
||||
"fields": ["ssot__Id__c", "ssot__AccountId__c", "ssot__Status__c", "ssot__GrandTotalAmount__c"],
|
||||
"sampleDetails": {"type": "TopN", "sortBy": []}
|
||||
}
|
||||
},
|
||||
"LOAD_ACCOUNTS": {
|
||||
"action": "load",
|
||||
"sources": [],
|
||||
"parameters": {
|
||||
"dataset": {"name": "ssot__Account__dlm", "type": "dataModelObject"},
|
||||
"fields": ["ssot__Id__c", "ssot__Name__c", "ssot__Industry__c"],
|
||||
"sampleDetails": {"type": "TopN", "sortBy": []}
|
||||
}
|
||||
},
|
||||
"FILTER_ACTIVE_ORDERS": {
|
||||
"action": "filter",
|
||||
"sources": ["LOAD_ORDERS"],
|
||||
"parameters": {
|
||||
"filterExpressions": [
|
||||
{"type": "TEXT", "field": "ssot__Status__c", "operator": "EQUAL", "operands": ["Active"]},
|
||||
{"type": "NUMBER", "field": "ssot__GrandTotalAmount__c", "operator": "GREATER_THAN", "operands": ["0"]},
|
||||
{"type": "NUMBER", "field": "ssot__GrandTotalAmount__c", "operator": "IS_NOT_NULL", "operands": []}
|
||||
],
|
||||
"filterBooleanLogic": "1 AND 2 AND 3"
|
||||
}
|
||||
},
|
||||
"JOIN_ORDER_ACCOUNT": {
|
||||
"action": "join",
|
||||
"sources": ["FILTER_ACTIVE_ORDERS", "LOAD_ACCOUNTS"],
|
||||
"parameters": {
|
||||
"joinType": "LEFT_OUTER",
|
||||
"leftKeys": ["ssot__AccountId__c"],
|
||||
"rightQualifier": "Account",
|
||||
"rightKeys": ["ssot__Id__c"]
|
||||
}
|
||||
},
|
||||
"OUTPUT_ENRICHED": {
|
||||
"action": "outputD360",
|
||||
"sources": ["JOIN_ORDER_ACCOUNT"],
|
||||
"parameters": {
|
||||
"name": "Order_Enriched__dlm",
|
||||
"type": "dataModelObject",
|
||||
"writeMode": "OVERWRITE",
|
||||
"fieldsMappings": [
|
||||
{"sourceField": "ssot__Id__c", "targetField": "OrderId__c"},
|
||||
{"sourceField": "ssot__AccountId__c", "targetField": "AccountId__c"},
|
||||
{"sourceField": "ssot__GrandTotalAmount__c", "targetField": "Amount__c"},
|
||||
{"sourceField": "Account.ssot__Name__c", "targetField": "AccountName__c"},
|
||||
{"sourceField": "Account.ssot__Industry__c", "targetField": "Industry__c"}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"nodes": {
|
||||
"LOAD_ORDERS": {"label": "Sales Orders", "type": "LOAD_DATASET", "top": 100, "left": 100},
|
||||
"LOAD_ACCOUNTS": {"label": "Accounts", "type": "LOAD_DATASET", "top": 260, "left": 100},
|
||||
"FILTER_ACTIVE_ORDERS": {"label": "Active, >0", "type": "FILTER", "top": 100, "left": 260},
|
||||
"JOIN_ORDER_ACCOUNT": {"label": "Join Account info", "type": "JOIN", "top": 180, "left": 420},
|
||||
"OUTPUT_ENRICHED": {"label": "Order Enriched", "type": "OUTPUT", "top": 180, "left": 580}
|
||||
},
|
||||
"connectors": [
|
||||
{"source": "LOAD_ORDERS", "target": "FILTER_ACTIVE_ORDERS"},
|
||||
{"source": "FILTER_ACTIVE_ORDERS", "target": "JOIN_ORDER_ACCOUNT"},
|
||||
{"source": "LOAD_ACCOUNTS", "target": "JOIN_ORDER_ACCOUNT"},
|
||||
{"source": "JOIN_ORDER_ACCOUNT", "target": "OUTPUT_ENRICHED"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
{
|
||||
"version": "66.0",
|
||||
"nodes": {
|
||||
"LOAD_ACCOUNT": {
|
||||
"action": "load",
|
||||
"sources": [],
|
||||
"parameters": {
|
||||
"dataset": {"name": "ssot__Account__dlm", "type": "dataModelObject"},
|
||||
"fields": ["ssot__Id__c", "ssot__Name__c", "ssot__Industry__c"],
|
||||
"sampleDetails": {"type": "TopN", "sortBy": []}
|
||||
}
|
||||
},
|
||||
"FORMULA_NAME_UPPER": {
|
||||
"action": "formula",
|
||||
"sources": ["LOAD_ACCOUNT"],
|
||||
"parameters": {
|
||||
"expressionType": "SQL",
|
||||
"fields": [
|
||||
{
|
||||
"name": "AccountNameUpper__c",
|
||||
"label": "Account Name (upper)",
|
||||
"formulaExpression": "upper(ssot__Name__c)",
|
||||
"type": "TEXT",
|
||||
"businessType": "Text",
|
||||
"precision": 255,
|
||||
"defaultValue": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"OUTPUT_ACCOUNT_UPPER": {
|
||||
"action": "outputD360",
|
||||
"sources": ["FORMULA_NAME_UPPER"],
|
||||
"parameters": {
|
||||
"name": "Account_Upper__dlm",
|
||||
"type": "dataModelObject",
|
||||
"writeMode": "OVERWRITE",
|
||||
"fieldsMappings": [
|
||||
{"sourceField": "ssot__Id__c", "targetField": "ssot__Id__c"},
|
||||
{"sourceField": "AccountNameUpper__c", "targetField": "Name_Upper__c"},
|
||||
{"sourceField": "ssot__Industry__c", "targetField": "Industry__c"}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"nodes": {
|
||||
"LOAD_ACCOUNT": {"label": "Account", "type": "LOAD_DATASET", "top": 100, "left": 100},
|
||||
"FORMULA_NAME_UPPER": {"label": "Uppercase Name", "type": "FORMULA", "top": 100, "left": 260},
|
||||
"OUTPUT_ACCOUNT_UPPER": {"label": "Account Upper", "type": "OUTPUT", "top": 100, "left": 420}
|
||||
},
|
||||
"connectors": [
|
||||
{"source": "LOAD_ACCOUNT", "target": "FORMULA_NAME_UPPER"},
|
||||
{"source": "FORMULA_NAME_UPPER", "target": "OUTPUT_ACCOUNT_UPPER"}
|
||||
],
|
||||
"hiddenColumns": []
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
{
|
||||
"version": "66.0",
|
||||
"nodes": {
|
||||
"LOAD_ORDERS": {
|
||||
"action": "load",
|
||||
"sources": [],
|
||||
"parameters": {
|
||||
"dataset": {"name": "ssot__SalesOrder__dlm", "type": "dataModelObject"},
|
||||
"fields": ["ssot__Id__c", "ssot__AccountId__c", "ssot__CreatedDate__c", "ssot__GrandTotalAmount__c"],
|
||||
"sampleDetails": {"type": "TopN", "sortBy": []}
|
||||
}
|
||||
},
|
||||
"RANK_ORDERS": {
|
||||
"action": "computeRelative",
|
||||
"sources": ["LOAD_ORDERS"],
|
||||
"parameters": {
|
||||
"partitionBy": ["ssot__AccountId__c"],
|
||||
"orderBy": [{"fieldName": "ssot__CreatedDate__c", "direction": "ASC"}],
|
||||
"expressionType": "SQL",
|
||||
"fields": [
|
||||
{
|
||||
"name": "OrderRank__c",
|
||||
"label": "Order Rank",
|
||||
"formulaExpression": "row_number()",
|
||||
"type": "NUMBER",
|
||||
"businessType": "NUMBER",
|
||||
"precision": 18,
|
||||
"scale": 0,
|
||||
"defaultValue": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"FIRST_ORDER_AMOUNT": {
|
||||
"action": "formula",
|
||||
"sources": ["RANK_ORDERS"],
|
||||
"parameters": {
|
||||
"expressionType": "SQL",
|
||||
"fields": [
|
||||
{
|
||||
"name": "FirstOrderAmount__c",
|
||||
"label": "First Order Amount (per account)",
|
||||
"formulaExpression": "case when OrderRank__c = 1 then ssot__GrandTotalAmount__c else 0 end",
|
||||
"type": "NUMBER",
|
||||
"businessType": "NUMBER",
|
||||
"precision": 18,
|
||||
"scale": 2,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"AGG_BY_ACCOUNT": {
|
||||
"action": "aggregate",
|
||||
"sources": ["FIRST_ORDER_AMOUNT"],
|
||||
"parameters": {
|
||||
"groupings": ["ssot__AccountId__c"],
|
||||
"aggregations": [
|
||||
{"action": "SUM", "name": "TotalAmount__c", "source": "ssot__GrandTotalAmount__c"},
|
||||
{"action": "SUM", "name": "FirstOrderTotal__c", "source": "FirstOrderAmount__c"},
|
||||
{"action": "COUNT", "name": "OrderCount__c", "source": "ssot__Id__c"}
|
||||
],
|
||||
"nodeType": "STANDARD"
|
||||
}
|
||||
},
|
||||
"OUTPUT_SUMMARY": {
|
||||
"action": "outputD360",
|
||||
"sources": ["AGG_BY_ACCOUNT"],
|
||||
"parameters": {
|
||||
"name": "Account_Summary__dlm",
|
||||
"type": "dataModelObject",
|
||||
"writeMode": "OVERWRITE",
|
||||
"fieldsMappings": [
|
||||
{"sourceField": "ssot__AccountId__c", "targetField": "AccountId__c"},
|
||||
{"sourceField": "TotalAmount__c", "targetField": "TotalAmount__c"},
|
||||
{"sourceField": "FirstOrderTotal__c", "targetField": "FirstOrderAmount__c"},
|
||||
{"sourceField": "OrderCount__c", "targetField": "OrderCount__c"}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"nodes": {
|
||||
"LOAD_ORDERS": {"label": "Sales Orders", "type": "LOAD_DATASET", "top": 100, "left": 100},
|
||||
"RANK_ORDERS": {"label": "Rank by account", "type": "COMPUTE_RELATIVE", "top": 100, "left": 260},
|
||||
"FIRST_ORDER_AMOUNT": {"label": "Extract first order amount", "type": "FORMULA", "top": 100, "left": 420},
|
||||
"AGG_BY_ACCOUNT": {"label": "Totals per account", "type": "AGGREGATE", "top": 100, "left": 580},
|
||||
"OUTPUT_SUMMARY": {"label": "Account Summary", "type": "OUTPUT", "top": 100, "left": 740}
|
||||
},
|
||||
"connectors": [
|
||||
{"source": "LOAD_ORDERS", "target": "RANK_ORDERS"},
|
||||
{"source": "RANK_ORDERS", "target": "FIRST_ORDER_AMOUNT"},
|
||||
{"source": "FIRST_ORDER_AMOUNT", "target": "AGG_BY_ACCOUNT"},
|
||||
{"source": "AGG_BY_ACCOUNT", "target": "OUTPUT_SUMMARY"}
|
||||
]
|
||||
}
|
||||
}
|
||||
124
skills/data-cloud-bdt-expert/references/bdt-function-catalog.md
Normal file
124
skills/data-cloud-bdt-expert/references/bdt-function-catalog.md
Normal file
@ -0,0 +1,124 @@
|
||||
# SFSQL Function Catalog — for `formula` reasoning
|
||||
|
||||
> **Last synced:** 2026-04-23 from the Data Processing Engine PDF (2025-06-24 version) and
|
||||
> the BDT help DITA XMLs (`c360_a_batch_transform_numeric.xml`, `_string.xml`,
|
||||
> `_date_functions.xml`, `_boolean_functions.xml`, `_multivalue_functions.xml`,
|
||||
> `_additionalfunctions.xml`). Consult this file before interpreting any `formulaExpression`.
|
||||
|
||||
## How formulas appear in BDT JSON
|
||||
|
||||
```jsonc
|
||||
"action": "formula",
|
||||
"parameters": {
|
||||
"expressionType": "SQL", // or "DCSQL"
|
||||
"fields": [
|
||||
{
|
||||
"name": "OutputField__c",
|
||||
"label": "Output Label",
|
||||
"type": "TEXT", // TEXT | NUMBER | BOOLEAN | DATE_ONLY | DATETIME
|
||||
"businessType": "Text", // user-visible type name
|
||||
"formulaExpression": "upper(coalesce(SourceField__c, ''))",
|
||||
"precision": 255,
|
||||
"scale": 0, // only for NUMBER
|
||||
"defaultValue": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Functions by family
|
||||
|
||||
### Math / Numeric
|
||||
|
||||
| Function | Signature | Purpose |
|
||||
|---|---|---|
|
||||
| `abs(n)` | number → number | Absolute value (strips sign). |
|
||||
| `ceiling(n)` | number → number | Round up, away from zero for negatives. |
|
||||
| `floor(n)` | number → number | Round toward negative infinity. Always rounds down on the number line: `floor(2.7) = 2`, `floor(-2.3) = -3`. |
|
||||
| `exp(n)` | number → number | e raised to n. |
|
||||
| `log(base, n)` | number → number | Logarithm of n in the given base. |
|
||||
| `max(a, b, …)` | number → number | Largest value. |
|
||||
| `min(a, b, …)` | number → number | Smallest value. |
|
||||
| `mod(a, b)` | number → number | Remainder after dividing a by b. |
|
||||
| `power(a, b)` | number → number | a raised to the power b. |
|
||||
| `round(n, digits)` | number → number | Round to `digits` places. |
|
||||
| `sqrt(n)` | number → number | Positive square root. |
|
||||
| `trunc(n, digits)` | number → number | Truncate to `digits` places (doesn't round). |
|
||||
|
||||
### String
|
||||
|
||||
| Function | Signature | Purpose |
|
||||
|---|---|---|
|
||||
| `begins(s, prefix)` | text → boolean | True if s starts with prefix. |
|
||||
| `concat(a, b, …)` | text → text | Concatenate. |
|
||||
| `contains(haystack, needle)` | text → boolean | True if haystack contains needle. |
|
||||
| `ends(s, suffix)` | text → boolean | True if s ends with suffix. |
|
||||
| `length(s)` | text → number | Character count. |
|
||||
| `lower(s)` | text → text | Lowercase (locale-aware if a locale is provided). |
|
||||
| `ltrim(s)` / `ltrim(s, substring)` | text → text | Remove leading whitespace (or a specific substring). |
|
||||
| `rtrim(s)` / `rtrim(s, substring)` | text → text | Remove trailing whitespace (or substring). |
|
||||
| `substitute(s, old, new)` | text → text | Replace old with new in s. |
|
||||
| `substr(s, start, length)` | text → text | Extract a substring. |
|
||||
| `text(n)` | number → text | Convert a number to its text form. |
|
||||
| `trim(s)` / `trim(s, substring)` | text → text | Remove leading + trailing whitespace or substring. |
|
||||
| `upper(s)` | text → text | Uppercase. |
|
||||
| `uuid()` | → text | Newly generated unique ID. |
|
||||
| `value(s)` | text → number | Parse a text representation of a number into a numeric value. |
|
||||
|
||||
### Date
|
||||
|
||||
| Function | Signature | Purpose |
|
||||
|---|---|---|
|
||||
| `adddays(date, n)` | date → date | Add n days. |
|
||||
| `addmonths(date, n)` | date → date | Add n months. |
|
||||
| `datediff(start, end)` | date × date → number | Days between two dates. |
|
||||
| `datetimevalue(text_or_date)` | → datetime | GMT/UTC date+time value. |
|
||||
| `datevalue(text_or_datetime)` | → date | Extract the date part. |
|
||||
| `day(date)` | date → number | 1-31. |
|
||||
| `monthdiff(start, end)` | date × date → number | Months between two dates. |
|
||||
| `now()` | → datetime | Current moment (UTC). |
|
||||
| `today()` | → date | Current date. |
|
||||
| `weekday(date)` | date → number | 1=Sunday, 2=Monday, … 7=Saturday. |
|
||||
|
||||
### Boolean / Logical
|
||||
|
||||
| Function | Signature | Purpose |
|
||||
|---|---|---|
|
||||
| `and(a, b, …)` | bool → bool | True when all are true. |
|
||||
| `or(a, b, …)` | bool → bool | True when any is true. |
|
||||
| `if(cond, then_val, else_val)` | → any | Ternary. |
|
||||
| `case when <cond1> then <val1> else <elseval> end` | — | Multi-branch (SQL CASE). |
|
||||
| `blankvalue(expr, substitute)` | → any | Substitute when expr is blank. |
|
||||
| `isblank(expr)` | → bool | True when expr is blank. |
|
||||
| `isnull(expr)` | → bool | True when expr is null. |
|
||||
| `nullvalue(expr, substitute)` | → any | Substitute when expr is null; returns expr otherwise. |
|
||||
|
||||
### Multivalue
|
||||
|
||||
| Function | Signature | Purpose |
|
||||
|---|---|---|
|
||||
| `sequence(start, end, step?)` | → array | Array of numbers/dates between start and end (step defaults to 1 / 1 day). |
|
||||
| `explode(array)` | → row per element | Convert multivalue data into one row per element. **Cannot be nested inside another function.** |
|
||||
|
||||
### Additional / Null-handling (commonly seen)
|
||||
|
||||
| Function | Signature | Purpose |
|
||||
|---|---|---|
|
||||
| `coalesce(a, b, …)` | → any | First non-null argument (SQL standard). |
|
||||
|
||||
## Patterns to recognize in narration
|
||||
|
||||
- **`coalesce(X, Y)`** — "use X when present, otherwise Y." Very common for default-value
|
||||
handling.
|
||||
- **`case when RANK = 1 then X else 0 end`** — the "first-occurrence extract" idiom paired
|
||||
with a `computeRelative` `row_number()` node.
|
||||
- **`case when FLAG = 'Y' then 'Y' else 'N' end`** — boolean-like text normalization.
|
||||
- **`concat(coalesce(A, ''), coalesce(B, ''))`** — safe string concatenation that defaults
|
||||
NULLs to empty strings (producing a synthetic composite key).
|
||||
|
||||
## Sources
|
||||
|
||||
- Data Processing Engine reference PDF (captured at `research/bdt-doc-10-data_processing_engine_6-24-2025.pdf.md`)
|
||||
- BDT help XML: `c360_a_batch_transform_numeric.xml`, `_string.xml`, `_date_functions.xml`,
|
||||
`_boolean_functions.xml`, `_multivalue_functions.xml`, `_additionalfunctions.xml` (captured
|
||||
at `research/help-xml/`).
|
||||
763
skills/data-cloud-bdt-expert/references/bdt-node-catalog.md
Normal file
763
skills/data-cloud-bdt-expert/references/bdt-node-catalog.md
Normal file
@ -0,0 +1,763 @@
|
||||
# BDT Node Catalog — per-action reference (on-demand)
|
||||
|
||||
> **Source of truth:** `research/bdt-schema-canonical.md`, extracted from the BDT Connect
|
||||
> API Java sources (`cdp-connect-api` module, release 264) on 2026-04-23. The entries
|
||||
> below are curated to answer the skill's job — explaining BDT JSON. For the full
|
||||
> extraction (all fields, minVersion annotations, polymorphism map), see the research doc.
|
||||
|
||||
Consult this file before explaining any node type. Each section covers:
|
||||
- **JSON action name** and UI name(s).
|
||||
- **Purpose** in plain English.
|
||||
- **Key parameters** with types (and enum values where applicable).
|
||||
- **How it affects lineage** — rename? add? drop? change row cardinality?
|
||||
- **Common gotchas**.
|
||||
- **Example JSON snippet**.
|
||||
- **Source citation** back to the canonical Java input-rep class.
|
||||
|
||||
---
|
||||
|
||||
## `action: "load"` — UI: "Load" / "Data Source"
|
||||
|
||||
**Purpose.** Reads rows from a DMO or DLO into the pipeline. Every BDT has at least one
|
||||
load node (they are the graph roots — `sources: []`).
|
||||
|
||||
**Key parameters** (class `LoadParametersInputRepresentation`):
|
||||
|
||||
| Param | Type | Notes |
|
||||
|---|---|---|
|
||||
| `dataset` | `LoadDatasetInputRepresentation` | `{ name: string, type: "dataModelObject" \| "dataLakeObject" }` |
|
||||
| `fields` | `string[]` | Field names to pull from the source. Not pulling a field means it's unavailable downstream. |
|
||||
| `sampleDetails` | `{ type: "TopN" \| "Custom" \| "Unique", sortBy: string[] }` | Editor-only sampling behavior; doesn't affect runtime. |
|
||||
|
||||
**Lineage effect.** Defines the *initial* set of field names available downstream. No row
|
||||
cardinality change (all rows are loaded).
|
||||
|
||||
**Gotchas.**
|
||||
- A field referenced by a downstream node must appear in some load's `fields` array, **or**
|
||||
be generated later by a formula/aggregate. If you can't trace a field to a load or a
|
||||
derivation, the BDT is broken.
|
||||
- `dataset.type` was historically called `dataLakeObject` in some older dialects; per
|
||||
canonical schema both `dataModelObject` and `dataLakeObject` are valid.
|
||||
- `sampleDetails.sortBy` is a list of strings — may be empty.
|
||||
|
||||
**Example.**
|
||||
```jsonc
|
||||
{
|
||||
"action": "load",
|
||||
"sources": [],
|
||||
"parameters": {
|
||||
"dataset": {"name": "ssot__SalesOrder__dlm", "type": "dataModelObject"},
|
||||
"fields": ["ssot__Id__c", "ssot__AccountId__c", "ssot__GrandTotalAmount__c"],
|
||||
"sampleDetails": {"type": "TopN", "sortBy": []}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Source.** `LoadNodeInputRepresentation` + `LoadParametersInputRepresentation` +
|
||||
`LoadDatasetInputRepresentation`.
|
||||
|
||||
---
|
||||
|
||||
## `action: "join"` — UI: "Join"
|
||||
|
||||
**Purpose.** Joins two upstream streams by key. Always has exactly two `sources`; field names
|
||||
on the right-hand side get a qualifier prefix so they don't collide with left-hand names.
|
||||
|
||||
**Key parameters** (class `JoinParametersInputRepresentation`):
|
||||
|
||||
| Param | Type | Notes |
|
||||
|---|---|---|
|
||||
| `joinType` | enum `JoinType`: `INNER`, `OUTER`, `LEFT_OUTER`, `RIGHT_OUTER`, `LOOKUP`, `MULTI_VALUE_LOOKUP`, `CROSS` | |
|
||||
| `leftKeys` | `string[]` | Join keys on the left (first) source. |
|
||||
| `rightKeys` | `string[]` | Join keys on the right (second) source. |
|
||||
| `leftQualifier` | `string` (optional) | Prefix for left-side field names in the output. Often omitted. |
|
||||
| `rightQualifier` | `string` (required) | Prefix for right-side fields (e.g., `SalesOrder` → right-side field `ssot__Id__c` becomes `SalesOrder.ssot__Id__c`). |
|
||||
|
||||
**Optional node-level** `schema.slice`:
|
||||
- `{ mode: "DROP" | "SELECT", fields: string[], ignoreMissingFields: bool }`
|
||||
- Used to trim unwanted fields from the joined output.
|
||||
|
||||
**Lineage effect.** Combines two field sets. Fields from the right side get renamed with
|
||||
`rightQualifier` prefix. Row cardinality depends on join type:
|
||||
- `INNER` — only matched pairs.
|
||||
- `LEFT_OUTER` — all left rows + matching right.
|
||||
- `RIGHT_OUTER` — all right rows + matching left.
|
||||
- `OUTER` — all rows from both sides.
|
||||
- `LOOKUP` — 1:1 left-side preserving.
|
||||
- `MULTI_VALUE_LOOKUP` — left-side preserving; multi-valued right.
|
||||
- `CROSS` — Cartesian product; every left paired with every right.
|
||||
|
||||
**Gotchas.**
|
||||
- Data types of joined keys should match. Type mismatches cause silent no-match or errors
|
||||
depending on pair.
|
||||
- `rightQualifier` is the *only* way to disambiguate same-named fields from two sources.
|
||||
Always surface it when narrating.
|
||||
- `schema.slice` on a join is a post-join projection, not a pre-join filter.
|
||||
|
||||
**Example.**
|
||||
```jsonc
|
||||
{
|
||||
"action": "join",
|
||||
"sources": ["JOIN7", "FILTER4"],
|
||||
"parameters": {
|
||||
"joinType": "LEFT_OUTER",
|
||||
"leftKeys": ["ssot__Id__c"],
|
||||
"rightQualifier": "SalesOrder",
|
||||
"rightKeys": ["ssot__SalesOrderId__c"]
|
||||
},
|
||||
"schema": {
|
||||
"slice": {
|
||||
"mode": "DROP",
|
||||
"ignoreMissingFields": true,
|
||||
"fields": ["SalesOrder.ssot__InternalOrganizationId__c"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Source.** `JoinNodeInputRepresentation` + `JoinParametersInputRepresentation`.
|
||||
|
||||
---
|
||||
|
||||
## `action: "filter"` — UI: "Filter"
|
||||
|
||||
**Purpose.** Keep only rows satisfying filter criteria combined by boolean logic.
|
||||
|
||||
**Key parameters** (class `FilterParametersInputRepresentation`):
|
||||
|
||||
| Param | Type | Notes |
|
||||
|---|---|---|
|
||||
| `filterExpressions` | `FilterExpression[]` | Each expression is `{ field, operator, type, operands[] }`. |
|
||||
| `filterBooleanLogic` | `string` | A formula like `"1 AND (2 OR 3)"` indexed 1..N into `filterExpressions`. If omitted, default is all AND. |
|
||||
|
||||
**FilterExpression fields** (class `FilterExpressionInputRepresentation`):
|
||||
- `field` — the field the filter examines.
|
||||
- `operator` — `string` (no typed enum in the canonical input-rep; free-form in the API).
|
||||
Commonly observed values: `EQUAL`, `NOT_EQUAL`, `GREATER_THAN`, `LESS_THAN`,
|
||||
`GREATER_OR_EQUAL`, `LESS_OR_EQUAL`, `IN_RANGE`, `LIKE`, `IS_NULL`, `IS_NOT_NULL`.
|
||||
- `type` — enum `DataType` (same as elsewhere in BDT): `TEXT`, `NUMBER`, `BOOLEAN`,
|
||||
`DATE_ONLY`, `DATETIME`.
|
||||
- `operands` — array of operand values (strings in JSON; interpreted per `type`).
|
||||
|
||||
**Lineage effect.** Does not change field names; only reduces rows.
|
||||
|
||||
**Gotchas.**
|
||||
- `filterBooleanLogic` operands are **1-indexed** into `filterExpressions`. Be careful when
|
||||
narrating which expression is "expression 1".
|
||||
- If a referenced field is dropped upstream, the filter becomes invalid at runtime.
|
||||
|
||||
**Example.**
|
||||
```jsonc
|
||||
{
|
||||
"action": "filter",
|
||||
"sources": ["LOAD_DATASET0"],
|
||||
"parameters": {
|
||||
"filterExpressions": [
|
||||
{"type": "TEXT", "field": "MobilePhone_Formatted_Flag__c", "operator": "EQUAL", "operands": ["Y"]},
|
||||
{"type": "TEXT", "field": "IsActive__c", "operator": "EQUAL", "operands": ["true"]}
|
||||
],
|
||||
"filterBooleanLogic": "1 AND 2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Source.** `FilterNodeInputRepresentation` + `FilterParametersInputRepresentation` +
|
||||
`FilterExpressionInputRepresentation`.
|
||||
|
||||
---
|
||||
|
||||
## `action: "sqlFilter"` — UI: "SQL Filter"
|
||||
|
||||
**Purpose.** A filter whose predicate is a raw SQL expression — more expressive than the
|
||||
structured `filter` node, at the cost of being harder to validate statically.
|
||||
|
||||
**Key parameters** (class `SqlFilterParametersInputRepresentation`):
|
||||
|
||||
| Param | Type | Notes |
|
||||
|---|---|---|
|
||||
| `sqlFilterExpression` | `string` | A SQL WHERE-clause-like predicate referring to fields by name. |
|
||||
|
||||
**Lineage effect.** Same as `filter` — reduces rows only.
|
||||
|
||||
**Example.**
|
||||
```jsonc
|
||||
{
|
||||
"action": "sqlFilter",
|
||||
"sources": ["JOIN1"],
|
||||
"parameters": {
|
||||
"sqlFilterExpression": "ssot__CreatedDate__c >= current_date() - interval '30' day"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Source.** `SqlFilterNodeInputRepresentation` + `SqlFilterParametersInputRepresentation`.
|
||||
|
||||
---
|
||||
|
||||
## `action: "formula"` — UI: "Formula"
|
||||
|
||||
**Purpose.** Add one or more derived columns via per-row SQL formulas. No window / cross-row
|
||||
semantics — for that, see `computeRelative`.
|
||||
|
||||
**Key parameters** (class `FormulaParametersInputRepresentation`):
|
||||
|
||||
| Param | Type | Notes |
|
||||
|---|---|---|
|
||||
| `expressionType` | enum `FormulaExpressionType`: `SQL`, `DCSQL` | |
|
||||
| `fields` | `SqlFormulaFieldInputRepresentation[]` | One entry per derived field. |
|
||||
|
||||
**Each field** carries:
|
||||
- `name` — the output field name.
|
||||
- `label` — user-visible label.
|
||||
- `formulaExpression` — SFSQL (or DCSQL) expression. See `bdt-function-catalog.md`.
|
||||
- `type` — enum `DataType`: `TEXT`, `NUMBER`, `BOOLEAN`, `DATE_ONLY`, `DATETIME`.
|
||||
- **`businessType`** — a user-facing business-semantic type name. Enum `BusinessTypeEnum` —
|
||||
**canonical values** (complete list; matches `BusinessTypeEnum.java` as of the capture date
|
||||
at the top of this file):
|
||||
- `"TEXT"`, `"NUMBER"`, `"BOOLEAN"`
|
||||
- `"EMAIL"`, `"PHONE"`, `"URL"` — text-valued with semantic meaning
|
||||
- `"PERCENT"`, `"CURRENCY"` — number-valued with semantic meaning
|
||||
- `"DATE"` — semantically a date, stored as datetime at the underlying `type` level
|
||||
- `"DATE_ONLY"` — date-only value (no time component)
|
||||
- `"DATETIME"` — full datetime
|
||||
|
||||
Note: `businessType` values map to underlying `type` (`DataType`) values. E.g.,
|
||||
`businessType: "PERCENT"` is stored as `type: "NUMBER"`; `businessType: "DATE"` is stored
|
||||
as `type: "DATETIME"`; `businessType: "EMAIL"` is stored as `type: "TEXT"`. If the skill
|
||||
ever encounters a `businessType` value outside this list, that is a sign the upstream BDT
|
||||
schema has evolved — surface the raw value in narration and flag it as undocumented.
|
||||
- `precision` — integer precision (default 10 for numbers; characters for text).
|
||||
- `scale` — decimal places; only for NUMBER.
|
||||
- `defaultValue` — value when the expression yields NULL.
|
||||
|
||||
**Lineage effect.** Adds new columns to the downstream row stream. Original columns pass
|
||||
through unchanged unless dropped later by a `schema` node. Row cardinality unchanged.
|
||||
|
||||
**Gotchas.**
|
||||
- If a field referenced in `formulaExpression` is removed upstream, the formula fails at
|
||||
runtime.
|
||||
- **`type` and `businessType`** — both use UPPER wire form per canonical enums
|
||||
(`type: "NUMBER"`, `businessType: "NUMBER"`). Some user-authored BDT JSON
|
||||
may show capitalized forms (`"Number"`) — the runtime is case-insensitive
|
||||
per `BusinessTypeEnum.valueOfInternal()`, but the canonical wire form is
|
||||
UPPER.
|
||||
- Concrete sub-classes exist for typed fields (`SqlFormulaNumericFieldInputRepresentation`,
|
||||
etc.) but the JSON shape is the same.
|
||||
|
||||
**Example.**
|
||||
```jsonc
|
||||
{
|
||||
"action": "formula",
|
||||
"sources": ["JOIN5"],
|
||||
"parameters": {
|
||||
"expressionType": "SQL",
|
||||
"fields": [
|
||||
{
|
||||
"name": "ssot__SalesOrderProductConcat__c",
|
||||
"label": "SalesOrderProductConcat",
|
||||
"formulaExpression": "concat(coalesce(\"SalesOrder.ssot__Id__c\",'NULL'),coalesce(\"SalesOrder.ssot__Id__c\",''))",
|
||||
"type": "TEXT",
|
||||
"businessType": "TEXT",
|
||||
"precision": 60,
|
||||
"defaultValue": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Source.** `FormulaNodeInputRepresentation` + `FormulaParametersInputRepresentation` +
|
||||
`SqlFormulaFieldInputRepresentation`.
|
||||
|
||||
---
|
||||
|
||||
## `action: "computeRelative"` — UI: "Window Transform"
|
||||
|
||||
**Purpose.** A formula that evaluates a **window function** over partitioned, ordered rows.
|
||||
Used for ranking, lead/lag, running totals, etc.
|
||||
|
||||
**Key parameters** (class `ComputeRelativeParametersInputRepresentation`):
|
||||
|
||||
| Param | Type | Notes |
|
||||
|---|---|---|
|
||||
| `partitionBy` | `string[]` | Column(s) to partition rows by. Empty = whole stream. |
|
||||
| `orderBy` | `ComputeRelativeSortParametersInputRepresentation[]` | Each `{ fieldName, direction: "ASC" \| "DESC" }`. |
|
||||
| `expressionType` | enum `FormulaExpressionType`: `SQL`, `DCSQL` | |
|
||||
| `fields` | `SqlFormulaFieldInputRepresentation[]` | Same shape as `formula` fields. |
|
||||
|
||||
**Lineage effect.** Adds one or more columns. Original columns pass through. Row cardinality
|
||||
unchanged.
|
||||
|
||||
**Gotchas.**
|
||||
- The documentation states "A formula can include only one Compute Relative function" — if
|
||||
you see multiple compute-relative calls in one expression, flag as unusual.
|
||||
- If `partitionBy` is empty, the function runs over the whole stream (one big window).
|
||||
- `orderBy` is required for order-dependent functions (`row_number`, `rank`, `lag`, etc.).
|
||||
Without it, results are non-deterministic.
|
||||
|
||||
**Example.**
|
||||
```jsonc
|
||||
{
|
||||
"action": "computeRelative",
|
||||
"sources": ["LOAD_ORDERS"],
|
||||
"parameters": {
|
||||
"partitionBy": ["ssot__AccountId__c"],
|
||||
"orderBy": [{"fieldName": "ssot__CreatedDate__c", "direction": "ASC"}],
|
||||
"expressionType": "SQL",
|
||||
"fields": [
|
||||
{
|
||||
"name": "OrderRank__c",
|
||||
"label": "Order Rank",
|
||||
"formulaExpression": "row_number()",
|
||||
"type": "NUMBER",
|
||||
"businessType": "NUMBER",
|
||||
"precision": 18,
|
||||
"scale": 0,
|
||||
"defaultValue": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Source.** `ComputeRelativeNodeInputRepresentation` +
|
||||
`ComputeRelativeParametersInputRepresentation` +
|
||||
`ComputeRelativeSortParametersInputRepresentation`. For the function catalog, see
|
||||
`bdt-window-functions.md`.
|
||||
|
||||
---
|
||||
|
||||
## `action: "aggregate"` — UI: "Aggregate" / "Group and Aggregate"
|
||||
|
||||
**Purpose.** Group-by aggregation. Also supports a hierarchical mode for parent/child aggregation.
|
||||
|
||||
**Key parameters** (class `AggregateParametersInputRepresentation`):
|
||||
|
||||
| Param | Type | Notes |
|
||||
|---|---|---|
|
||||
| `groupings` | `string[]` | Group-by field names. **Not** `groupBy`. |
|
||||
| `aggregations` | `AggregateInputRepresentation[]` | Each `{ action: AggregateType, name, source, label? }`. |
|
||||
| `nodeType` | enum `AggregateNodeEnum`: `STANDARD`, `HIERARCHICAL` | |
|
||||
| `selfField`, `parentField`, `percentageField` | `string` (hierarchical-only) | |
|
||||
| `pivot_v2` | `PivotV2InputRepresentation` (optional) | Advanced; pivot in the same node. |
|
||||
|
||||
**Aggregation functions** (enum `AggregateType`):
|
||||
`UNIQUE`, `SUM`, `AVG`, `COUNT`, `MAX`, `MIN`, `MEDIAN`, `STDDEVP`, `STDDEV`, `VARP`, `VAR`.
|
||||
|
||||
**Lineage effect.**
|
||||
- Output columns = `groupings` (pass-through) + each `aggregation.name` (new derived column).
|
||||
- Row cardinality: one output row per unique combination of `groupings`.
|
||||
|
||||
**Gotchas.**
|
||||
- Group-by column is called `groupings` not `groupBy` in the JSON.
|
||||
- `aggregation.source` is the field being aggregated; `aggregation.name` is the output column name.
|
||||
- In `HIERARCHICAL` mode, the three `*Field` parameters carry parent-child semantics; the
|
||||
aggregations roll up across the hierarchy.
|
||||
|
||||
**Example.**
|
||||
```jsonc
|
||||
{
|
||||
"action": "aggregate",
|
||||
"sources": ["FILTER0"],
|
||||
"parameters": {
|
||||
"groupings": ["ssot__AccountId__c"],
|
||||
"aggregations": [
|
||||
{"action": "SUM", "name": "TotalAmount__c", "source": "ssot__GrandTotalAmount__c"},
|
||||
{"action": "COUNT", "name": "OrderCount__c", "source": "ssot__Id__c"}
|
||||
],
|
||||
"nodeType": "STANDARD"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Source.** `AggregateNodeInputRepresentation` + `AggregateParametersInputRepresentation` +
|
||||
`AggregateInputRepresentation`.
|
||||
|
||||
---
|
||||
|
||||
## `action: "schema"` — UI: "Edit Attributes" / "Drop Fields"
|
||||
|
||||
**Purpose.** Modify column-level schema: rename columns, change properties, or slice the set
|
||||
of columns.
|
||||
|
||||
**Key parameters** (class `SchemaParametersInputRepresentation`):
|
||||
|
||||
| Param | Type | Notes |
|
||||
|---|---|---|
|
||||
| `fields` | `SchemaFieldParametersInputRepresentation[]` | Per-field: `{ name, newProperties: { name?, label? } }`. |
|
||||
| `slice` | `SchemaSliceInputRepresentation` (optional) | `{ mode: "DROP" \| "SELECT", fields: string[], ignoreMissingFields: bool }`. |
|
||||
|
||||
**Slice semantics.**
|
||||
- `mode: DROP` — remove the listed fields from the output.
|
||||
- `mode: SELECT` — keep only the listed fields.
|
||||
- `ignoreMissingFields: true` means listed fields that aren't present are silently ignored.
|
||||
Without this flag, missing fields would error.
|
||||
|
||||
**Lineage effect.** Schema-only: renames or drops columns. Row cardinality unchanged.
|
||||
|
||||
**Gotchas.**
|
||||
- `ignoreMissingFields: true` plus a typo = silent failure. When explaining a schema node
|
||||
that uses it, flag the risk ("these field names are silently skipped if missing").
|
||||
- `fields[].newProperties.name` is where a rename lands; the original `name` is the key.
|
||||
|
||||
**Example (drop).**
|
||||
```jsonc
|
||||
{
|
||||
"action": "schema",
|
||||
"sources": ["FORMULA43"],
|
||||
"parameters": {
|
||||
"slice": {
|
||||
"mode": "DROP",
|
||||
"ignoreMissingFields": true,
|
||||
"fields": ["ssot__SalesOrderProductConcat__c", "FirstPurchase__c"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example (rename).**
|
||||
```jsonc
|
||||
{
|
||||
"action": "schema",
|
||||
"sources": ["AGGREGATE0"],
|
||||
"parameters": {
|
||||
"fields": [
|
||||
{"name": "sum_amount", "newProperties": {"name": "TotalAmount__c", "label": "Total Amount"}}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Source.** `SchemaNodeInputRepresentation` + `SchemaParametersInputRepresentation` +
|
||||
`SchemaFieldParametersInputRepresentation` + `SchemaSliceInputRepresentation`.
|
||||
|
||||
---
|
||||
|
||||
## `action: "outputD360"` — UI: "Output" / "Writeback"
|
||||
|
||||
**Purpose.** Writes the row stream to a target DMO or DLO. Every BDT has at least one
|
||||
outputD360 node — these are the graph sinks.
|
||||
|
||||
**Key parameters** (class `OutputD360ParametersInputRepresentation`):
|
||||
|
||||
| Param | Type | Notes |
|
||||
|---|---|---|
|
||||
| `name` | `string` | Target object's API name (e.g., `Account_Upper__dlm`). |
|
||||
| `type` | enum `D360OutputTypeEnum`: `dataModelObject`, `dataLakeObject` | |
|
||||
| `writeMode` | enum `WriteModeEnum`: `APPEND`, `MERGE`, `OVERWRITE`, `MERGE_UPSERT_DELETE`, `DELETE_ONLY` | Write semantics. |
|
||||
| `fieldsMappings` | `OutputD360FieldsMappingInputRepresentation[]` | Each `{ sourceField, targetField }` pair. |
|
||||
| `dedupOrder` | `SortSpecificationRepresentation[]` | Tiebreaker for deduplicating records with the same primary key. |
|
||||
| `streaming` | `StreamingParametersInputRepresentation` (optional) | Streaming-specific; not relevant for BDT. |
|
||||
|
||||
**Write modes.**
|
||||
- `APPEND` — add rows; no primary-key checks.
|
||||
- `OVERWRITE` — replace the target with the dataset.
|
||||
- `MERGE` — merge on PK: update matching rows (only for columns present in the input), insert
|
||||
new rows.
|
||||
- `MERGE_UPSERT_DELETE` — merge with per-row UPSERT/DELETE markers.
|
||||
- `DELETE_ONLY` — delete matching rows.
|
||||
|
||||
The underlying DaaS library supports more modes (`OVERWRITE_PARTITIONS`,
|
||||
`OVERWRITE_PARTITION_FILTER`, `SECONDARY_INDEX_INCREMENTAL_WRITE`), but the BDT Connect API
|
||||
exposes only the five above.
|
||||
|
||||
**Lineage effect.** Terminal node. Maps source-stream fields to target-object fields; any
|
||||
unmapped source field is discarded.
|
||||
|
||||
**Gotchas.**
|
||||
- `OUTPUT0 nodes must have DataModelObject type` is a known restriction in some frameworks
|
||||
(data-kit templates). If a BDT has `type: dataLakeObject` and is failing to install in a
|
||||
template context, that may be the cause.
|
||||
- If multiple input rows have the same primary key, `dedupOrder` decides which wins.
|
||||
- Fields not listed in `fieldsMappings` are not written.
|
||||
|
||||
**Example.**
|
||||
```jsonc
|
||||
{
|
||||
"action": "outputD360",
|
||||
"sources": ["DROP_FIELDS0"],
|
||||
"parameters": {
|
||||
"name": "Kohler_Internal_Users__dlm",
|
||||
"type": "dataModelObject",
|
||||
"writeMode": "MERGE",
|
||||
"fieldsMappings": [
|
||||
{"sourceField": "Formatted_MobilePhone__c", "targetField": "Formatted_MobilePhone__c"},
|
||||
{"sourceField": "Kohler_Internal_User_Flag", "targetField": "Kohler_Internal_User_Flag__c"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Source.** `OutputD360NodeInputRepresentation` + `OutputD360ParametersInputRepresentation` +
|
||||
`OutputD360FieldsMappingInputRepresentation`.
|
||||
|
||||
---
|
||||
|
||||
## `action: "appendV2"` — UI: "Append"
|
||||
|
||||
**Purpose.** Union rows from two or more upstream streams into one output.
|
||||
|
||||
**Key parameters** (class `AppendParametersInputRepresentation`):
|
||||
|
||||
| Param | Type | Notes |
|
||||
|---|---|---|
|
||||
| `columnMappings` | `Map<string, string>` | Map of source-stream-node-name → column mapping. |
|
||||
| `fieldMappings` | `AppendMappingInputRepresentation[]` | Explicit per-field mappings. |
|
||||
| `allowImplicitDisjointSchema` | `boolean` | When true, sources with different fields are merged; missing fields become NULL. |
|
||||
|
||||
**Lineage effect.**
|
||||
- Rows: union of input streams.
|
||||
- Columns: the merged schema (union if `allowImplicitDisjointSchema`; otherwise intersection).
|
||||
|
||||
**Gotchas.**
|
||||
- Source nodes must have the same column count *and* matching column names (in order), unless
|
||||
`allowImplicitDisjointSchema: true`.
|
||||
- Append can accept up to 200 fields total from its sources (per help docs).
|
||||
|
||||
**Example.**
|
||||
```jsonc
|
||||
{
|
||||
"action": "appendV2",
|
||||
"sources": ["LOAD_A", "LOAD_B"],
|
||||
"parameters": {
|
||||
"fieldMappings": [
|
||||
{"targetField": "Id__c", "sources": [{"node": "LOAD_A", "field": "ssot__Id__c"},
|
||||
{"node": "LOAD_B", "field": "ssot__Id__c"}]}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Source.** `AppendV2NodeInputRepresentation` + `AppendParametersInputRepresentation` +
|
||||
`AppendMappingInputRepresentation`.
|
||||
|
||||
---
|
||||
|
||||
## `action: "split"` — UI: "Split"
|
||||
|
||||
**Purpose.** Split the value of one source string field into multiple target columns based on a delimiter. One row in, one row out — each row's `sourceField` is split into the named `targetFields`.
|
||||
|
||||
**Key parameters** (class `SplitParametersInputRepresentation`):
|
||||
|
||||
| Param | Type | Notes |
|
||||
|---|---|---|
|
||||
| `sourceField` | `string` | Name of the field whose value will be split. |
|
||||
| `delimiter` | `string` | Delimiter used to split the source value. |
|
||||
| `targetFields` | `{name, label}[]` | One entry per column the split produces. Order matches the left-to-right order of the split parts. |
|
||||
|
||||
**Lineage effect.**
|
||||
- Rows: unchanged. Row cardinality is preserved — split does not route rows into branches.
|
||||
- Columns: adds each `targetFields[i].name` as a new column. The original `sourceField` passes through unchanged.
|
||||
|
||||
**Gotchas.**
|
||||
- `split` is string-splitting, not row-routing. If you need to route rows into multiple branches based on predicates, use `filter` nodes downstream of a common source, not `split`.
|
||||
- If a row's `sourceField` has fewer delimited parts than the `targetFields` length, the remaining target columns are populated with NULL (no error).
|
||||
- If a row's `sourceField` has more delimited parts than `targetFields` length, the extra parts are discarded.
|
||||
|
||||
**Example.**
|
||||
```jsonc
|
||||
{
|
||||
"action": "split",
|
||||
"sources": ["LOAD_RAW"],
|
||||
"parameters": {
|
||||
"sourceField": "FullName__c",
|
||||
"delimiter": " ",
|
||||
"targetFields": [
|
||||
{"name": "FirstName__c", "label": "First Name"},
|
||||
{"name": "LastName__c", "label": "Last Name"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Source.** `SplitNodeInputRepresentation` + `SplitParametersInputRepresentation` + `NameLabelInputRepresentation`.
|
||||
|
||||
---
|
||||
|
||||
## `action: "flatten"` / `"flattenJson"` — UI: "Flatten" / "Flatten JSON"
|
||||
|
||||
**Purpose.**
|
||||
- `flatten` — flatten a nested (array-valued or structured) field into additional rows.
|
||||
- `flattenJson` — flatten a JSON string field by parsing it and emitting its fields as
|
||||
columns (or extracting array elements as rows via a subsequent `extractTable`).
|
||||
|
||||
**Key parameters** (`FlattenParametersInputRepresentation`,
|
||||
`FlattenJsonParametersInputRepresentation`):
|
||||
|
||||
- `fields` — list of `FlattenFieldInputRepresentation { name, attributePath?, label? }`.
|
||||
- For JSON, a schema description may be embedded.
|
||||
|
||||
**Lineage effect.** May increase rows (array-explode) or add columns (object-flatten).
|
||||
|
||||
**Source.** `FlattenNodeInputRepresentation`, `FlattenJsonNodeInputRepresentation`.
|
||||
|
||||
---
|
||||
|
||||
## `action: "extractGrains"` — UI: "Extract Grains"
|
||||
|
||||
**Purpose.** Expand rows across time grains — e.g., a date column + list of grains produces
|
||||
one row per source row per grain.
|
||||
|
||||
**Key parameters** (class `ExtractGrainParametersInputRepresentation`):
|
||||
|
||||
- `grainExtractions` — each `{ source, targets: [{ name, label, grainType }] }`.
|
||||
- `dateConfigurationName` — which date configuration (fiscal calendar, etc.) to use.
|
||||
|
||||
**Valid `grainType` values** (enum `DateGrain`):
|
||||
`YEAR, QUARTER, MONTH, WEEK, DAY, HOUR, MINUTE, SECOND, DAY_EPOCH, SEC_EPOCH, FISCAL_YEAR,
|
||||
FISCAL_QUARTER, FISCAL_MONTH, FISCAL_WEEK`.
|
||||
|
||||
**Lineage effect.** Usually adds one column per grain type; may or may not multiply rows
|
||||
depending on configuration.
|
||||
|
||||
**Source.** `ExtractGrainNodeInputRepresentation` + `ExtractGrainParametersInputRepresentation`.
|
||||
|
||||
---
|
||||
|
||||
## `action: "extractTable"` — UI: "Extract Table"
|
||||
|
||||
**Purpose.** Pairs with `flattenJson`: extract a named table (from a JSON array) as a
|
||||
separate output stream.
|
||||
|
||||
**Key parameters.** See `ExtractTableParametersInputRepresentation`.
|
||||
|
||||
**Source.** `ExtractTableNodeInputRepresentation`.
|
||||
|
||||
---
|
||||
|
||||
## `action: "typeCast"` — UI: "Type Cast"
|
||||
|
||||
**Purpose.** Cast one or more fields to new types.
|
||||
|
||||
**Key parameters.** See `TypecastParametersInputRepresentation`,
|
||||
`SchemaTypePropertiesCastInputRepresentation`.
|
||||
|
||||
**Lineage effect.** Schema-only; row cardinality unchanged.
|
||||
|
||||
---
|
||||
|
||||
## `action: "bucket"` — UI: "Bucket Date/Dimension/Measure"
|
||||
|
||||
**Purpose.** Assign bucket labels to values of a source field, per a bucket setup.
|
||||
|
||||
**Key parameters** (class `BucketParametersInputRepresentation`):
|
||||
|
||||
- `fields` — list of `BucketFieldInputRepresentation`, polymorphic on the field type:
|
||||
- Boolean: `BucketBooleanFieldInputRepresentation`
|
||||
- DateOnly: `BucketDateOnlyFieldInputRepresentation`
|
||||
- DateTime: `BucketDateTimeFieldInputRepresentation`
|
||||
- Dimension: `BucketDimensionFieldInputRepresentation`
|
||||
- Measure: `BucketMeasureFieldInputRepresentation`
|
||||
|
||||
Each sub-class includes a `setup` object describing the buckets (ranges, algorithms, labels).
|
||||
|
||||
**Algorithm type** (enum `BucketAlgorithmType`): `TYPOGRAPHIC_CLUSTERING` (and potentially
|
||||
others — verify against sample if needed).
|
||||
|
||||
**Lineage effect.** Adds a derived bucket-label column. Row cardinality unchanged.
|
||||
|
||||
---
|
||||
|
||||
## `action: "formatDate"` — UI: "Format Dates"
|
||||
|
||||
**Purpose.** Reformat date fields — e.g., parse a custom string format into a date type,
|
||||
or produce a formatted text representation.
|
||||
|
||||
**Key parameters.** See `FormatDateParametersInputRepresentation`,
|
||||
`FormatDatePatternInputRepresentation`.
|
||||
|
||||
---
|
||||
|
||||
## `action: "update"` — UI: "Update"
|
||||
|
||||
**Purpose.** Update records in place in the pipeline (used in specific update workflows).
|
||||
|
||||
**Key parameters.** See `UpdateParametersInputRepresentation`.
|
||||
|
||||
---
|
||||
|
||||
## `action: "extension"` / `"extensionFunction"` — UI: custom extensions
|
||||
|
||||
**Purpose.** Run a custom extension node / function registered with Data Cloud.
|
||||
|
||||
**Key parameters.** See `ExtensionParametersInputRepresentation`,
|
||||
`ExtensionFunctionParametersInputRepresentation`,
|
||||
`ExtensionFunctionOutputFieldInputRepresentation`.
|
||||
|
||||
**Gotcha.** Extensions are user-defined; explanation must rely on parameter content since
|
||||
the semantics are defined outside the BDT spec.
|
||||
|
||||
---
|
||||
|
||||
## `action: "cdpPredict"` — UI: "Predict"
|
||||
|
||||
**Purpose.** Apply a prediction model (CDP Predict / Einstein).
|
||||
|
||||
**Key parameters.** See `CdpPredictNodeInputRepresentation` +
|
||||
`CdpPredictParametersInputRepresentation` + `PredictionFieldInputRepresentation` +
|
||||
`PredictSourceInputRepresentation`.
|
||||
|
||||
---
|
||||
|
||||
## `action: "jsonAggregate"` — UI: "JSON Aggregate"
|
||||
|
||||
**Purpose.** Aggregate JSON-valued fields into structured output.
|
||||
|
||||
**Key parameters.** See the `JsonAggregateEnum` enum and the relevant input representations
|
||||
(currently sparsely documented — inspect raw parameters when encountered).
|
||||
|
||||
---
|
||||
|
||||
## `action: "save"` — UI: "Save"
|
||||
|
||||
**Purpose.** Save an intermediate result to a checkpoint (not a final output). Less common.
|
||||
|
||||
---
|
||||
|
||||
## `action: "recommendation"` — UI: "Recommendation"
|
||||
|
||||
**Purpose.** Apply a recommendation model (product recommendations).
|
||||
|
||||
**Key parameters.** See `PredictionContributorInputRepresentation`.
|
||||
|
||||
---
|
||||
|
||||
## Common `schema.slice` block (can appear on most node types)
|
||||
|
||||
Many nodes accept an optional top-level `schema.slice` block to trim fields at the node
|
||||
boundary. Its shape is always the same:
|
||||
|
||||
```jsonc
|
||||
"schema": {
|
||||
"slice": {
|
||||
"mode": "DROP" | "SELECT",
|
||||
"fields": ["Field1", "Field2"],
|
||||
"ignoreMissingFields": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `DROP` — remove the listed fields.
|
||||
- `SELECT` — keep only the listed fields.
|
||||
- `ignoreMissingFields: true` — silent skip when a listed field doesn't exist.
|
||||
|
||||
---
|
||||
|
||||
## Polymorphism map (for JSON parsers)
|
||||
|
||||
Several input reps are polymorphic; the discriminator is a JSON property:
|
||||
|
||||
- `AbstractBucketAlgorithmInputRepresentation` discriminated by `"type"`:
|
||||
- `"TYPOGRAPHIC_CLUSTERING"` → `TypographicClusterInputRepresentation`.
|
||||
|
||||
Other polymorphic classes (e.g., `SqlFormulaFieldInputRepresentation` → numeric / text /
|
||||
boolean / date variants) are resolved at deserialization based on `type`. When narrating,
|
||||
the field-level JSON usually carries the concrete fields directly — no special handling
|
||||
needed.
|
||||
|
||||
## Source citations
|
||||
|
||||
Every section above traces back to one or more classes in the BDT Connect API
|
||||
`cdp-connect-api` module, packages `sfdc.cdp.connect.api.{input,enums}.datatransform`.
|
||||
See `research/bdt-schema-canonical.md` for the full machine-extracted schema plus
|
||||
version-drift verification across releases 260, 262, and 264.
|
||||
179
skills/data-cloud-bdt-expert/references/bdt-reference.md
Normal file
179
skills/data-cloud-bdt-expert/references/bdt-reference.md
Normal file
@ -0,0 +1,179 @@
|
||||
# BDT Reference — Overview (always loaded)
|
||||
|
||||
> **Last synced:** 2026-04-23 from canonical BDT Connect API Java sources
|
||||
> (`cdp-connect-api` module, `sfdc.cdp.connect.api.{input,enums}.datatransform` packages,
|
||||
> release 264). Cross-verified identical across releases 260, 262, and 264. See
|
||||
> `research/bdt-schema-canonical.md` for the full machine-extracted schema.
|
||||
|
||||
## What a BDT is
|
||||
|
||||
**Batch Data Transform (BDT)** — a repeatable, scheduled or on-demand data pipeline inside
|
||||
Salesforce Data Cloud. It reads from one or more source objects, applies a DAG of
|
||||
transformations (joins, filters, formulas, aggregations, etc.), and writes one or more
|
||||
outputs. BDTs run over batches; their streaming counterpart is **SDT** (Streaming Data
|
||||
Transform).
|
||||
|
||||
- Salesforce object type: `MktDataTransform`.
|
||||
- Typical export: a JSON file from the BDT viewer, or via Workbench
|
||||
`/ssot/data-transforms?htmlEncode=false`.
|
||||
- Underlying runtime: the Data Processing Engine (DPE) / DCSQL.
|
||||
|
||||
## Top-level JSON shape
|
||||
|
||||
A BDT JSON can arrive in **three** shapes. The skill's parser accepts all three
|
||||
transparently (since commit `ee2485c`); the inner node graph is identical in every case.
|
||||
|
||||
### 1. Editor export (the default "download" shape)
|
||||
|
||||
What you get when you export a BDT from the BDT editor UI, or fetch it via
|
||||
`/ssot/data-transforms?htmlEncode=false` on Workbench. The outer object is the
|
||||
**definition** itself, with no wrapper metadata:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": "66.0", // Schema version string
|
||||
"nodes": { /* NodeName → Node */ },
|
||||
"ui": { // Optional: layout hints + human-readable labels
|
||||
"nodes": { /* NodeName → { label, description, type, top, left } */ },
|
||||
"connectors": [ /* { source, target } edges */ ],
|
||||
"hiddenColumns": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** editor exports have no top-level `type` field. The definition-type
|
||||
discriminator (`"STL"`) only appears when the definition is nested inside a Connect
|
||||
API payload (see below).
|
||||
|
||||
### 2. Connect API create payload — single definition
|
||||
|
||||
What developers POST to the `MktDataTransform` Connect API when authoring
|
||||
programmatically. The outer object is a `DataTransformInputRepresentation`; the
|
||||
editor-export shape is nested inside under `definition`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "MyTransform", // Required — API name of the transform
|
||||
"label": "My Transform", // Required — user-visible label
|
||||
"description": "…", // Optional
|
||||
"type": "BATCH", // DataTransformType: "BATCH" | "STREAMING"
|
||||
"dataSpaceName": "default", // Data space the transform lives in
|
||||
"definition": {
|
||||
"version": "66.0",
|
||||
"type": "STL", // DataTransformDefinitionType: "STL" | "SQL" | "DCSQL" | …
|
||||
"nodes": { /* … */ },
|
||||
"ui": { /* … */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Two distinct `type` fields live at different nesting levels:
|
||||
- Outer `type` = `DataTransformType` (batch vs streaming).
|
||||
- Inner `definition.type` = `DataTransformDefinitionType` (STL for batch BDTs; SQL /
|
||||
DCSQL for raw-SQL transforms; hidden variants exist but are not surfaced to users).
|
||||
|
||||
### 3. Connect API create payload — multi-definition variant
|
||||
|
||||
For transforms with multiple definitions. Same outer fields; instead of a single
|
||||
`definition` object there's a `definitions` array:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "…", "label": "…", "type": "BATCH", "dataSpaceName": "default",
|
||||
"definitions": [
|
||||
{ "version": "66.0", "type": "STL", "nodes": { /* … */ }, "ui": { /* … */ } },
|
||||
/* … additional definitions … */
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
v1 of this skill reads the first definition and explains only that one; multi-definition
|
||||
explanations are out of scope.
|
||||
|
||||
### Each node (inner shape, identical in all three outer shapes)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"action": "<action-type>", // One of the 25 documented action strings below
|
||||
"sources": ["NODE_A", "NODE_B"], // Array of node names (empty for load/roots)
|
||||
"parameters": { /* action-specific */ },
|
||||
"schema": { /* optional — slice/rename at node boundary */ }
|
||||
}
|
||||
```
|
||||
|
||||
## The 25 canonical action types
|
||||
|
||||
| `action` string | Purpose (one line) |
|
||||
|---|---|
|
||||
| `load` | Read rows from a DMO or DLO |
|
||||
| `filter` | Keep rows matching structured filter criteria |
|
||||
| `sqlFilter` | Keep rows matching a raw SQL predicate |
|
||||
| `join` | Join two upstream streams by key |
|
||||
| `formula` | Add derived columns via per-row SQL formulas |
|
||||
| `computeRelative` | Window function over partitioned / ordered rows |
|
||||
| `aggregate` | Group-by aggregation |
|
||||
| `extractGrains` | Fan out rows across time grains |
|
||||
| `extractTable` | Extract a table from a flatten-JSON output |
|
||||
| `schema` | Rename / drop / reshape columns |
|
||||
| `outputD360` | Write rows to a target DMO or DLO |
|
||||
| `appendV2` | Union rows from multiple upstream streams (v2 variant) |
|
||||
| `flatten` | Flatten nested structure |
|
||||
| `flattenJson` | Flatten a JSON field into rows/columns |
|
||||
| `split` | Split rows into multiple downstream branches |
|
||||
| `typeCast` | Cast field types |
|
||||
| `update` | Update records |
|
||||
| `bucket` | Assign bucket labels to field values |
|
||||
| `formatDate` | Reformat date values |
|
||||
| `extension` | Run a custom extension node |
|
||||
| `extensionFunction` | Run a custom extension function |
|
||||
| `cdpPredict` | Apply a prediction model |
|
||||
| `jsonAggregate` | Aggregate JSON fields |
|
||||
| `save` | Save an intermediate result |
|
||||
| `recommendation` | Apply a recommendation model |
|
||||
|
||||
**For full parameter shapes, enums, and examples: see `references/bdt-node-catalog.md`.**
|
||||
**For SFSQL functions used in `formula` expressions: see `references/bdt-function-catalog.md`.**
|
||||
**For window functions used in `computeRelative`: see `references/bdt-window-functions.md`.**
|
||||
|
||||
## Terminology
|
||||
|
||||
- **DMO** — Data Model Object. Canonical modeled entity. Suffix convention: `__dlm`.
|
||||
- **DLO** — Data Lake Object. Raw-lake table. Suffix convention: `__dll`.
|
||||
- **SFSQL** — Salesforce's internal SQL dialect used in formula expressions and SQL filters.
|
||||
- **DCSQL** — a related SQL dialect used for Spark push-down execution (you may see it as
|
||||
an `expressionType` value).
|
||||
- **`ssot__` prefix** — Salesforce Standard Schema field, canonical / semantic.
|
||||
- **`__c` suffix** — Custom field.
|
||||
- **`KQ_` prefix** — Key-qualifier pattern (external ID / composite-key helper). Observed in
|
||||
practice; not always documented.
|
||||
- **UI label vs. JSON action name** — The JSON uses concise strings (e.g., `computeRelative`);
|
||||
the BDT editor shows friendlier labels (e.g., "Window Transform"). The `ui.nodes[X].label`
|
||||
field in the JSON carries the user-specified human-readable name per node. **Always use
|
||||
the UI label in explanations when present.**
|
||||
|
||||
## Key enums (for quick orientation)
|
||||
|
||||
- **`DataTransformType`**: `BATCH`, `STREAMING`
|
||||
- **`JoinType`**: `INNER`, `OUTER`, `LEFT_OUTER`, `RIGHT_OUTER`, `LOOKUP`, `MULTI_VALUE_LOOKUP`, `CROSS`
|
||||
- **`WriteModeEnum`** (outputD360): `APPEND`, `MERGE`, `OVERWRITE`, `MERGE_UPSERT_DELETE`, `DELETE_ONLY`
|
||||
- **`D360OutputTypeEnum`**: `dataLakeObject`, `dataModelObject`
|
||||
- **`SliceMode`**: `SELECT`, `DROP`
|
||||
- **`AggregateType`**: `UNIQUE`, `SUM`, `AVG`, `COUNT`, `MAX`, `MIN`, `MEDIAN`, `STDDEVP`, `STDDEV`, `VARP`, `VAR`
|
||||
- **`DataType`**: `TEXT`, `NUMBER`, `DATE_ONLY`, `DATETIME`, `BOOLEAN`
|
||||
- **`SortDirection`**: `ASC`, `DESC`
|
||||
|
||||
For exhaustive enum lists with semantics: `bdt-node-catalog.md` or (for the raw extraction)
|
||||
`research/bdt-schema-canonical.md`.
|
||||
|
||||
## How this skill uses these references
|
||||
|
||||
- `bdt-reference.md` (this file) — always in context.
|
||||
- `bdt-node-catalog.md` — consulted before explaining any node type not previously covered
|
||||
in the current conversation.
|
||||
- `bdt-function-catalog.md` — consulted before interpreting any `formulaExpression` (job I1)
|
||||
or reasoning about conditional logic (I2).
|
||||
- `bdt-window-functions.md` — consulted whenever a `computeRelative` node is narrated.
|
||||
|
||||
**If a node type or function isn't in any of the reference files**, the skill narrates what's
|
||||
visible in the JSON and explicitly flags the item as undocumented in the materials available.
|
||||
It does not guess at semantics.
|
||||
@ -0,0 +1,73 @@
|
||||
# Window Functions — for `computeRelative` nodes
|
||||
|
||||
> **Last synced:** 2026-04-23 from the SFSQL window-functions reference and the BDT
|
||||
> canonical schema. Consult this file whenever narrating a `computeRelative` node or
|
||||
> explaining a window-function expression.
|
||||
|
||||
## When this applies
|
||||
|
||||
`computeRelative` nodes evaluate a **window function** over rows. The `parameters`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"partitionBy": ["ssot__AccountId__c"], // → SQL `PARTITION BY`
|
||||
"orderBy": [ // → SQL `ORDER BY`
|
||||
{"fieldName": "ssot__CreatedDate__c", "direction": "ASC"}
|
||||
],
|
||||
"expressionType": "SQL", // or "DCSQL"
|
||||
"fields": [
|
||||
{
|
||||
"name": "OrderRank__c",
|
||||
"formulaExpression": "row_number()", // the window function call
|
||||
"type": "NUMBER", "businessType": "Number",
|
||||
"precision": 18, "scale": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `formulaExpression` names the window function; partitioning and ordering come from the
|
||||
top-level `partitionBy` and `orderBy`. **A computeRelative node may include at most one
|
||||
compute-relative function per expression** (per upstream BDT docs).
|
||||
|
||||
## Available window functions
|
||||
|
||||
| Function | Returns | What it does |
|
||||
|---|---|---|
|
||||
| `row_number()` | NUMBER | 1, 2, 3… for each row in its partition, in the given order. Non-deterministic when sort keys tie. |
|
||||
| `rank()` | NUMBER | Like `row_number` but peers share a rank; next rank after N peers is N+1 (gaps). |
|
||||
| `dense_rank()` | NUMBER | Like `rank` but no gaps — consecutive integers even with ties. |
|
||||
| `percent_rank()` | NUMBER | `(rank - 1) / (partition_rows - 1)` — relative rank within partition, 0 to 1. |
|
||||
| `cume_dist()` | NUMBER | Cumulative distribution: fraction of partition rows at or before current. |
|
||||
| `ntile(n)` | NUMBER | Bucket number 1..n, dividing partition rows as evenly as possible. |
|
||||
| `lag(value)` / `lag(value, offset)` / `lag(value, offset, default)` | same as value | Value at offset rows *before* current (default offset=1; default if no such row is NULL unless a default is supplied). |
|
||||
| `lead(value)` / `lead(value, offset)` / `lead(value, offset, default)` | same as value | Symmetric with `lag` but looks forward. |
|
||||
| `first_value(value)` | same as value | Value at the first row of the current window frame. |
|
||||
| `last_value(value)` | same as value | Value at the last row of the frame. Default frame ends at "current + peers", which is often *not* what users want. |
|
||||
| `nth_value(value, n)` | same as value | Value at the nth row of the frame (counting from 1). NULL if no such row. |
|
||||
| Any aggregate with `OVER(...)` | depends | Runs the aggregate over the window (running sum, etc.). |
|
||||
|
||||
## How this maps to BDT JSON
|
||||
|
||||
- `partitionBy` is the SQL `PARTITION BY` — the columns that group rows into windows.
|
||||
- `orderBy` is the SQL `ORDER BY` — the ordering within each partition.
|
||||
- **Peers** are rows with identical sort keys.
|
||||
- Default **frame** (when not otherwise specified): rows from the first row of the partition
|
||||
through the current row's last peer. For `last_value` and `nth_value` this is often not
|
||||
the user's intent — narrate accordingly.
|
||||
|
||||
## Common narration patterns
|
||||
|
||||
- **`row_number()` partitioned by X** → "numbers each row within the same X, in the order
|
||||
given by `orderBy`."
|
||||
- **`rank() partitioned by X order by Y`** → "ranks rows within each X group by Y; ties
|
||||
share a rank and the next rank has gaps."
|
||||
- **`case when row_number()=1 then VAL else 0 end`** → "keeps VAL only on the first ranked
|
||||
row per partition; everything else is 0. This is the canonical 'first-occurrence extract'
|
||||
idiom."
|
||||
|
||||
## Sources
|
||||
|
||||
- SFSQL window-functions reference (internal Data Cloud / SDB SFSQL docs).
|
||||
- BDT canonical schema: `ComputeRelativeParametersInputRepresentation`,
|
||||
`ComputeRelativeSortParametersInputRepresentation`.
|
||||
1381
skills/data-cloud-bdt-expert/scripts/bdt_analyze.py
Normal file
1381
skills/data-cloud-bdt-expert/scripts/bdt_analyze.py
Normal file
File diff suppressed because it is too large
Load Diff
7
skills/data-cloud-bdt-expert/tests/fixtures/adversarial/broken_ref.json
vendored
Normal file
7
skills/data-cloud-bdt-expert/tests/fixtures/adversarial/broken_ref.json
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": "66.0",
|
||||
"nodes": {
|
||||
"LOAD0": {"action": "load", "sources": [], "parameters": {}},
|
||||
"JOIN0": {"action": "join", "sources": ["LOAD0", "NO_SUCH_NODE"], "parameters": {}}
|
||||
}
|
||||
}
|
||||
8
skills/data-cloud-bdt-expert/tests/fixtures/adversarial/cycle.json
vendored
Normal file
8
skills/data-cloud-bdt-expert/tests/fixtures/adversarial/cycle.json
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"version": "66.0",
|
||||
"nodes": {
|
||||
"A": {"action": "formula", "sources": ["C"], "parameters": {}},
|
||||
"B": {"action": "formula", "sources": ["A"], "parameters": {}},
|
||||
"C": {"action": "formula", "sources": ["B"], "parameters": {}}
|
||||
}
|
||||
}
|
||||
1
skills/data-cloud-bdt-expert/tests/fixtures/adversarial/empty_nodes.json
vendored
Normal file
1
skills/data-cloud-bdt-expert/tests/fixtures/adversarial/empty_nodes.json
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"version": "66.0", "nodes": {}}
|
||||
7
skills/data-cloud-bdt-expert/tests/fixtures/adversarial/no_ui.json
vendored
Normal file
7
skills/data-cloud-bdt-expert/tests/fixtures/adversarial/no_ui.json
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": "66.0",
|
||||
"nodes": {
|
||||
"LOAD0": {"action": "load", "sources": [], "parameters": {}},
|
||||
"OUT0": {"action": "outputD360", "sources": ["LOAD0"], "parameters": {}}
|
||||
}
|
||||
}
|
||||
7
skills/data-cloud-bdt-expert/tests/fixtures/adversarial/unknown_action.json
vendored
Normal file
7
skills/data-cloud-bdt-expert/tests/fixtures/adversarial/unknown_action.json
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": "99.0",
|
||||
"nodes": {
|
||||
"LOAD0": {"action": "load", "sources": [], "parameters": {}},
|
||||
"MYSTERY0": {"action": "someFutureAction", "sources": ["LOAD0"], "parameters": {"foo": "bar"}}
|
||||
}
|
||||
}
|
||||
71
skills/data-cloud-bdt-expert/tests/fixtures/api_input_multi.json
vendored
Normal file
71
skills/data-cloud-bdt-expert/tests/fixtures/api_input_multi.json
vendored
Normal file
@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "TestMultiTransform",
|
||||
"label": "Test Multi Transform",
|
||||
"description": "Wraps two BDTs in the Connect API multi-definitions shape",
|
||||
"type": "BATCH",
|
||||
"dataSpaceName": "default",
|
||||
"definitions": [
|
||||
{
|
||||
"version": "66.0",
|
||||
"type": "STL",
|
||||
"nodes": {
|
||||
"LOAD_A": {
|
||||
"action": "load",
|
||||
"sources": [],
|
||||
"parameters": {
|
||||
"dataset": {"name": "ssot__A__dlm", "type": "dataModelObject"},
|
||||
"fields": ["ssot__Id__c"],
|
||||
"sampleDetails": {"type": "TopN", "sortBy": []}
|
||||
}
|
||||
},
|
||||
"OUTPUT_A": {
|
||||
"action": "outputD360",
|
||||
"sources": ["LOAD_A"],
|
||||
"parameters": {
|
||||
"name": "A_Out__dlm",
|
||||
"type": "dataModelObject",
|
||||
"writeMode": "OVERWRITE",
|
||||
"fieldsMappings": [{"sourceField": "ssot__Id__c", "targetField": "ssot__Id__c"}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"nodes": {
|
||||
"LOAD_A": {"label": "Load A", "type": "LOAD_DATASET", "top": 100, "left": 100},
|
||||
"OUTPUT_A": {"label": "Output A", "type": "OUTPUT", "top": 100, "left": 260}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "66.0",
|
||||
"type": "STL",
|
||||
"nodes": {
|
||||
"LOAD_B": {
|
||||
"action": "load",
|
||||
"sources": [],
|
||||
"parameters": {
|
||||
"dataset": {"name": "ssot__B__dlm", "type": "dataModelObject"},
|
||||
"fields": ["ssot__Id__c"],
|
||||
"sampleDetails": {"type": "TopN", "sortBy": []}
|
||||
}
|
||||
},
|
||||
"OUTPUT_B": {
|
||||
"action": "outputD360",
|
||||
"sources": ["LOAD_B"],
|
||||
"parameters": {
|
||||
"name": "B_Out__dlm",
|
||||
"type": "dataModelObject",
|
||||
"writeMode": "OVERWRITE",
|
||||
"fieldsMappings": [{"sourceField": "ssot__Id__c", "targetField": "ssot__Id__c"}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"nodes": {
|
||||
"LOAD_B": {"label": "Load B", "type": "LOAD_DATASET", "top": 100, "left": 100},
|
||||
"OUTPUT_B": {"label": "Output B", "type": "OUTPUT", "top": 100, "left": 260}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
38
skills/data-cloud-bdt-expert/tests/fixtures/api_input_single.json
vendored
Normal file
38
skills/data-cloud-bdt-expert/tests/fixtures/api_input_single.json
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "TestTransform",
|
||||
"label": "Test Transform",
|
||||
"description": "Wraps minimal BDT in the Connect API single-definition shape",
|
||||
"type": "BATCH",
|
||||
"dataSpaceName": "default",
|
||||
"definition": {
|
||||
"version": "66.0",
|
||||
"type": "STL",
|
||||
"nodes": {
|
||||
"LOAD_X": {
|
||||
"action": "load",
|
||||
"sources": [],
|
||||
"parameters": {
|
||||
"dataset": {"name": "ssot__X__dlm", "type": "dataModelObject"},
|
||||
"fields": ["ssot__Id__c"],
|
||||
"sampleDetails": {"type": "TopN", "sortBy": []}
|
||||
}
|
||||
},
|
||||
"OUTPUT_X": {
|
||||
"action": "outputD360",
|
||||
"sources": ["LOAD_X"],
|
||||
"parameters": {
|
||||
"name": "X_Out__dlm",
|
||||
"type": "dataModelObject",
|
||||
"writeMode": "OVERWRITE",
|
||||
"fieldsMappings": [{"sourceField": "ssot__Id__c", "targetField": "ssot__Id__c"}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"nodes": {
|
||||
"LOAD_X": {"label": "Load X", "type": "LOAD_DATASET", "top": 100, "left": 100},
|
||||
"OUTPUT_X": {"label": "Output X", "type": "OUTPUT", "top": 100, "left": 260}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
57
skills/data-cloud-bdt-expert/tests/fixtures/minimal.json
vendored
Normal file
57
skills/data-cloud-bdt-expert/tests/fixtures/minimal.json
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
{
|
||||
"version": "66.0",
|
||||
"nodes": {
|
||||
"LOAD_DATASET0": {
|
||||
"action": "load",
|
||||
"sources": [],
|
||||
"parameters": {
|
||||
"dataset": {"name": "ssot__Account__dlm", "type": "dataModelObject"},
|
||||
"fields": ["ssot__Id__c", "ssot__Name__c"],
|
||||
"sampleDetails": {"type": "TopN", "sortBy": []}
|
||||
}
|
||||
},
|
||||
"FORMULA0": {
|
||||
"action": "formula",
|
||||
"sources": ["LOAD_DATASET0"],
|
||||
"parameters": {
|
||||
"expressionType": "SQL",
|
||||
"fields": [
|
||||
{
|
||||
"name": "AccountNameUpper__c",
|
||||
"label": "Account Name Upper",
|
||||
"formulaExpression": "upper(ssot__Name__c)",
|
||||
"type": "TEXT",
|
||||
"businessType": "Text",
|
||||
"precision": 255,
|
||||
"defaultValue": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"OUTPUT0": {
|
||||
"action": "outputD360",
|
||||
"sources": ["FORMULA0"],
|
||||
"parameters": {
|
||||
"name": "Account_Upper__dlm",
|
||||
"type": "dataModelObject",
|
||||
"writeMode": "OVERWRITE",
|
||||
"fieldsMappings": [
|
||||
{"sourceField": "ssot__Id__c", "targetField": "ssot__Id__c"},
|
||||
{"sourceField": "AccountNameUpper__c", "targetField": "Name_Upper__c"}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"nodes": {
|
||||
"LOAD_DATASET0": {"label": "Account", "type": "LOAD_DATASET", "top": 100, "left": 100},
|
||||
"FORMULA0": {"label": "Uppercase Name", "type": "FORMULA", "top": 100, "left": 240},
|
||||
"OUTPUT0": {"label": "Account Upper", "type": "OUTPUT", "top": 100, "left": 380}
|
||||
},
|
||||
"connectors": [
|
||||
{"source": "LOAD_DATASET0", "target": "FORMULA0"},
|
||||
{"source": "FORMULA0", "target": "OUTPUT0"}
|
||||
],
|
||||
"hiddenColumns": []
|
||||
}
|
||||
}
|
||||
61
skills/data-cloud-bdt-expert/tests/fixtures/window_and_aggregate.json
vendored
Normal file
61
skills/data-cloud-bdt-expert/tests/fixtures/window_and_aggregate.json
vendored
Normal file
@ -0,0 +1,61 @@
|
||||
{
|
||||
"version": "66.0",
|
||||
"nodes": {
|
||||
"LOAD_ORDERS": {
|
||||
"action": "load",
|
||||
"sources": [],
|
||||
"parameters": {
|
||||
"dataset": {"name": "ssot__SalesOrder__dlm", "type": "dataModelObject"},
|
||||
"fields": ["ssot__Id__c", "ssot__AccountId__c", "ssot__CreatedDate__c", "ssot__GrandTotalAmount__c"],
|
||||
"sampleDetails": {"type": "TopN", "sortBy": []}
|
||||
}
|
||||
},
|
||||
"RANK_ORDERS": {
|
||||
"action": "computeRelative",
|
||||
"sources": ["LOAD_ORDERS"],
|
||||
"parameters": {
|
||||
"partitionBy": ["ssot__AccountId__c"],
|
||||
"orderBy": [{"fieldName": "ssot__CreatedDate__c", "direction": "ASC"}],
|
||||
"expressionType": "SQL",
|
||||
"fields": [
|
||||
{
|
||||
"name": "OrderRank__c",
|
||||
"label": "Order Rank",
|
||||
"formulaExpression": "row_number()",
|
||||
"type": "NUMBER",
|
||||
"businessType": "Number",
|
||||
"precision": 18,
|
||||
"scale": 0,
|
||||
"defaultValue": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"AGG_BY_ACCOUNT": {
|
||||
"action": "aggregate",
|
||||
"sources": ["RANK_ORDERS"],
|
||||
"parameters": {
|
||||
"groupings": ["ssot__AccountId__c"],
|
||||
"aggregations": [
|
||||
{"action": "SUM", "name": "TotalAmount__c", "source": "ssot__GrandTotalAmount__c"},
|
||||
{"action": "COUNT", "name": "OrderCount__c", "source": "ssot__Id__c"}
|
||||
],
|
||||
"nodeType": "STANDARD"
|
||||
}
|
||||
},
|
||||
"OUTPUT_SUMMARY": {
|
||||
"action": "outputD360",
|
||||
"sources": ["AGG_BY_ACCOUNT"],
|
||||
"parameters": {
|
||||
"name": "Account_Summary__dlm",
|
||||
"type": "dataModelObject",
|
||||
"writeMode": "OVERWRITE",
|
||||
"fieldsMappings": [
|
||||
{"sourceField": "ssot__AccountId__c", "targetField": "AccountId__c"},
|
||||
{"sourceField": "TotalAmount__c", "targetField": "TotalAmount__c"},
|
||||
{"sourceField": "OrderCount__c", "targetField": "OrderCount__c"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
810
skills/data-cloud-bdt-expert/tests/test_bdt_analyze.py
Normal file
810
skills/data-cloud-bdt-expert/tests/test_bdt_analyze.py
Normal file
@ -0,0 +1,810 @@
|
||||
"""Unit tests for bdt_analyze.py.
|
||||
|
||||
Run:
|
||||
cd afv-library/skills/data-cloud-bdt-expert
|
||||
python -m unittest tests.test_bdt_analyze -v
|
||||
"""
|
||||
import argparse
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
# Add scripts/ to path so we can import the module
|
||||
SKILL_DIR = pathlib.Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(SKILL_DIR / "scripts"))
|
||||
|
||||
FIXTURES = pathlib.Path(__file__).resolve().parent / "fixtures"
|
||||
|
||||
import bdt_analyze # noqa: E402
|
||||
|
||||
|
||||
class TestLoad(unittest.TestCase):
|
||||
"""Loading a valid BDT JSON."""
|
||||
|
||||
def test_load_minimal(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
self.assertEqual(bdt.version, "66.0")
|
||||
self.assertEqual(len(bdt.nodes), 3)
|
||||
self.assertIn("LOAD_DATASET0", bdt.nodes)
|
||||
self.assertIn("FORMULA0", bdt.nodes)
|
||||
self.assertIn("OUTPUT0", bdt.nodes)
|
||||
|
||||
|
||||
class TestBadInput(unittest.TestCase):
|
||||
"""Adversarial / malformed inputs. Raise BdtInputError."""
|
||||
|
||||
def test_invalid_json_raises(self):
|
||||
p = FIXTURES / "_tmp_invalid.json"
|
||||
self.addCleanup(p.unlink, missing_ok=True)
|
||||
p.write_text("{not valid json")
|
||||
with self.assertRaises(bdt_analyze.BdtInputError) as cm:
|
||||
bdt_analyze.DataTransform.from_path(p)
|
||||
self.assertIn("Invalid JSON", str(cm.exception))
|
||||
|
||||
def test_missing_nodes_raises(self):
|
||||
with self.assertRaises(bdt_analyze.BdtInputError):
|
||||
bdt_analyze.DataTransform.from_dict({"version": "66.0"})
|
||||
|
||||
def test_nodes_not_object_raises(self):
|
||||
with self.assertRaises(bdt_analyze.BdtInputError):
|
||||
bdt_analyze.DataTransform.from_dict({"nodes": "not-an-object"})
|
||||
|
||||
def test_node_missing_action_raises(self):
|
||||
with self.assertRaises(bdt_analyze.BdtInputError) as cm:
|
||||
bdt_analyze.DataTransform.from_dict({
|
||||
"nodes": {"X": {"sources": []}}
|
||||
})
|
||||
self.assertIn("missing string 'action'", str(cm.exception))
|
||||
|
||||
def test_node_sources_not_list_raises(self):
|
||||
with self.assertRaises(bdt_analyze.BdtInputError):
|
||||
bdt_analyze.DataTransform.from_dict({
|
||||
"nodes": {"X": {"action": "load", "sources": "not-a-list"}}
|
||||
})
|
||||
|
||||
def test_nonexistent_file_raises(self):
|
||||
with self.assertRaises(bdt_analyze.BdtInputError):
|
||||
bdt_analyze.DataTransform.from_path(FIXTURES / "_does_not_exist.json")
|
||||
|
||||
|
||||
class TestEmptyNodes(unittest.TestCase):
|
||||
"""`nodes: {}` is valid — no graph, but don't crash."""
|
||||
|
||||
def test_empty_nodes_parses(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "adversarial" / "empty_nodes.json")
|
||||
self.assertEqual(bdt.nodes, {})
|
||||
|
||||
|
||||
class TestGraph(unittest.TestCase):
|
||||
"""Graph primitives — roots, sinks, topo order."""
|
||||
|
||||
def setUp(self):
|
||||
self.bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
|
||||
def test_roots(self):
|
||||
roots = self.bdt.roots()
|
||||
self.assertEqual(roots, ["LOAD_DATASET0"])
|
||||
|
||||
def test_sinks(self):
|
||||
sinks = self.bdt.sinks()
|
||||
self.assertEqual(sinks, ["OUTPUT0"])
|
||||
|
||||
def test_topo_order(self):
|
||||
order = self.bdt.topo_order()
|
||||
self.assertEqual(order, ["LOAD_DATASET0", "FORMULA0", "OUTPUT0"])
|
||||
|
||||
def test_topo_order_is_valid(self):
|
||||
"""Every node appears after all its sources."""
|
||||
order = self.bdt.topo_order()
|
||||
position = {name: i for i, name in enumerate(order)}
|
||||
for n in self.bdt.nodes.values():
|
||||
for s in n.sources:
|
||||
self.assertLess(position[s], position[n.name],
|
||||
f"{s} should come before {n.name}")
|
||||
|
||||
|
||||
class TestCycle(unittest.TestCase):
|
||||
"""Cycle detection surfaces a clear error."""
|
||||
|
||||
def test_cycle_raises(self):
|
||||
with self.assertRaises(bdt_analyze.BdtInputError) as cm:
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "adversarial" / "cycle.json")
|
||||
bdt.topo_order()
|
||||
msg = str(cm.exception)
|
||||
self.assertIn("Cycle", msg)
|
||||
# All three nodes are stuck
|
||||
self.assertIn("'A'", msg)
|
||||
self.assertIn("'B'", msg)
|
||||
self.assertIn("'C'", msg)
|
||||
|
||||
|
||||
class TestTraversal(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
|
||||
def test_upstream_of_output(self):
|
||||
# OUTPUT0 ← FORMULA0 ← LOAD_DATASET0
|
||||
up = self.bdt.upstream("OUTPUT0")
|
||||
self.assertEqual(up, ["LOAD_DATASET0", "FORMULA0"]) # topo order
|
||||
|
||||
def test_upstream_of_root_is_empty(self):
|
||||
self.assertEqual(self.bdt.upstream("LOAD_DATASET0"), [])
|
||||
|
||||
def test_upstream_unknown_node_raises(self):
|
||||
with self.assertRaises(bdt_analyze.BdtNotFoundError):
|
||||
self.bdt.upstream("NO_SUCH_NODE")
|
||||
|
||||
def test_downstream_of_root(self):
|
||||
# LOAD_DATASET0 → FORMULA0 → OUTPUT0
|
||||
down = self.bdt.downstream("LOAD_DATASET0")
|
||||
self.assertEqual(down, ["FORMULA0", "OUTPUT0"])
|
||||
|
||||
def test_downstream_of_sink_is_empty(self):
|
||||
self.assertEqual(self.bdt.downstream("OUTPUT0"), [])
|
||||
|
||||
|
||||
class TestBrokenReferences(unittest.TestCase):
|
||||
def test_broken_references_reported(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "adversarial" / "broken_ref.json")
|
||||
broken = bdt.broken_references()
|
||||
# One entry: (referring_node, missing_source)
|
||||
self.assertEqual(broken, [("JOIN0", "NO_SUCH_NODE")])
|
||||
|
||||
def test_no_broken_references_on_clean_bdt(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
self.assertEqual(bdt.broken_references(), [])
|
||||
|
||||
def test_topo_raises_on_broken_ref(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "adversarial" / "broken_ref.json")
|
||||
with self.assertRaises(bdt_analyze.BdtInputError):
|
||||
bdt.topo_order()
|
||||
|
||||
|
||||
class TestResilience(unittest.TestCase):
|
||||
"""Skill must never crash on schema evolution or missing ui section."""
|
||||
|
||||
def test_unknown_action_loads_without_crash(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "adversarial" / "unknown_action.json")
|
||||
self.assertEqual(bdt.nodes["MYSTERY0"].action, "someFutureAction")
|
||||
# Parameters preserved verbatim
|
||||
self.assertEqual(bdt.nodes["MYSTERY0"].parameters, {"foo": "bar"})
|
||||
# Graph operations still work
|
||||
self.assertEqual(bdt.topo_order(), ["LOAD0", "MYSTERY0"])
|
||||
|
||||
def test_no_ui_section_loads_without_crash(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "adversarial" / "no_ui.json")
|
||||
# display_name falls back to node key when no ui label
|
||||
self.assertEqual(bdt.nodes["LOAD0"].display_name, "LOAD0")
|
||||
|
||||
def test_display_name_uses_label_when_present(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
self.assertEqual(bdt.nodes["LOAD_DATASET0"].display_name, "Account (LOAD_DATASET0)")
|
||||
|
||||
|
||||
class TestSummary(unittest.TestCase):
|
||||
def test_summary_markdown_has_key_sections(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
args = argparse.Namespace(as_json=False)
|
||||
out = bdt_analyze.cmd_summary(bdt, args)
|
||||
# Top-line shape
|
||||
self.assertIn("# BDT Summary", out)
|
||||
self.assertIn("Version: 66.0", out)
|
||||
self.assertIn("Total nodes: 3", out)
|
||||
# Action counts
|
||||
self.assertIn("load: 1", out)
|
||||
self.assertIn("formula: 1", out)
|
||||
self.assertIn("outputD360: 1", out)
|
||||
# Sources table includes the load's dataset
|
||||
self.assertIn("ssot__Account__dlm", out)
|
||||
# Outputs table includes the output's target
|
||||
self.assertIn("Account_Upper__dlm", out)
|
||||
|
||||
def test_summary_json_mode(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
args = argparse.Namespace(as_json=True)
|
||||
out = bdt_analyze.cmd_summary(bdt, args)
|
||||
parsed = json.loads(out) # must be valid JSON
|
||||
self.assertEqual(parsed["version"], "66.0")
|
||||
self.assertEqual(parsed["total_nodes"], 3)
|
||||
self.assertEqual(parsed["action_counts"]["load"], 1)
|
||||
|
||||
def test_summary_empty_nodes(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "adversarial" / "empty_nodes.json")
|
||||
args = argparse.Namespace(as_json=False)
|
||||
out = bdt_analyze.cmd_summary(bdt, args)
|
||||
self.assertIn("Total nodes: 0", out)
|
||||
|
||||
|
||||
class TestSources(unittest.TestCase):
|
||||
def test_sources_markdown(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
args = argparse.Namespace(as_json=False)
|
||||
out = bdt_analyze.cmd_sources(bdt, args)
|
||||
self.assertIn("# Sources", out)
|
||||
self.assertIn("LOAD_DATASET0", out)
|
||||
self.assertIn("ssot__Account__dlm", out)
|
||||
# Fields are listed
|
||||
self.assertIn("ssot__Id__c", out)
|
||||
self.assertIn("ssot__Name__c", out)
|
||||
|
||||
def test_sources_json(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
args = argparse.Namespace(as_json=True)
|
||||
out = bdt_analyze.cmd_sources(bdt, args)
|
||||
data = json.loads(out)
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data[0]["name"], "LOAD_DATASET0")
|
||||
self.assertIn("ssot__Id__c", data[0]["fields"])
|
||||
|
||||
|
||||
class TestOutputs(unittest.TestCase):
|
||||
def test_outputs_markdown(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
args = argparse.Namespace(as_json=False)
|
||||
out = bdt_analyze.cmd_outputs(bdt, args)
|
||||
self.assertIn("# Outputs", out)
|
||||
self.assertIn("OUTPUT0", out)
|
||||
self.assertIn("Account_Upper__dlm", out)
|
||||
self.assertIn("OVERWRITE", out)
|
||||
# Mapping table
|
||||
self.assertIn("ssot__Id__c", out)
|
||||
self.assertIn("Name_Upper__c", out)
|
||||
|
||||
def test_outputs_json(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
args = argparse.Namespace(as_json=True)
|
||||
data = json.loads(bdt_analyze.cmd_outputs(bdt, args))
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data[0]["target_name"], "Account_Upper__dlm")
|
||||
self.assertEqual(data[0]["write_mode"], "OVERWRITE")
|
||||
|
||||
|
||||
class TestStages(unittest.TestCase):
|
||||
def test_stages_follows_topo_order(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
args = argparse.Namespace(as_json=False)
|
||||
out = bdt_analyze.cmd_stages(bdt, args)
|
||||
self.assertIn("# Stages", out)
|
||||
# Ordering: LOAD_DATASET0 must come before FORMULA0 which must come before OUTPUT0
|
||||
i_load = out.index("LOAD_DATASET0")
|
||||
i_formula = out.index("FORMULA0")
|
||||
i_output = out.index("OUTPUT0")
|
||||
self.assertLess(i_load, i_formula)
|
||||
self.assertLess(i_formula, i_output)
|
||||
# Hints present
|
||||
self.assertIn("load", out.lower())
|
||||
self.assertIn("formula", out.lower())
|
||||
self.assertIn("outputD360", out)
|
||||
|
||||
def test_stages_json(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
args = argparse.Namespace(as_json=True)
|
||||
data = json.loads(bdt_analyze.cmd_stages(bdt, args))
|
||||
self.assertEqual([n["name"] for n in data],
|
||||
["LOAD_DATASET0", "FORMULA0", "OUTPUT0"])
|
||||
|
||||
|
||||
class TestNodes(unittest.TestCase):
|
||||
def test_nodes_table_present(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
args = argparse.Namespace(as_json=False, limit=0)
|
||||
out = bdt_analyze.cmd_nodes(bdt, args)
|
||||
self.assertIn("| Node |", out)
|
||||
self.assertIn("LOAD_DATASET0", out)
|
||||
self.assertIn("FORMULA0", out)
|
||||
self.assertIn("OUTPUT0", out)
|
||||
|
||||
def test_nodes_limit(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
args = argparse.Namespace(as_json=False, limit=1)
|
||||
out = bdt_analyze.cmd_nodes(bdt, args)
|
||||
# Only the first node appears (LOAD_DATASET0 in topo order)
|
||||
self.assertIn("LOAD_DATASET0", out)
|
||||
self.assertNotIn("OUTPUT0", out)
|
||||
self.assertIn("limited to 1", out.lower())
|
||||
|
||||
|
||||
class TestNodeDetail(unittest.TestCase):
|
||||
def test_node_detail_markdown(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
args = argparse.Namespace(as_json=False, node_name="FORMULA0")
|
||||
out = bdt_analyze.cmd_node(bdt, args)
|
||||
self.assertIn("# FORMULA0", out)
|
||||
self.assertIn("formula", out)
|
||||
self.assertIn("upper(ssot__Name__c)", out)
|
||||
# Sources and consumers listed
|
||||
self.assertIn("LOAD_DATASET0", out)
|
||||
self.assertIn("OUTPUT0", out)
|
||||
|
||||
def test_node_detail_unknown_raises_not_found(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
args = argparse.Namespace(as_json=False, node_name="NOPE")
|
||||
with self.assertRaises(bdt_analyze.BdtNotFoundError):
|
||||
bdt_analyze.cmd_node(bdt, args)
|
||||
|
||||
def test_node_cli_unknown_exits_2(self):
|
||||
"""End-to-end: `bdt_analyze.py node <path> NOPE` exits 2."""
|
||||
test = TestCliDispatch()
|
||||
code, out, err = test._run(["node", str(FIXTURES / "minimal.json"), "NOPE"])
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("No node named", err)
|
||||
|
||||
|
||||
class TestCliDispatch(unittest.TestCase):
|
||||
"""Top-level CLI: argparse routing + exit codes."""
|
||||
|
||||
def _run(self, argv):
|
||||
"""Run bdt_analyze.main with argv and capture (exit_code, stdout, stderr)."""
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
code = None
|
||||
try:
|
||||
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
||||
code = bdt_analyze.main(argv)
|
||||
except SystemExit as e:
|
||||
code = e.code
|
||||
return code, out.getvalue(), err.getvalue()
|
||||
|
||||
def test_no_args_prints_help_exit_2(self):
|
||||
code, out, err = self._run([])
|
||||
# argparse exits 2 when no subcommand is given
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("usage:", (out + err).lower())
|
||||
|
||||
def test_unknown_subcommand_exit_2(self):
|
||||
code, out, err = self._run(["nonexistent", str(FIXTURES / "minimal.json")])
|
||||
self.assertEqual(code, 2)
|
||||
|
||||
def test_missing_file_exit_3(self):
|
||||
code, out, err = self._run(["summary", str(FIXTURES / "_does_not_exist.json")])
|
||||
self.assertEqual(code, 3)
|
||||
self.assertIn("cannot read", err.lower())
|
||||
|
||||
|
||||
class TestOutputSizeBudget(unittest.TestCase):
|
||||
"""Outputs must stay within the 5 KB per-subcommand budget on a tiny BDT."""
|
||||
|
||||
SIZE_LIMIT = 5 * 1024 # 5 KB — per spec §3
|
||||
|
||||
def _run(self, argv):
|
||||
test = TestCliDispatch()
|
||||
return test._run(argv)
|
||||
|
||||
def test_summary_size(self):
|
||||
code, out, err = self._run(["summary", str(FIXTURES / "minimal.json")])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertLess(len(out), self.SIZE_LIMIT)
|
||||
|
||||
def test_stages_size(self):
|
||||
code, out, err = self._run(["stages", str(FIXTURES / "minimal.json")])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertLess(len(out), self.SIZE_LIMIT)
|
||||
|
||||
def test_nodes_size(self):
|
||||
code, out, err = self._run(["nodes", str(FIXTURES / "minimal.json")])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertLess(len(out), self.SIZE_LIMIT)
|
||||
|
||||
def test_node_detail_size(self):
|
||||
code, out, err = self._run(["node", str(FIXTURES / "minimal.json"), "FORMULA0"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertLess(len(out), 2 * 1024) # node detail target: ≤ 2 KB
|
||||
|
||||
def test_lineage_size(self):
|
||||
test = TestCliDispatch()
|
||||
code, out, err = test._run(["lineage", str(FIXTURES / "minimal.json"), "OUTPUT0"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertLess(len(out), 2 * 1024) # L1 target: ≤ 2 KB
|
||||
|
||||
def test_field_trace_size(self):
|
||||
test = TestCliDispatch()
|
||||
code, out, err = test._run(["field-trace", str(FIXTURES / "minimal.json"),
|
||||
"AccountNameUpper__c"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertLess(len(out), 1 * 1024) # L3 target: ≤ 1 KB
|
||||
|
||||
def test_formula_size(self):
|
||||
test = TestCliDispatch()
|
||||
code, out, err = test._run(["formula", str(FIXTURES / "minimal.json"), "FORMULA0"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertLess(len(out), 1 * 1024) # I1 target: ≤ 1 KB
|
||||
|
||||
|
||||
class TestTraversalResilience(unittest.TestCase):
|
||||
"""Upstream/downstream must not crash on BDTs with broken refs or cycles,
|
||||
as long as the queried node's own walk is well-formed."""
|
||||
|
||||
def test_upstream_works_with_unrelated_broken_ref(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "adversarial" / "broken_ref.json")
|
||||
# JOIN0's second source NO_SUCH_NODE is broken; LOAD0 is clean.
|
||||
# upstream('JOIN0') should return the clean ancestors, not crash.
|
||||
up = bdt.upstream("JOIN0")
|
||||
self.assertIn("LOAD0", up)
|
||||
|
||||
def test_downstream_works_when_unrelated_nodes_have_broken_refs(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "adversarial" / "broken_ref.json")
|
||||
down = bdt.downstream("LOAD0")
|
||||
self.assertIn("JOIN0", down)
|
||||
|
||||
def test_broken_references_is_sorted(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "adversarial" / "broken_ref.json")
|
||||
broken = bdt.broken_references()
|
||||
self.assertEqual(broken, sorted(broken))
|
||||
|
||||
def test_from_path_on_directory_raises_input_error(self):
|
||||
"""OSError subclasses (IsADirectoryError) should be mapped to BdtInputError."""
|
||||
import pathlib
|
||||
with self.assertRaises(bdt_analyze.BdtInputError) as cm:
|
||||
bdt_analyze.DataTransform.from_path(FIXTURES) # the fixtures dir itself
|
||||
self.assertIn("Cannot read", str(cm.exception))
|
||||
|
||||
|
||||
class TestLineage(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
|
||||
def test_lineage_of_output_markdown(self):
|
||||
args = argparse.Namespace(as_json=False, node_name="OUTPUT0")
|
||||
out = bdt_analyze.cmd_lineage(self.bdt, args)
|
||||
self.assertIn("# Lineage of OUTPUT0", out)
|
||||
# Both upstream nodes named, in topo order
|
||||
i_load = out.index("LOAD_DATASET0")
|
||||
i_formula = out.index("FORMULA0")
|
||||
self.assertLess(i_load, i_formula)
|
||||
|
||||
def test_lineage_of_root_says_root(self):
|
||||
args = argparse.Namespace(as_json=False, node_name="LOAD_DATASET0")
|
||||
out = bdt_analyze.cmd_lineage(self.bdt, args)
|
||||
self.assertIn("is a graph root", out)
|
||||
|
||||
def test_lineage_unknown_raises(self):
|
||||
args = argparse.Namespace(as_json=False, node_name="NOPE")
|
||||
with self.assertRaises(bdt_analyze.BdtNotFoundError):
|
||||
bdt_analyze.cmd_lineage(self.bdt, args)
|
||||
|
||||
def test_lineage_json(self):
|
||||
args = argparse.Namespace(as_json=True, node_name="OUTPUT0")
|
||||
data = json.loads(bdt_analyze.cmd_lineage(self.bdt, args))
|
||||
self.assertEqual(data["target"], "OUTPUT0")
|
||||
self.assertEqual(data["upstream"],
|
||||
[{"name": "LOAD_DATASET0", "action": "load",
|
||||
"label": "Account", "sources": []},
|
||||
{"name": "FORMULA0", "action": "formula",
|
||||
"label": "Uppercase Name", "sources": ["LOAD_DATASET0"]}])
|
||||
|
||||
|
||||
class TestFieldDiscovery(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
|
||||
def test_load_produces_its_field_list(self):
|
||||
n = self.bdt.nodes["LOAD_DATASET0"]
|
||||
produced = bdt_analyze.fields_produced(n)
|
||||
self.assertIn("ssot__Id__c", produced)
|
||||
self.assertIn("ssot__Name__c", produced)
|
||||
|
||||
def test_load_consumes_nothing(self):
|
||||
n = self.bdt.nodes["LOAD_DATASET0"]
|
||||
self.assertEqual(bdt_analyze.fields_consumed(n), [])
|
||||
|
||||
def test_formula_produces_its_output_field(self):
|
||||
n = self.bdt.nodes["FORMULA0"]
|
||||
produced = bdt_analyze.fields_produced(n)
|
||||
self.assertEqual(produced, ["AccountNameUpper__c"])
|
||||
|
||||
def test_formula_consumes_fields_mentioned_in_expression(self):
|
||||
n = self.bdt.nodes["FORMULA0"]
|
||||
consumed = bdt_analyze.fields_consumed(n)
|
||||
self.assertIn("ssot__Name__c", consumed)
|
||||
|
||||
def test_output_consumes_source_fields_from_mappings(self):
|
||||
n = self.bdt.nodes["OUTPUT0"]
|
||||
consumed = bdt_analyze.fields_consumed(n)
|
||||
self.assertIn("ssot__Id__c", consumed)
|
||||
self.assertIn("AccountNameUpper__c", consumed)
|
||||
|
||||
def test_output_produces_target_fields_from_mappings(self):
|
||||
n = self.bdt.nodes["OUTPUT0"]
|
||||
produced = bdt_analyze.fields_produced(n)
|
||||
self.assertIn("ssot__Id__c", produced)
|
||||
self.assertIn("Name_Upper__c", produced)
|
||||
|
||||
|
||||
class TestFieldTrace(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
|
||||
def test_trace_field_defined_in_formula(self):
|
||||
args = argparse.Namespace(as_json=False, field_name="AccountNameUpper__c")
|
||||
out = bdt_analyze.cmd_field_trace(self.bdt, args)
|
||||
self.assertIn("AccountNameUpper__c", out)
|
||||
# Defined by FORMULA0
|
||||
self.assertIn("FORMULA0", out)
|
||||
# Back-trace reaches the load
|
||||
self.assertIn("LOAD_DATASET0", out)
|
||||
# Shows the expression
|
||||
self.assertIn("upper(ssot__Name__c)", out)
|
||||
|
||||
def test_trace_field_defined_in_load(self):
|
||||
args = argparse.Namespace(as_json=False, field_name="ssot__Id__c")
|
||||
out = bdt_analyze.cmd_field_trace(self.bdt, args)
|
||||
self.assertIn("LOAD_DATASET0", out)
|
||||
|
||||
def test_trace_unknown_field(self):
|
||||
args = argparse.Namespace(as_json=False, field_name="NOPE__c")
|
||||
with self.assertRaises(bdt_analyze.BdtNotFoundError) as cm:
|
||||
bdt_analyze.cmd_field_trace(self.bdt, args)
|
||||
self.assertIn("NOPE__c", str(cm.exception))
|
||||
|
||||
def test_trace_json(self):
|
||||
args = argparse.Namespace(as_json=True, field_name="AccountNameUpper__c")
|
||||
data = json.loads(bdt_analyze.cmd_field_trace(self.bdt, args))
|
||||
self.assertEqual(data["field"], "AccountNameUpper__c")
|
||||
self.assertTrue(any(e["node"] == "FORMULA0" for e in data["definitions"]))
|
||||
|
||||
|
||||
class TestFieldTraceFocus(unittest.TestCase):
|
||||
"""field-trace should narrow upstream deps to the specific field, not
|
||||
dump all fields consumed by the defining node."""
|
||||
|
||||
def test_output_node_only_reports_source_of_mapped_field(self):
|
||||
# In minimal.json OUTPUT0 has 2 field mappings; tracing one targetField
|
||||
# should list only that mapping's sourceField as the dep, not both.
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
args = argparse.Namespace(as_json=True, field_name="Name_Upper__c")
|
||||
data = json.loads(bdt_analyze.cmd_field_trace(bdt, args))
|
||||
# Find the entry where the definer is OUTPUT0
|
||||
output_defs = [d for d in data["definitions"] if d["node"] == "OUTPUT0"]
|
||||
if output_defs:
|
||||
# Only AccountNameUpper__c should be listed as an upstream dep,
|
||||
# NOT ssot__Id__c (which maps to ssot__Id__c target, unrelated to Name_Upper__c).
|
||||
deps = output_defs[0]["upstream_deps"]
|
||||
self.assertIn("AccountNameUpper__c", deps)
|
||||
self.assertNotIn("ssot__Id__c", deps)
|
||||
|
||||
def test_formula_node_reports_only_fields_in_that_expression(self):
|
||||
# FORMULA0 in minimal.json has one output field AccountNameUpper__c
|
||||
# with expression upper(ssot__Name__c). Tracing AccountNameUpper__c
|
||||
# should show exactly ssot__Name__c as a dep.
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
args = argparse.Namespace(as_json=True, field_name="AccountNameUpper__c")
|
||||
data = json.loads(bdt_analyze.cmd_field_trace(bdt, args))
|
||||
formula_defs = [d for d in data["definitions"] if d["node"] == "FORMULA0"]
|
||||
self.assertEqual(len(formula_defs), 1)
|
||||
deps = formula_defs[0]["upstream_deps"]
|
||||
self.assertIn("ssot__Name__c", deps)
|
||||
|
||||
def test_compute_relative_reports_partition_and_order_fields(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "window_and_aggregate.json")
|
||||
args = argparse.Namespace(as_json=True, field_name="OrderRank__c")
|
||||
data = json.loads(bdt_analyze.cmd_field_trace(bdt, args))
|
||||
rank_defs = [d for d in data["definitions"] if d["node"] == "RANK_ORDERS"]
|
||||
self.assertEqual(len(rank_defs), 1)
|
||||
deps = rank_defs[0]["upstream_deps"]
|
||||
# partitionBy field
|
||||
self.assertIn("ssot__AccountId__c", deps)
|
||||
# orderBy field
|
||||
self.assertIn("ssot__CreatedDate__c", deps)
|
||||
|
||||
def test_aggregate_reports_only_source_of_matching_aggregation(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "window_and_aggregate.json")
|
||||
args = argparse.Namespace(as_json=True, field_name="TotalAmount__c")
|
||||
data = json.loads(bdt_analyze.cmd_field_trace(bdt, args))
|
||||
agg_defs = [d for d in data["definitions"] if d["node"] == "AGG_BY_ACCOUNT"]
|
||||
self.assertEqual(len(agg_defs), 1)
|
||||
deps = agg_defs[0]["upstream_deps"]
|
||||
# Only the SUM source — not the COUNT source, not the groupings.
|
||||
self.assertIn("ssot__GrandTotalAmount__c", deps)
|
||||
self.assertNotIn("ssot__Id__c", deps)
|
||||
|
||||
|
||||
class TestWindowAndAggregateTrace(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "window_and_aggregate.json")
|
||||
|
||||
def test_order_rank_defined_by_compute_relative(self):
|
||||
args = argparse.Namespace(as_json=False, field_name="OrderRank__c")
|
||||
out = bdt_analyze.cmd_field_trace(self.bdt, args)
|
||||
self.assertIn("RANK_ORDERS", out)
|
||||
self.assertIn("row_number()", out)
|
||||
# Partition and order are reported as upstream deps
|
||||
self.assertIn("ssot__AccountId__c", out)
|
||||
self.assertIn("ssot__CreatedDate__c", out)
|
||||
|
||||
def test_total_amount_traces_through_aggregate(self):
|
||||
args = argparse.Namespace(as_json=False, field_name="TotalAmount__c")
|
||||
out = bdt_analyze.cmd_field_trace(self.bdt, args)
|
||||
# AGG_BY_ACCOUNT produces TotalAmount__c
|
||||
self.assertIn("AGG_BY_ACCOUNT", out)
|
||||
self.assertIn("SUM", out)
|
||||
self.assertIn("ssot__GrandTotalAmount__c", out)
|
||||
|
||||
def test_aggregate_groupings_carried_through(self):
|
||||
# AccountId__c → ssot__AccountId__c passes from aggregate output through output mapping
|
||||
args = argparse.Namespace(as_json=False, field_name="AccountId__c")
|
||||
out = bdt_analyze.cmd_field_trace(self.bdt, args)
|
||||
self.assertIn("OUTPUT_SUMMARY", out)
|
||||
self.assertIn("ssot__AccountId__c", out)
|
||||
|
||||
|
||||
class TestFormulaSubcommand(unittest.TestCase):
|
||||
def test_formula_of_formula_node(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
args = argparse.Namespace(as_json=False, node_name="FORMULA0")
|
||||
out = bdt_analyze.cmd_formula(bdt, args)
|
||||
self.assertIn("FORMULA0", out)
|
||||
self.assertIn("upper(ssot__Name__c)", out)
|
||||
# Upstream field `ssot__Name__c` is defined by LOAD_DATASET0
|
||||
self.assertIn("ssot__Name__c", out)
|
||||
self.assertIn("LOAD_DATASET0", out)
|
||||
|
||||
def test_formula_of_compute_relative(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "window_and_aggregate.json")
|
||||
args = argparse.Namespace(as_json=False, node_name="RANK_ORDERS")
|
||||
out = bdt_analyze.cmd_formula(bdt, args)
|
||||
self.assertIn("row_number()", out)
|
||||
self.assertIn("partitionBy", out)
|
||||
self.assertIn("ssot__AccountId__c", out)
|
||||
|
||||
def test_formula_of_non_formula_node_rejects(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
args = argparse.Namespace(as_json=False, node_name="LOAD_DATASET0")
|
||||
with self.assertRaises(bdt_analyze.BdtInputError):
|
||||
bdt_analyze.cmd_formula(bdt, args)
|
||||
|
||||
def test_formula_unknown_node(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
args = argparse.Namespace(as_json=False, node_name="NOPE")
|
||||
with self.assertRaises(bdt_analyze.BdtNotFoundError):
|
||||
bdt_analyze.cmd_formula(bdt, args)
|
||||
|
||||
|
||||
class TestInputShapeDetection(unittest.TestCase):
|
||||
"""Parser accepts both the editor-export shape and the Connect API create payload shape."""
|
||||
|
||||
def test_editor_export_shape_still_works(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
self.assertEqual(len(bdt.nodes), 3)
|
||||
# No outer wrapper metadata
|
||||
self.assertIsNone(bdt.name)
|
||||
self.assertIsNone(bdt.data_transform_type)
|
||||
# `definitions` is always non-empty; length 1 for editor-export.
|
||||
self.assertEqual(bdt.definition_count, 1)
|
||||
|
||||
def test_api_input_single_definition(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "api_input_single.json")
|
||||
self.assertEqual(bdt.name, "TestTransform")
|
||||
self.assertEqual(bdt.label, "Test Transform")
|
||||
self.assertEqual(bdt.data_transform_type, "BATCH")
|
||||
self.assertEqual(bdt.data_space_name, "default")
|
||||
self.assertEqual(bdt.definition_count, 1)
|
||||
self.assertEqual(bdt.definition_index, 0)
|
||||
# Nodes accessible at top of DataTransform
|
||||
self.assertIn("LOAD_X", bdt.nodes)
|
||||
self.assertIn("OUTPUT_X", bdt.nodes)
|
||||
# Version from the definition (not the wrapper)
|
||||
self.assertEqual(bdt.version, "66.0")
|
||||
# No warnings for single-definition
|
||||
self.assertEqual(bdt.warnings(), [])
|
||||
|
||||
def test_api_input_multi_definition(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "api_input_multi.json")
|
||||
self.assertEqual(bdt.definition_count, 2)
|
||||
self.assertEqual(bdt.definition_index, 0)
|
||||
# Descends into first definition by default
|
||||
self.assertIn("LOAD_A", bdt.nodes)
|
||||
self.assertNotIn("LOAD_B", bdt.nodes)
|
||||
# Multi-definition no longer emits a "first-only" warning — the parser
|
||||
# now exposes every definition and the CLI has a `--definition N` flag.
|
||||
self.assertEqual(bdt.warnings(), [])
|
||||
|
||||
def test_unknown_shape_raises(self):
|
||||
with self.assertRaises(bdt_analyze.BdtInputError) as cm:
|
||||
bdt_analyze.DataTransform.from_dict({"name": "MyTransform", "label": "L"})
|
||||
self.assertIn("Expected 'nodes'", str(cm.exception))
|
||||
|
||||
def test_all_subcommands_work_on_api_input(self):
|
||||
"""summary, stages, lineage, field-trace should all work on the API-wrapped BDT."""
|
||||
import io, contextlib
|
||||
for subcmd, extra_arg in [("summary", None), ("stages", None), ("nodes", None),
|
||||
("sources", None), ("outputs", None),
|
||||
("lineage", "OUTPUT_X"), ("node", "LOAD_X")]:
|
||||
with self.subTest(subcmd=subcmd):
|
||||
argv = [subcmd, str(FIXTURES / "api_input_single.json")]
|
||||
if extra_arg: argv.append(extra_arg)
|
||||
out = io.StringIO(); err = io.StringIO()
|
||||
try:
|
||||
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
||||
code = bdt_analyze.main(argv)
|
||||
except SystemExit as e:
|
||||
code = e.code
|
||||
self.assertEqual(code, 0, f"Subcommand {subcmd} failed: {err.getvalue()}")
|
||||
|
||||
|
||||
class TestMultiDefinition(unittest.TestCase):
|
||||
"""Parser exposes every definition in a multi-definition payload, not just the first."""
|
||||
|
||||
def setUp(self):
|
||||
self.bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "api_input_multi.json")
|
||||
|
||||
def test_definitions_length(self):
|
||||
self.assertEqual(len(self.bdt.definitions), 2)
|
||||
self.assertEqual(self.bdt.definition_count, 2)
|
||||
# Default selected index is 0
|
||||
self.assertEqual(self.bdt.definition_index, 0)
|
||||
|
||||
def test_default_selection_is_first(self):
|
||||
self.assertIn("LOAD_A", self.bdt.nodes)
|
||||
self.assertNotIn("LOAD_B", self.bdt.nodes)
|
||||
|
||||
def test_select_second_definition(self):
|
||||
self.bdt.select_definition(1)
|
||||
self.assertEqual(self.bdt.definition_index, 1)
|
||||
self.assertIn("LOAD_B", self.bdt.nodes)
|
||||
self.assertNotIn("LOAD_A", self.bdt.nodes)
|
||||
|
||||
def test_select_out_of_range_raises(self):
|
||||
with self.assertRaises(bdt_analyze.BdtNotFoundError) as cm:
|
||||
self.bdt.select_definition(5)
|
||||
self.assertIn("out of range", str(cm.exception))
|
||||
|
||||
def test_warnings_no_longer_mentions_first_only(self):
|
||||
# Previously warned "2 definitions; explains first only". No longer should.
|
||||
warnings = self.bdt.warnings()
|
||||
self.assertEqual(warnings, [])
|
||||
|
||||
def test_definitions_subcommand_markdown(self):
|
||||
args = argparse.Namespace(as_json=False)
|
||||
out = bdt_analyze.cmd_definitions(self.bdt, args)
|
||||
self.assertIn("# Definitions", out)
|
||||
self.assertIn("| 0 |", out)
|
||||
self.assertIn("| 1 |", out)
|
||||
|
||||
def test_definitions_subcommand_json(self):
|
||||
args = argparse.Namespace(as_json=True)
|
||||
data = json.loads(bdt_analyze.cmd_definitions(self.bdt, args))
|
||||
self.assertEqual(len(data), 2)
|
||||
self.assertEqual(data[0]["index"], 0)
|
||||
self.assertEqual(data[1]["index"], 1)
|
||||
|
||||
def test_definition_flag_routes_summary_to_second(self):
|
||||
"""Full CLI smoke: --definition 1 makes summary operate on the second definition."""
|
||||
import io, contextlib
|
||||
argv = ["summary", "--definition", "1", str(FIXTURES / "api_input_multi.json")]
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
try:
|
||||
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
||||
code = bdt_analyze.main(argv)
|
||||
except SystemExit as e:
|
||||
code = e.code
|
||||
self.assertEqual(code, 0, err.getvalue())
|
||||
self.assertIn("LOAD_B", out.getvalue())
|
||||
self.assertNotIn("LOAD_A", out.getvalue())
|
||||
|
||||
def test_definition_flag_out_of_range_exits_2(self):
|
||||
import io, contextlib
|
||||
argv = ["summary", "--definition", "5", str(FIXTURES / "api_input_multi.json")]
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
try:
|
||||
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
||||
code = bdt_analyze.main(argv)
|
||||
except SystemExit as e:
|
||||
code = e.code
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("out of range", err.getvalue())
|
||||
|
||||
def test_editor_export_has_single_definition(self):
|
||||
bdt = bdt_analyze.DataTransform.from_path(FIXTURES / "minimal.json")
|
||||
self.assertEqual(len(bdt.definitions), 1)
|
||||
self.assertEqual(bdt.definition_count, 1)
|
||||
# definitions subcommand on a single-definition input still works
|
||||
args = argparse.Namespace(as_json=False)
|
||||
out = bdt_analyze.cmd_definitions(bdt, args)
|
||||
self.assertIn("# Definitions", out)
|
||||
self.assertIn("| 0 |", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -1,191 +0,0 @@
|
||||
---
|
||||
name: trigger-refactor-pipeline
|
||||
description: "Refactor Salesforce triggers into handler patterns with automated test generation and deployment. Use when modernizing legacy triggers with DML/SOQL in loops or inconsistent patterns."
|
||||
license: Apache-2.0
|
||||
compatibility: Requires Salesforce CLI, Python 3.9+
|
||||
metadata:
|
||||
author: afv-library
|
||||
version: "1.0"
|
||||
allowed-tools: Bash Read Write
|
||||
---
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when you need to:
|
||||
- Modernize legacy triggers with DML/SOQL operations inside loops
|
||||
- Refactor triggers that lack clear separation of concerns
|
||||
- Implement bulk-safe patterns in existing trigger code
|
||||
- Generate comprehensive test coverage for refactored triggers
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before starting, ensure you have:
|
||||
1. Salesforce CLI installed and authenticated to your target org
|
||||
2. Python 3.9 or higher installed
|
||||
3. The baseline trigger deployed (see Setup section)
|
||||
|
||||
## Setup
|
||||
|
||||
Deploy the baseline anti-pattern trigger to analyze and refactor:
|
||||
|
||||
```apex
|
||||
// ❌ Anti-pattern: all logic stuffed into the trigger, with DML/SOQL in loops.
|
||||
trigger OpportunityTrigger on Opportunity (before insert, before update, after update) {
|
||||
// BEFORE INSERT: validate Closed Won w/ low Amount
|
||||
if (Trigger.isBefore && Trigger.isInsert) {
|
||||
for (Opportunity o : Trigger.new) {
|
||||
if (o.StageName == 'Closed Won' && (o.Amount == null || o.Amount < 1000)) {
|
||||
o.addError('Closed Won opportunities must have Amount ≥ 1000.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BEFORE UPDATE: if Stage changed, overwrite Description
|
||||
if (Trigger.isBefore && Trigger.isUpdate) {
|
||||
for (Opportunity o : Trigger.new) {
|
||||
Opportunity oldO = Trigger.oldMap.get(o.Id);
|
||||
if (o.StageName != oldO.StageName) {
|
||||
o.Description = 'Stage changed from ' + oldO.StageName + ' to ' + o.StageName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AFTER UPDATE: when Stage becomes Closed Won, create a follow-up Task
|
||||
if (Trigger.isAfter && Trigger.isUpdate) {
|
||||
for (Opportunity o : Trigger.new) {
|
||||
Opportunity oldO = Trigger.oldMap.get(o.Id);
|
||||
if (o.StageName == 'Closed Won' && oldO.StageName != 'Closed Won') {
|
||||
Task t = new Task(
|
||||
WhatId = o.Id,
|
||||
OwnerId = o.OwnerId,
|
||||
Subject = 'Send thank-you',
|
||||
Status = 'Not Started',
|
||||
Priority = 'Normal',
|
||||
ActivityDate = Date.today()
|
||||
);
|
||||
insert t; // ❌ DML in a loop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Deploy this to your org:
|
||||
```bash
|
||||
sf project deploy start --source-dir force-app/main/default/triggers
|
||||
```
|
||||
|
||||
## Step 1: Analyze the Trigger
|
||||
|
||||
Run the analysis script to identify anti-patterns and generate a report:
|
||||
|
||||
```bash
|
||||
python scripts/analyze_trigger.py OpportunityTrigger
|
||||
```
|
||||
|
||||
The script will output:
|
||||
- **DML in loops** - Line numbers where DML operations occur inside iteration
|
||||
- **SOQL in loops** - Line numbers where SOQL queries occur inside iteration
|
||||
- **Missing bulkification** - Areas where collection-based processing is needed
|
||||
- **Complexity score** - Overall trigger complexity rating (1-10)
|
||||
- **Recommended approach** - Suggested handler pattern based on trigger contexts
|
||||
|
||||
Review the analysis report before proceeding to refactoring.
|
||||
|
||||
## Step 2: Review Handler Patterns
|
||||
|
||||
Consult the [handler patterns reference](references/handler_patterns.md) to understand:
|
||||
- **Single-responsibility handlers** - One handler class per trigger context
|
||||
- **Unified handler approach** - Single handler with context methods
|
||||
- **Bulk collection strategies** - How to aggregate DML/SOQL outside loops
|
||||
- **Best practices** - Error handling, test boundaries, deployment order
|
||||
|
||||
Choose the pattern that best fits your trigger's complexity and team conventions.
|
||||
|
||||
## Step 3: Refactor the Trigger
|
||||
|
||||
Create the handler class using the appropriate pattern from the reference guide:
|
||||
|
||||
1. **Extract logic** into handler methods with descriptive names
|
||||
2. **Implement bulk-safe collections** for DML operations
|
||||
3. **Add proper error handling** using try-catch or Database methods
|
||||
4. **Update the trigger** to delegate only, passing Trigger context variables
|
||||
5. **Preserve behavior** - ensure the refactored code produces identical results
|
||||
|
||||
The trigger should be reduced to simple delegation:
|
||||
|
||||
```apex
|
||||
trigger OpportunityTrigger on Opportunity (before insert, before update, after update) {
|
||||
OpportunityTriggerHandler handler = new OpportunityTriggerHandler();
|
||||
|
||||
if (Trigger.isBefore && Trigger.isInsert) {
|
||||
handler.beforeInsert(Trigger.new);
|
||||
}
|
||||
|
||||
if (Trigger.isBefore && Trigger.isUpdate) {
|
||||
handler.beforeUpdate(Trigger.new, Trigger.oldMap);
|
||||
}
|
||||
|
||||
if (Trigger.isAfter && Trigger.isUpdate) {
|
||||
handler.afterUpdate(Trigger.new, Trigger.oldMap);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: Generate Tests
|
||||
|
||||
Use the test template from `assets/test_template.apex` to scaffold your test class:
|
||||
|
||||
1. **Copy the template** and rename for your handler
|
||||
2. **Implement setup methods** to create test data
|
||||
3. **Write unit tests** covering each handler method:
|
||||
- Positive cases with valid data
|
||||
- Negative cases with invalid data
|
||||
- Boundary conditions
|
||||
4. **Add bulk tests** with 200+ records to verify bulkification
|
||||
5. **Test mixed scenarios** where only some records qualify for logic
|
||||
|
||||
Required test coverage:
|
||||
- Each handler method must have at least 2 test methods (positive + negative)
|
||||
- At least one bulk test with 200+ records
|
||||
- Overall code coverage must be 100%
|
||||
|
||||
## Step 5: Deploy and Validate
|
||||
|
||||
Deploy the refactored trigger, handler, and tests:
|
||||
|
||||
```bash
|
||||
# Deploy all components
|
||||
sf project deploy start --source-dir force-app/main/default
|
||||
|
||||
# Run tests
|
||||
sf apex test run --class-names OpportunityTriggerHandlerTest --result-format human --code-coverage
|
||||
|
||||
# Verify no regressions
|
||||
sf apex test run --test-level RunLocalTests --result-format human
|
||||
```
|
||||
|
||||
Validation checklist:
|
||||
- [ ] All new tests pass with 100% coverage
|
||||
- [ ] No new governor limit warnings in debug logs
|
||||
- [ ] Existing functionality remains unchanged
|
||||
- [ ] Deployment to production planned with rollback strategy
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Issue**: Tests fail with "System.LimitException: Too many DML statements"
|
||||
- **Solution**: Ensure handler methods collect DML operations and execute outside loops
|
||||
|
||||
**Issue**: Code coverage below 100%
|
||||
- **Solution**: Add negative test cases and verify all conditional branches are tested
|
||||
|
||||
**Issue**: Behavior differs from original trigger
|
||||
- **Solution**: Review Trigger context variables (new, old, oldMap) are passed correctly to handler
|
||||
|
||||
## Next Steps
|
||||
|
||||
After successful refactoring:
|
||||
1. Document the new handler pattern in your team's wiki
|
||||
2. Update code review checklist to enforce handler patterns for new triggers
|
||||
3. Identify other legacy triggers for refactoring using this skill
|
||||
4. Consider implementing a trigger framework if managing many triggers
|
||||
@ -1,321 +0,0 @@
|
||||
/**
|
||||
* Test class template for trigger handlers
|
||||
*
|
||||
* INSTRUCTIONS:
|
||||
* 1. Replace [ObjectName] with your SObject (e.g., Opportunity)
|
||||
* 2. Replace [HandlerClass] with your handler class name
|
||||
* 3. Implement the setupTestData() method with your test records
|
||||
* 4. Add specific test methods for each handler method
|
||||
* 5. Ensure 100% code coverage
|
||||
*/
|
||||
@IsTest
|
||||
private class [ObjectName]TriggerHandlerTest {
|
||||
|
||||
/**
|
||||
* Setup test data that all test methods can use
|
||||
*/
|
||||
@TestSetup
|
||||
static void setupTestData() {
|
||||
// TODO: Create test records here
|
||||
// Example:
|
||||
// List<Opportunity> testOpps = new List<Opportunity>();
|
||||
// for (Integer i = 0; i < 10; i++) {
|
||||
// testOpps.add(new Opportunity(
|
||||
// Name = 'Test Opp ' + i,
|
||||
// StageName = 'Prospecting',
|
||||
// CloseDate = Date.today().addDays(30),
|
||||
// Amount = 5000
|
||||
// ));
|
||||
// }
|
||||
// insert testOpps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test beforeInsert handler - Positive case
|
||||
*/
|
||||
@IsTest
|
||||
static void testBeforeInsert_Positive() {
|
||||
Test.startTest();
|
||||
|
||||
// TODO: Create valid test records
|
||||
// List<Opportunity> testRecords = new List<Opportunity>{
|
||||
// new Opportunity(
|
||||
// Name = 'Valid Opp',
|
||||
// StageName = 'Prospecting',
|
||||
// CloseDate = Date.today().addDays(30),
|
||||
// Amount = 10000
|
||||
// )
|
||||
// };
|
||||
|
||||
// Insert should succeed
|
||||
// insert testRecords;
|
||||
|
||||
Test.stopTest();
|
||||
|
||||
// TODO: Add assertions
|
||||
// List<Opportunity> inserted = [SELECT Id, Name FROM Opportunity WHERE Name = 'Valid Opp'];
|
||||
// System.Assert.areEqual(1, inserted.size(), 'Should insert 1 record');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test beforeInsert handler - Negative case
|
||||
*/
|
||||
@IsTest
|
||||
static void testBeforeInsert_Negative() {
|
||||
Test.startTest();
|
||||
|
||||
Boolean exceptionThrown = false;
|
||||
|
||||
try {
|
||||
// TODO: Create invalid test records that should fail validation
|
||||
// List<Opportunity> testRecords = new List<Opportunity>{
|
||||
// new Opportunity(
|
||||
// Name = 'Invalid Opp',
|
||||
// StageName = 'Closed Won',
|
||||
// CloseDate = Date.today(),
|
||||
// Amount = 500 // Below minimum
|
||||
// )
|
||||
// };
|
||||
|
||||
// insert testRecords;
|
||||
|
||||
} catch (DmlException e) {
|
||||
exceptionThrown = true;
|
||||
// TODO: Assert error message
|
||||
// System.Assert.isTrue(e.getMessage().contains('must have Amount'),
|
||||
// 'Should throw validation error');
|
||||
}
|
||||
|
||||
Test.stopTest();
|
||||
|
||||
// System.Assert.isTrue(exceptionThrown, 'Should have thrown an exception');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test beforeUpdate handler - Positive case
|
||||
*/
|
||||
@IsTest
|
||||
static void testBeforeUpdate_Positive() {
|
||||
// TODO: Query existing test data from @TestSetup
|
||||
// List<Opportunity> testOpps = [SELECT Id, StageName, Description FROM Opportunity LIMIT 1];
|
||||
|
||||
Test.startTest();
|
||||
|
||||
// TODO: Update records to trigger handler logic
|
||||
// testOpps[0].StageName = 'Qualification';
|
||||
// update testOpps;
|
||||
|
||||
Test.stopTest();
|
||||
|
||||
// TODO: Add assertions
|
||||
// Opportunity updated = [SELECT Description FROM Opportunity WHERE Id = :testOpps[0].Id];
|
||||
// System.Assert.isTrue(updated.Description.contains('Stage changed'),
|
||||
// 'Description should be updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test beforeUpdate handler - Negative case
|
||||
*/
|
||||
@IsTest
|
||||
static void testBeforeUpdate_Negative() {
|
||||
// TODO: Implement negative test for beforeUpdate
|
||||
Test.startTest();
|
||||
|
||||
// TODO: Attempt update that should fail
|
||||
|
||||
Test.stopTest();
|
||||
|
||||
// TODO: Assert failure occurred
|
||||
}
|
||||
|
||||
/**
|
||||
* Test afterInsert handler - Positive case
|
||||
*/
|
||||
@IsTest
|
||||
static void testAfterInsert_Positive() {
|
||||
Test.startTest();
|
||||
|
||||
// TODO: Create and insert records
|
||||
|
||||
Test.stopTest();
|
||||
|
||||
// TODO: Query for related records created by handler
|
||||
// List<Task> createdTasks = [SELECT Id FROM Task];
|
||||
// System.Assert.areEqual(1, createdTasks.size(), 'Should create 1 task');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test afterUpdate handler - Positive case
|
||||
*/
|
||||
@IsTest
|
||||
static void testAfterUpdate_Positive() {
|
||||
// TODO: Query existing test data
|
||||
// List<Opportunity> testOpps = [SELECT Id, StageName FROM Opportunity LIMIT 1];
|
||||
|
||||
Test.startTest();
|
||||
|
||||
// TODO: Update to trigger after-update logic
|
||||
// testOpps[0].StageName = 'Closed Won';
|
||||
// update testOpps;
|
||||
|
||||
Test.stopTest();
|
||||
|
||||
// TODO: Query for side effects (e.g., Tasks created)
|
||||
// List<Task> tasks = [SELECT Id, Subject FROM Task WHERE WhatId = :testOpps[0].Id];
|
||||
// System.Assert.areEqual(1, tasks.size(), 'Should create 1 task');
|
||||
// System.Assert.areEqual('Send thank-you', tasks[0].Subject);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bulk operations - 200+ records
|
||||
* Critical for validating bulkification
|
||||
*/
|
||||
@IsTest
|
||||
static void testBulkInsert() {
|
||||
Test.startTest();
|
||||
|
||||
// TODO: Create 200+ records
|
||||
// List<Opportunity> bulkOpps = new List<Opportunity>();
|
||||
// for (Integer i = 0; i < 200; i++) {
|
||||
// bulkOpps.add(new Opportunity(
|
||||
// Name = 'Bulk Opp ' + i,
|
||||
// StageName = 'Prospecting',
|
||||
// CloseDate = Date.today().addDays(30),
|
||||
// Amount = 10000
|
||||
// ));
|
||||
// }
|
||||
|
||||
// insert bulkOpps;
|
||||
|
||||
Test.stopTest();
|
||||
|
||||
// TODO: Assert all records inserted successfully
|
||||
// List<Opportunity> inserted = [SELECT Id FROM Opportunity WHERE Name LIKE 'Bulk Opp%'];
|
||||
// System.Assert.areEqual(200, inserted.size(), 'Should insert all 200 records');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bulk update with mixed scenarios
|
||||
* Some records qualify for logic, others don't
|
||||
*/
|
||||
@IsTest
|
||||
static void testBulkUpdate_Mixed() {
|
||||
// Create test data
|
||||
List<Opportunity> testOpps = new List<Opportunity>();
|
||||
for (Integer i = 0; i < 50; i++) {
|
||||
testOpps.add(new Opportunity(
|
||||
Name = 'Bulk Update Opp ' + i,
|
||||
StageName = 'Prospecting',
|
||||
CloseDate = Date.today().addDays(30),
|
||||
Amount = 10000
|
||||
));
|
||||
}
|
||||
insert testOpps;
|
||||
|
||||
Test.startTest();
|
||||
|
||||
// Update half to trigger logic, half to not trigger
|
||||
for (Integer i = 0; i < testOpps.size(); i++) {
|
||||
if (Math.mod(i, 2) == 0) {
|
||||
// TODO: Set condition that triggers handler logic
|
||||
// testOpps[i].StageName = 'Closed Won';
|
||||
} else {
|
||||
// TODO: Set condition that doesn't trigger handler logic
|
||||
// testOpps[i].Amount = 15000;
|
||||
}
|
||||
}
|
||||
|
||||
// update testOpps;
|
||||
|
||||
Test.stopTest();
|
||||
|
||||
// TODO: Assert only qualifying records triggered side effects
|
||||
// List<Task> tasks = [SELECT Id FROM Task WHERE WhatId IN :testOpps];
|
||||
// System.Assert.areEqual(25, tasks.size(), 'Should create tasks for 25 qualifying records');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test governor limits are not exceeded
|
||||
*/
|
||||
@IsTest
|
||||
static void testGovernorLimits() {
|
||||
Test.startTest();
|
||||
|
||||
// TODO: Create maximum allowed records
|
||||
// List<Opportunity> maxOpps = new List<Opportunity>();
|
||||
// for (Integer i = 0; i < 200; i++) {
|
||||
// maxOpps.add(new Opportunity(
|
||||
// Name = 'Limit Test ' + i,
|
||||
// StageName = 'Closed Won',
|
||||
// CloseDate = Date.today(),
|
||||
// Amount = 10000
|
||||
// ));
|
||||
// }
|
||||
|
||||
// insert maxOpps;
|
||||
|
||||
Test.stopTest();
|
||||
|
||||
// Assert we're under governor limits
|
||||
System.Assert.isTrue(Limits.getDmlStatements() < Limits.getLimitDmlStatements(),
|
||||
'Should not exceed DML statement limit');
|
||||
System.Assert.isTrue(Limits.getQueries() < Limits.getLimitQueries(),
|
||||
'Should not exceed SOQL query limit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with null or empty collections
|
||||
* Ensures handler doesn't break with edge cases
|
||||
*/
|
||||
@IsTest
|
||||
static void testWithEmptyCollection() {
|
||||
Test.startTest();
|
||||
|
||||
// TODO: Call handler methods with empty lists
|
||||
// OpportunityTriggerHandler handler = new OpportunityTriggerHandler();
|
||||
// handler.beforeInsert(new List<Opportunity>());
|
||||
|
||||
Test.stopTest();
|
||||
|
||||
// If we get here without exception, test passes
|
||||
System.Assert.isTrue(true, 'Handler should handle empty collections gracefully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create test data inline
|
||||
* Use when @TestSetup is not sufficient
|
||||
*/
|
||||
private static List<Opportunity> createTestOpportunities(Integer count) {
|
||||
List<Opportunity> testOpps = new List<Opportunity>();
|
||||
|
||||
for (Integer i = 0; i < count; i++) {
|
||||
testOpps.add(new Opportunity(
|
||||
Name = 'Test Opp ' + i,
|
||||
StageName = 'Prospecting',
|
||||
CloseDate = Date.today().addDays(30),
|
||||
Amount = 10000
|
||||
));
|
||||
}
|
||||
|
||||
return testOpps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to assert task creation
|
||||
*/
|
||||
private static void assertTasksCreated(List<Id> oppIds, Integer expectedCount, String subject) {
|
||||
List<Task> tasks = [
|
||||
SELECT Id, Subject, WhatId
|
||||
FROM Task
|
||||
WHERE WhatId IN :oppIds
|
||||
];
|
||||
|
||||
System.Assert.areEqual(expectedCount, tasks.size(),
|
||||
'Should create ' + expectedCount + ' task(s)');
|
||||
|
||||
if (expectedCount > 0) {
|
||||
System.Assert.areEqual(subject, tasks[0].Subject,
|
||||
'Task subject should match');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,442 +0,0 @@
|
||||
# Trigger Handler Patterns Reference
|
||||
|
||||
This guide covers common patterns for refactoring Salesforce triggers into handler classes with bulk-safe operations.
|
||||
|
||||
## Pattern 1: Simple Handler Class
|
||||
|
||||
**Best for**: Triggers with 1-3 contexts and straightforward logic.
|
||||
|
||||
### Structure
|
||||
|
||||
```apex
|
||||
public class OpportunityTriggerHandler {
|
||||
|
||||
public void beforeInsert(List<Opportunity> newRecords) {
|
||||
validateClosedWonAmount(newRecords);
|
||||
}
|
||||
|
||||
public void beforeUpdate(List<Opportunity> newRecords, Map<Id, Opportunity> oldMap) {
|
||||
updateDescriptionOnStageChange(newRecords, oldMap);
|
||||
}
|
||||
|
||||
public void afterUpdate(List<Opportunity> newRecords, Map<Id, Opportunity> oldMap) {
|
||||
createTasksForClosedWon(newRecords, oldMap);
|
||||
}
|
||||
|
||||
// Private helper methods below
|
||||
private void validateClosedWonAmount(List<Opportunity> opportunities) {
|
||||
for (Opportunity opp : opportunities) {
|
||||
if (opp.StageName == 'Closed Won' &&
|
||||
(opp.Amount == null || opp.Amount < 1000)) {
|
||||
opp.addError('Closed Won opportunities must have Amount ≥ 1000.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateDescriptionOnStageChange(
|
||||
List<Opportunity> newRecords,
|
||||
Map<Id, Opportunity> oldMap
|
||||
) {
|
||||
for (Opportunity opp : newRecords) {
|
||||
Opportunity oldOpp = oldMap.get(opp.Id);
|
||||
if (opp.StageName != oldOpp.StageName) {
|
||||
opp.Description = 'Stage changed from ' +
|
||||
oldOpp.StageName + ' to ' + opp.StageName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void createTasksForClosedWon(
|
||||
List<Opportunity> newRecords,
|
||||
Map<Id, Opportunity> oldMap
|
||||
) {
|
||||
List<Task> tasksToInsert = new List<Task>();
|
||||
|
||||
for (Opportunity opp : newRecords) {
|
||||
Opportunity oldOpp = oldMap.get(opp.Id);
|
||||
|
||||
// Check if stage changed to Closed Won
|
||||
if (opp.StageName == 'Closed Won' &&
|
||||
oldOpp.StageName != 'Closed Won') {
|
||||
|
||||
tasksToInsert.add(new Task(
|
||||
WhatId = opp.Id,
|
||||
OwnerId = opp.OwnerId,
|
||||
Subject = 'Send thank-you',
|
||||
Status = 'Not Started',
|
||||
Priority = 'Normal',
|
||||
ActivityDate = Date.today()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk DML outside loop
|
||||
if (!tasksToInsert.isEmpty()) {
|
||||
insert tasksToInsert;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Trigger Delegation
|
||||
|
||||
```apex
|
||||
trigger OpportunityTrigger on Opportunity (
|
||||
before insert, before update, after update
|
||||
) {
|
||||
OpportunityTriggerHandler handler = new OpportunityTriggerHandler();
|
||||
|
||||
if (Trigger.isBefore) {
|
||||
if (Trigger.isInsert) {
|
||||
handler.beforeInsert(Trigger.new);
|
||||
} else if (Trigger.isUpdate) {
|
||||
handler.beforeUpdate(Trigger.new, Trigger.oldMap);
|
||||
}
|
||||
}
|
||||
|
||||
if (Trigger.isAfter && Trigger.isUpdate) {
|
||||
handler.afterUpdate(Trigger.new, Trigger.oldMap);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 2: Handler with Database Methods
|
||||
|
||||
**Best for**: When you need granular error handling and partial success.
|
||||
|
||||
### Key Features
|
||||
|
||||
- Uses `Database.insert()` instead of `insert` for partial saves
|
||||
- Returns `Database.SaveResult` for error handling
|
||||
- Logs errors without stopping execution
|
||||
|
||||
### Example
|
||||
|
||||
```apex
|
||||
private void createTasksForClosedWon(
|
||||
List<Opportunity> newRecords,
|
||||
Map<Id, Opportunity> oldMap
|
||||
) {
|
||||
List<Task> tasksToInsert = new List<Task>();
|
||||
|
||||
for (Opportunity opp : newRecords) {
|
||||
Opportunity oldOpp = oldMap.get(opp.Id);
|
||||
|
||||
if (opp.StageName == 'Closed Won' &&
|
||||
oldOpp.StageName != 'Closed Won') {
|
||||
|
||||
tasksToInsert.add(new Task(
|
||||
WhatId = opp.Id,
|
||||
OwnerId = opp.OwnerId,
|
||||
Subject = 'Send thank-you',
|
||||
Status = 'Not Started',
|
||||
Priority = 'Normal',
|
||||
ActivityDate = Date.today()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (!tasksToInsert.isEmpty()) {
|
||||
Database.SaveResult[] results = Database.insert(tasksToInsert, false);
|
||||
|
||||
// Log errors without stopping execution
|
||||
for (Integer i = 0; i < results.size(); i++) {
|
||||
if (!results[i].isSuccess()) {
|
||||
System.debug('Failed to create task: ' + results[i].getErrors());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 3: Handler with Maps for Lookups
|
||||
|
||||
**Best for**: When you need to query related records for processing.
|
||||
|
||||
### Key Features
|
||||
|
||||
- Pre-queries related records using Sets
|
||||
- Uses Maps for O(1) lookups instead of nested loops
|
||||
- Avoids SOQL in loops
|
||||
|
||||
### Example
|
||||
|
||||
```apex
|
||||
private void enrichOpportunitiesWithAccountData(List<Opportunity> opportunities) {
|
||||
// Collect Account IDs
|
||||
Set<Id> accountIds = new Set<Id>();
|
||||
for (Opportunity opp : opportunities) {
|
||||
if (opp.AccountId != null) {
|
||||
accountIds.add(opp.AccountId);
|
||||
}
|
||||
}
|
||||
|
||||
// Single SOQL query outside loop
|
||||
Map<Id, Account> accountMap = new Map<Id, Account>([
|
||||
SELECT Id, Name, Industry, AnnualRevenue
|
||||
FROM Account
|
||||
WHERE Id IN :accountIds
|
||||
]);
|
||||
|
||||
// Use Map for O(1) lookup
|
||||
for (Opportunity opp : opportunities) {
|
||||
if (opp.AccountId != null && accountMap.containsKey(opp.AccountId)) {
|
||||
Account acc = accountMap.get(opp.AccountId);
|
||||
// Process with account data
|
||||
opp.Description = 'Account Industry: ' + acc.Industry;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 4: Unified Handler Framework
|
||||
|
||||
**Best for**: Complex triggers with many contexts and cross-cutting concerns.
|
||||
|
||||
### Structure
|
||||
|
||||
```apex
|
||||
public abstract class TriggerHandler {
|
||||
|
||||
protected Boolean isBefore;
|
||||
protected Boolean isAfter;
|
||||
protected Boolean isInsert;
|
||||
protected Boolean isUpdate;
|
||||
protected Boolean isDelete;
|
||||
protected Boolean isUndelete;
|
||||
|
||||
public void run() {
|
||||
isBefore = Trigger.isBefore;
|
||||
isAfter = Trigger.isAfter;
|
||||
isInsert = Trigger.isInsert;
|
||||
isUpdate = Trigger.isUpdate;
|
||||
isDelete = Trigger.isDelete;
|
||||
isUndelete = Trigger.isUndelete;
|
||||
|
||||
if (isBefore) {
|
||||
if (isInsert) beforeInsert();
|
||||
if (isUpdate) beforeUpdate();
|
||||
if (isDelete) beforeDelete();
|
||||
}
|
||||
|
||||
if (isAfter) {
|
||||
if (isInsert) afterInsert();
|
||||
if (isUpdate) afterUpdate();
|
||||
if (isDelete) afterDelete();
|
||||
if (isUndelete) afterUndelete();
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual void beforeInsert() {}
|
||||
protected virtual void beforeUpdate() {}
|
||||
protected virtual void beforeDelete() {}
|
||||
protected virtual void afterInsert() {}
|
||||
protected virtual void afterUpdate() {}
|
||||
protected virtual void afterDelete() {}
|
||||
protected virtual void afterUndelete() {}
|
||||
}
|
||||
```
|
||||
|
||||
### Concrete Handler
|
||||
|
||||
```apex
|
||||
public class OpportunityTriggerHandler extends TriggerHandler {
|
||||
|
||||
private List<Opportunity> newRecords;
|
||||
private List<Opportunity> oldRecords;
|
||||
private Map<Id, Opportunity> newMap;
|
||||
private Map<Id, Opportunity> oldMap;
|
||||
|
||||
public OpportunityTriggerHandler() {
|
||||
this.newRecords = (List<Opportunity>) Trigger.new;
|
||||
this.oldRecords = (List<Opportunity>) Trigger.old;
|
||||
this.newMap = (Map<Id, Opportunity>) Trigger.newMap;
|
||||
this.oldMap = (Map<Id, Opportunity>) Trigger.oldMap;
|
||||
}
|
||||
|
||||
protected override void beforeInsert() {
|
||||
validateClosedWonAmount();
|
||||
}
|
||||
|
||||
protected override void beforeUpdate() {
|
||||
updateDescriptionOnStageChange();
|
||||
}
|
||||
|
||||
protected override void afterUpdate() {
|
||||
createTasksForClosedWon();
|
||||
}
|
||||
|
||||
// Private helper methods omitted for brevity
|
||||
}
|
||||
```
|
||||
|
||||
### Trigger Delegation
|
||||
|
||||
```apex
|
||||
trigger OpportunityTrigger on Opportunity (
|
||||
before insert, before update, after update
|
||||
) {
|
||||
new OpportunityTriggerHandler().run();
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Bulkification
|
||||
|
||||
Always process records in collections:
|
||||
|
||||
```apex
|
||||
// ✓ Good: Collect DML outside loop
|
||||
List<Task> tasksToInsert = new List<Task>();
|
||||
for (Opportunity opp : opportunities) {
|
||||
tasksToInsert.add(new Task(...));
|
||||
}
|
||||
if (!tasksToInsert.isEmpty()) {
|
||||
insert tasksToInsert;
|
||||
}
|
||||
|
||||
// ✗ Bad: DML inside loop
|
||||
for (Opportunity opp : opportunities) {
|
||||
insert new Task(...); // SOQL/DML in loop!
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Defensive Null Checks
|
||||
|
||||
```apex
|
||||
// ✓ Good: Check for null before accessing
|
||||
if (opp.AccountId != null && accountMap.containsKey(opp.AccountId)) {
|
||||
Account acc = accountMap.get(opp.AccountId);
|
||||
// Safe to use acc
|
||||
}
|
||||
|
||||
// ✗ Bad: Assumes data exists
|
||||
Account acc = accountMap.get(opp.AccountId);
|
||||
String industry = acc.Industry; // NullPointerException risk
|
||||
```
|
||||
|
||||
### 3. Clear Method Names
|
||||
|
||||
```apex
|
||||
// ✓ Good: Descriptive, verb-noun pattern
|
||||
private void validateClosedWonAmount(List<Opportunity> opportunities)
|
||||
private void createTasksForClosedWon(List<Opportunity> opportunities)
|
||||
|
||||
// ✗ Bad: Vague or unclear
|
||||
private void validate(List<Opportunity> opportunities)
|
||||
private void doStuff(List<Opportunity> opportunities)
|
||||
```
|
||||
|
||||
### 4. Single Responsibility
|
||||
|
||||
Each handler method should do one thing:
|
||||
|
||||
```apex
|
||||
// ✓ Good: Separate concerns
|
||||
private void validateClosedWonAmount(List<Opportunity> opportunities)
|
||||
private void validateRequiredFields(List<Opportunity> opportunities)
|
||||
private void calculateDiscounts(List<Opportunity> opportunities)
|
||||
|
||||
// ✗ Bad: One method does everything
|
||||
private void processOpportunities(List<Opportunity> opportunities)
|
||||
```
|
||||
|
||||
### 5. Test Boundaries
|
||||
|
||||
Structure code to make testing easier:
|
||||
|
||||
```apex
|
||||
// ✓ Good: Public method for testing, private for implementation
|
||||
@TestVisible
|
||||
private void createTasksForClosedWon(
|
||||
List<Opportunity> newRecords,
|
||||
Map<Id, Opportunity> oldMap
|
||||
) {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
## Deployment Order
|
||||
|
||||
When deploying refactored triggers:
|
||||
|
||||
1. Deploy handler class(es) first
|
||||
2. Update trigger to use handler
|
||||
3. Deploy test class
|
||||
4. Run all tests before production deployment
|
||||
5. Monitor debug logs for 24-48 hours after production deployment
|
||||
|
||||
## Rollback Strategy
|
||||
|
||||
Keep the old trigger code commented out or in version control:
|
||||
|
||||
```apex
|
||||
trigger OpportunityTrigger on Opportunity (...) {
|
||||
// New handler approach
|
||||
new OpportunityTriggerHandler().run();
|
||||
|
||||
/* OLD CODE - REMOVE AFTER 1 WEEK IF NO ISSUES
|
||||
if (Trigger.isBefore && Trigger.isInsert) {
|
||||
for (Opportunity o : Trigger.new) {
|
||||
// old logic
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Recursive Triggers
|
||||
|
||||
**Problem**: Handler calls DML which triggers the same trigger again.
|
||||
|
||||
**Solution**: Use static flag to prevent recursion:
|
||||
|
||||
```apex
|
||||
public class OpportunityTriggerHandler {
|
||||
private static Boolean isExecuting = false;
|
||||
|
||||
public void beforeUpdate(List<Opportunity> newRecords, Map<Id, Opportunity> oldMap) {
|
||||
if (isExecuting) return;
|
||||
|
||||
isExecuting = true;
|
||||
try {
|
||||
// Your logic here
|
||||
} finally {
|
||||
isExecuting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pitfall 2: Mixed Context Logic
|
||||
|
||||
**Problem**: Before-context logic mixed with after-context logic.
|
||||
|
||||
**Solution**: Keep context methods separate and focused:
|
||||
|
||||
```apex
|
||||
// ✓ Good: Separate methods per context
|
||||
public void beforeUpdate(List<Opportunity> newRecords, Map<Id, Opportunity> oldMap)
|
||||
public void afterUpdate(List<Opportunity> newRecords, Map<Id, Opportunity> oldMap)
|
||||
|
||||
// ✗ Bad: Mixed logic in one method
|
||||
public void handleUpdate(List<Opportunity> newRecords, Map<Id, Opportunity> oldMap)
|
||||
```
|
||||
|
||||
### Pitfall 3: Over-Engineering
|
||||
|
||||
**Problem**: Using complex framework for simple triggers.
|
||||
|
||||
**Solution**: Choose the right pattern for your complexity:
|
||||
- 1-3 contexts with simple logic → Simple Handler (Pattern 1)
|
||||
- 3-5 contexts with moderate complexity → Handler with Database Methods (Pattern 2)
|
||||
- 5+ contexts with cross-cutting concerns → Unified Framework (Pattern 4)
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Apex Developer Guide: Trigger and Bulk Request Best Practices](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_triggers_bulk_requests.htm)
|
||||
- [Apex Enterprise Patterns](https://github.com/apex-enterprise-patterns)
|
||||
- [Trigger Framework Comparison](https://github.com/kevinohara80/sfdc-trigger-framework)
|
||||
@ -1,258 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Analyze Salesforce Apex triggers for common anti-patterns.
|
||||
|
||||
Usage:
|
||||
python analyze_trigger.py <TriggerName>
|
||||
|
||||
Requirements:
|
||||
- Salesforce CLI authenticated to target org
|
||||
- Python 3.9+
|
||||
"""
|
||||
|
||||
import sys
|
||||
import subprocess
|
||||
import json
|
||||
import re
|
||||
from typing import List, Dict, Tuple
|
||||
|
||||
class TriggerAnalyzer:
|
||||
def __init__(self, trigger_name: str):
|
||||
self.trigger_name = trigger_name
|
||||
self.trigger_body = ""
|
||||
self.issues = {
|
||||
'dml_in_loops': [],
|
||||
'soql_in_loops': [],
|
||||
'missing_bulk': [],
|
||||
}
|
||||
self.complexity_score = 0
|
||||
|
||||
def retrieve_trigger(self) -> bool:
|
||||
"""Retrieve trigger source code from Salesforce org."""
|
||||
try:
|
||||
cmd = [
|
||||
'sf', 'apex', 'get', 'trigger',
|
||||
'--trigger-name', self.trigger_name,
|
||||
'--json'
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"Error retrieving trigger: {result.stderr}")
|
||||
return False
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
self.trigger_body = data.get('result', {}).get('body', '')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to retrieve trigger: {e}")
|
||||
return False
|
||||
|
||||
def analyze_dml_in_loops(self) -> None:
|
||||
"""Detect DML operations inside for loops."""
|
||||
lines = self.trigger_body.split('\n')
|
||||
in_loop = False
|
||||
loop_start = 0
|
||||
|
||||
dml_patterns = [
|
||||
r'\binsert\s+', r'\bupdate\s+', r'\bdelete\s+',
|
||||
r'\bundelete\s+', r'\bupsert\s+'
|
||||
]
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
# Track loop entry
|
||||
if re.search(r'\bfor\s*\(', line):
|
||||
in_loop = True
|
||||
loop_start = i
|
||||
|
||||
# Track loop exit
|
||||
if in_loop and line.strip() == '}':
|
||||
# Check if this closes the loop (simplified heuristic)
|
||||
in_loop = False
|
||||
|
||||
# Check for DML in loop
|
||||
if in_loop:
|
||||
for pattern in dml_patterns:
|
||||
if re.search(pattern, line):
|
||||
self.issues['dml_in_loops'].append({
|
||||
'line': i,
|
||||
'code': line.strip(),
|
||||
'loop_start': loop_start
|
||||
})
|
||||
|
||||
def analyze_soql_in_loops(self) -> None:
|
||||
"""Detect SOQL queries inside for loops."""
|
||||
lines = self.trigger_body.split('\n')
|
||||
in_loop = False
|
||||
loop_start = 0
|
||||
|
||||
soql_pattern = r'\[SELECT\s+'
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
if re.search(r'\bfor\s*\(', line):
|
||||
in_loop = True
|
||||
loop_start = i
|
||||
|
||||
if in_loop and line.strip() == '}':
|
||||
in_loop = False
|
||||
|
||||
if in_loop and re.search(soql_pattern, line, re.IGNORECASE):
|
||||
self.issues['soql_in_loops'].append({
|
||||
'line': i,
|
||||
'code': line.strip(),
|
||||
'loop_start': loop_start
|
||||
})
|
||||
|
||||
def analyze_bulkification(self) -> None:
|
||||
"""Check for proper bulkification patterns."""
|
||||
# Simple heuristic: if we have DML in loops, bulkification is missing
|
||||
if self.issues['dml_in_loops']:
|
||||
self.issues['missing_bulk'].append({
|
||||
'message': 'DML operations should be collected and executed outside loops',
|
||||
'affected_lines': [issue['line'] for issue in self.issues['dml_in_loops']]
|
||||
})
|
||||
|
||||
if self.issues['soql_in_loops']:
|
||||
self.issues['missing_bulk'].append({
|
||||
'message': 'SOQL queries should be moved outside loops or use Maps for lookups',
|
||||
'affected_lines': [issue['line'] for issue in self.issues['soql_in_loops']]
|
||||
})
|
||||
|
||||
def calculate_complexity(self) -> None:
|
||||
"""Calculate overall complexity score (1-10)."""
|
||||
score = 1
|
||||
|
||||
# Add points for each issue type
|
||||
score += len(self.issues['dml_in_loops']) * 2
|
||||
score += len(self.issues['soql_in_loops']) * 2
|
||||
score += len(self.issues['missing_bulk']) * 1
|
||||
|
||||
# Count trigger contexts
|
||||
contexts = len(re.findall(r'Trigger\.(isBefore|isAfter)', self.trigger_body))
|
||||
score += contexts
|
||||
|
||||
# Count lines of code (normalized)
|
||||
loc = len([l for l in self.trigger_body.split('\n') if l.strip()])
|
||||
score += min(loc // 10, 3)
|
||||
|
||||
self.complexity_score = min(score, 10)
|
||||
|
||||
def recommend_approach(self) -> str:
|
||||
"""Recommend refactoring approach based on analysis."""
|
||||
if self.complexity_score <= 3:
|
||||
return "Simple handler class with separate methods for each trigger context"
|
||||
elif self.complexity_score <= 6:
|
||||
return "Handler class with bulkified collections and helper methods"
|
||||
else:
|
||||
return "Unified handler framework with separate concern classes (validation, DML, etc.)"
|
||||
|
||||
def generate_report(self) -> None:
|
||||
"""Print analysis report."""
|
||||
print("\n" + "="*70)
|
||||
print(f"TRIGGER ANALYSIS REPORT: {self.trigger_name}")
|
||||
print("="*70 + "\n")
|
||||
|
||||
print(f"Complexity Score: {self.complexity_score}/10")
|
||||
print(f"Recommended Approach: {self.recommend_approach()}\n")
|
||||
|
||||
# DML in loops
|
||||
if self.issues['dml_in_loops']:
|
||||
print("⚠️ DML OPERATIONS IN LOOPS:")
|
||||
for issue in self.issues['dml_in_loops']:
|
||||
print(f" Line {issue['line']}: {issue['code']}")
|
||||
print(f" └─ Loop started at line {issue['loop_start']}")
|
||||
print()
|
||||
else:
|
||||
print("✓ No DML operations found in loops\n")
|
||||
|
||||
# SOQL in loops
|
||||
if self.issues['soql_in_loops']:
|
||||
print("⚠️ SOQL QUERIES IN LOOPS:")
|
||||
for issue in self.issues['soql_in_loops']:
|
||||
print(f" Line {issue['line']}: {issue['code']}")
|
||||
print(f" └─ Loop started at line {issue['loop_start']}")
|
||||
print()
|
||||
else:
|
||||
print("✓ No SOQL queries found in loops\n")
|
||||
|
||||
# Bulkification
|
||||
if self.issues['missing_bulk']:
|
||||
print("⚠️ BULKIFICATION RECOMMENDATIONS:")
|
||||
for issue in self.issues['missing_bulk']:
|
||||
print(f" • {issue['message']}")
|
||||
print(f" Affected lines: {', '.join(map(str, issue['affected_lines']))}")
|
||||
print()
|
||||
else:
|
||||
print("✓ Bulkification patterns look good\n")
|
||||
|
||||
print("="*70)
|
||||
print("NEXT STEPS:")
|
||||
print("1. Review the handler patterns reference guide")
|
||||
print("2. Create handler class with bulk-safe collections")
|
||||
print("3. Extract trigger logic into handler methods")
|
||||
print("4. Generate comprehensive tests using the template")
|
||||
print("="*70 + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python analyze_trigger.py <TriggerName>")
|
||||
sys.exit(1)
|
||||
|
||||
trigger_name = sys.argv[1]
|
||||
|
||||
print(f"Analyzing trigger: {trigger_name}...")
|
||||
|
||||
analyzer = TriggerAnalyzer(trigger_name)
|
||||
|
||||
# For demo purposes, use inline example if retrieval fails
|
||||
if not analyzer.retrieve_trigger():
|
||||
print("⚠️ Could not retrieve from org. Using example trigger for demonstration.\n")
|
||||
# Use the example from the SKILL.md
|
||||
analyzer.trigger_body = """trigger OpportunityTrigger on Opportunity (before insert, before update, after update) {
|
||||
if (Trigger.isBefore && Trigger.isInsert) {
|
||||
for (Opportunity o : Trigger.new) {
|
||||
if (o.StageName == 'Closed Won' && (o.Amount == null || o.Amount < 1000)) {
|
||||
o.addError('Closed Won opportunities must have Amount ≥ 1000.');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Trigger.isBefore && Trigger.isUpdate) {
|
||||
for (Opportunity o : Trigger.new) {
|
||||
Opportunity oldO = Trigger.oldMap.get(o.Id);
|
||||
if (o.StageName != oldO.StageName) {
|
||||
o.Description = 'Stage changed from ' + oldO.StageName + ' to ' + o.StageName;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Trigger.isAfter && Trigger.isUpdate) {
|
||||
for (Opportunity o : Trigger.new) {
|
||||
Opportunity oldO = Trigger.oldMap.get(o.Id);
|
||||
if (o.StageName == 'Closed Won' && oldO.StageName != 'Closed Won') {
|
||||
Task t = new Task(
|
||||
WhatId = o.Id,
|
||||
OwnerId = o.OwnerId,
|
||||
Subject = 'Send thank-you',
|
||||
Status = 'Not Started',
|
||||
Priority = 'Normal',
|
||||
ActivityDate = Date.today()
|
||||
);
|
||||
insert t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}"""
|
||||
|
||||
# Run analysis
|
||||
analyzer.analyze_dml_in_loops()
|
||||
analyzer.analyze_soql_in_loops()
|
||||
analyzer.analyze_bulkification()
|
||||
analyzer.calculate_complexity()
|
||||
|
||||
# Generate report
|
||||
analyzer.generate_report()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
Reference in New Issue
Block a user