mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-08-04 14:25:31 +08:00
1382 lines
55 KiB
Python
1382 lines
55 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""Analyze Salesforce Data Cloud Batch Data Transform (BDT) JSON.
|
|||
|
|
|
|||
|
|
This module provides a generic typed-DAG parser over BDT JSON definitions.
|
|||
|
|
Grounded against the canonical BDT Connect API InputRepresentation classes.
|
|||
|
|
Design principle: structural facts come from deterministic graph walks here;
|
|||
|
|
narrative explanations are built by the host LLM.
|
|||
|
|
|
|||
|
|
Usage (CLI lands in a later plan):
|
|||
|
|
python bdt_analyze.py <subcommand> <bdt.json> [...]
|
|||
|
|
|
|||
|
|
Requirements:
|
|||
|
|
- Python 3.9+ (standard library only).
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import json
|
|||
|
|
import pathlib
|
|||
|
|
import re
|
|||
|
|
import sys
|
|||
|
|
from collections import Counter
|
|||
|
|
from dataclasses import dataclass, field
|
|||
|
|
from typing import Any, Dict, List, Mapping, Optional, Set, Tuple
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --------- Exit codes ---------------------------------------------------------
|
|||
|
|
EXIT_OK = 0
|
|||
|
|
EXIT_NOT_FOUND = 2 # unknown node / field
|
|||
|
|
EXIT_BAD_INPUT = 3 # malformed JSON / bad shape / cycle
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --------- Exceptions --------------------------------------------------------
|
|||
|
|
class BdtInputError(Exception):
|
|||
|
|
"""Raised on any malformed input. CLI maps this to exit code 3."""
|
|||
|
|
|
|||
|
|
|
|||
|
|
class BdtNotFoundError(Exception):
|
|||
|
|
"""Raised when a queried node or field doesn't exist. CLI maps to exit 2."""
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class Node:
|
|||
|
|
"""One entry in BDT `nodes{}`."""
|
|||
|
|
name: str
|
|||
|
|
action: str
|
|||
|
|
sources: List[str] = field(default_factory=list)
|
|||
|
|
parameters: Dict[str, Any] = field(default_factory=dict)
|
|||
|
|
schema: Optional[Dict[str, Any]] = None
|
|||
|
|
ui_label: Optional[str] = None
|
|||
|
|
ui_description: Optional[str] = None
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def display_name(self) -> str:
|
|||
|
|
"""UI label if present and non-empty; else node name."""
|
|||
|
|
if self.ui_label:
|
|||
|
|
return f"{self.ui_label} ({self.name})"
|
|||
|
|
return self.name
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class Definition:
|
|||
|
|
"""One parsed STL block from a BDT payload.
|
|||
|
|
|
|||
|
|
A `DataTransform` always holds at least one of these (length 1 for the
|
|||
|
|
editor-export and single-definition Connect API shapes, length N for the
|
|||
|
|
multi-definition Connect API shape).
|
|||
|
|
"""
|
|||
|
|
index: int
|
|||
|
|
version: Optional[str]
|
|||
|
|
nodes: Dict[str, Node]
|
|||
|
|
ui: Dict[str, Any]
|
|||
|
|
name: Optional[str] = None
|
|||
|
|
label: Optional[str] = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class DataTransform:
|
|||
|
|
"""Parsed BDT JSON, with a typed DAG view.
|
|||
|
|
|
|||
|
|
The parser accepts both the editor-export shape (`nodes` at top level) and
|
|||
|
|
the Connect API create-payload shapes (`definition` or `definitions[]`
|
|||
|
|
wrappers around the editor-export content). When a wrapper is detected the
|
|||
|
|
parser descends into the STL definition(s) transparently; outer metadata
|
|||
|
|
(name, label, type, dataSpaceName) is preserved as optional attributes.
|
|||
|
|
|
|||
|
|
For multi-definition payloads, every definition is parsed and kept on
|
|||
|
|
`self.definitions`. The currently-selected definition (default index 0)
|
|||
|
|
drives the pass-through properties `nodes`, `version`, and `ui`, so all
|
|||
|
|
existing methods operate on the selected block with no signature changes.
|
|||
|
|
Callers can hop between definitions with `select_definition(index)`.
|
|||
|
|
"""
|
|||
|
|
definitions: List[Definition]
|
|||
|
|
# Outer-wrapper metadata (only populated for Connect API input shapes).
|
|||
|
|
name: Optional[str] = None
|
|||
|
|
label: Optional[str] = None
|
|||
|
|
data_transform_type: Optional[str] = None
|
|||
|
|
data_space_name: Optional[str] = None
|
|||
|
|
# Which definition the accessor properties return; 0 by default.
|
|||
|
|
selected_index: int = 0
|
|||
|
|
|
|||
|
|
# --- Backward-compat pass-through properties ---------------------------
|
|||
|
|
# Existing callers (tests + CLI handlers) read `.nodes`, `.version`, `.ui`
|
|||
|
|
# as simple attributes; keep that shape by delegating to the selected
|
|||
|
|
# definition.
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def nodes(self) -> Dict[str, Node]:
|
|||
|
|
return self.definitions[self.selected_index].nodes
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def version(self) -> Optional[str]:
|
|||
|
|
return self.definitions[self.selected_index].version
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def ui(self) -> Dict[str, Any]:
|
|||
|
|
return self.definitions[self.selected_index].ui
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def definition_count(self) -> int:
|
|||
|
|
return len(self.definitions)
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def definition_index(self) -> int:
|
|||
|
|
return self.selected_index
|
|||
|
|
|
|||
|
|
def select_definition(self, index: int) -> None:
|
|||
|
|
"""Pick which definition the accessor properties (and the rest of the
|
|||
|
|
API surface) operate on. Raises `BdtNotFoundError` if `index` is out
|
|||
|
|
of range."""
|
|||
|
|
if type(index) is not int or index < 0 or index >= len(self.definitions):
|
|||
|
|
raise BdtNotFoundError(
|
|||
|
|
f"Definition index {index} out of range; payload has "
|
|||
|
|
f"{len(self.definitions)} definitions "
|
|||
|
|
f"(indices 0-{len(self.definitions) - 1})."
|
|||
|
|
)
|
|||
|
|
self.selected_index = index
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def from_path(cls, path: pathlib.Path) -> "DataTransform":
|
|||
|
|
path = pathlib.Path(path)
|
|||
|
|
try:
|
|||
|
|
raw = json.loads(path.read_text())
|
|||
|
|
except OSError as e:
|
|||
|
|
raise BdtInputError(f"Cannot read {path}: {e}") from e
|
|||
|
|
except json.JSONDecodeError as e:
|
|||
|
|
raise BdtInputError(
|
|||
|
|
f"Invalid JSON in {path} at line {e.lineno}, col {e.colno}: {e.msg}"
|
|||
|
|
) from e
|
|||
|
|
return cls.from_dict(raw)
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def from_dict(cls, raw: Mapping[str, Any]) -> "DataTransform":
|
|||
|
|
if not isinstance(raw, Mapping):
|
|||
|
|
raise BdtInputError("Top-level BDT JSON must be an object")
|
|||
|
|
|
|||
|
|
# --- Shape detection -------------------------------------------------
|
|||
|
|
# 1. Editor-export shape: `nodes` at top level — exactly one definition.
|
|||
|
|
# 2. Connect API single-definition shape: `definition` object with
|
|||
|
|
# `nodes` — one definition, plus outer wrapper metadata.
|
|||
|
|
# 3. Connect API multi-definition shape: non-empty `definitions` array
|
|||
|
|
# whose entries each have `nodes` — N definitions, plus outer
|
|||
|
|
# wrapper metadata. Every definition is parsed.
|
|||
|
|
outer_name: Optional[str] = None
|
|||
|
|
outer_label: Optional[str] = None
|
|||
|
|
outer_type: Optional[str] = None
|
|||
|
|
outer_data_space: Optional[str] = None
|
|||
|
|
raw_definitions: List[Mapping[str, Any]]
|
|||
|
|
|
|||
|
|
if isinstance(raw.get("nodes"), Mapping):
|
|||
|
|
raw_definitions = [raw]
|
|||
|
|
elif (isinstance(raw.get("definition"), Mapping)
|
|||
|
|
and isinstance(raw["definition"].get("nodes"), Mapping)):
|
|||
|
|
raw_definitions = [raw["definition"]]
|
|||
|
|
outer_name = raw.get("name") if isinstance(raw.get("name"), str) else None
|
|||
|
|
outer_label = raw.get("label") if isinstance(raw.get("label"), str) else None
|
|||
|
|
outer_type = raw.get("type") if isinstance(raw.get("type"), str) else None
|
|||
|
|
outer_data_space = (
|
|||
|
|
raw.get("dataSpaceName") if isinstance(raw.get("dataSpaceName"), str) else None
|
|||
|
|
)
|
|||
|
|
elif (isinstance(raw.get("definitions"), list)
|
|||
|
|
and len(raw["definitions"]) > 0
|
|||
|
|
and all(
|
|||
|
|
isinstance(d, Mapping) and isinstance(d.get("nodes"), Mapping)
|
|||
|
|
for d in raw["definitions"]
|
|||
|
|
)):
|
|||
|
|
raw_definitions = list(raw["definitions"])
|
|||
|
|
outer_name = raw.get("name") if isinstance(raw.get("name"), str) else None
|
|||
|
|
outer_label = raw.get("label") if isinstance(raw.get("label"), str) else None
|
|||
|
|
outer_type = raw.get("type") if isinstance(raw.get("type"), str) else None
|
|||
|
|
outer_data_space = (
|
|||
|
|
raw.get("dataSpaceName") if isinstance(raw.get("dataSpaceName"), str) else None
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
raise BdtInputError(
|
|||
|
|
"Expected 'nodes' at top level, or 'definition.nodes', "
|
|||
|
|
"or 'definitions[].nodes'; none found"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
definitions: List[Definition] = []
|
|||
|
|
for idx, def_raw in enumerate(raw_definitions):
|
|||
|
|
definitions.append(
|
|||
|
|
cls._parse_definition(
|
|||
|
|
idx, def_raw,
|
|||
|
|
fallback_name=outer_name,
|
|||
|
|
fallback_label=outer_label,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return cls(
|
|||
|
|
definitions=definitions,
|
|||
|
|
name=outer_name,
|
|||
|
|
label=outer_label,
|
|||
|
|
data_transform_type=outer_type,
|
|||
|
|
data_space_name=outer_data_space,
|
|||
|
|
selected_index=0,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def _parse_definition(
|
|||
|
|
cls,
|
|||
|
|
index: int,
|
|||
|
|
definition: Mapping[str, Any],
|
|||
|
|
fallback_name: Optional[str] = None,
|
|||
|
|
fallback_label: Optional[str] = None,
|
|||
|
|
) -> Definition:
|
|||
|
|
"""Parse a single STL block into a `Definition`.
|
|||
|
|
|
|||
|
|
`name`/`label` come from the definition itself when present; otherwise
|
|||
|
|
fall back to the outer wrapper's values (so a single-definition API
|
|||
|
|
payload still carries the transform's name on `definitions[0]`).
|
|||
|
|
"""
|
|||
|
|
nodes_raw = definition.get("nodes")
|
|||
|
|
if not isinstance(nodes_raw, Mapping):
|
|||
|
|
# Defensive: the shape-detection step should keep this out.
|
|||
|
|
raise BdtInputError(
|
|||
|
|
"Expected 'nodes' at top level, or 'definition.nodes', "
|
|||
|
|
"or 'definitions[].nodes'; none found"
|
|||
|
|
)
|
|||
|
|
ui = definition.get("ui") or {}
|
|||
|
|
ui_nodes = (ui.get("nodes") if isinstance(ui, Mapping) else {}) or {}
|
|||
|
|
|
|||
|
|
nodes: Dict[str, Node] = {}
|
|||
|
|
for name, body in nodes_raw.items():
|
|||
|
|
if not isinstance(body, Mapping):
|
|||
|
|
raise BdtInputError(f"Node {name!r} body is not an object")
|
|||
|
|
action = body.get("action")
|
|||
|
|
if not isinstance(action, str) or not action:
|
|||
|
|
raise BdtInputError(f"Node {name!r} missing string 'action'")
|
|||
|
|
srcs = body.get("sources") or []
|
|||
|
|
if not isinstance(srcs, list):
|
|||
|
|
raise BdtInputError(f"Node {name!r} 'sources' is not a list")
|
|||
|
|
ui_entry = ui_nodes.get(name) or {}
|
|||
|
|
nodes[name] = Node(
|
|||
|
|
name=name,
|
|||
|
|
action=action,
|
|||
|
|
sources=list(srcs),
|
|||
|
|
parameters=dict(body.get("parameters") or {}),
|
|||
|
|
schema=body.get("schema"),
|
|||
|
|
ui_label=(ui_entry.get("label") if isinstance(ui_entry, Mapping) else None) or None,
|
|||
|
|
ui_description=(ui_entry.get("description") if isinstance(ui_entry, Mapping) else None) or None,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def_name = definition.get("name") if isinstance(definition.get("name"), str) else None
|
|||
|
|
def_label = definition.get("label") if isinstance(definition.get("label"), str) else None
|
|||
|
|
|
|||
|
|
return Definition(
|
|||
|
|
index=index,
|
|||
|
|
version=definition.get("version"),
|
|||
|
|
nodes=nodes,
|
|||
|
|
ui=dict(ui) if isinstance(ui, Mapping) else {},
|
|||
|
|
name=def_name or fallback_name,
|
|||
|
|
label=def_label or fallback_label,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def warnings(self) -> List[str]:
|
|||
|
|
"""Non-fatal advisories about the parsed BDT.
|
|||
|
|
|
|||
|
|
The multi-definition case used to emit an advisory here ("payload
|
|||
|
|
contains N definitions; this skill explains the first only"). With
|
|||
|
|
full multi-definition support that warning is gone; the method is
|
|||
|
|
preserved for future advisories and currently returns an empty list.
|
|||
|
|
"""
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
def roots(self) -> List[str]:
|
|||
|
|
"""Node names with no sources (data enters here)."""
|
|||
|
|
return [name for name, n in self.nodes.items() if not n.sources]
|
|||
|
|
|
|||
|
|
def sinks(self) -> List[str]:
|
|||
|
|
"""Node names that are not listed as a source by any other node (data exits here)."""
|
|||
|
|
referenced = set()
|
|||
|
|
for n in self.nodes.values():
|
|||
|
|
referenced.update(n.sources)
|
|||
|
|
return [name for name in self.nodes if name not in referenced]
|
|||
|
|
|
|||
|
|
def topo_order(self) -> List[str]:
|
|||
|
|
"""Kahn's algorithm. Raises BdtInputError on cycles."""
|
|||
|
|
from collections import deque
|
|||
|
|
in_degree: Dict[str, int] = {name: 0 for name in self.nodes}
|
|||
|
|
forward: Dict[str, List[str]] = {name: [] for name in self.nodes}
|
|||
|
|
for name, n in self.nodes.items():
|
|||
|
|
for s in n.sources:
|
|||
|
|
if s not in self.nodes:
|
|||
|
|
# Broken reference — count as in-edge so the dependent node stays blocked
|
|||
|
|
in_degree[name] += 1
|
|||
|
|
continue
|
|||
|
|
forward[s].append(name)
|
|||
|
|
in_degree[name] += 1
|
|||
|
|
|
|||
|
|
# Sort zero-in-degree nodes once for deterministic starting order
|
|||
|
|
ready = deque(sorted([name for name, d in in_degree.items() if d == 0]))
|
|||
|
|
order: List[str] = []
|
|||
|
|
while ready:
|
|||
|
|
current = ready.popleft()
|
|||
|
|
order.append(current)
|
|||
|
|
# Collect newly-ready nodes, sort them once, append
|
|||
|
|
newly_ready = []
|
|||
|
|
for consumer in forward[current]:
|
|||
|
|
in_degree[consumer] -= 1
|
|||
|
|
if in_degree[consumer] == 0:
|
|||
|
|
newly_ready.append(consumer)
|
|||
|
|
for node in sorted(newly_ready):
|
|||
|
|
ready.append(node)
|
|||
|
|
|
|||
|
|
if len(order) != len(self.nodes):
|
|||
|
|
stuck = [n for n in self.nodes if n not in order]
|
|||
|
|
raise BdtInputError(
|
|||
|
|
f"Cycle or broken reference detected; unresolved nodes: {sorted(stuck)}"
|
|||
|
|
)
|
|||
|
|
return order
|
|||
|
|
|
|||
|
|
def _topo_order_subset(self, subset: Set[str]) -> List[str]:
|
|||
|
|
"""Kahn's algorithm restricted to `subset`.
|
|||
|
|
|
|||
|
|
Only edges whose endpoints are both in `subset` are considered. This
|
|||
|
|
tolerates broken references that sit outside the queried sub-DAG and
|
|||
|
|
avoids crashing on unrelated cycles. Assumes the sub-DAG itself is
|
|||
|
|
acyclic (caller guarantees this by restricting to a reachable set
|
|||
|
|
via a walk that skips broken refs).
|
|||
|
|
"""
|
|||
|
|
in_degree: Dict[str, int] = {name: 0 for name in subset}
|
|||
|
|
forward: Dict[str, List[str]] = {name: [] for name in subset}
|
|||
|
|
for name in subset:
|
|||
|
|
n = self.nodes[name]
|
|||
|
|
for s in n.sources:
|
|||
|
|
if s in subset:
|
|||
|
|
forward[s].append(name)
|
|||
|
|
in_degree[name] += 1
|
|||
|
|
ready = sorted([name for name, d in in_degree.items() if d == 0])
|
|||
|
|
order: List[str] = []
|
|||
|
|
while ready:
|
|||
|
|
ready.sort()
|
|||
|
|
current = ready.pop(0)
|
|||
|
|
order.append(current)
|
|||
|
|
for consumer in sorted(forward[current]):
|
|||
|
|
in_degree[consumer] -= 1
|
|||
|
|
if in_degree[consumer] == 0:
|
|||
|
|
ready.append(consumer)
|
|||
|
|
return order
|
|||
|
|
|
|||
|
|
def upstream(self, node_name: str) -> List[str]:
|
|||
|
|
"""All nodes transitively feeding `node_name`, in topological order."""
|
|||
|
|
if node_name not in self.nodes:
|
|||
|
|
raise BdtNotFoundError(
|
|||
|
|
f"No node named {node_name!r}. Available: "
|
|||
|
|
f"{sorted(list(self.nodes.keys()))[:20]}"
|
|||
|
|
)
|
|||
|
|
visited: Set[str] = set()
|
|||
|
|
stack = list(self.nodes[node_name].sources)
|
|||
|
|
while stack:
|
|||
|
|
cur = stack.pop()
|
|||
|
|
if cur in visited:
|
|||
|
|
continue
|
|||
|
|
if cur not in self.nodes:
|
|||
|
|
# Broken reference — skip (surfaced via broken_references())
|
|||
|
|
continue
|
|||
|
|
visited.add(cur)
|
|||
|
|
stack.extend(self.nodes[cur].sources)
|
|||
|
|
# Sort only the reachable sub-DAG (+ the queried node itself), which is
|
|||
|
|
# guaranteed clean. Drop the queried node from the returned list so
|
|||
|
|
# upstream() returns just the ancestors.
|
|||
|
|
sub = visited | {node_name}
|
|||
|
|
topo = self._topo_order_subset(sub)
|
|||
|
|
return [n for n in topo if n in visited]
|
|||
|
|
|
|||
|
|
def downstream(self, node_name: str) -> List[str]:
|
|||
|
|
"""All nodes transitively consuming `node_name`, in topological order."""
|
|||
|
|
if node_name not in self.nodes:
|
|||
|
|
raise BdtNotFoundError(
|
|||
|
|
f"No node named {node_name!r}. Available: "
|
|||
|
|
f"{sorted(list(self.nodes.keys()))[:20]}"
|
|||
|
|
)
|
|||
|
|
# Build reverse adjacency on the fly
|
|||
|
|
consumers: Dict[str, List[str]] = {name: [] for name in self.nodes}
|
|||
|
|
for name, n in self.nodes.items():
|
|||
|
|
for s in n.sources:
|
|||
|
|
if s in consumers:
|
|||
|
|
consumers[s].append(name)
|
|||
|
|
visited: Set[str] = set()
|
|||
|
|
stack = list(consumers[node_name])
|
|||
|
|
while stack:
|
|||
|
|
cur = stack.pop()
|
|||
|
|
if cur in visited:
|
|||
|
|
continue
|
|||
|
|
visited.add(cur)
|
|||
|
|
stack.extend(consumers[cur])
|
|||
|
|
# Sort only the reachable sub-DAG; tolerate unrelated broken refs/cycles.
|
|||
|
|
sub = visited | {node_name}
|
|||
|
|
topo = self._topo_order_subset(sub)
|
|||
|
|
return [n for n in topo if n in visited]
|
|||
|
|
|
|||
|
|
def broken_references(self) -> List[Tuple[str, str]]:
|
|||
|
|
"""List of (node_name, missing_source) pairs. Empty if BDT is well-formed."""
|
|||
|
|
out = []
|
|||
|
|
for name, n in self.nodes.items():
|
|||
|
|
for s in n.sources:
|
|||
|
|
if s not in self.nodes:
|
|||
|
|
out.append((name, s))
|
|||
|
|
return sorted(out)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --------- CLI ---------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_parser() -> argparse.ArgumentParser:
|
|||
|
|
p = argparse.ArgumentParser(
|
|||
|
|
prog="bdt_analyze",
|
|||
|
|
description="Explain and investigate Salesforce Data Cloud BDT JSON.",
|
|||
|
|
)
|
|||
|
|
sub = p.add_subparsers(dest="subcommand", required=True)
|
|||
|
|
|
|||
|
|
# Common: --json flag + path positional + --definition selector.
|
|||
|
|
# `--definition N` picks which entry in a multi-definition Connect API
|
|||
|
|
# payload the subcommand operates on. Default 0 (the first definition,
|
|||
|
|
# matching the historical behavior).
|
|||
|
|
def _common(sp, with_definition: bool = True):
|
|||
|
|
sp.add_argument("path", help="Path to BDT JSON file")
|
|||
|
|
sp.add_argument("--json", action="store_true", dest="as_json",
|
|||
|
|
help="Emit machine-readable JSON instead of Markdown")
|
|||
|
|
if with_definition:
|
|||
|
|
sp.add_argument(
|
|||
|
|
"--definition", type=int, default=0, dest="definition",
|
|||
|
|
help=(
|
|||
|
|
"Which definition to operate on in a multi-definition "
|
|||
|
|
"Connect API payload (0-indexed, default 0). Use the "
|
|||
|
|
"'definitions' subcommand to list them."
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
for name, help_text in [
|
|||
|
|
("summary", "High-level digest: counts, sources, outputs"),
|
|||
|
|
("sources", "All graph-root nodes with their dataset details"),
|
|||
|
|
("outputs", "All graph-sink nodes with their target details"),
|
|||
|
|
("stages", "Topologically ordered list of nodes with action + hint"),
|
|||
|
|
("nodes", "Every node in topo order with a one-line parameter digest"),
|
|||
|
|
]:
|
|||
|
|
s = sub.add_parser(name, help=help_text)
|
|||
|
|
_common(s)
|
|||
|
|
if name == "nodes":
|
|||
|
|
s.add_argument("--limit", type=int, default=0,
|
|||
|
|
help="Cap row count (0 = no limit)")
|
|||
|
|
|
|||
|
|
# `node <path> <name>`
|
|||
|
|
n = sub.add_parser("node", help="Full detail of one node")
|
|||
|
|
_common(n)
|
|||
|
|
n.add_argument("node_name", help="The node name (key in `nodes` map)")
|
|||
|
|
|
|||
|
|
# Lineage / tracing subcommands
|
|||
|
|
lg = sub.add_parser("lineage", help="All upstream nodes feeding a target node")
|
|||
|
|
_common(lg)
|
|||
|
|
lg.add_argument("node_name", help="The target node name")
|
|||
|
|
|
|||
|
|
ft = sub.add_parser("field-trace", help="Trace a field back through the DAG")
|
|||
|
|
_common(ft)
|
|||
|
|
ft.add_argument("field_name", help="Field name (qualified or unqualified)")
|
|||
|
|
|
|||
|
|
fm = sub.add_parser("formula", help="Focused formula expression + upstream field deps")
|
|||
|
|
_common(fm)
|
|||
|
|
fm.add_argument("node_name", help="A formula or computeRelative node name")
|
|||
|
|
|
|||
|
|
# `definitions <path>` — list every definition in a payload. Always
|
|||
|
|
# operates on the whole set, so no --definition flag here.
|
|||
|
|
dfs = sub.add_parser("definitions",
|
|||
|
|
help="List every definition in the payload (one row per definition)")
|
|||
|
|
_common(dfs, with_definition=False)
|
|||
|
|
|
|||
|
|
return p
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main(argv: Optional[List[str]] = None) -> int:
|
|||
|
|
"""CLI entry point. Returns exit code."""
|
|||
|
|
parser = _build_parser()
|
|||
|
|
args = parser.parse_args(argv)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
bdt = DataTransform.from_path(args.path)
|
|||
|
|
except BdtInputError as e:
|
|||
|
|
print(str(e), file=sys.stderr)
|
|||
|
|
return EXIT_BAD_INPUT
|
|||
|
|
|
|||
|
|
# Select the requested definition (default 0). `definitions` subcommand
|
|||
|
|
# doesn't expose the flag — it always reports on the full list.
|
|||
|
|
definition_idx = getattr(args, "definition", 0)
|
|||
|
|
if definition_idx != 0 or args.subcommand != "definitions":
|
|||
|
|
# Only validate/select when the flag is present (it's missing on
|
|||
|
|
# `definitions`, which always walks the full list). The 0-default
|
|||
|
|
# also goes through here for consistency; `select_definition(0)` is
|
|||
|
|
# always safe given we guarantee at least one definition exists.
|
|||
|
|
try:
|
|||
|
|
bdt.select_definition(definition_idx)
|
|||
|
|
except BdtNotFoundError as e:
|
|||
|
|
# Spec: out-of-range `--definition` exits 2 with a message that
|
|||
|
|
# points the user at the `definitions` subcommand.
|
|||
|
|
msg = (
|
|||
|
|
f"Definition index {definition_idx} out of range; payload "
|
|||
|
|
f"has {bdt.definition_count} definitions "
|
|||
|
|
f"(indices 0-{bdt.definition_count - 1}). "
|
|||
|
|
f"Use 'definitions <path>' to list them."
|
|||
|
|
)
|
|||
|
|
# Preserve the underlying error type for completeness, but surface
|
|||
|
|
# the user-facing message on stderr.
|
|||
|
|
_ = e
|
|||
|
|
print(msg, file=sys.stderr)
|
|||
|
|
return EXIT_NOT_FOUND
|
|||
|
|
|
|||
|
|
dispatch = {
|
|||
|
|
"summary": cmd_summary,
|
|||
|
|
"sources": cmd_sources,
|
|||
|
|
"outputs": cmd_outputs,
|
|||
|
|
"stages": cmd_stages,
|
|||
|
|
"nodes": cmd_nodes,
|
|||
|
|
"node": cmd_node,
|
|||
|
|
"lineage": cmd_lineage,
|
|||
|
|
"field-trace": cmd_field_trace,
|
|||
|
|
"formula": cmd_formula,
|
|||
|
|
"definitions": cmd_definitions,
|
|||
|
|
}
|
|||
|
|
handler = dispatch.get(args.subcommand)
|
|||
|
|
if handler is None:
|
|||
|
|
print(f"Unknown subcommand: {args.subcommand!r}", file=sys.stderr)
|
|||
|
|
return EXIT_BAD_INPUT
|
|||
|
|
try:
|
|||
|
|
output = handler(bdt, args)
|
|||
|
|
except BdtNotFoundError as e:
|
|||
|
|
print(str(e), file=sys.stderr)
|
|||
|
|
return EXIT_NOT_FOUND
|
|||
|
|
except BdtInputError as e:
|
|||
|
|
print(str(e), file=sys.stderr)
|
|||
|
|
return EXIT_BAD_INPUT
|
|||
|
|
print(output)
|
|||
|
|
return EXIT_OK
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --------- Field-discovery helpers ------------------------------------------
|
|||
|
|
|
|||
|
|
# Matches an unquoted identifier reasonable as a field name: optional qualifier
|
|||
|
|
# (e.g., "SalesOrder.ssot__Id__c") followed by the identifier itself.
|
|||
|
|
_FIELD_NAME_RE = re.compile(r'\b(?:[A-Za-z_][A-Za-z0-9_]*\.)?[A-Za-z_][A-Za-z0-9_]*\b')
|
|||
|
|
|
|||
|
|
# Reserved identifiers we exclude when scraping a formula expression for field refs.
|
|||
|
|
_SQL_RESERVED = {
|
|||
|
|
"case", "when", "then", "else", "end", "and", "or", "not", "null", "true", "false",
|
|||
|
|
"if", "is", "in", "like", "between", "cast", "as",
|
|||
|
|
# Common SFSQL functions — callers can add more if needed.
|
|||
|
|
"abs", "ceiling", "exp", "floor", "log", "max", "min", "mod", "power", "round", "sqrt", "trunc",
|
|||
|
|
"concat", "contains", "ends", "length", "lower", "ltrim", "rtrim", "substitute", "substr",
|
|||
|
|
"text", "trim", "upper", "uuid", "value", "begins",
|
|||
|
|
"adddays", "addmonths", "datediff", "datetimevalue", "datevalue", "day", "monthdiff",
|
|||
|
|
"now", "today", "weekday",
|
|||
|
|
"blankvalue", "isblank", "isnull", "nullvalue",
|
|||
|
|
"sequence", "explode",
|
|||
|
|
"row_number", "rank", "dense_rank", "lag", "lead", "first_value", "last_value",
|
|||
|
|
"nth_value", "ntile", "percent_rank", "cume_dist",
|
|||
|
|
"coalesce",
|
|||
|
|
# Type names
|
|||
|
|
"number", "text", "boolean", "date", "datetime",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def fields_produced(node: "Node") -> List[str]:
|
|||
|
|
"""Which field names this node emits into its output stream.
|
|||
|
|
|
|||
|
|
Heuristic per action type. Unknown actions fall back to [] (caller must cope).
|
|||
|
|
"""
|
|||
|
|
p = node.parameters if isinstance(node.parameters, dict) else {}
|
|||
|
|
if node.action == "load":
|
|||
|
|
return list(p.get("fields") or [])
|
|||
|
|
if node.action in ("formula", "computeRelative"):
|
|||
|
|
return [f.get("name") for f in (p.get("fields") or [])
|
|||
|
|
if isinstance(f, dict) and f.get("name")]
|
|||
|
|
if node.action == "outputD360":
|
|||
|
|
return [m.get("targetField") for m in (p.get("fieldsMappings") or [])
|
|||
|
|
if isinstance(m, dict) and m.get("targetField")]
|
|||
|
|
if node.action == "schema":
|
|||
|
|
# Schema nodes rename/drop; renamed names come from slice.fields[].newProperties.name
|
|||
|
|
fs = (p.get("fields") or [])
|
|||
|
|
out = []
|
|||
|
|
for f in fs:
|
|||
|
|
if isinstance(f, dict):
|
|||
|
|
np_ = f.get("newProperties") or {}
|
|||
|
|
out.append(np_.get("name") or f.get("name"))
|
|||
|
|
return [x for x in out if x]
|
|||
|
|
if node.action == "aggregate":
|
|||
|
|
# Aggregations add their output names; groupings pass through as-is
|
|||
|
|
out = list(p.get("groupings") or [])
|
|||
|
|
for a in (p.get("aggregations") or []):
|
|||
|
|
if isinstance(a, dict) and a.get("name"):
|
|||
|
|
out.append(a["name"])
|
|||
|
|
return out
|
|||
|
|
# Default: we can't guess — caller must inspect parameters directly.
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
|
|||
|
|
def fields_consumed(node: "Node") -> List[str]:
|
|||
|
|
"""Which upstream field names this node reads.
|
|||
|
|
|
|||
|
|
For pass-through nodes we return [] — callers that need "what flows through
|
|||
|
|
this node" should look at `fields_produced` on the source(s).
|
|||
|
|
"""
|
|||
|
|
p = node.parameters if isinstance(node.parameters, dict) else {}
|
|||
|
|
if node.action == "load":
|
|||
|
|
return []
|
|||
|
|
if node.action == "join":
|
|||
|
|
out = list(p.get("leftKeys") or []) + list(p.get("rightKeys") or [])
|
|||
|
|
return out
|
|||
|
|
if node.action == "filter":
|
|||
|
|
return [e.get("field") for e in (p.get("filterExpressions") or [])
|
|||
|
|
if isinstance(e, dict) and e.get("field")]
|
|||
|
|
if node.action in ("formula", "computeRelative"):
|
|||
|
|
consumed = set()
|
|||
|
|
for f in (p.get("fields") or []):
|
|||
|
|
if isinstance(f, dict):
|
|||
|
|
expr = f.get("formulaExpression") or ""
|
|||
|
|
consumed.update(_scrape_field_refs(expr))
|
|||
|
|
# computeRelative also consumes partitionBy + orderBy fields
|
|||
|
|
if node.action == "computeRelative":
|
|||
|
|
consumed.update(p.get("partitionBy") or [])
|
|||
|
|
for ob in (p.get("orderBy") or []):
|
|||
|
|
if isinstance(ob, dict) and ob.get("fieldName"):
|
|||
|
|
consumed.add(ob["fieldName"])
|
|||
|
|
return sorted(consumed)
|
|||
|
|
if node.action == "aggregate":
|
|||
|
|
out = list(p.get("groupings") or [])
|
|||
|
|
for a in (p.get("aggregations") or []):
|
|||
|
|
if isinstance(a, dict) and a.get("source"):
|
|||
|
|
out.append(a["source"])
|
|||
|
|
# Also hierarchical fields
|
|||
|
|
for f_key in ("selfField", "parentField", "percentageField"):
|
|||
|
|
v = p.get(f_key)
|
|||
|
|
if v:
|
|||
|
|
out.append(v)
|
|||
|
|
return out
|
|||
|
|
if node.action == "outputD360":
|
|||
|
|
return [m.get("sourceField") for m in (p.get("fieldsMappings") or [])
|
|||
|
|
if isinstance(m, dict) and m.get("sourceField")]
|
|||
|
|
if node.action == "schema":
|
|||
|
|
return [f.get("name") for f in (p.get("fields") or [])
|
|||
|
|
if isinstance(f, dict) and f.get("name")]
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _scrape_field_refs(expression: str) -> List[str]:
|
|||
|
|
"""Pull likely field identifiers out of a SQL-ish formula expression.
|
|||
|
|
|
|||
|
|
This is a heuristic: it returns identifiers that aren't reserved keywords or
|
|||
|
|
function names. Callers should still treat results as a *candidate* set, not
|
|||
|
|
a verified one. In particular, we also pick up quoted identifiers.
|
|||
|
|
"""
|
|||
|
|
if not isinstance(expression, str):
|
|||
|
|
return []
|
|||
|
|
# Also capture "Quoted.Name" style identifiers
|
|||
|
|
quoted = re.findall(r'"([A-Za-z_][\w.]*)"', expression)
|
|||
|
|
bare = _FIELD_NAME_RE.findall(expression)
|
|||
|
|
candidates = set(quoted) | set(bare)
|
|||
|
|
# Drop reserved tokens (case-insensitive) and numeric-looking tokens
|
|||
|
|
out = []
|
|||
|
|
for c in candidates:
|
|||
|
|
last = c.rsplit(".", 1)[-1]
|
|||
|
|
if last.lower() in _SQL_RESERVED:
|
|||
|
|
continue
|
|||
|
|
if last.isdigit():
|
|||
|
|
continue
|
|||
|
|
out.append(c)
|
|||
|
|
# Deduplicate while preserving order
|
|||
|
|
seen = set()
|
|||
|
|
dedup = []
|
|||
|
|
for c in out:
|
|||
|
|
if c not in seen:
|
|||
|
|
seen.add(c)
|
|||
|
|
dedup.append(c)
|
|||
|
|
return dedup
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _field_specific_upstream_deps(node: "Node", field: str) -> List[str]:
|
|||
|
|
"""Deps relevant to `field` specifically, not all deps the node consumes.
|
|||
|
|
|
|||
|
|
`fields_consumed(node)` gives the union of every field the node reads
|
|||
|
|
across *all* of its outputs. For field-trace narration we want a much
|
|||
|
|
tighter set: only the upstream fields that feed *this one* field. This
|
|||
|
|
keeps the trace output small and focused, which matters for real BDTs
|
|||
|
|
where a single `outputD360` may have 40+ mappings or a `formula` node
|
|||
|
|
may define a dozen unrelated output fields.
|
|||
|
|
"""
|
|||
|
|
p = node.parameters if isinstance(node.parameters, dict) else {}
|
|||
|
|
action = node.action
|
|||
|
|
if action in ("formula", "computeRelative"):
|
|||
|
|
out: List[str] = []
|
|||
|
|
for f in (p.get("fields") or []):
|
|||
|
|
if isinstance(f, dict) and f.get("name") == field:
|
|||
|
|
out.extend(_scrape_field_refs(f.get("formulaExpression") or ""))
|
|||
|
|
break
|
|||
|
|
if action == "computeRelative":
|
|||
|
|
out.extend(p.get("partitionBy") or [])
|
|||
|
|
for ob in (p.get("orderBy") or []):
|
|||
|
|
if isinstance(ob, dict) and ob.get("fieldName"):
|
|||
|
|
out.append(ob["fieldName"])
|
|||
|
|
# Deduplicate, preserve order
|
|||
|
|
seen: set = set()
|
|||
|
|
dedup: List[str] = []
|
|||
|
|
for x in out:
|
|||
|
|
if x not in seen:
|
|||
|
|
seen.add(x)
|
|||
|
|
dedup.append(x)
|
|||
|
|
return dedup
|
|||
|
|
if action == "outputD360":
|
|||
|
|
for m in (p.get("fieldsMappings") or []):
|
|||
|
|
if isinstance(m, dict) and m.get("targetField") == field:
|
|||
|
|
src = m.get("sourceField")
|
|||
|
|
return [src] if src else []
|
|||
|
|
return []
|
|||
|
|
if action == "aggregate":
|
|||
|
|
# If the field is one of the aggregations, return just its source.
|
|||
|
|
for a in (p.get("aggregations") or []):
|
|||
|
|
if isinstance(a, dict) and a.get("name") == field:
|
|||
|
|
src = a.get("source")
|
|||
|
|
return [src] if src else []
|
|||
|
|
# If it's one of the groupings (pass-through), the dep is itself.
|
|||
|
|
if field in (p.get("groupings") or []):
|
|||
|
|
return [field]
|
|||
|
|
return []
|
|||
|
|
if action == "schema":
|
|||
|
|
# Renames: if this node renamed some X to `field`, the dep is X.
|
|||
|
|
for f in (p.get("fields") or []):
|
|||
|
|
if isinstance(f, dict):
|
|||
|
|
np_ = f.get("newProperties") or {}
|
|||
|
|
if np_.get("name") == field:
|
|||
|
|
return [f.get("name")] if f.get("name") else []
|
|||
|
|
return []
|
|||
|
|
if action == "load":
|
|||
|
|
return []
|
|||
|
|
# Fallback for unknown / unhandled actions — reuse the generic consumed set
|
|||
|
|
return fields_consumed(node)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def cmd_definitions(bdt: "DataTransform", args) -> str:
|
|||
|
|
"""List every definition in the payload — one row per definition.
|
|||
|
|
|
|||
|
|
Safe to call on editor-export or single-definition inputs: they always
|
|||
|
|
have exactly one row. On multi-definition payloads this is the entry
|
|||
|
|
point for the SKILL.md flow that offers the user a choice.
|
|||
|
|
"""
|
|||
|
|
rows = []
|
|||
|
|
for d in bdt.definitions:
|
|||
|
|
rows.append({
|
|||
|
|
"index": d.index,
|
|||
|
|
"name": d.name,
|
|||
|
|
"label": d.label,
|
|||
|
|
"version": d.version,
|
|||
|
|
"node_count": len(d.nodes),
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if getattr(args, "as_json", False):
|
|||
|
|
return json.dumps(rows, indent=2)
|
|||
|
|
|
|||
|
|
lines = [f"# Definitions ({len(rows)})", ""]
|
|||
|
|
lines.append("| Index | Name | Label | Version | Nodes |")
|
|||
|
|
lines.append("|---|---|---|---|---|")
|
|||
|
|
for r in rows:
|
|||
|
|
lines.append(
|
|||
|
|
f"| {r['index']} | "
|
|||
|
|
f"{r['name'] or '(no name)'} | "
|
|||
|
|
f"{r['label'] or '(no label)'} | "
|
|||
|
|
f"{r['version'] or '(unknown)'} | "
|
|||
|
|
f"{r['node_count']} |"
|
|||
|
|
)
|
|||
|
|
return "\n".join(lines)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def cmd_summary(bdt: "DataTransform", args) -> str:
|
|||
|
|
"""High-level digest of the BDT."""
|
|||
|
|
action_counts = Counter(n.action for n in bdt.nodes.values())
|
|||
|
|
roots = bdt.roots()
|
|||
|
|
sinks = bdt.sinks()
|
|||
|
|
|
|||
|
|
if getattr(args, "as_json", False):
|
|||
|
|
data = {
|
|||
|
|
"version": bdt.version,
|
|||
|
|
"total_nodes": len(bdt.nodes),
|
|||
|
|
"action_counts": dict(action_counts),
|
|||
|
|
"sources": [_source_digest(bdt, r) for r in roots],
|
|||
|
|
"outputs": [_output_digest(bdt, s) for s in sinks],
|
|||
|
|
}
|
|||
|
|
return json.dumps(data, indent=2)
|
|||
|
|
|
|||
|
|
# Markdown
|
|||
|
|
lines = []
|
|||
|
|
lines.append("# BDT Summary")
|
|||
|
|
lines.append(f"Version: {bdt.version or '(unknown)'}")
|
|||
|
|
lines.append(f"Total nodes: {len(bdt.nodes)}")
|
|||
|
|
if action_counts:
|
|||
|
|
counts_str = ", ".join(f"{a}: {c}" for a, c in action_counts.most_common())
|
|||
|
|
lines.append(f"By action: {counts_str}")
|
|||
|
|
lines.append("")
|
|||
|
|
if roots:
|
|||
|
|
lines.append(f"## Sources ({len(roots)} nodes)")
|
|||
|
|
lines.append("| Node | Label | Dataset | Type | Field count |")
|
|||
|
|
lines.append("|---|---|---|---|---|")
|
|||
|
|
for r in roots:
|
|||
|
|
d = _source_digest(bdt, r)
|
|||
|
|
lines.append(
|
|||
|
|
f"| {r} | {d['label'] or '(no label)'} | "
|
|||
|
|
f"{d['dataset_name'] or '(none)'} | "
|
|||
|
|
f"{d['dataset_type'] or '(none)'} | "
|
|||
|
|
f"{d['field_count']} |"
|
|||
|
|
)
|
|||
|
|
lines.append("")
|
|||
|
|
if sinks:
|
|||
|
|
lines.append(f"## Outputs ({len(sinks)} nodes)")
|
|||
|
|
lines.append("| Node | Label | Target | Type | Mapping count |")
|
|||
|
|
lines.append("|---|---|---|---|---|")
|
|||
|
|
for s in sinks:
|
|||
|
|
d = _output_digest(bdt, s)
|
|||
|
|
lines.append(
|
|||
|
|
f"| {s} | {d['label'] or '(no label)'} | "
|
|||
|
|
f"{d['target_name'] or '(none)'} | "
|
|||
|
|
f"{d['target_type'] or '(none)'} | "
|
|||
|
|
f"{d['mapping_count']} |"
|
|||
|
|
)
|
|||
|
|
return "\n".join(lines)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _source_digest(bdt: "DataTransform", name: str) -> dict:
|
|||
|
|
n = bdt.nodes[name]
|
|||
|
|
ds = (n.parameters.get("dataset") or {}) if isinstance(n.parameters, dict) else {}
|
|||
|
|
fields = n.parameters.get("fields") or [] if isinstance(n.parameters, dict) else []
|
|||
|
|
return {
|
|||
|
|
"name": name,
|
|||
|
|
"label": n.ui_label,
|
|||
|
|
"dataset_name": ds.get("name") if isinstance(ds, dict) else None,
|
|||
|
|
"dataset_type": ds.get("type") if isinstance(ds, dict) else None,
|
|||
|
|
"field_count": len(fields) if isinstance(fields, list) else 0,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _output_digest(bdt: "DataTransform", name: str) -> dict:
|
|||
|
|
n = bdt.nodes[name]
|
|||
|
|
p = n.parameters if isinstance(n.parameters, dict) else {}
|
|||
|
|
mappings = p.get("fieldsMappings") or []
|
|||
|
|
return {
|
|||
|
|
"name": name,
|
|||
|
|
"label": n.ui_label,
|
|||
|
|
"target_name": p.get("name"),
|
|||
|
|
"target_type": p.get("type"),
|
|||
|
|
"write_mode": p.get("writeMode"),
|
|||
|
|
"mapping_count": len(mappings) if isinstance(mappings, list) else 0,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def cmd_sources(bdt: "DataTransform", args) -> str:
|
|||
|
|
roots = bdt.roots()
|
|||
|
|
enriched = []
|
|||
|
|
for r in roots:
|
|||
|
|
n = bdt.nodes[r]
|
|||
|
|
p = n.parameters if isinstance(n.parameters, dict) else {}
|
|||
|
|
ds = p.get("dataset") or {}
|
|||
|
|
enriched.append({
|
|||
|
|
"name": r,
|
|||
|
|
"label": n.ui_label,
|
|||
|
|
"dataset_name": ds.get("name") if isinstance(ds, dict) else None,
|
|||
|
|
"dataset_type": ds.get("type") if isinstance(ds, dict) else None,
|
|||
|
|
"fields": list(p.get("fields") or []),
|
|||
|
|
"action": n.action,
|
|||
|
|
})
|
|||
|
|
if getattr(args, "as_json", False):
|
|||
|
|
return json.dumps(enriched, indent=2)
|
|||
|
|
lines = [f"# Sources ({len(enriched)} node(s))", ""]
|
|||
|
|
for e in enriched:
|
|||
|
|
lines.append(f"## {e['name']}" + (f" — {e['label']}" if e['label'] else ""))
|
|||
|
|
lines.append(f"- action: `{e['action']}`")
|
|||
|
|
lines.append(f"- dataset: `{e['dataset_name']}` ({e['dataset_type']})")
|
|||
|
|
if e['fields']:
|
|||
|
|
lines.append(f"- fields ({len(e['fields'])}): " + ", ".join(f"`{f}`" for f in e['fields']))
|
|||
|
|
else:
|
|||
|
|
lines.append("- fields: _(none declared)_")
|
|||
|
|
lines.append("")
|
|||
|
|
return "\n".join(lines).rstrip() + "\n"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def cmd_outputs(bdt: "DataTransform", args) -> str:
|
|||
|
|
sinks = bdt.sinks()
|
|||
|
|
enriched = []
|
|||
|
|
for s in sinks:
|
|||
|
|
n = bdt.nodes[s]
|
|||
|
|
p = n.parameters if isinstance(n.parameters, dict) else {}
|
|||
|
|
mappings = p.get("fieldsMappings") or []
|
|||
|
|
enriched.append({
|
|||
|
|
"name": s,
|
|||
|
|
"label": n.ui_label,
|
|||
|
|
"action": n.action,
|
|||
|
|
"target_name": p.get("name"),
|
|||
|
|
"target_type": p.get("type"),
|
|||
|
|
"write_mode": p.get("writeMode"),
|
|||
|
|
"dedup_order": list(p.get("dedupOrder") or []),
|
|||
|
|
"mappings": [
|
|||
|
|
{
|
|||
|
|
"source": m.get("sourceField") if isinstance(m, dict) else None,
|
|||
|
|
"target": m.get("targetField") if isinstance(m, dict) else None,
|
|||
|
|
}
|
|||
|
|
for m in (mappings if isinstance(mappings, list) else [])
|
|||
|
|
],
|
|||
|
|
})
|
|||
|
|
if getattr(args, "as_json", False):
|
|||
|
|
return json.dumps(enriched, indent=2)
|
|||
|
|
lines = [f"# Outputs ({len(enriched)} node(s))", ""]
|
|||
|
|
for e in enriched:
|
|||
|
|
lines.append(f"## {e['name']}" + (f" — {e['label']}" if e['label'] else ""))
|
|||
|
|
lines.append(f"- action: `{e['action']}`")
|
|||
|
|
lines.append(f"- target: `{e['target_name']}` ({e['target_type']})")
|
|||
|
|
lines.append(f"- writeMode: `{e['write_mode']}`")
|
|||
|
|
if e['dedup_order']:
|
|||
|
|
lines.append(f"- dedupOrder: {e['dedup_order']}")
|
|||
|
|
if e['mappings']:
|
|||
|
|
lines.append("")
|
|||
|
|
lines.append(f"### Field mappings ({len(e['mappings'])})")
|
|||
|
|
lines.append("| Source field | Target field |")
|
|||
|
|
lines.append("|---|---|")
|
|||
|
|
for m in e['mappings']:
|
|||
|
|
lines.append(f"| `{m['source']}` | `{m['target']}` |")
|
|||
|
|
lines.append("")
|
|||
|
|
return "\n".join(lines).rstrip() + "\n"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# One-line purpose hint per known action. Unknown actions fall back to the raw action name.
|
|||
|
|
_ACTION_HINT = {
|
|||
|
|
"load": "Load data from a DMO or DLO",
|
|||
|
|
"filter": "Keep rows matching filter criteria",
|
|||
|
|
"sqlFilter": "Keep rows matching a raw SQL predicate",
|
|||
|
|
"join": "Join two upstream streams",
|
|||
|
|
"formula": "Add derived columns via per-row formulas",
|
|||
|
|
"computeRelative": "Compute a window function over partitioned/ordered rows",
|
|||
|
|
"aggregate": "Group-by aggregation",
|
|||
|
|
"extractGrains": "Fan out rows across time grains",
|
|||
|
|
"extractTable": "Extract a table from flattened JSON",
|
|||
|
|
"schema": "Rename/drop/reshape columns",
|
|||
|
|
"outputD360": "Write rows to a target DMO/DLO",
|
|||
|
|
"appendV2": "Union rows from multiple upstream streams",
|
|||
|
|
"flatten": "Flatten nested structure",
|
|||
|
|
"flattenJson": "Flatten a JSON field into rows/columns",
|
|||
|
|
"split": "Split rows across multiple outputs",
|
|||
|
|
"typeCast": "Cast field types",
|
|||
|
|
"update": "Update records",
|
|||
|
|
"bucket": "Bucket field values",
|
|||
|
|
"formatDate": "Reformat date values",
|
|||
|
|
"extension": "Run custom extension logic",
|
|||
|
|
"extensionFunction":"Run custom extension function",
|
|||
|
|
"cdpPredict": "Apply a prediction model",
|
|||
|
|
"jsonAggregate": "Aggregate JSON fields",
|
|||
|
|
"save": "Save intermediate result",
|
|||
|
|
"recommendation": "Apply a recommendation model",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def cmd_stages(bdt: "DataTransform", args) -> str:
|
|||
|
|
order = bdt.topo_order()
|
|||
|
|
rows = []
|
|||
|
|
for name in order:
|
|||
|
|
n = bdt.nodes[name]
|
|||
|
|
hint = _ACTION_HINT.get(n.action, f"({n.action} — not in known catalog)")
|
|||
|
|
rows.append({
|
|||
|
|
"name": name,
|
|||
|
|
"label": n.ui_label,
|
|||
|
|
"action": n.action,
|
|||
|
|
"sources": list(n.sources),
|
|||
|
|
"hint": hint,
|
|||
|
|
})
|
|||
|
|
if getattr(args, "as_json", False):
|
|||
|
|
return json.dumps(rows, indent=2)
|
|||
|
|
lines = [f"# Stages ({len(rows)} node(s), topological order)", ""]
|
|||
|
|
for i, r in enumerate(rows, 1):
|
|||
|
|
srcs = ", ".join(f"`{s}`" for s in r["sources"]) if r["sources"] else "_(root)_"
|
|||
|
|
label_part = f" — {r['label']}" if r['label'] else ""
|
|||
|
|
lines.append(f"{i}. **{r['name']}**{label_part} `{r['action']}`")
|
|||
|
|
lines.append(f" - sources: {srcs}")
|
|||
|
|
lines.append(f" - {r['hint']}")
|
|||
|
|
return "\n".join(lines)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def cmd_nodes(bdt: "DataTransform", args) -> str:
|
|||
|
|
order = bdt.topo_order()
|
|||
|
|
limit = getattr(args, "limit", 0) or 0
|
|||
|
|
limited = limit > 0 and len(order) > limit
|
|||
|
|
shown = order[:limit] if limited else order
|
|||
|
|
|
|||
|
|
rows = []
|
|||
|
|
for name in shown:
|
|||
|
|
n = bdt.nodes[name]
|
|||
|
|
rows.append({
|
|||
|
|
"name": name,
|
|||
|
|
"label": n.ui_label,
|
|||
|
|
"action": n.action,
|
|||
|
|
"sources": list(n.sources),
|
|||
|
|
"param_digest": _param_digest(n),
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if getattr(args, "as_json", False):
|
|||
|
|
return json.dumps({"total": len(order), "shown": len(rows), "nodes": rows}, indent=2)
|
|||
|
|
|
|||
|
|
lines = [f"# Nodes ({len(order)} total)"]
|
|||
|
|
if limited:
|
|||
|
|
lines.append(f"_Output limited to {limit} of {len(order)} — re-run without --limit for full list._")
|
|||
|
|
lines.append("")
|
|||
|
|
lines.append("| Node | Label | Action | Sources | Digest |")
|
|||
|
|
lines.append("|---|---|---|---|---|")
|
|||
|
|
for r in rows:
|
|||
|
|
srcs = ", ".join(r["sources"]) if r["sources"] else "_(root)_"
|
|||
|
|
label = r["label"] or ""
|
|||
|
|
digest = r["param_digest"].replace("|", "\\|")
|
|||
|
|
lines.append(f"| {r['name']} | {label} | {r['action']} | {srcs} | {digest} |")
|
|||
|
|
return "\n".join(lines)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _param_digest(n: "Node") -> str:
|
|||
|
|
"""One-line Markdown-safe parameter summary. Never exceeds ~120 chars."""
|
|||
|
|
p = n.parameters if isinstance(n.parameters, dict) else {}
|
|||
|
|
if n.action == "load":
|
|||
|
|
ds = p.get("dataset") or {}
|
|||
|
|
ds_name = ds.get("name") if isinstance(ds, dict) else "?"
|
|||
|
|
fc = len(p.get("fields") or [])
|
|||
|
|
return f"dataset=`{ds_name}`, {fc} field(s)"
|
|||
|
|
if n.action == "join":
|
|||
|
|
return (f"type=`{p.get('joinType')}` "
|
|||
|
|
f"leftKeys={p.get('leftKeys')} rightKeys={p.get('rightKeys')} "
|
|||
|
|
f"rightQualifier=`{p.get('rightQualifier')}`")
|
|||
|
|
if n.action == "filter":
|
|||
|
|
exprs = p.get("filterExpressions") or []
|
|||
|
|
return f"{len(exprs)} expression(s), logic=`{p.get('filterBooleanLogic') or 'all AND'}`"
|
|||
|
|
if n.action in ("formula", "computeRelative"):
|
|||
|
|
fs = p.get("fields") or []
|
|||
|
|
names = ", ".join(f.get("name", "?") for f in fs if isinstance(f, dict))
|
|||
|
|
extra = ""
|
|||
|
|
if n.action == "computeRelative":
|
|||
|
|
extra = f" partitionBy={p.get('partitionBy')} orderBy={p.get('orderBy')}"
|
|||
|
|
return f"{len(fs)} output field(s): {names}{extra}"
|
|||
|
|
if n.action == "aggregate":
|
|||
|
|
aggs = p.get("aggregations") or []
|
|||
|
|
groupings = p.get("groupings") or []
|
|||
|
|
return f"{len(aggs)} aggregation(s), groupBy={groupings}"
|
|||
|
|
if n.action == "outputD360":
|
|||
|
|
mc = len(p.get("fieldsMappings") or [])
|
|||
|
|
return f"target=`{p.get('name')}` ({p.get('type')}) writeMode=`{p.get('writeMode')}` {mc} mapping(s)"
|
|||
|
|
if n.action == "schema":
|
|||
|
|
slc = p.get("slice") or {}
|
|||
|
|
return f"slice.mode=`{slc.get('mode')}` {len(slc.get('fields') or [])} field(s)"
|
|||
|
|
# Fallback: show param keys only
|
|||
|
|
keys = list(p.keys())[:5]
|
|||
|
|
return f"parameters keys: {keys}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def cmd_node(bdt: "DataTransform", args) -> str:
|
|||
|
|
name = args.node_name
|
|||
|
|
if name not in bdt.nodes:
|
|||
|
|
available = sorted(bdt.nodes.keys())[:20]
|
|||
|
|
raise BdtNotFoundError(
|
|||
|
|
f"No node named {name!r}. Available (first 20): {available}"
|
|||
|
|
)
|
|||
|
|
n = bdt.nodes[name]
|
|||
|
|
# Compute consumers (nodes that list this one as a source)
|
|||
|
|
consumers = [m for m, other in bdt.nodes.items() if name in other.sources]
|
|||
|
|
|
|||
|
|
if getattr(args, "as_json", False):
|
|||
|
|
return json.dumps({
|
|||
|
|
"name": n.name,
|
|||
|
|
"label": n.ui_label,
|
|||
|
|
"description": n.ui_description,
|
|||
|
|
"action": n.action,
|
|||
|
|
"sources": n.sources,
|
|||
|
|
"consumers": consumers,
|
|||
|
|
"parameters": n.parameters,
|
|||
|
|
"schema": n.schema,
|
|||
|
|
}, indent=2)
|
|||
|
|
|
|||
|
|
lines = [f"# {n.name}" + (f" — {n.ui_label}" if n.ui_label else "")]
|
|||
|
|
if n.ui_description:
|
|||
|
|
lines.append(f"> {n.ui_description}")
|
|||
|
|
lines.append("")
|
|||
|
|
lines.append(f"- **action:** `{n.action}`")
|
|||
|
|
lines.append(f"- **hint:** {_ACTION_HINT.get(n.action, '(not in known catalog)')}")
|
|||
|
|
lines.append(f"- **sources:** {n.sources or '_(root)_'}")
|
|||
|
|
lines.append(f"- **direct consumers:** {consumers or '_(sink — no downstream)_'}")
|
|||
|
|
lines.append("")
|
|||
|
|
lines.append("## Parameters")
|
|||
|
|
lines.append("```json")
|
|||
|
|
lines.append(json.dumps(n.parameters, indent=2, default=str))
|
|||
|
|
lines.append("```")
|
|||
|
|
if n.schema:
|
|||
|
|
lines.append("")
|
|||
|
|
lines.append("## Schema block")
|
|||
|
|
lines.append("```json")
|
|||
|
|
lines.append(json.dumps(n.schema, indent=2, default=str))
|
|||
|
|
lines.append("```")
|
|||
|
|
return "\n".join(lines)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def cmd_lineage(bdt: "DataTransform", args) -> str:
|
|||
|
|
target = args.node_name
|
|||
|
|
if target not in bdt.nodes:
|
|||
|
|
available = sorted(bdt.nodes.keys())[:20]
|
|||
|
|
raise BdtNotFoundError(
|
|||
|
|
f"No node named {target!r}. Available (first 20): {available}"
|
|||
|
|
)
|
|||
|
|
upstream_names = bdt.upstream(target) # topo-ordered
|
|||
|
|
|
|||
|
|
rows = []
|
|||
|
|
for name in upstream_names:
|
|||
|
|
n = bdt.nodes[name]
|
|||
|
|
rows.append({
|
|||
|
|
"name": name,
|
|||
|
|
"action": n.action,
|
|||
|
|
"label": n.ui_label,
|
|||
|
|
"sources": list(n.sources),
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if getattr(args, "as_json", False):
|
|||
|
|
return json.dumps({"target": target, "upstream": rows}, indent=2)
|
|||
|
|
|
|||
|
|
lines = [f"# Lineage of {target}"]
|
|||
|
|
t = bdt.nodes[target]
|
|||
|
|
if t.ui_label:
|
|||
|
|
lines.append(f"_{t.ui_label}_")
|
|||
|
|
lines.append("")
|
|||
|
|
if not upstream_names:
|
|||
|
|
lines.append(f"`{target}` is a graph root (no upstream).")
|
|||
|
|
return "\n".join(lines)
|
|||
|
|
lines.append(f"{len(upstream_names)} upstream node(s), topological order:")
|
|||
|
|
lines.append("")
|
|||
|
|
for r in rows:
|
|||
|
|
arrow = " ← " if r["sources"] else " "
|
|||
|
|
label_part = f" — {r['label']}" if r['label'] else ""
|
|||
|
|
lines.append(f"{arrow}**{r['name']}** `{r['action']}`{label_part}")
|
|||
|
|
if r['sources']:
|
|||
|
|
srcs = ", ".join(f"`{s}`" for s in r['sources'])
|
|||
|
|
lines.append(f" (fed by {srcs})")
|
|||
|
|
lines.append("")
|
|||
|
|
lines.append(f" → **{target}** `{t.action}`" + (f" — {t.ui_label}" if t.ui_label else ""))
|
|||
|
|
return "\n".join(lines)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def cmd_field_trace(bdt: "DataTransform", args) -> str:
|
|||
|
|
field = args.field_name
|
|||
|
|
# Cache topo order once — `bdt.topo_order()` walks the whole graph.
|
|||
|
|
topo = bdt.topo_order()
|
|||
|
|
# 1. Find all nodes that "produce" this field (per fields_produced heuristic).
|
|||
|
|
definers = []
|
|||
|
|
for name in topo:
|
|||
|
|
n = bdt.nodes[name]
|
|||
|
|
produced = fields_produced(n)
|
|||
|
|
if field in produced:
|
|||
|
|
definers.append(n)
|
|||
|
|
|
|||
|
|
# 2. If nothing matched, also try matching the unqualified part (drop any "Qualifier." prefix)
|
|||
|
|
if not definers:
|
|||
|
|
unq = field.rsplit(".", 1)[-1]
|
|||
|
|
for name in topo:
|
|||
|
|
n = bdt.nodes[name]
|
|||
|
|
produced = fields_produced(n)
|
|||
|
|
if unq in produced:
|
|||
|
|
definers.append(n)
|
|||
|
|
field = unq # report with the unqualified name we actually matched
|
|||
|
|
|
|||
|
|
if not definers:
|
|||
|
|
available_sample = []
|
|||
|
|
for name in list(bdt.nodes.keys())[:10]:
|
|||
|
|
available_sample.extend(fields_produced(bdt.nodes[name]))
|
|||
|
|
raise BdtNotFoundError(
|
|||
|
|
f"Field {args.field_name!r} is not defined by any node in this BDT. "
|
|||
|
|
f"Sample of visible field names (from the first 10 nodes): "
|
|||
|
|
f"{sorted(set(available_sample))[:20]}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 3. For each definer, compute: (a) the formula or mapping that defines it, if any;
|
|||
|
|
# (b) its direct upstream field dependencies (`fields_consumed`).
|
|||
|
|
def _definition_of(n: "Node", fname: str) -> dict:
|
|||
|
|
p = n.parameters if isinstance(n.parameters, dict) else {}
|
|||
|
|
detail = {"kind": n.action}
|
|||
|
|
if n.action in ("formula", "computeRelative"):
|
|||
|
|
for f in (p.get("fields") or []):
|
|||
|
|
if isinstance(f, dict) and f.get("name") == fname:
|
|||
|
|
detail["formulaExpression"] = f.get("formulaExpression")
|
|||
|
|
detail["type"] = f.get("type")
|
|||
|
|
detail["label"] = f.get("label")
|
|||
|
|
break
|
|||
|
|
elif n.action == "outputD360":
|
|||
|
|
for m in (p.get("fieldsMappings") or []):
|
|||
|
|
if isinstance(m, dict) and m.get("targetField") == fname:
|
|||
|
|
detail["sourceField"] = m.get("sourceField")
|
|||
|
|
break
|
|||
|
|
elif n.action == "load":
|
|||
|
|
ds = p.get("dataset") or {}
|
|||
|
|
detail["dataset"] = {"name": ds.get("name"), "type": ds.get("type")}
|
|||
|
|
elif n.action == "aggregate":
|
|||
|
|
for a in (p.get("aggregations") or []):
|
|||
|
|
if isinstance(a, dict) and a.get("name") == fname:
|
|||
|
|
detail["aggregation"] = {
|
|||
|
|
"action": a.get("action"),
|
|||
|
|
"source": a.get("source"),
|
|||
|
|
}
|
|||
|
|
break
|
|||
|
|
elif n.action == "schema":
|
|||
|
|
for f in (p.get("fields") or []):
|
|||
|
|
if isinstance(f, dict):
|
|||
|
|
np_ = f.get("newProperties") or {}
|
|||
|
|
if (np_.get("name") or f.get("name")) == fname:
|
|||
|
|
detail["renamed_from"] = f.get("name")
|
|||
|
|
detail["renamed_to"] = np_.get("name")
|
|||
|
|
break
|
|||
|
|
return detail
|
|||
|
|
|
|||
|
|
result = []
|
|||
|
|
for n in definers:
|
|||
|
|
entry = {
|
|||
|
|
"node": n.name,
|
|||
|
|
"label": n.ui_label,
|
|||
|
|
"action": n.action,
|
|||
|
|
"definition": _definition_of(n, field),
|
|||
|
|
# Narrowed to deps of *this specific field*, not every field the
|
|||
|
|
# node consumes. For output/formula/aggregate nodes with many
|
|||
|
|
# unrelated output fields this dramatically reduces trace size
|
|||
|
|
# and noise. The broader node-level context is still available
|
|||
|
|
# via `upstream_chain` below.
|
|||
|
|
"upstream_deps": _field_specific_upstream_deps(n, field),
|
|||
|
|
"sources": list(n.sources),
|
|||
|
|
}
|
|||
|
|
result.append(entry)
|
|||
|
|
|
|||
|
|
# Upstream node chain for the first definer (for narration convenience)
|
|||
|
|
chain = []
|
|||
|
|
if definers:
|
|||
|
|
chain = bdt.upstream(definers[0].name) + [definers[0].name]
|
|||
|
|
|
|||
|
|
if getattr(args, "as_json", False):
|
|||
|
|
return json.dumps({
|
|||
|
|
"field": field,
|
|||
|
|
"definitions": result,
|
|||
|
|
"upstream_chain": chain,
|
|||
|
|
}, indent=2)
|
|||
|
|
|
|||
|
|
lines = [f"# Field trace: `{field}`"]
|
|||
|
|
if len(definers) > 1:
|
|||
|
|
lines.append(f"> Field is defined in {len(definers)} places (first in topological order listed first).")
|
|||
|
|
lines.append("")
|
|||
|
|
for i, entry in enumerate(result, 1):
|
|||
|
|
header = f"## {i}. Defined by {entry['node']}"
|
|||
|
|
if entry['label']:
|
|||
|
|
header += f" — {entry['label']}"
|
|||
|
|
lines.append(header)
|
|||
|
|
lines.append(f"- action: `{entry['action']}`")
|
|||
|
|
d = entry['definition']
|
|||
|
|
if d.get("formulaExpression"):
|
|||
|
|
lines.append(f"- formula: `{d['formulaExpression']}`")
|
|||
|
|
if d.get("type"):
|
|||
|
|
lines.append(f"- type: `{d['type']}`")
|
|||
|
|
if d.get("sourceField"):
|
|||
|
|
lines.append(f"- sourced from: `{d['sourceField']}`")
|
|||
|
|
if d.get("dataset"):
|
|||
|
|
lines.append(f"- loaded from: `{d['dataset']['name']}` ({d['dataset']['type']})")
|
|||
|
|
if d.get("aggregation"):
|
|||
|
|
a = d['aggregation']
|
|||
|
|
lines.append(f"- aggregation: `{a['action']}` over `{a['source']}`")
|
|||
|
|
if d.get("renamed_from"):
|
|||
|
|
lines.append(f"- renamed: `{d['renamed_from']}` → `{d['renamed_to']}`")
|
|||
|
|
if entry['upstream_deps']:
|
|||
|
|
lines.append(f"- upstream field deps: {entry['upstream_deps']}")
|
|||
|
|
lines.append(f"- upstream nodes: {entry['sources'] or '_(root)_'}")
|
|||
|
|
lines.append("")
|
|||
|
|
if chain:
|
|||
|
|
lines.append("## Upstream node chain")
|
|||
|
|
lines.append(" → ".join(f"`{c}`" for c in chain))
|
|||
|
|
return "\n".join(lines)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def cmd_formula(bdt: "DataTransform", args) -> str:
|
|||
|
|
name = args.node_name
|
|||
|
|
if name not in bdt.nodes:
|
|||
|
|
raise BdtNotFoundError(
|
|||
|
|
f"No node named {name!r}. Available (first 20): "
|
|||
|
|
f"{sorted(bdt.nodes.keys())[:20]}"
|
|||
|
|
)
|
|||
|
|
n = bdt.nodes[name]
|
|||
|
|
if n.action not in ("formula", "computeRelative"):
|
|||
|
|
raise BdtInputError(
|
|||
|
|
f"Node {name!r} has action {n.action!r}; `formula` subcommand "
|
|||
|
|
f"only applies to `formula` and `computeRelative` nodes. "
|
|||
|
|
f"Use `node {name}` instead."
|
|||
|
|
)
|
|||
|
|
p = n.parameters if isinstance(n.parameters, dict) else {}
|
|||
|
|
fields_out = p.get("fields") or []
|
|||
|
|
consumed = fields_consumed(n)
|
|||
|
|
|
|||
|
|
# For each consumed field, find the node that defines it (if any) — give
|
|||
|
|
# the LLM ready context for I2-style reasoning. Hoist `bdt.upstream(name)`
|
|||
|
|
# out of the loop — it's a pure function of `name` and walks the graph.
|
|||
|
|
# Pre-compute fields_produced for every upstream node once (was O(consumed × upstream))
|
|||
|
|
upstream_names = bdt.upstream(name)
|
|||
|
|
upstream_fields_map = {up_name: fields_produced(bdt.nodes[up_name]) for up_name in upstream_names}
|
|||
|
|
upstream_defs = {}
|
|||
|
|
for f in consumed:
|
|||
|
|
matches = []
|
|||
|
|
for up_name in upstream_names:
|
|||
|
|
up = bdt.nodes[up_name]
|
|||
|
|
prod = upstream_fields_map[up_name]
|
|||
|
|
if f in prod or f.rsplit(".", 1)[-1] in prod:
|
|||
|
|
matches.append({
|
|||
|
|
"node": up_name,
|
|||
|
|
"action": up.action,
|
|||
|
|
"label": up.ui_label,
|
|||
|
|
})
|
|||
|
|
if matches:
|
|||
|
|
upstream_defs[f] = matches[-1]
|
|||
|
|
|
|||
|
|
payload = {
|
|||
|
|
"node": name,
|
|||
|
|
"label": n.ui_label,
|
|||
|
|
"action": n.action,
|
|||
|
|
"formula_fields": [
|
|||
|
|
{
|
|||
|
|
"name": f.get("name"),
|
|||
|
|
"label": f.get("label"),
|
|||
|
|
"type": f.get("type"),
|
|||
|
|
"formulaExpression": f.get("formulaExpression"),
|
|||
|
|
}
|
|||
|
|
for f in fields_out if isinstance(f, dict)
|
|||
|
|
],
|
|||
|
|
"consumed_fields": consumed,
|
|||
|
|
"upstream_field_definitions": upstream_defs,
|
|||
|
|
}
|
|||
|
|
if n.action == "computeRelative":
|
|||
|
|
payload["partitionBy"] = list(p.get("partitionBy") or [])
|
|||
|
|
payload["orderBy"] = p.get("orderBy") or []
|
|||
|
|
|
|||
|
|
if getattr(args, "as_json", False):
|
|||
|
|
return json.dumps(payload, indent=2)
|
|||
|
|
|
|||
|
|
lines = [f"# Formula extract: {name}" + (f" — {n.ui_label}" if n.ui_label else "")]
|
|||
|
|
lines.append(f"- action: `{n.action}`")
|
|||
|
|
if n.action == "computeRelative":
|
|||
|
|
lines.append(f"- partitionBy: {payload['partitionBy']}")
|
|||
|
|
lines.append(f"- orderBy: {payload['orderBy']}")
|
|||
|
|
lines.append("")
|
|||
|
|
lines.append("## Output fields")
|
|||
|
|
for f in payload["formula_fields"]:
|
|||
|
|
lines.append(f"### `{f['name']}`" + (f" _{f['label']}_" if f['label'] else ""))
|
|||
|
|
lines.append(f"- type: `{f['type']}`")
|
|||
|
|
lines.append(f"- expression: `{f['formulaExpression']}`")
|
|||
|
|
lines.append("")
|
|||
|
|
lines.append("## Upstream field dependencies")
|
|||
|
|
if not consumed:
|
|||
|
|
lines.append("_(none detected — expression may reference only literals)_")
|
|||
|
|
else:
|
|||
|
|
for f in consumed:
|
|||
|
|
defn = upstream_defs.get(f)
|
|||
|
|
if defn:
|
|||
|
|
label = f" _{defn['label']}_" if defn.get('label') else ""
|
|||
|
|
lines.append(f"- `{f}` ← **{defn['node']}** (`{defn['action']}`){label}")
|
|||
|
|
else:
|
|||
|
|
lines.append(f"- `{f}` ← (no upstream definition found — may be a literal or a passthrough)")
|
|||
|
|
return "\n".join(lines)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
sys.exit(main(sys.argv[1:]))
|