"""Template library and payload builders for semantic metrics.
Provides common formula templates and functions to build semantic metric
payloads for Salesforce Tableau Next API.
Semantic metrics are simpler than calculated measurements - they only require
apiName, label, and expression (no aggregationType, dataType, decimalPlace, etc.).
"""
import re
from typing import Dict, List, Optional, Tuple
# -- Junk-date guard ------------------------------------------------------------
# A metric's time anchor must be a business-meaningful event date (Close_Date,
# Created_Date, Order_Date...). Anchoring on a system/plumbing column produces a
# metric whose time series tracks data-pipeline events, not the business — a
# silent correctness bug. These patterns match the non-business columns Data
# Cloud and ingestion add; matching is case-insensitive on the field apiName.
_JUNK_DATE_PATTERNS = (
r"^cdp_sys_", # cdp_sys_PartitionDate, cdp_sys_* plumbing columns
r"_?partitiondate$", # partition / load partitioning dates
r"sourceversion", # *_SourceVersion ingest-version timestamps
r"^kq_", # KQ_* Data Cloud key-qualifier system fields
r"(^|_)(load|ingest|ingestion|sys|system)_?(date|time|timestamp|ts)$",
r"datasource(object)?__c$", # connector bookkeeping columns
)
def is_junk_date_field(field_name: str) -> bool:
"""Whether a field apiName looks like a non-business (plumbing) date.
True for system/load/ingest columns (e.g. ``cdp_sys_PartitionDate``,
``X_SourceVersion``) that must not be used as a metric's time anchor.
"""
if not field_name:
return False
name = field_name.strip().lower()
return any(re.search(p, name) for p in _JUNK_DATE_PATTERNS)
# -- Template Functions ---------------------------------------------------------
def sum_metric(field: str) -> str:
"""Generate sum aggregation formula.
Args:
field: Field name to sum
Returns:
Tableau formula string
"""
return f"SUM([{field}])"
def avg_metric(field: str) -> str:
"""Generate average aggregation formula.
Args:
field: Field name to average
Returns:
Tableau formula string
"""
return f"AVG([{field}])"
def count_metric(field: str) -> str:
"""Generate count aggregation formula.
Args:
field: Field name to count
Returns:
Tableau formula string
"""
return f"COUNT([{field}])"
def win_rate_metric(won_field: str, total_field: str) -> str:
"""Generate win rate formula.
Args:
won_field: Field name for won count
total_field: Field name for total count
Returns:
Tableau formula string
"""
return f"SUM([{won_field}]) / SUM([{total_field}])"
def conversion_rate_metric(converted_field: str, total_field: str) -> str:
"""Generate conversion rate formula.
Args:
converted_field: Field name for converted count
total_field: Field name for total count
Returns:
Tableau formula string
"""
return f"SUM([{converted_field}]) / SUM([{total_field}])"
def weighted_pipeline_metric(amount_field: str, probability_field: str) -> str:
"""Generate weighted pipeline value formula.
Args:
amount_field: Field name for amount
probability_field: Field name for probability (0-1)
Returns:
Tableau formula string
"""
return f"SUM([{amount_field}] * [{probability_field}])"
def sales_cycle_metric(start_field: str, end_field: str) -> str:
"""Generate sales cycle (days between) formula.
Args:
start_field: Start date field name
end_field: End date field name
Returns:
Tableau formula string
"""
return f"AVG(DATEDIFF('day', [{start_field}], [{end_field}]))"
# -- Filters -------------------------------------------------------------------
# Operators accepted in --filter specs, mapped to the metric filters[] operator
# enum. The enum was confirmed live against a real org:
# the server accepts the CamelCase forms below and REJECTS SQL-style names
# (EQUAL, GREATER_THAN_OR_EQUAL, NOT_EQUAL) and has NO >=, <=, or != operator.
# Both symbolic shortcuts and the canonical names are accepted as input.
FILTER_OPERATORS = {
# equality
"=": "Equals",
"==": "Equals",
"EQUALS": "Equals",
# comparisons (no >= / <= on the server; only strict GT/LT and Between)
">": "GreaterThan",
"GREATERTHAN": "GreaterThan",
"<": "LessThan",
"LESSTHAN": "LessThan",
"BETWEEN": "Between",
# set / string membership
"IN": "In",
"NOTIN": "NotIn",
"CONTAINS": "Contains",
"NOTCONTAINS": "NotContains",
"STARTSWITH": "StartsWith",
}
def parse_metric_filter(spec: str) -> Dict:
"""Parse a ``"
. "`` filter spec into a filters[] entry.
Metric filter fields MUST be fully qualified (``Table.Field``); a bare
field name makes the metric unqueryable with the server error
``Metric Definition Filter Field is not found in Metric
Definition``. We reject bare fields up front.
Args:
spec: e.g. ``"Opportunity.Region = West"`` or ``"Opportunity.Amount > 1000"``
Returns:
The ``filters[]`` dict (``fieldName`` qualified, ``operator``, ``values``).
Raises:
ValueError: if the spec is malformed, unqualified, or uses an unknown operator.
"""
parts = spec.split(None, 2)
if len(parts) < 3:
raise ValueError(
f"Invalid --filter '{spec}'. Expected '. ' "
f"(e.g. 'Opportunity.Region = West')."
)
qualified_field, op, value = parts[0], parts[1], parts[2]
if "." not in qualified_field:
raise ValueError(
f"Metric filter field '{qualified_field}' must be fully qualified as "
f"'Table.Field'. A bare field name makes the metric unqueryable "
f"(server error: 'Metric Definition Filter Field {qualified_field} is "
f"not found in Metric Definition')."
)
op_key = op.upper() if op.upper() in FILTER_OPERATORS else op
if op_key not in FILTER_OPERATORS:
raise ValueError(
f"Unsupported filter operator '{op}'. Supported: "
f"{', '.join(sorted(FILTER_OPERATORS))}."
)
return {
"fieldName": qualified_field,
"operator": FILTER_OPERATORS[op_key],
"values": [value],
}
def _filter_field_to_dim_ref(field_name: str) -> Optional[Dict]:
"""Build an additionalDimensions-shaped ref from a qualified ``Table.Field``."""
if "." not in field_name:
return None
table_name, fld = field_name.split(".", 1)
return {"tableFieldReference": {"fieldApiName": fld, "tableApiName": table_name}}
def _filter_field_to_dim_key(field_name: str) -> Optional[Tuple]:
"""Map a ``filters[].fieldName`` (``Table.Field``) to a dim identity key."""
if "." not in field_name:
return None
table_name, fld = field_name.split(".", 1)
return ("table", fld, table_name)
def validate_additional_dimensions_superset(payload: Dict) -> None:
"""Enforce the additionalDimensions superset rule on an assembled payload.
Every field referenced by ``insightsSettings.identifyingDimension``, each
``insightsSettings.insightsDimensionsReferences[]`` entry, and each
``filters[].fieldName`` MUST appear in top-level ``additionalDimensions[]``.
Two distinct server failure modes this guards (quoted in the message):
- Insight/identifying dims missing fail at *create*:
``Validation Failed: ... Insight dimension (.) is missing
from the metric additional dimensions.``
- Filter fields missing succeed at create but make the metric *unqueryable*:
``Metric Definition Filter Field is not found in Metric
Definition.``
Raises:
ValueError: naming the offending field, quoting the server's text.
"""
additional = payload.get("additionalDimensions", []) or []
present = {_dim_ref_key(d) for d in additional}
insights = payload.get("insightsSettings", {}) or {}
# identifyingDimension
ident = insights.get("identifyingDimension", {})
ref = ident.get("identifierDimensionReference")
if ref and _dim_ref_key(ref) not in present:
field = _describe_dim_ref(ref)
raise ValueError(
f"Identifying dimension ({field}) is missing from the metric "
f"additional dimensions. Add it to additionalDimensions "
f"(server error: 'Insight dimension ({field}) is missing from the "
f"metric additional dimensions')."
)
# insightsDimensionsReferences[]
for dref in insights.get("insightsDimensionsReferences", []) or []:
if _dim_ref_key(dref) not in present:
field = _describe_dim_ref(dref)
raise ValueError(
f"Insight dimension ({field}) is missing from the metric "
f"additional dimensions. Add it to additionalDimensions."
)
# filters[].fieldName
for filt in payload.get("filters", []) or []:
field_name = filt.get("fieldName", "")
key = _filter_field_to_dim_key(field_name)
if key is None or key not in present:
bare = field_name.split(".", 1)[-1] if field_name else field_name
raise ValueError(
f"Filter field '{field_name}' is missing from the metric "
f"additional dimensions; the metric would be unqueryable "
f"(server error: 'Metric Definition Filter Field {bare} is not "
f"found in Metric Definition'). Mirror it into "
f"additionalDimensions."
)
def _describe_dim_ref(ref: Dict) -> str:
"""Human-readable ``Table.Field`` (or calc name) for an error message."""
if "tableFieldReference" in ref:
t = ref["tableFieldReference"]
return f"{t.get('tableApiName')}.{t.get('fieldApiName')}"
if "calculatedFieldApiName" in ref:
return ref["calculatedFieldApiName"]
return repr(ref)
# -- Payload Builder -----------------------------------------------------------
def _dim_ref_key(dim: Dict) -> Tuple:
"""Return a comparable identity key for a dimension reference dict.
Dimension references come in two shapes: a raw/table field
(``{"tableFieldReference": {"fieldApiName", "tableApiName"}}``) or a calc
field (``{"calculatedFieldApiName": ...}``). The key lets us de-dupe and
membership-test dims regardless of surrounding dict structure.
"""
if "tableFieldReference" in dim:
ref = dim["tableFieldReference"]
return ("table", ref.get("fieldApiName"), ref.get("tableApiName"))
if "calculatedFieldApiName" in dim:
return ("calc", dim["calculatedFieldApiName"])
return ("raw", repr(sorted(dim.items())))
def build_default_insights_settings(
additional_dimensions: Optional[List[Dict]] = None,
sentiment: str = "SentimentTypeUpIsGood",
identifying_dimension: Optional[Dict] = None,
) -> Dict[str, any]:
"""Build default insightsSettings structure based on collection patterns.
Args:
additional_dimensions: List of dimension references (optional)
sentiment: Sentiment value (default: "SentimentTypeUpIsGood")
identifying_dimension: Dimension reference (same shape as an
additionalDimensions entry) used as the metric's identifying
dimension. When provided, emit
``identifyingDimension.identifierDimensionReference`` — the Tableau
Next metric UI dereferences this on load and crashes if it is
absent (Feature 1).
Returns:
Complete insightsSettings dict
"""
insights_dimensions_refs = []
if additional_dimensions:
# Match insightsDimensionsReferences to additionalDimensions
for dim in additional_dimensions:
if "tableFieldReference" in dim:
insights_dimensions_refs.append({
"tableFieldReference": dim["tableFieldReference"]
})
settings: Dict[str, any] = {
"insightTypes": [
{"enabled": False, "type": "TopContributors"},
{"enabled": False, "type": "ComparisonToExpectedRangeAlert"},
{"enabled": True, "type": "TrendChangeAlert"},
{"enabled": True, "type": "BottomContributors"},
{"enabled": True, "type": "ConcentratedContributionAlert"},
{"enabled": True, "type": "TopDrivers"},
{"enabled": True, "type": "TopDetractors"},
{"enabled": True, "type": "CurrentTrend"},
{"enabled": False, "type": "OutlierDetection"},
{"enabled": False, "type": "RecordLevelTable"}
],
"insightsDimensionsReferences": insights_dimensions_refs,
"pluralNoun": "",
"sentiment": sentiment,
"singularNoun": ""
}
# The TN metric UI dereferences insightsSettings.identifyingDimension on
# load; emit it whenever we have a dimension to identify the metric by.
if identifying_dimension:
settings["identifyingDimension"] = {
"identifierDimensionReference": identifying_dimension
}
return settings
def build_semantic_metric(
api_name: str,
label: str,
calculated_field_api_name: str,
time_dimension_field_name: str,
time_dimension_table_name: str,
description: str = "",
aggregation_type: str = "UserAgg",
filters: Optional[List[Dict]] = None,
is_cumulative: bool = False,
is_goal_editing_blocked: bool = False,
time_grains: Optional[List[str]] = None,
additional_dimensions: Optional[List[Dict]] = None,
insights_settings: Optional[Dict[str, any]] = None,
sentiment: str = "SentimentTypeUpIsGood",
identifying_dimension: Optional[Dict] = None,
allow_junk_time_anchor: bool = False,
) -> Dict[str, any]:
"""Build semantic metric payload.
Semantic metrics reference calculated fields via measurementReference.
Based on production examples (HR_Workforce1_package, Sales_Cloud12_package), metrics use:
- measurementReference.calculatedFieldApiName (not expression)
- aggregationType: "UserAgg"
- timeDimensionReference (required)
- timeGrains (required)
- additionalDimensions (optional, for breakdown analysis)
- insightsSettings (optional, auto-generated from additionalDimensions if not provided)
- filters, isCumulative, isGoalEditingBlocked
Args:
api_name: API name (must end with _mtc)
label: Display label
calculated_field_api_name: API name of calculated field to reference
time_dimension_field_name: Time dimension field API name (e.g., "Close_Date")
time_dimension_table_name: Time dimension table API name (e.g., "Opportunity_TAB_Sales_Cloud")
description: Optional field description
aggregation_type: Aggregation type (default: "UserAgg")
filters: Optional list of filter dictionaries
is_cumulative: Whether metric is cumulative (default: False)
is_goal_editing_blocked: Whether goal editing is blocked (default: False)
time_grains: List of time grains (default: ["Day", "Week", "Month", "Quarter", "Year"])
additional_dimensions: Optional list of dimension references for breakdown analysis
insights_settings: Optional insightsSettings dict (auto-generated if not provided)
sentiment: Sentiment value (default: "SentimentTypeUpIsGood")
identifying_dimension: Optional dimension reference (same shape as an
additionalDimensions entry) to use as the identifying dimension.
Defaults to the first additionalDimensions entry. The chosen field
is mirrored into additionalDimensions if not already present (the
UI requires the identifying dimension to be an additional
dimension). If there are no additional dimensions and no override,
identifyingDimension is omitted (a no-breakdown metric needs none).
Returns:
Complete semantic metric payload dict
"""
if time_grains is None:
time_grains = ["Day", "Week", "Month", "Quarter", "Year"]
# Guard the time anchor: a metric anchored on a system/plumbing date tracks
# pipeline events, not the business. Reject by default; allow_junk_time_anchor
# is the explicit escape hatch for the rare case the column really is the
# intended anchor.
if not allow_junk_time_anchor and is_junk_date_field(time_dimension_field_name):
raise ValueError(
f"Time anchor '{time_dimension_field_name}' looks like a non-business "
"(system/load) date. Metrics should anchor on a business-meaningful "
"event date (e.g. Close_Date, Created_Date). Pass "
"allow_junk_time_anchor=True (CLI: --allow-junk-time-anchor) to override."
)
# Work on a mutable copy so an override / filter field can be mirrored into
# the list without surprising the caller.
if additional_dimensions is not None:
additional_dimensions = list(additional_dimensions)
# Auto-mirror filter fields into additionalDimensions. A metric filter whose
# field is not also an additional dimension creates a metric that succeeds
# at create but is unqueryable ("Metric Definition Filter Field is
# not found in Metric Definition"). Mirroring keeps the metric queryable.
if filters:
for filt in filters:
dim_ref = _filter_field_to_dim_ref(filt.get("fieldName", ""))
if dim_ref is None:
continue
if additional_dimensions is None:
additional_dimensions = []
existing_keys = {_dim_ref_key(d) for d in additional_dimensions}
if _dim_ref_key(dim_ref) not in existing_keys:
additional_dimensions.append(dim_ref)
# Resolve the identifying dimension: explicit override wins, else default
# to the first additional dimension. An override not already in
# additionalDimensions is mirrored in (the UI requires membership).
if identifying_dimension is None and additional_dimensions:
identifying_dimension = additional_dimensions[0]
elif identifying_dimension is not None:
if additional_dimensions is None:
additional_dimensions = []
existing_keys = {_dim_ref_key(d) for d in additional_dimensions}
if _dim_ref_key(identifying_dimension) not in existing_keys:
additional_dimensions.append(identifying_dimension)
payload: Dict[str, any] = {
"apiName": api_name,
"label": label,
"aggregationType": aggregation_type,
"measurementReference": {
"calculatedFieldApiName": calculated_field_api_name
},
"timeDimensionReference": {
"tableFieldReference": {
"fieldApiName": time_dimension_field_name,
"tableApiName": time_dimension_table_name
}
},
"timeGrains": time_grains,
"filters": filters or [],
"isCumulative": is_cumulative,
"isGoalEditingBlocked": is_goal_editing_blocked,
}
# filterLogic is required alongside a non-empty filters[]. Auto-generate it
# as "1 AND 2 AND ..." (1-based, in filter order) unless the caller already
# supplied one on the filter list (not the current API, but future-proof).
if filters:
payload["filterLogic"] = " AND ".join(str(i + 1) for i in range(len(filters)))
# Add additionalDimensions if provided
if additional_dimensions:
payload["additionalDimensions"] = additional_dimensions
# Add insightsSettings (auto-generate if not provided and additionalDimensions exist, or if sentiment is explicitly set)
if insights_settings:
payload["insightsSettings"] = insights_settings
elif additional_dimensions or sentiment != "SentimentTypeUpIsGood":
# Auto-generate insightsSettings from additionalDimensions (or empty if none)
# Also generate if sentiment is explicitly set to non-default value
payload["insightsSettings"] = build_default_insights_settings(
additional_dimensions=additional_dimensions,
sentiment=sentiment,
identifying_dimension=identifying_dimension,
)
# Only include description if provided
if description:
payload["description"] = description
# Enforce the additionalDimensions superset rule before returning the
# payload (fail fast, pre-POST, with the server's own error strings).
validate_additional_dimensions_superset(payload)
return payload
# -- Validation ----------------------------------------------------------------
def validate_metric(
api_name: str,
expression: Optional[str] = None
) -> Tuple[bool, List[str]]:
"""Validate semantic metric structure and optionally expression syntax.
Args:
api_name: API name to validate
expression: Optional expression to validate function names
Returns:
(is_valid, list_of_errors)
"""
errors: List[str] = []
# Check API name format
if not api_name.endswith("_mtc"):
errors.append("API name must end with '_mtc'")
# Check for double underscores (Salesforce API restriction)
if "__" in api_name:
errors.append("API name cannot contain double underscores (__)")
# Validate expression functions if provided
if expression:
try:
from .tableau_functions import validate_functions
is_valid_funcs, invalid_funcs, suggestions = validate_functions(expression)
if not is_valid_funcs:
for invalid in invalid_funcs:
error_msg = f"Invalid function '{invalid}' in expression"
# Add suggestions if available
suggestion = next((s for s in suggestions if invalid in s), None)
if suggestion:
error_msg += f". Did you mean: {suggestion.split(' -> ')[1]}"
errors.append(error_msg)
except ImportError:
# tableau_functions module not available, skip function validation
pass
return len(errors) == 0, errors
# -- Template Registry ---------------------------------------------------------
METRIC_TEMPLATE_REGISTRY = {
"sum": sum_metric,
"avg": avg_metric,
"count": count_metric,
"win_rate": win_rate_metric,
"conversion_rate": conversion_rate_metric,
"weighted_pipeline": weighted_pipeline_metric,
"sales_cycle": sales_cycle_metric,
}