mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-08-09 00:42:46 +08:00
Adds a skill for authoring Tableau Next semantic models (SDMs) on Data 360: build from scratch, add data objects, define joins, enrich with calculated fields and metrics, and make models AI-ready. Smoke-tested against a live Data 360 org: SDM discovery, AI-readiness flip, dimension creation, metric creation, and description backfill all exercised end-to-end.
238 lines
8.3 KiB
Python
238 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Discover Semantic Data Models and their fields via the Salesforce REST API.
|
|
|
|
Usage:
|
|
python scripts/discover_sdm.py --list
|
|
python scripts/discover_sdm.py --sdm Sales_Intelligence_Model
|
|
python scripts/discover_sdm.py --sdm Sales_Intelligence_Model --json
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from typing import Any, Dict, List
|
|
|
|
from _shared.sf_api import get_credentials, sdm_list_endpoint, sdm_detail_endpoint, sf_get
|
|
|
|
|
|
def list_sdms(token: str, instance: str, as_json: bool) -> None:
|
|
data = sf_get(token, instance, sdm_list_endpoint())
|
|
if data is None:
|
|
sys.exit(1)
|
|
|
|
models = data.get("semantic_models") or data.get("items") or []
|
|
if as_json:
|
|
print(json.dumps(models, indent=2))
|
|
return
|
|
|
|
if not models:
|
|
print("No Semantic Data Models found.")
|
|
return
|
|
|
|
print(f"{'API Name':<40} {'Label':<40} {'Dataspace'}")
|
|
print("-" * 100)
|
|
for m in models:
|
|
api = m.get("apiName", "")
|
|
label = m.get("label", "")
|
|
ds = m.get("dataspace", "")
|
|
print(f"{api:<40} {label:<40} {ds}")
|
|
|
|
|
|
def show_sdm(token: str, instance: str, sdm_name: str, as_json: bool) -> None:
|
|
data = sf_get(token, instance, sdm_detail_endpoint(sdm_name))
|
|
if data is None:
|
|
print(f"Error: SDM '{sdm_name}' not found or API error.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if as_json:
|
|
print(json.dumps(_structured_output(data), indent=2))
|
|
return
|
|
|
|
print(f"SDM: {data.get('apiName', sdm_name)}")
|
|
print(f"Label: {data.get('label', '')}")
|
|
print()
|
|
|
|
for obj in data.get("semanticDataObjects", []):
|
|
obj_name = obj.get("apiName", "")
|
|
print(f"Object: {obj_name}")
|
|
|
|
dims = obj.get("semanticDimensions", [])
|
|
if dims:
|
|
print(" Dimensions:")
|
|
for d in dims:
|
|
api = d.get("apiName", "")
|
|
dtype = d.get("dataType", "")
|
|
print(f" {api:<35} ({dtype:<12}) objectName={obj_name}")
|
|
|
|
measures = obj.get("semanticMeasurements", [])
|
|
if measures:
|
|
print(" Measures:")
|
|
for m in measures:
|
|
api = m.get("apiName", "")
|
|
agg = m.get("aggregationType", "Sum")
|
|
print(f" {api:<35} ({agg:<12}) objectName={obj_name}")
|
|
print()
|
|
|
|
calc_dims = data.get("semanticCalculatedDimensions", [])
|
|
if calc_dims:
|
|
print("Calculated Dimensions:")
|
|
for d in calc_dims:
|
|
api = d.get("apiName", "")
|
|
dtype = d.get("dataType", "")
|
|
print(f" {api:<37} ({dtype:<12}) objectName=null")
|
|
print()
|
|
|
|
calc_measures = data.get("semanticCalculatedMeasurements", [])
|
|
if calc_measures:
|
|
print("Calculated Measures:")
|
|
for m in calc_measures:
|
|
api = m.get("apiName", "")
|
|
agg = m.get("aggregationType", "Sum")
|
|
note = " <-- NOT Sum!" if agg != "Sum" else ""
|
|
print(f" {api:<37} ({agg:<12}) objectName=null{note}")
|
|
print()
|
|
|
|
metrics = data.get("semanticMetrics", [])
|
|
if metrics:
|
|
print("Metrics:")
|
|
for m in metrics:
|
|
api = m.get("apiName", "")
|
|
label = m.get("label", "")
|
|
agg = m.get("aggregationType", "")
|
|
print(f" {api:<37} ({agg:<12}) label={label}")
|
|
print()
|
|
|
|
relationships = data.get("semanticRelationships", [])
|
|
if relationships:
|
|
print("Relationships:")
|
|
for rel in relationships:
|
|
api = rel.get("apiName", "")
|
|
card = rel.get("cardinality", "")
|
|
left = rel.get("leftSemanticDefinitionApiName", "")
|
|
right = rel.get("rightSemanticDefinitionApiName", "")
|
|
queryable = rel.get("isQueryable", "")
|
|
print(f" {api:<37} ({card:<12}) {left} <-> {right} [{queryable}]")
|
|
for crit in rel.get("criteria", []):
|
|
lf = crit.get("leftSemanticFieldApiName", "")
|
|
rf = crit.get("rightSemanticFieldApiName", "")
|
|
op = crit.get("joinOperator", "")
|
|
print(f" {lf} {op} {rf}")
|
|
print()
|
|
|
|
|
|
def _structured_output(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Build a machine-readable summary of an SDM.
|
|
|
|
Args:
|
|
data: Raw SDM detail response from API
|
|
|
|
Returns:
|
|
Structured dict with apiName, label, objects, calculatedDimensions,
|
|
calculatedMeasures, and metrics
|
|
"""
|
|
result: Dict[str, Any] = {
|
|
"apiName": data.get("apiName", ""),
|
|
"label": data.get("label", ""),
|
|
"objects": [],
|
|
"calculatedDimensions": [],
|
|
"calculatedMeasures": [],
|
|
"metrics": [],
|
|
"relationships": [],
|
|
}
|
|
|
|
for obj in data.get("semanticDataObjects", []):
|
|
obj_entry: dict = {
|
|
"objectName": obj.get("apiName", ""),
|
|
"dimensions": [],
|
|
"measures": [],
|
|
}
|
|
for d in obj.get("semanticDimensions", []):
|
|
obj_entry["dimensions"].append({
|
|
"fieldName": d.get("apiName", ""),
|
|
"dataType": d.get("dataType", ""),
|
|
"objectName": obj.get("apiName", ""),
|
|
"role": "Dimension",
|
|
"displayCategory": "Discrete",
|
|
"function": None,
|
|
})
|
|
for m in obj.get("semanticMeasurements", []):
|
|
obj_entry["measures"].append({
|
|
"fieldName": m.get("apiName", ""),
|
|
"aggregationType": m.get("aggregationType", "Sum"),
|
|
"objectName": obj.get("apiName", ""),
|
|
"role": "Measure",
|
|
"displayCategory": "Continuous",
|
|
"function": m.get("aggregationType", "Sum"),
|
|
})
|
|
result["objects"].append(obj_entry)
|
|
|
|
for d in data.get("semanticCalculatedDimensions", []):
|
|
result["calculatedDimensions"].append({
|
|
"fieldName": d.get("apiName", ""),
|
|
"dataType": d.get("dataType", ""),
|
|
"objectName": None,
|
|
"role": "Dimension",
|
|
"displayCategory": "Discrete",
|
|
"function": None,
|
|
})
|
|
|
|
for m in data.get("semanticCalculatedMeasurements", []):
|
|
result["calculatedMeasures"].append({
|
|
"fieldName": m.get("apiName", ""),
|
|
"aggregationType": m.get("aggregationType", "Sum"),
|
|
"objectName": None,
|
|
"role": "Measure",
|
|
"displayCategory": "Continuous",
|
|
"function": m.get("aggregationType", "Sum"),
|
|
})
|
|
|
|
for m in data.get("semanticMetrics", []):
|
|
result["metrics"].append({
|
|
"apiName": m.get("apiName", ""),
|
|
"label": m.get("label", ""),
|
|
"aggregationType": m.get("aggregationType", ""),
|
|
})
|
|
|
|
for rel in data.get("semanticRelationships", []):
|
|
result["relationships"].append({
|
|
"apiName": rel.get("apiName", ""),
|
|
"label": rel.get("label", ""),
|
|
"joinType": rel.get("joinType", ""),
|
|
"cardinality": rel.get("cardinality", ""),
|
|
"isQueryable": rel.get("isQueryable", ""),
|
|
"leftObject": rel.get("leftSemanticDefinitionApiName", ""),
|
|
"rightObject": rel.get("rightSemanticDefinitionApiName", ""),
|
|
"criteria": [
|
|
{
|
|
"leftField": c.get("leftSemanticFieldApiName", ""),
|
|
"rightField": c.get("rightSemanticFieldApiName", ""),
|
|
"leftFieldType": c.get("leftFieldType", ""),
|
|
"rightFieldType": c.get("rightFieldType", ""),
|
|
"joinOperator": c.get("joinOperator", ""),
|
|
}
|
|
for c in rel.get("criteria", [])
|
|
],
|
|
})
|
|
|
|
return result
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Discover Tableau Next Semantic Data Models")
|
|
group = parser.add_mutually_exclusive_group(required=True)
|
|
group.add_argument("--list", action="store_true", help="List all available SDMs")
|
|
group.add_argument("--sdm", type=str, help="Show fields for a specific SDM (by API name)")
|
|
parser.add_argument("--json", action="store_true", help="Output as JSON (machine-readable)")
|
|
args = parser.parse_args()
|
|
|
|
token, instance = get_credentials()
|
|
|
|
if args.list:
|
|
list_sdms(token, instance, args.json)
|
|
elif args.sdm:
|
|
show_sdm(token, instance, args.sdm, args.json)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|