afv-library/skills/tableau-next-semantic-model-generate/references/sdm-creation-api.md
Antoine Laviron b26d254871 feat: add tableau-next-semantic-model-generate skill
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.
2026-07-26 13:33:29 +02:00

16 KiB
Raw Blame History

SDM Creation API Reference

REST endpoints + payload shapes for building a Semantic Data Model from scratch — create the model with an anchor data object, add further objects incrementally, bind base dimensions/measures, and author model-level relationships (joins). This is the contract the SDM-creation scripts (create_sdm.py, add_data_object.py, add_relationship.py) build against. All shapes are verified against the v66.0 semantic REST API. The examples below use a Workforce_SDM model joining the DMOs qb_hw_employee__dlm and qb_hw_calendar__dlm.

Base URL & conventions

https://{instance}.salesforce.com/services/data/v66.0
  • No minorVersion. Semantic endpoints (/ssot/semantic/...) omit the minorVersion query param that visualization/dashboard endpoints require.
  • Bearer auth: Authorization: Bearer {access_token}, Content-Type: application/json.
  • Bodies are camelCase. The relationship body uses leftSemanticFieldApiName/rightSemanticFieldApiName, captured below.

1. Create SDM (anchor-only)

POST /services/data/v66.0/ssot/semantic/models201

Creates the model with a single anchor data object embedded in semanticDataObjects[]. Keep it to one object per create call (the anchor + incremental rule dodges the bulk-create timeout).

Request:

{
  "apiName": "Workforce_SDM",
  "label": "Workforce SDM",
  "dataspace": "default",
  "semanticDataObjects": [
    {
      "apiName": "qb_hw_employee",
      "label": "qb_hw_employee",
      "dataObjectName": "qb_hw_employee__dlm",
      "dataObjectType": "Dmo",
      "tableType": "Standard",
      "shouldIncludeAllFields": true
    }
  ]
}

Response (201, abbreviated): full SDM body echoed back, including the suffixed field apiNames the server assigned and the sub-resource URLs used by operations 25:

{
  "apiName": "Workforce_SDM",
  "label": "Workforce SDM",
  "dataspace": "default",
  "id": "2SMMM0000005Zd34AE",
  "queryUnrelatedDataObjects": "Exception",
  "semanticDataObjects": [
    {
      "apiName": "qb_hw_employee",
      "dataObjectName": "qb_hw_employee__dlm",
      "dataObjectType": "Dmo",
      "tableType": "Standard",
      "shouldIncludeAllFields": true,
      "isQueryable": "Queryable",
      "semanticDimensions": [
        { "apiName": "first_name1", "dataObjectFieldName": "first_name__c", "dataType": "Text", "displayCategory": "Discrete", "label": "first_name" },
        { "apiName": "Data_Source58", "dataObjectFieldName": "DataSource__c", "dataType": "Text", "displayCategory": "Discrete", "label": "Data Source" }
      ],
      "semanticMeasurements": [
        { "apiName": "position_id2", "dataObjectFieldName": "position_id__c", "dataType": "Number", "aggregationType": "Sum", "decimalPlace": 2, "displayCategory": "Continuous", "label": "position_id" },
        { "apiName": "organization_id2", "dataObjectFieldName": "organization_id__c", "dataType": "Number", "aggregationType": "Sum", "label": "organization_id" }
      ],
      "semanticDimensionsUrl": "/services/data/v66.0/ssot/semantic/models/Workforce_SDM/data-objects/qb_hw_employee/dimensions",
      "semanticMeasurementsUrl": "/services/data/v66.0/ssot/semantic/models/Workforce_SDM/data-objects/qb_hw_employee/measurements"
    }
  ],
  "semanticDataObjectsUrl": "/services/data/v66.0/ssot/semantic/models/Workforce_SDM/data-objects",
  "semanticRelationshipsUrl": "/services/data/v66.0/ssot/semantic/models/Workforce_SDM/relationships"
}

apiName regex

The SDM apiName rules — the server rejects a malformed name (e.g. 1BadName) with a 400:

"The Semantic Model API Name can only contain underscores and alphanumeric characters. It must be unique, begin with a letter, not include spaces, not end with an underscore, and not contain two consecutive underscores."

So the practical regex is [A-Za-z][A-Za-z0-9_]{0,79} with extra rules:

  • begins with a letter,
  • alphanumeric + single underscores only,
  • no trailing underscore, no consecutive __,
  • 180 chars, unique within the org.

The builder validates this client-side so the user never sees a server round-trip for an obvious mistake.

Data-object source suffixes

dataObjectName requires a type suffix; bare names are rejected. dataObjectType must match:

Source type dataObjectType dataObjectName suffix Example
Data Lake Object Dlo __dll accounts__dll
Data Model Object Dmo __dlm qb_hw_employee__dlm
Calculated Insight Cio __dlc churn_score__dlc

The skill accepts either a DLO or a DMO. Prefer __dlm (DMO) in examples when a DMO is present; accept __dll directly when only a DLO exists. DMO/DLO creation is out of scope — the SDM is built on objects that already exist.

The apiName-suffixing landmine

With shouldIncludeAllFields: true, the server numerically suffixes every auto-bound apiName, even on the first object with no name collision. For example:

Source column Stored apiName
first_name__c first_name1
position_id__c position_id2
organization_id__c organization_id2
DataSource__c Data_Source58

The suffix is not predictable from the source name. Always resolve the stored apiName (operation 6 / discover_sdm.py --json) before referencing a field in a relationship or query. Never construct the suffixed name yourself.


2. Add data object (incremental)

POST /services/data/v66.0/ssot/semantic/models/{sdm}/data-objects200

Add one further object to an existing SDM. Same object shape as the anchor. One object per call (avoids the bulk-create timeout).

Request:

{
  "apiName": "qb_hw_position",
  "label": "qb_hw_position",
  "dataObjectName": "qb_hw_position__dlm",
  "dataObjectType": "Dmo",
  "tableType": "Standard",
  "shouldIncludeAllFields": true
}

Response (200, abbreviated): the object with server-suffixed field apiNames (position_id3, department_id2, Data_Source59, …). Resolve these before using them as join keys.


3. Add base dimension (controllable apiName)

POST /services/data/v66.0/ssot/semantic/models/{sdm}/data-objects/{obj}/dimensions201

Bind a single base (non-calc) dimension with a caller-supplied apiName that is preserved verbatim — no suffixing, because you are binding one field by hand rather than via shouldIncludeAllFields. This is the clean-naming path.

Request:

{
  "apiName": "Position_Title_Ctl",
  "label": "Position Title Controlled",
  "dataObjectFieldName": "position_title__c",
  "dataType": "Text",
  "displayCategory": "Discrete"
}

Response (201): apiName returned unchanged (Position_Title_Ctl) — confirming the caller controls the apiName on a single-field add.


4. Add base measure (controllable apiName)

POST /services/data/v66.0/ssot/semantic/models/{sdm}/data-objects/{obj}/measurements201

Request:

{
  "apiName": "Dept_Id_Measure_Ctl",
  "label": "Department Id Measure Controlled",
  "dataObjectFieldName": "department_id__c",
  "dataType": "Number",
  "aggregationType": "Sum",
  "decimalPlace": 2,
  "displayCategory": "Continuous"
}

Response (201): apiName preserved (Dept_Id_Measure_Ctl), aggregationType: "Sum", displayCategory: "Continuous".

dataType ↔ aggregationType allow-list

Match the column's real storage type:

dataType Valid aggregationType
Number, Percent, Currency Sum, Avg, Count, CountDistinct, Min, Max
Text, Boolean Count, CountDistinct, Min, Max (no Sum/Avg)
Date, DateTime Count, CountDistinct, Min, Max

A Sum/Avg on a Text/Boolean/Date measure is invalid — the builder rejects it pre-POST (the server otherwise fails opaquely at query time).


5. Add relationship (model-level join)

POST /services/data/v66.0/ssot/semantic/models/{sdm}/relationships201

Relationships author and become queryable on REST. The join-key references use the resolved apiName — bind the fields first (via the data-object add) then reference their stored apiNames here.

Request:

{
  "apiName": "qb_hw_employee_qb_hw_position",
  "label": "qb_hw_employee : qb_hw_position",
  "joinType": "Auto",
  "cardinality": "ManyToOne",
  "leftSemanticDefinitionApiName": "qb_hw_employee",
  "rightSemanticDefinitionApiName": "qb_hw_position",
  "criteria": [
    {
      "joinOperator": "Equals",
      "leftFieldType": "TableField",
      "leftSemanticFieldApiName": "position_id2",
      "rightFieldType": "TableField",
      "rightSemanticFieldApiName": "position_id3"
    }
  ]
}

Response (201):

{
  "apiName": "qb_hw_employee_qb_hw_position",
  "cardinality": "ManyToOne",
  "joinType": "Auto",
  "label": "qb_hw_employee : qb_hw_position",
  "isEnabled": true,
  "isQueryable": "Queryable",
  "leftSemanticDefinitionApiName": "qb_hw_employee",
  "rightSemanticDefinitionApiName": "qb_hw_position",
  "criteria": [
    { "joinOperator": "Equals",
      "leftFieldType": "TableField", "leftSemanticFieldApiName": "position_id2",
      "rightFieldType": "TableField", "rightSemanticFieldApiName": "position_id3" }
  ]
}

Relationship rules

  • joinType MUST be "Auto" for model-level relationships. Left/Right/Inner/Full are valid only inside a logical view (logicalViewId set) — and logical views are UI-only / out of scope.
  • label is REQUIRED despite the schema marking it optional.
  • cardinalityOneToOne / OneToMany / ManyToOne / ManyToMany / Unspecified. A fact→dimension join is typically ManyToOne (the default).
  • leftSemanticDefinitionApiName / rightSemanticDefinitionApiName name the two data-object apiNames (the SDM-level object apiNames, e.g. qb_hw_employee).
  • criteria[] is usually a single joinOperator: "Equals" on the natural key.
  • leftFieldType / rightFieldType = "TableField" for regular dimensions/measures (the common case).
  • leftSemanticFieldApiName / rightSemanticFieldApiName MUST be the resolved semantic apiName (e.g. position_id2) — read from the add-object response or discover_sdm.py --json.

The #1 relationship error — do NOT use the raw source column name

Passing the raw __c source column name (e.g. join_1__c) instead of the resolved semantic apiName returns HTTP 400:

"The field with API name (join_1__c) used in the relationship (neg_test_join) could not be found in the data object or logical view (qb_hw_employee). Verify that the field exists and matches the API name in the source configuration."

The field DOES exist — but its semantic apiName is join_12, not join_1__c. add_relationship.py rejects a __c-style reference pre-POST with guidance pointing at the resolved apiName.

Other *FieldType values (informational; not used by the base join path): SemanticField for calculated dimensions (leftSemanticFieldApiName = the calc dim apiName; row-level dependency required); Formula for expression-based joins. The skill's add_relationship.py builds the TableField case.


6. Resolve apiNames (discovery)

GET /services/data/v66.0/ssot/semantic/models/{sdm}200

After every create/add, fetch the SDM to resolve the server-stored apiNames. The relevant arrays:

  • semanticDataObjects[].apiName — object apiNames.
  • semanticDataObjects[].semanticDimensions[].apiName / semanticMeasurements[].apiName — the suffixed field apiNames (with dataObjectFieldName = the raw source column for reference).
  • semanticRelationships[] — the authored joins, each with criteria[], leftSemanticDefinitionApiName/rightSemanticDefinitionApiName, cardinality, joinType, and isQueryable.

discover_sdm.py --json surfaces these (objects/dims/measures + relationships).


Proof the join works (cross-object query)

POST /services/data/v66.0/semantic-engine/gateway201, status: "SUCCESS"

The semantic-query gateway takes a camelCase body (tableField, semanticAggregationMethod, limitOptions, simpleSortOrder). Worked query spanning the employee↔calendar join (keys join_12join_13):

{
  "semanticModelApiName": "Workforce_SDM",
  "structuredSemanticQuery": {
    "fields": [
      { "expression": { "tableField": { "name": "report_date1", "tableName": "qb_hw_calendar" } },
        "alias": "cal.report_date", "grouping": "ROW_GROUPING" },
      { "expression": { "tableField": { "name": "occupation_rate1", "tableName": "qb_hw_employee" } },
        "alias": "emp.occ_rate", "semanticAggregationMethod": "SEMANTIC_AGGREGATION_METHOD_SUM" }
    ],
    "options": {
      "limitOptions": { "limit": 8 },
      "sortOrders": [ { "simpleSortOrder": { "sortByFieldAlias": "emp.occ_rate", "sortingOrder": "DESC" } } ]
    }
  }
}

Returned status: "SUCCESS" with non-null report_date values (from qb_hw_calendar) alongside SUM(occupation_rate) (from qb_hw_employee):

{ "status": "SUCCESS",
  "queryResults": { "queryData": { "rows": [
    { "values": ["2023-02-24", 234670.0] },
    { "values": ["2025-05-15", 234670.0] },
    { "values": ["2024-10-01", 234670.0] }
  ] } } }

Because the model's queryUnrelatedDataObjects is "Exception", a cross-object query with no join path throws rather than returning data. This query returned grouped rows that combine fields from both objects — definitive proof the relationship authored and is traversed. table_name/tableName MUST be the SDM object apiName (qb_hw_calendar), and name MUST be the suffixed field apiName (report_date1).

  • Aggregation method enum (query-time suffixes): SUM, AVG, COUNT, MIN, MAX, USER_AGG. Model aggregationType maps: Sum→SUM, Average→AVG, Count→COUNT, CountDistinct→COUNT, Min→MIN, Max→MAX.

6b. Register the SDM in a workspace

POST /services/data/v66.0/tableau/workspaces/{ws}/assets201

After create, register the SDM so it surfaces in a workspace. The body takes exactly {assetId, assetType, assetUsageType}assetId is the SDM's server id (NOT its apiName), and name/label are rejected (Unrecognized field "name"; omitting assetUsageType gives MISSING_PARAM).

Request:

{
  "assetId": "2SMMM0000005a654AA",
  "assetType": "SemanticModel",
  "assetUsageType": "Referenced"
}

Response (201): the asset echoed back with name/label/createdBy/url. create_sdm.py --workspace <ws> does this automatically after a successful create.

7. Delete SDM (cleanup)

DELETE /services/data/v66.0/ssot/semantic/models/{sdm}204

Deletes the SDM (a subsequent GET returns 404). There is no delete-DMO/DLO surface — only the SDM itself can be deleted.


Endpoint summary

Operation Method Path (no minorVersion) Status
Create SDM (anchor) POST /ssot/semantic/models 201
Add data object POST /ssot/semantic/models/{sdm}/data-objects 200
Add base dimension POST /ssot/semantic/models/{sdm}/data-objects/{obj}/dimensions 201
Add base measure POST /ssot/semantic/models/{sdm}/data-objects/{obj}/measurements 201
Add relationship POST /ssot/semantic/models/{sdm}/relationships 201
Resolve apiNames (discovery) GET /ssot/semantic/models/{sdm} 200
Cross-object query POST /semantic-engine/gateway 201
Delete SDM DELETE /ssot/semantic/models/{sdm} 204

Bulk-create timeout recovery

Creating an SDM with many semanticDataObjects[] in one call can return a generic timeout/error even though the SDM persists ~1030s later. Recovery: wait ~30s, then discover_sdm.py --list to confirm persistence — do NOT blind-retry the POST (a retry hits "Unique constraint violated"). This is why the skill creates with a single anchor object and adds the rest incrementally (one object per call).

Key guidance

  1. DLO or DMO: accept either; prefer __dlm (DMO) when one is present.
  2. Join keys: use the resolved apiName (e.g. position_id2) — the auto-bound name from the create/add response or discover_sdm.py --json. Never the raw __c source column, and never a guessed suffix.
  3. Relationships author on REST (isQueryable: "Queryable"); confirm with a cross-object query that returns join-spanning rows.