afv-library/skills/tableau-next-semantic-model-generate/scripts/add_data_object.py

196 lines
8.2 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Add a data object — or a base dimension/measure — to an existing SDM.
Three modes (one per call, to dodge the bulk-create timeout):
--mode object add a data object incrementally to the SDM
--mode dimension add ONE base (non-calc) dimension with a controlled apiName
--mode measure add ONE base (non-calc) measure with a controlled apiName
After every add the script reads the SDM back and prints the server-stored
(possibly suffixed) field apiNames the primary source of join keys for
add_relationship.py. A single-field add (dimension/measure) preserves the
caller-supplied apiName verbatim (no auto-suffix), giving clean join-key names.
Usage:
# Add a data object (auto-bind all fields → apiNames get suffixed)
python scripts/add_data_object.py --mode object \\
--sdm Join_SDM --data-object qb_hw_position__dlm
# Add a base dimension with a controlled apiName (preserved verbatim)
python scripts/add_data_object.py --mode dimension \\
--sdm Join_SDM --object qb_hw_position \\
--api-name Position_Title --source-field position_title__c --data-type Text
# Add a base measure (agg/dataType allow-list enforced)
python scripts/add_data_object.py --mode measure \\
--sdm Join_SDM --object qb_hw_position \\
--api-name Headcount --source-field employee_count__c \\
--data-type Number --aggregation Sum
# Dry-run
python scripts/add_data_object.py --mode object \\
--sdm Join_SDM --data-object qb_hw_position__dlm --dry-run
"""
import argparse
import json
import sys
from typing import Optional
from _shared.sdm_discovery import extract_object_field_apinames, get_sdm_details
from _shared.sdm_structure_templates import (
build_base_dimension,
build_base_measure,
build_data_object,
infer_data_object_type,
validate_data_object_name,
validate_measure_aggregation,
)
from _shared.sf_api import (
get_credentials,
sdm_data_objects_endpoint,
sdm_dimensions_endpoint,
sdm_measurements_endpoint,
sf_post,
)
def _print_readback(sdm_name: str, focus_object: Optional[str] = None) -> None:
"""Fetch the SDM and print the resolved (suffixed) field apiNames."""
data = get_sdm_details(sdm_name)
if not data:
print(" (SDM fetch failed: could not fetch SDM)", file=sys.stderr)
return
field_map = extract_object_field_apinames(data)
print("\nStored field apiNames (use these as join keys — never guess the suffix):", file=sys.stderr)
for obj, fields in field_map.items():
if focus_object and obj != focus_object:
continue
print(f" {obj}:", file=sys.stderr)
if fields["dimensions"]:
print(f" dimensions: {', '.join(fields['dimensions'])}", file=sys.stderr)
if fields["measures"]:
print(f" measures: {', '.join(fields['measures'])}", file=sys.stderr)
def _build_object_payload(args) -> tuple:
"""Returns (payload, endpoint, focus_object) for --mode object."""
ok, err = validate_data_object_name(args.data_object)
if not ok:
print(f"Error: {err}", file=sys.stderr)
sys.exit(1)
object_api_name = args.object_api_name
if not object_api_name:
object_api_name = args.data_object
for suffix in ("__dll", "__dlm", "__dlc"):
if object_api_name.endswith(suffix):
object_api_name = object_api_name[: -len(suffix)]
break
payload = build_data_object(
api_name=object_api_name,
data_object_name=args.data_object,
label=args.object_label or object_api_name,
data_object_type=args.object_type or infer_data_object_type(args.data_object),
should_include_all_fields=not args.no_include_all_fields,
)
return payload, sdm_data_objects_endpoint(args.sdm), object_api_name
def _build_dimension_payload(args) -> tuple:
if not args.object or not args.api_name or not args.source_field:
print("Error: --mode dimension requires --object, --api-name, and --source-field.", file=sys.stderr)
sys.exit(1)
payload = build_base_dimension(
api_name=args.api_name,
data_object_field_name=args.source_field,
label=args.label or args.api_name,
data_type=args.data_type or "Text",
description=args.description,
)
return payload, sdm_dimensions_endpoint(args.sdm, args.object), args.object
def _build_measure_payload(args) -> tuple:
if not args.object or not args.api_name or not args.source_field:
print("Error: --mode measure requires --object, --api-name, and --source-field.", file=sys.stderr)
sys.exit(1)
data_type = args.data_type or "Number"
aggregation = args.aggregation or "Sum"
ok, err = validate_measure_aggregation(data_type, aggregation)
if not ok:
print(f"Error: {err}", file=sys.stderr)
sys.exit(1)
payload = build_base_measure(
api_name=args.api_name,
data_object_field_name=args.source_field,
label=args.label or args.api_name,
data_type=data_type,
aggregation_type=aggregation,
description=args.description,
)
return payload, sdm_measurements_endpoint(args.sdm, args.object), args.object
def main() -> None:
parser = argparse.ArgumentParser(
description="Add a data object / base dimension / base measure to an SDM.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--mode", required=True, choices=["object", "dimension", "measure"])
parser.add_argument("--sdm", required=True, help="Target SDM apiName")
# object mode
parser.add_argument("--data-object", help="[object] source name with suffix (__dll/__dlm/__dlc)")
parser.add_argument("--object-api-name", help="[object] SDM-level object apiName (default: source without suffix)")
parser.add_argument("--object-label", help="[object] object display label")
parser.add_argument("--object-type", choices=["Dlo", "Dmo", "Cio"], help="[object] dataObjectType (default: inferred)")
parser.add_argument("--no-include-all-fields", action="store_true",
help="[object] do not auto-bind all source columns")
# dimension / measure mode
parser.add_argument("--object", help="[dimension/measure] SDM-level object apiName to add the field to")
parser.add_argument("--api-name", help="[dimension/measure] caller-controlled field apiName (preserved verbatim)")
parser.add_argument("--source-field", help="[dimension/measure] raw source column (e.g. position_title__c)")
parser.add_argument("--label", help="[dimension/measure] field display label")
parser.add_argument("--data-type", help="[dimension/measure] dataType (dim default Text, measure default Number)")
parser.add_argument("--aggregation", help="[measure] aggregationType (default Sum; allow-list enforced)")
parser.add_argument("--description", default="", help="[dimension/measure] field description")
parser.add_argument("--dry-run", action="store_true", help="Print payload without POSTing")
args = parser.parse_args()
if args.mode == "object":
if not args.data_object:
print("Error: --mode object requires --data-object.", file=sys.stderr)
sys.exit(1)
payload, endpoint, focus = _build_object_payload(args)
elif args.mode == "dimension":
payload, endpoint, focus = _build_dimension_payload(args)
else:
payload, endpoint, focus = _build_measure_payload(args)
print(json.dumps(payload, indent=2))
if args.dry_run:
print("\n[Dry-run mode - payload shown above, not POSTed]", file=sys.stderr)
return
token, instance = get_credentials()
resp, err = sf_post(token, instance, endpoint, payload)
if err:
print(f"Error: {err}", file=sys.stderr)
sys.exit(1)
stored_name = (resp or {}).get("apiName", payload["apiName"])
print(f"\n✓ Added {args.mode}: {stored_name}", file=sys.stderr)
if args.mode in ("dimension", "measure") and stored_name != payload["apiName"]:
print(f" → Server stored apiName as: {stored_name} (differs from requested {payload['apiName']})", file=sys.stderr)
# Always resolve the stored apiNames after an add (the join-key source).
_print_readback(args.sdm, focus_object=focus)
if __name__ == "__main__":
main()