Compare commits

...

13 Commits

Author SHA1 Message Date
gbajpai-sf
3bf5a3493c
Merge 60848ef341 into 4d37555dd8 2026-05-05 09:44:54 +05:30
Hemant Singh Bisht
4d37555dd8
Merge remote-tracking branch 'origin/main' into develop 2026-05-05 08:35:50 +05:30
Jasmine Kaur
0af0eaecf6
fix: remove unused skill @W-22336514@ (#235)
skill-changes-related-to-apex
2026-05-04 10:53:20 +05:30
Gaurav Bajpai
60848ef341
fix: address external PR review feedback @W-22196528@
Apply review feedback from external PR #227:

- SKILL.md frontmatter: remove `license`, `compatibility`, and
  `metadata` (author/version) fields per reviewer. Keeps only the
  minimal `name`, `description`, `allowed-tools` frontmatter shape
  used by newer sibling skills in afv-library.
- Remove internal Salesforce release references (`core-264`,
  `core-262`) from user-facing docs (SKILL.md troubleshooting
  table, bdt-node-catalog.md BusinessTypeEnum section) and from
  bdt_analyze.py module docstring. External users should not see
  internal release codenames; the schema reference stands on its
  own with its capture date at the top of the catalog file.
- Fix test docstring path `afv-library/skills/explaining-...`
  -> `afv-library/skills/data-cloud-bdt-expert` (missed in the
  earlier rename commit).

No behavior change. All 92 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:23:41 +05:30
Gaurav Bajpai
0f94d02786
refactor: rename skill to data-cloud-bdt-expert @W-22196528@
Rename skill directory and frontmatter name from
`explaining-batch-data-transform` to `data-cloud-bdt-expert`
per internal PR reviewer feedback. A noun-form name (identifies
the skill by what it's about) is clearer than a gerund-form name
(describes what it does) for user-facing plugin/skill identity.

- skills/explaining-batch-data-transform/ -> skills/data-cloud-bdt-expert/
- SKILL.md frontmatter `name:` updated
- tests docstring path updated

No behavior change. All 92 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:41:17 +05:30
Gaurav Bajpai
67d919947f
fix: address PRizm round-2 review findings from internal port @W-22196528@
Ports the 14 applicable round-2 fixes from the internal PRizm review to the
external afv-library source. (plugin.json finding #13 is internal-only and
does not apply here.)

Accepted fixes:
  1  bdt_analyze.py select_definition: isinstance(index, int) -> type(index) is int
     (reject bool subclass of int).
  2  bdt_analyze.py cmd_formula: precompute upstream fields_produced map to
     eliminate O(consumed x upstream) recomputation.
  3  bdt_analyze.py topo_order: replace sort-on-every-iteration with deque-based
     Kahn (preserves deterministic order).
  4  test_bdt_analyze.py test_all_subcommands_work_on_api_input: wrap loop in
     self.subTest for per-iteration failure reporting.
  5  test_bdt_analyze.py test_invalid_json_raises: register addCleanup BEFORE
     write_text so cleanup runs even if write fails.
  6  SKILL.md: remove misleading "strip __c suffix" field-trace troubleshooting
     advice; replace with source-DMO passthrough guidance.
  7  SKILL.md: rewrite cycle error message to direct user to Data Cloud viewer
     (re-export will not fix a cycle in the BDT definition).
  8  SKILL.md: add explicit --definition N flag documentation and list every
     subcommand that accepts it.
  9  SKILL.md: refine I2 routing guidance — pick the earliest formula/
     computeRelative node, not the output mapping.
  11 bdt-function-catalog.md: clean floor() wording ("always rounds down on the
     number line").
  12 bdt-node-catalog.md: businessType enum — assert list is canonical/complete;
     instruct skill to surface+flag any unknown value.
  14 append_and_split.json: change split subject from fragile
     CustomerFullName__c split-on-space to ChannelOrderKey__c split-on-hyphen;
     avoids name-parsing pitfall (middle names / multi-space).
  15 window_and_aggregate.json: use the computed OrderRank__c via a new
     FIRST_ORDER_AMOUNT formula node (case when rank=1 then amount else 0)
     feeding AGG_BY_ACCOUNT; illustrates the rank+formula+aggregate idiom.
  16 joins_and_filters.json: add explicit IS_NOT_NULL filter expression on
     GrandTotalAmount__c (defense-in-depth beyond GREATER_THAN 0).

Rejected finding (rationale posted as PR comment):
  10 bdt-node-catalog.md split section — reviewer claimed split is a
     pipeline-branching node. Canonical sources (core-262
     SplitParametersInputRepresentation.java + Salesforce help DITA
     c360_a_batch_transform_split.xml) both describe split as a
     string-splitting operation. Current docs are correct; not changing.

Tests: 92/92 passing.
2026-04-24 10:55:18 +05:30
Gaurav Bajpai
db1aca7221
fix: address PRizm review findings from internal port PR @W-22196528@
Addresses 4 critical findings surfaced during PRizm code review of the
internal plugin port of this skill (internal PR #19). Applying the same
fixes here keeps the external canonical source and the internal port in
sync.

1. Test cleanup discipline: switch `test_invalid_json_raises` from
   `try/finally` to `self.addCleanup(p.unlink, missing_ok=True)` — the
   unittest-idiomatic way to guarantee temp-file cleanup regardless of
   how the test exits.

2. `floor()` description in bdt-function-catalog.md: the old row was
   self-contradictory ("toward zero" AND "toward next integer up" in
   the same cell). Replace with a single coherent definition: rounds
   toward negative infinity; for negatives rounds away from zero
   (e.g., `floor(-2.3) = -3`).

3. Split-node documentation in bdt-node-catalog.md: the old doc claimed
   `split` routes rows into downstream branches via `branches[]` with
   per-branch predicates. That is not the canonical schema. Per
   `SplitParametersInputRepresentation` in core-262-public, `split` is
   a string-splitting operation: one `sourceField` + `delimiter` →
   N `targetFields` (one row in, one row out; columns added). Rewrote
   the section with the correct parameters, lineage effect, gotchas,
   and a canonical example. Row-routing belongs in `filter` nodes.

4. Sample `assets/sample_bdts/append_and_split.json`: the old sample
   used the invented `branches[]` shape AND routed the same split into
   two downstream outputs that each expected different rows — which is
   not how `split` works. Rewrote the sample so:
     - `appendV2` unions two order sources (unchanged intent).
     - `split` uses canonical `{sourceField, delimiter, targetFields}`
       splitting `CustomerFullName__c` into first + last name columns.
     - One downstream output consumes the new columns (removes the
       fake two-branch fan-out).

Tests: 92/92 passing. Sample parses and runs through `bdt_analyze.py
summary` cleanly (5 nodes: 2 load + 1 appendV2 + 1 split + 1 outputD360).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:19:36 +05:30
Gaurav Bajpai
fc4d14bce0
test(bdt): unit tests + adversarial fixtures @W-22196528@
Adds the full test suite for bdt_analyze.py: 92 unit tests backed by
9 JSON fixtures (4 normative + 5 adversarial). All tests stdlib-only,
runnable via `python3 -m unittest tests.test_bdt_analyze`.

tests/test_bdt_analyze.py coverage:

DAG primitives:
- roots() / sinks() / topo_order() on minimal + branching graphs.
- upstream() / downstream() reachability correctness.
- Cycle tolerance: topo_order + traversals do not infinite-loop or
  raise when the graph contains a cycle; cycle is reported via
  broken_references-adjacent signals.
- Broken-reference tolerance: nodes referencing non-existent upstreams
  are handled gracefully; broken_references() enumerates them.

CLI subcommands (happy-path + error-path for each):
- summary, sources, outputs, stages, nodes, node, lineage,
  field-trace, formula, definitions — each exercised in both
  human-readable and --json modes.
- Exit-code discipline: unknown node -> exit 2, malformed input ->
  exit 3, success -> exit 0.
- Unknown-action graceful degradation: nodes whose action isn't in
  the catalog still appear in summary/nodes output with a generic
  label rather than crashing.
- Output size budgets: default-mode outputs are asserted to stay
  within configured character caps.

Field discovery:
- fields_produced() / fields_consumed() / _scrape_field_refs()
  heuristics tested against both clean and noisy formula bodies.
- TestFieldTraceFocus — narrowing tests preventing false-positive
  substring hits in formula text.

Dual-shape input:
- TestInputShapeDetection — editor export vs. Connect API
  single-definition auto-detection.
- TestMultiDefinition — Connect API multi-definition payload:
  definitions subcommand, --definition N routing, out-of-range
  index error handling.

Fixtures (tests/fixtures/):
- minimal.json               — smallest valid editor-export BDT.
- window_and_aggregate.json  — window + aggregate composition.
- api_input_single.json      — Connect API single-definition payload.
- api_input_multi.json       — Connect API multi-definition payload.

Adversarial fixtures (tests/fixtures/adversarial/):
- cycle.json           — graph with a cycle; parser must not hang.
- broken_ref.json      — node referencing a non-existent upstream.
- unknown_action.json  — node with an action not in the catalog.
- no_ui.json           — BDT missing the UI layer entirely.
- empty_nodes.json     — valid envelope but zero nodes.

Suite status: Ran 92 tests, OK.

@W-22196528@
2026-04-23 23:29:05 +05:30
Gaurav Bajpai
9c47f28221
feat(bdt): reference docs + sample BDTs @W-22196528@
Adds the curated reference library the skill loads on demand, plus
four synthetic sample BDTs used by docs, tests, and LLM-mode demos.

references/ (4 curated Markdown files):
- bdt-reference.md        — top-level BDT JSON anatomy: envelope,
                            nodes, edges, UI layer, definitions,
                            businessType semantics. Cites the core-262
                            upstream JSON schema and Connect API spec.
- bdt-node-catalog.md     — every node type (DMO Source, DMO Sink,
                            Filter, Join, Union, Aggregate, Window,
                            Formula, Split, Append, etc.) with its
                            required/optional fields and typical
                            usage. Audited against core-262 enums.
- bdt-function-catalog.md — the expression-language function surface
                            (string, numeric, date, conditional,
                            aggregate). Grouped by category with
                            signature + one-line semantics.
- bdt-window-functions.md — windowing operators (ROW_NUMBER, RANK,
                            LEAD/LAG, running aggregates) with PARTITION
                            BY / ORDER BY grammar and gotchas.

assets/sample_bdts/ (4 synthetic, dependency-free BDTs):
- minimal_dmo_to_dmo.json   — smallest valid BDT (1 source, 1 sink).
- joins_and_filters.json    — join + filter composition.
- window_and_aggregate.json — window function + aggregate in one graph.
- append_and_split.json     — append-then-split branching topology.

Grounding rules enforced in this commit:
- Every claim in references/ cites an upstream source (core-262 JSON
  schema, Connect API reference, or the Data Cloud BDT editor spec).
  No speculative content.
- No raw DITA or internal-only documentation is shipped; references
  are synthesized from public-facing material.
- BusinessTypeEnum values use the canonical camelCase casing from
  core-262 (case-cleanup fix included here).
- Sample BDTs are original synthetic fixtures, not redacted customer
  data. Each is small enough to read end-to-end.

@W-22196528@
2026-04-23 23:29:05 +05:30
Gaurav Bajpai
9f1603ed2f
feat(bdt): Python parser core + CLI subcommands @W-22196528@
Adds scripts/bdt_analyze.py — a generic, stdlib-only DAG parser and
query CLI for Salesforce Data Cloud BDT JSON. 1,379 lines.

Parser primitives:
- DataTransform.from_path / from_dict — accepts three input shapes:
  editor export ({version, nodes, ui, ...}), Connect API
  single-definition ({name, label, type, definition: {...}}), and
  Connect API multi-definition ({name, definitions: [{name, label,
  type, definition}, ...]}).
- Node dataclass with ui_label / ui_description fallback resolution.
- roots() / sinks() / topo_order() — Kahn's algorithm with
  deterministic tie-break for reproducible output.
- upstream() / downstream() traversal resilient to broken refs and
  cycles (does not infinite-loop on self-edges or cycles).
- broken_references(), fields_produced(), fields_consumed(),
  _scrape_field_refs() heuristics for field-trace discovery.

CLI (argparse, 10 subcommands, each supports --json for machine
output):
- summary      — node counts, source/sink counts, stage totals.
- sources      — list source nodes (no upstream).
- outputs      — list sink nodes (no downstream).
- stages       — topologically ordered stages.
- nodes        — flat node listing with labels.
- node <name>  — per-node detail (action, inputs, outputs, fields,
                 UI label/description).
- lineage <node>       — upstream + downstream chain from a node.
- field-trace <field>  — which nodes produce/consume a given field.
- formula <node>       — extract formulas/expressions from a node.
- definitions          — lists definitions in multi-definition
                         payloads; every other subcommand accepts
                         --definition N (default 0) to route into a
                         specific definition within the payload.

Error contract:
- Exit 0 on success, 2 on unknown node/field, 3 on malformed input.
- BdtInputError (exit 3) and BdtNotFoundError (exit 2) classes
  centralize error handling so the CLI shell stays thin.
- Field-trace narrowing refinements prevent false positives from
  substring matches in formula bodies.
- Upstream/downstream walkers harden against broken refs discovered
  during internal-BDT audit.

Design invariants:
- Python owns truth (parsing, DAG math, field discovery). LLM owns
  narrative (explaining what the structure means to a user).
- No external dependencies — stdlib only: argparse, json, pathlib,
  re, sys, collections, dataclasses, typing.
- Output size budgets: every subcommand caps its default-mode output
  so summaries fit in a single LLM context window; --json dumps
  everything for agents that need raw data.

@W-22196528@
2026-04-23 23:29:05 +05:30
Gaurav Bajpai
9be9667779
feat(bdt): scaffold skill directory + SKILL.md body @W-22196528@
Scaffolds the explaining-batch-data-transform skill directory and delivers
the full, production-grade SKILL.md body. 194 lines.

Directory layout established (skills/explaining-batch-data-transform/):
- SKILL.md                 — the entry point this commit adds
- scripts/                 — parser lands in commit 2
- references/, assets/     — reference docs + samples land in commit 3
- tests/                   — unit tests + fixtures land in commit 4

SKILL.md frontmatter:
- name, description, tier, and owner fields matching the library's CI
  validator schema.
- Description covers the full production surface: "Explain, audit, and
  trace Salesforce Data Cloud Batch Data Transform (BDT) JSON — sources,
  sinks, stages, lineage, field traces, formulas, multi-definition
  payloads, and dual input shapes (editor export + Connect API)."

SKILL.md body (12 sections):
1. When to use this skill
2. Inputs you accept (editor export, Connect API single-definition,
   Connect API multi-definition payloads)
3. Mode A — structural survey (summary / sources / outputs / stages)
4. Mode B — per-node explanation (nodes / node)
5. Mode C — lineage + field trace
6. Mode D — formulas and windows
7. Question routing table (user phrasing -> CLI subcommand)
8. Multi-definition payload handling (--definition flag semantics)
9. Troubleshooting (malformed JSON, unknown node refs, broken upstream)
10. Non-goals as explicit "Do not" directives (no execution, no schema
    inference beyond what the JSON states, no speculation about runtime)
11. References + sample BDTs index
12. Exit-code + error-class contract surfaced from bdt_analyze.py

Structure passes the repo's skill-validator (npm run validate:skills):
single top-level H1, required sections present, frontmatter schema
aligned, no broken internal links.

@W-22196528@
2026-04-23 23:29:04 +05:30
Shelby Hubick
2d508c8ac0
@W-21965936@ Add applying and validating SLDS skills to afv-library (#187)
* feat: add SLDS applying and auditing quality skills

Two new skills for SLDS v2 compliance:
- applying-slds: guides artifact selection, styling hooks, utilities, icons
- auditing-slds-quality: scored quality audit with linter + static analysis

Made-with: Cursor

* refactor: rename auditing-slds-quality skill to validating-slds

Renames the skill directory and updates all references in
applying-slds/SKILL.md and applying-slds/checklists.md.

Made-with: Cursor

* fix: improve accuracy and add manual review gate across SLDS skills

Correct hook families, badge modifiers, and severity levels; add a
manual review gate to validating-slds so automated grades alone cannot
declare production readiness; make analyze-quality.cjs portable with
explicit --hooks-index flag; remove dead parseYaml code; add version
field to all three skill frontmatters.

Made-with: Cursor

* chore: retrigger CI

Made-with: Cursor

* fix: quote YAML descriptions and improve color hook disambiguation

- Quote description frontmatter in all three skills for valid YAML
  (inner double quotes now escaped)
- Strengthen Step 4 directive: MUST read color-hooks guide before
  choosing a hook — linter suggestions are unranked
- Add surface vs surface-container disambiguation table
- Add accent hook context table and state progression
- Add standalone-component exception for surface classification
- Add modal background example to examples.md
- Remove redundant brand-button example (covered by new context table)

Made-with: Cursor
2026-04-21 14:43:15 +05:30
Scott Mo
73db45cabf
refactor: mv skill to dev branch (#182)
* refactor: mv skill to separate branch

* feat: add fallbacks to sf site create commands (from fiona)

* fix: target config instrx (from karthik)
2026-04-21 14:43:15 +05:30
24 changed files with 4084 additions and 1212 deletions

View 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.

View File

@ -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"}
]
}
}

View File

@ -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"}
]
}
}

View File

@ -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": []
}
}

View File

@ -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"}
]
}
}

View 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/`).

View 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.

View 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.

View File

@ -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`.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
{
"version": "66.0",
"nodes": {
"LOAD0": {"action": "load", "sources": [], "parameters": {}},
"JOIN0": {"action": "join", "sources": ["LOAD0", "NO_SUCH_NODE"], "parameters": {}}
}
}

View 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": {}}
}
}

View File

@ -0,0 +1 @@
{"version": "66.0", "nodes": {}}

View File

@ -0,0 +1,7 @@
{
"version": "66.0",
"nodes": {
"LOAD0": {"action": "load", "sources": [], "parameters": {}},
"OUT0": {"action": "outputD360", "sources": ["LOAD0"], "parameters": {}}
}
}

View File

@ -0,0 +1,7 @@
{
"version": "99.0",
"nodes": {
"LOAD0": {"action": "load", "sources": [], "parameters": {}},
"MYSTERY0": {"action": "someFutureAction", "sources": ["LOAD0"], "parameters": {"foo": "bar"}}
}
}

View 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}
}
}
}
]
}

View 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}
}
}
}
}

View 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": []
}
}

View 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"}
]
}
}
}
}

View 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()

View File

@ -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

View File

@ -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');
}
}
}

View File

@ -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)

View File

@ -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()