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.
264 lines
9.7 KiB
Python
Executable File
264 lines
9.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Create calculated measurements and dimensions on semantic models.
|
|
|
|
Usage:
|
|
# Free-form measurement (with aggregation in expression → auto UserAgg)
|
|
python scripts/create_calc_field.py \\
|
|
--sdm Sales_Cloud12_SDM_1772196180 \\
|
|
--type measurement \\
|
|
--name Avg_Deal_Size_clc \\
|
|
--label "Average Deal Size" \\
|
|
--expression "AVG([Amount])" \\
|
|
--aggregation Avg
|
|
|
|
# Template-based
|
|
python scripts/create_calc_field.py \\
|
|
--sdm Sales_Cloud12_SDM_1772196180 \\
|
|
--template win_rate \\
|
|
--template-args '{"won_field": "Stage_Won_Count", "total_field": "Stage_Total_Count"}' \\
|
|
--name Win_Rate_clc \\
|
|
--label "Win Rate"
|
|
|
|
# Boolean dimension
|
|
python scripts/create_calc_field.py \\
|
|
--sdm Sales_Cloud12_SDM_1772196180 \\
|
|
--type dimension \\
|
|
--name Is_Large_Deal_clc \\
|
|
--label "Is Large Deal?" \\
|
|
--expression "[Amount] > 100000" \\
|
|
--data-type Boolean
|
|
|
|
# Dry-run (show payload without POSTing)
|
|
python scripts/create_calc_field.py \\
|
|
--sdm Sales_Cloud12_SDM_1772196180 \\
|
|
--template bucket_amount \\
|
|
--template-args '{"field": "Amount", "small_threshold": 10000, "medium_threshold": 50000}' \\
|
|
--name Deal_Size_Bucket_clc \\
|
|
--label "Deal Size" \\
|
|
--dry-run
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from typing import Any, Dict, Optional
|
|
|
|
from _shared.calc_field_templates import (
|
|
TEMPLATE_REGISTRY,
|
|
build_calculated_dimension,
|
|
build_calculated_measurement,
|
|
validate_calc_field,
|
|
)
|
|
from _shared.sf_api import calculated_field_endpoint, get_credentials, sf_post
|
|
from _shared.verify import report_verification, verify_calc_field_has_data
|
|
|
|
|
|
def resolve_expression(
|
|
template: Optional[str],
|
|
template_args: Optional[str],
|
|
expression: Optional[str]
|
|
) -> str:
|
|
"""Resolve expression from template or use provided expression.
|
|
|
|
Args:
|
|
template: Template name (win_rate, days_between, etc.)
|
|
template_args: JSON string with template arguments
|
|
expression: Direct expression string
|
|
|
|
Returns:
|
|
Resolved expression string
|
|
"""
|
|
if template:
|
|
if template not in TEMPLATE_REGISTRY:
|
|
print(f"Error: Unknown template '{template}'. Valid: {', '.join(TEMPLATE_REGISTRY.keys())}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if not template_args:
|
|
print(f"Error: --template-args required when using --template", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
args = json.loads(template_args)
|
|
except json.JSONDecodeError as e:
|
|
print(f"Error: Invalid JSON in --template-args: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
template_func = TEMPLATE_REGISTRY[template]
|
|
try:
|
|
return template_func(**args)
|
|
except TypeError as e:
|
|
print(f"Error: Template '{template}' arguments mismatch: {e}", file=sys.stderr)
|
|
print(f"Expected arguments: {template_func.__code__.co_varnames[:template_func.__code__.co_argcount]}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if expression:
|
|
return expression
|
|
|
|
print("Error: Either --template or --expression must be provided", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def normalize_api_name(name: str) -> str:
|
|
"""Ensure API name ends with _clc and doesn't have double underscores.
|
|
|
|
Salesforce API names cannot contain double underscores (__).
|
|
|
|
Args:
|
|
name: API name
|
|
|
|
Returns:
|
|
Normalized API name (no double underscores, ends with _clc)
|
|
"""
|
|
name = name.strip()
|
|
# Replace double underscores with single underscore
|
|
while "__" in name:
|
|
name = name.replace("__", "_")
|
|
if not name.endswith("_clc"):
|
|
return f"{name}_clc"
|
|
return name
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Create calculated measurements and dimensions on semantic models",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
|
|
parser.add_argument("--sdm", required=True, help="Semantic model API name")
|
|
parser.add_argument("--type", required=True, choices=["measurement", "dimension"], help="Field type")
|
|
parser.add_argument("--name", required=True, help="API name (auto-appends _clc if missing)")
|
|
parser.add_argument("--label", required=True, help="Display label")
|
|
|
|
# Expression source (mutually exclusive). Not required at the argparse layer
|
|
# so --verify-only can run without it; the create path enforces it below.
|
|
expr_group = parser.add_mutually_exclusive_group(required=False)
|
|
expr_group.add_argument("--expression", help="Tableau formula expression")
|
|
expr_group.add_argument("--template", choices=list(TEMPLATE_REGISTRY.keys()), help="Template name")
|
|
|
|
parser.add_argument("--template-args", help="JSON dict of template arguments (required with --template)")
|
|
|
|
# Measurement-specific
|
|
parser.add_argument("--aggregation", default="UserAgg",
|
|
choices=["Sum", "Avg", "Count", "Min", "Max", "UserAgg", "Median"],
|
|
help="Aggregation type (for measurements, default: UserAgg)")
|
|
parser.add_argument("--data-type", choices=["Number", "Text", "Boolean", "DateTime"],
|
|
help="Data type (default: Number for measurements, inferred for dimensions)")
|
|
parser.add_argument("--decimal-places", type=int, default=2, help="Decimal places for Number fields (default: 2)")
|
|
parser.add_argument("--description", default="", help="Field description")
|
|
|
|
parser.add_argument("--dry-run", action="store_true", help="Show payload without POSTing")
|
|
parser.add_argument("--skip-verify", action="store_true",
|
|
help="Skip the post-create query that confirms the field returns data")
|
|
parser.add_argument("--verify-only", action="store_true",
|
|
help="Do not create; only query the EXISTING calc field (--name) and report "
|
|
"whether it returns data (verified-done / NOT-shippable). Use this to "
|
|
"re-check an already-created field — never re-run a create on one.")
|
|
parser.add_argument("-o", "--output", help="Write JSON to file (default: stdout)")
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Verify-only: skip creation, just query the existing calc field for data.
|
|
if args.verify_only:
|
|
existing = normalize_api_name(args.name)
|
|
has_data, verify_err = verify_calc_field_has_data(args.sdm, existing)
|
|
return report_verification("calc field", existing, has_data, verify_err, created=False)
|
|
|
|
# Resolve expression
|
|
expression = resolve_expression(args.template, args.template_args, args.expression)
|
|
|
|
# Normalize API name
|
|
api_name = normalize_api_name(args.name)
|
|
|
|
# Determine data type
|
|
data_type = args.data_type
|
|
if not data_type:
|
|
if args.type == "measurement":
|
|
data_type = "Number"
|
|
else:
|
|
# Try to infer from expression (basic heuristics)
|
|
if "THEN" in expression.upper() or "IF" in expression.upper():
|
|
data_type = "Text" # Likely a bucketing or conditional
|
|
else:
|
|
data_type = "Text" # Default for dimensions
|
|
|
|
# Validate
|
|
is_valid, errors = validate_calc_field(
|
|
api_name=api_name,
|
|
field_type=args.type,
|
|
aggregation_type=args.aggregation if args.type == "measurement" else None,
|
|
data_type=data_type,
|
|
expression=expression
|
|
)
|
|
if not is_valid:
|
|
print("Validation errors:", file=sys.stderr)
|
|
for err in errors:
|
|
print(f" - {err}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Build payload
|
|
if args.type == "measurement":
|
|
payload = build_calculated_measurement(
|
|
api_name=api_name,
|
|
label=args.label,
|
|
expression=expression,
|
|
aggregation_type=args.aggregation,
|
|
data_type=data_type,
|
|
decimal_place=args.decimal_places,
|
|
description=args.description,
|
|
)
|
|
else:
|
|
payload = build_calculated_dimension(
|
|
api_name=api_name,
|
|
label=args.label,
|
|
expression=expression,
|
|
data_type=data_type,
|
|
description=args.description,
|
|
)
|
|
|
|
# Output payload
|
|
output_json = json.dumps(payload, indent=2)
|
|
|
|
if args.output:
|
|
with open(args.output, "w") as f:
|
|
f.write(output_json)
|
|
f.write("\n")
|
|
print(f"Wrote {args.output}", file=sys.stderr)
|
|
else:
|
|
print(output_json)
|
|
|
|
# Dry-run mode - exit before POST
|
|
if args.dry_run:
|
|
print("\n[Dry-run mode - payload shown above, not POSTed]", file=sys.stderr)
|
|
return 0
|
|
|
|
# POST to API
|
|
token, instance = get_credentials()
|
|
endpoint = calculated_field_endpoint(args.sdm, f"{args.type}s") # measurement -> measurements
|
|
|
|
resp, err = sf_post(token, instance, endpoint, payload)
|
|
if err:
|
|
print(f"Error: {err}", file=sys.stderr)
|
|
return 1
|
|
|
|
actual_name = api_name
|
|
if resp:
|
|
actual_name = resp.get('apiName', api_name)
|
|
print(f"\n✓ Created calculated {args.type}: {api_name}", file=sys.stderr)
|
|
if actual_name != api_name:
|
|
print(f" → Actual API name: {actual_name}", file=sys.stderr)
|
|
if "label" in resp:
|
|
print(f" Label: {resp['label']}", file=sys.stderr)
|
|
else:
|
|
print(f"\n✓ Calculated {args.type} created successfully", file=sys.stderr)
|
|
|
|
# Verify-by-querying: a created field that returns no data is NOT shippable.
|
|
# Creation success is not data success — confirm before declaring done.
|
|
if args.skip_verify:
|
|
return 0
|
|
has_data, verify_err = verify_calc_field_has_data(args.sdm, actual_name)
|
|
return report_verification("calc field", actual_name, has_data, verify_err)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|