Exploring inherited SDMs in Tableau Next (Data Cloud 360) and want to export all fields and their descriptions into Excel to validate definitions with business stakeholders and build a data dictionary. I confirmed via Workbench REST describe that `inlineHelpText` is null on the DMOs — so descriptions are in the Tableau Next SDM layer, not Data Cloud. 1. Is there a native UI export for SDM field metadata in Tableau Next? 2. If not, is the Tableau Metadata API (GraphQL) the right approach? Any sample queries would be appreciated.
Hi, for this the only answer I know currently is to use the REST API (maybe there could be other approaches).
I will assume you know how to get an access and refresh token, but I will give some detail
You would need to create a External Client App (Setup, External Clien App) with the following scopes
Under Oauth, get your Client ID and Secret Value:
and the following settings:
After you have created and save your app, you would need to get a refresh token (well, I am using constantly this app, thus a refresh token works pretty well for me to skip authentication every time). To generate the token I use a python function like this:
def run_oauth_flow(sf_login_url, client_id, client_secret):
"""Open browser for OAuth authorization, capture code via local server, exchange for tokens."""
# Build auth URL
CALLBACK_URL=f"http://localhost:8080/callback"
auth_params = urllib.parse.urlencode({
"response_type": "code",
"client_id": client_id,
"redirect_uri": CALLBACK_URL,
"prompt": "login consent",
"scope": "cdp_ingest_api cdp_query_api api sfap_api refresh_token",
})
auth_url = f"{sf_login_url}/services/oauth2/authorize?{auth_params}"
# Start local callback server in background thread
server = HTTPServer(("localhost", CALLBACK_PORT), _CallbackHandler)
thread = threading.Thread(target=server.handle_request, daemon=True)
thread.start()
print("\n Opening your browser for Salesforce login...")
print(f" If the browser doesn't open, visit this URL:\n")
print(f" {auth_url}\n")
webbrowser.open(auth_url)
# Wait up to 120 seconds for the callback
print(" Waiting for authorization (up to 2 minutes)...")
for _ in range(120):
time.sleep(1)
if _CallbackHandler.captured_code:
break
server.server_close()
if not _CallbackHandler.captured_code:
print("\n ❌ Timed out waiting for authorization. Run the script again.")
sys.exit(1)
code = _CallbackHandler.captured_code
print(" ✅ Authorization code received.")
# Exchange code for tokens
r = requests.post(
f"{sf_login_url}/services/oauth2/token",
data={
"grant_type": "authorization_code",
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": CALLBACK_URL,
}
)
if not r.ok:
print(f"\n ❌ Token exchange failed: {r.status_code} {r.text[:300]}")
sys.exit(1)
data = r.json()
sf_token = data["access_token"]
refresh_token = data.get("refresh_token")
sf_instance = data["instance_url"]
if not refresh_token:
print("\n ❌ No refresh_token returned. Make sure your Connected App has")
print(" 'Perform requests at any time (refresh_token, offline_access)' scope enabled.")
sys.exit(1)
print("✅Refresh token captured")
return sf_token, refresh_token, sf_instance
which I use with something like:
sf_token, refresh_token, sf_instance = run_oauth_flow("https://myorgname.my.salesforce.com", client_id, client_secret)After you get the refresh token, I created a file with the credentials (next_config.json) (login url ending in
my.salesforce-setup.com, client id, client secrent, and refresh token){
"sf_login_url": "https://myorgname.my.salesforce-setup.com/",
"client_id": "3MVGFPAT",
"client_secret": "69CE3112C1E43",
"refresh_token": "5AepzHaP9"
}
Finally, I use a python code like this:
import json, os, requests
import pandas as pd
from pathlib import Path
import json
_PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
config_file = os.path.join(_PROJECT_ROOT, "next_config.json")
CONFIG = json.loads(Path(config_file).read_text())
def get_tokens(config):
sf_resp = requests.post(
f"{config['sf_login_url']}/services/oauth2/token",
data={"grant_type": "refresh_token", "refresh_token": config["refresh_token"],
"client_id": config["client_id"], "client_secret": config["client_secret"]}
)
sf_resp.raise_for_status()
sf_token = sf_resp.json()["access_token"]
sf_instance = sf_resp.json()["instance_url"]
return sf_token, sf_instance
sf_token, sf_instance = get_tokens(CONFIG)
BASE_SEM = f"{sf_instance}/services/data/v65.0"
SF_HDRS = {"Authorization": f"Bearer {sf_token}", "Content-Type": "application/json"}
model_api_name = f"novatel_director_of_network_operations"
r = requests.get(f"{BASE_SEM}/ssot/semantic/models/{model_api_name}",
headers=SF_HDRS, params={"includeModelContent": True})
r.raise_for_status()
data = r.json()
rows = []
# Extract dimensions
for obj in data.get("semanticDataObjects", []):
# semanticDimensions
for field in obj.get("semanticDimensions", []):
rows.append({
"category": "Dimension",
"object": obj.get("label"),
"field_name": field.get("label"),
"description": field.get("description")
})
# semanticMeasurements
for field in obj.get("semanticMeasurements", []):
rows.append({
"category": "Measurement",
"object": obj.get("label"),
"field_name": field.get("label"),
"description": field.get("description")
})
# Calculated dimensions
for field in data.get("semanticCalculatedDimensions", []):
rows.append({
"category": "Calculated Dimension",
"object": "Global",
"field_name": field.get("label"),
"description": field.get("description")
})
# Calculated measurements
for field in data.get("semanticCalculatedMeasurements", []):
rows.append({
"category": "Calculated Measurement",
"object": "Global",
"field_name": field.get("label"),
"description": field.get("description")
})
# Metrics
for field in data.get("semanticMetrics", []):
rows.append({
"category": "Metric",
"object": "Global",
"field_name": field.get("label"),
"description": field.get("description")
})
# Create dataframe
df = pd.DataFrame(rows)
# Export to CSV
df.to_csv("fields_descriptions.csv", index=False)
print(df.head())
print("CSV exported successfully!")
Once last thing, you may get your semantic model_api_name from Tableau Next (In my case it was novatel_director_of_network_operations.
The final csv files look like this:
If this post resolves the question, would you be so kind to "Accept this Answer"?. This will help other users find the same answer/resolution and help community keep track of answered questions. Thank you.
Regards,
Diego Martinez
Tableau Visionary and Tableau Ambassador