Skip to content
4 changes: 2 additions & 2 deletions examples/advanced/walkthrough.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ def _run_walkthrough(client):
print(f" Logical Name: {table_info.get('table_logical_name')}")
print(f" Entity Set: {table_info.get('entity_set_name')}")
else:
log_call(f"client.tables.create('{table_name}', columns={{...}})")
log_call(f"client.tables.create('{table_name}', columns={{...}}, display_name='Walkthrough Demo')")
columns = {
"new_Title": "string",
"new_Quantity": "int",
Expand All @@ -123,7 +123,7 @@ def _run_walkthrough(client):
"new_Notes": "memo",
"new_Priority": Priority,
}
table_info = backoff(lambda: client.tables.create(table_name, columns))
table_info = backoff(lambda: client.tables.create(table_name, columns, display_name="Walkthrough Demo"))
print(f"[OK] Created table: {table_info.get('table_schema_name')}")
print(f" Columns created: {', '.join(table_info.get('columns_created', []))}")

Expand Down
3 changes: 2 additions & 1 deletion src/PowerPlatform/Dataverse/data/_batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ class _TableCreate:
columns: Dict[str, Any]
solution: Optional[str] = None
primary_column: Optional[str] = None
display_name: Optional[str] = None


@dataclass
Expand Down Expand Up @@ -409,7 +410,7 @@ def _require_entity_metadata(self, table: str) -> str:
return ent["MetadataId"]

def _resolve_table_create(self, op: _TableCreate) -> List[_RawRequest]:
return [self._od._build_create_entity(op.table, op.columns, op.solution, op.primary_column)]
return [self._od._build_create_entity(op.table, op.columns, op.solution, op.primary_column, op.display_name)]

def _resolve_table_delete(self, op: _TableDelete) -> List[_RawRequest]:
metadata_id = self._require_entity_metadata(op.table)
Expand Down
20 changes: 16 additions & 4 deletions src/PowerPlatform/Dataverse/data/_odata.py
Original file line number Diff line number Diff line change
Expand Up @@ -1636,6 +1636,7 @@ def _create_table(
schema: Dict[str, Any],
solution_unique_name: Optional[str] = None,
primary_column_schema_name: Optional[str] = None,
display_name: Optional[str] = None,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in one of the existing integration tests, pass the display name to make sure coverage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added display name to integration walkthrough tests on commit be8350a

) -> Dict[str, Any]:
"""Create a custom table with specified columns.

Expand All @@ -1647,6 +1648,8 @@ def _create_table(
:type solution_unique_name: ``str`` | ``None``
:param primary_column_schema_name: Optional primary column schema name.
:type primary_column_schema_name: ``str`` | ``None``
:param display_name: Human-readable display name for the table. Defaults to ``table_schema_name``.
:type display_name: ``str`` | ``None``

:return: Metadata summary for the created table including created column schema names.
:rtype: ``dict[str, Any]``
Expand Down Expand Up @@ -1690,9 +1693,13 @@ def _create_table(
if not solution_unique_name:
raise ValueError("solution_unique_name cannot be empty")

if display_name is not None:
if not isinstance(display_name, str) or not display_name.strip():
raise TypeError("display_name must be a non-empty string when provided")

metadata = self._create_entity(
table_schema_name=table_schema_name,
display_name=table_schema_name,
display_name=display_name if display_name is not None else table_schema_name,
attributes=attributes,
solution_unique_name=solution_unique_name,
Comment thread
abelmilash-msft marked this conversation as resolved.
)
Expand Down Expand Up @@ -2099,6 +2106,7 @@ def _build_create_entity(
columns: Dict[str, Any],
solution: Optional[str] = None,
primary_column: Optional[str] = None,
display_name: Optional[str] = None,
) -> _RawRequest:
"""Build an EntityDefinitions POST request without sending it."""
if primary_column:
Expand All @@ -2114,12 +2122,16 @@ def _build_create_entity(
subcode=VALIDATION_UNSUPPORTED_COLUMN_TYPE,
)
attributes.append(attr)
if display_name is not None:
if not isinstance(display_name, str) or not display_name.strip():
raise TypeError("display_name must be a non-empty string when provided")
label = display_name if display_name is not None else table
body = {
"@odata.type": "Microsoft.Dynamics.CRM.EntityMetadata",
"SchemaName": table,
"DisplayName": self._label(table),
"DisplayCollectionName": self._label(table + "s"),
"Description": self._label(f"Custom entity for {table}"),
"DisplayName": self._label(label),
"DisplayCollectionName": self._label(label + "s"),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will be wrong if the DisplayName is 'Person'. Instead of computing, there should be a library we are using to convert to plural. Try to use that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created an issue for this: #166

"Description": self._label(f"Custom entity for {label}"),
"OwnershipType": "UserOwned",
"HasActivities": False,
"HasNotes": True,
Expand Down
5 changes: 5 additions & 0 deletions src/PowerPlatform/Dataverse/operations/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ def create(
*,
solution: Optional[str] = None,
primary_column: Optional[str] = None,
display_name: Optional[str] = None,
) -> None:
"""
Add a table-create operation to the batch.
Expand All @@ -375,13 +376,17 @@ def create(
:type solution: str or None
:param primary_column: Optional primary column schema name.
:type primary_column: str or None
:param display_name: Human-readable display name for the table.
When omitted, defaults to the table schema name.
:type display_name: str or None
"""
self._batch._items.append(
_TableCreate(
table=table,
columns=columns,
solution=solution,
primary_column=primary_column,
display_name=display_name,
)
)

Expand Down
6 changes: 6 additions & 0 deletions src/PowerPlatform/Dataverse/operations/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def create(
*,
solution: Optional[str] = None,
primary_column: Optional[str] = None,
display_name: Optional[str] = None,
) -> TableInfo:
"""Create a custom table with the specified columns.

Expand All @@ -96,6 +97,9 @@ def create(
customization prefix (e.g. ``"new_ProductName"``). If not provided,
defaults to ``"{prefix}_Name"``.
:type primary_column: :class:`str` or None
:param display_name: Human-readable display name for the table
(e.g. ``"Product"``). When omitted, defaults to the table schema name.
:type display_name: :class:`str` or None

:return: Table metadata with ``schema_name``, ``entity_set_name``,
``logical_name``, ``metadata_id``, and ``columns_created``.
Expand Down Expand Up @@ -124,6 +128,7 @@ class ItemStatus(IntEnum):
},
solution="MySolution",
primary_column="new_ProductName",
display_name="Product",
)
print(f"Created: {result['table_schema_name']}")
"""
Expand All @@ -133,6 +138,7 @@ class ItemStatus(IntEnum):
columns,
solution,
primary_column,
display_name,
)
return TableInfo.from_dict(raw)

Expand Down
10 changes: 9 additions & 1 deletion tests/unit/data/test_batch_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -691,9 +691,17 @@ def test_dispatch_table_create(self):
od._build_create_entity.return_value = MagicMock()
op = _TableCreate(table="new_Widget", columns={"new_name": str})
result = client._resolve_item(op)
od._build_create_entity.assert_called_once_with("new_Widget", {"new_name": str}, None, None)
od._build_create_entity.assert_called_once_with("new_Widget", {"new_name": str}, None, None, None)
self.assertEqual(len(result), 1)

def test_dispatch_table_create_forwards_display_name(self):
"""_resolve_item forwards display_name to _build_create_entity."""
client, od = self._client_and_od()
od._build_create_entity.return_value = MagicMock()
op = _TableCreate(table="new_Widget", columns={}, display_name="Widget")
client._resolve_item(op)
od._build_create_entity.assert_called_once_with("new_Widget", {}, None, None, "Widget")

def test_dispatch_table_delete(self):
"""_resolve_item routes _TableDelete, resolving MetadataId before calling _build_delete_entity."""
client, od = self._client_and_od()
Expand Down
161 changes: 161 additions & 0 deletions tests/unit/data/test_odata_internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -1824,6 +1824,40 @@ def test_primary_column_schema_name_used_when_provided(self):
self.assertIsNotNone(primary_attr)
self.assertEqual(primary_attr["SchemaName"], "new_CustomName")

def test_display_name_used_in_payload_when_provided(self):
"""_create_table uses provided display_name in the POST payload DisplayName."""
self._setup_for_create()
self.od._create_table("new_TestTable", {}, display_name="My Test Table")
post_json = self.od._request.call_args.kwargs["json"]
label_value = post_json["DisplayName"]["LocalizedLabels"][0]["Label"]
self.assertEqual(label_value, "My Test Table")

def test_display_name_defaults_to_schema_name(self):
"""_create_table defaults DisplayName to table_schema_name when display_name is omitted."""
self._setup_for_create()
self.od._create_table("new_TestTable", {})
post_json = self.od._request.call_args.kwargs["json"]
label_value = post_json["DisplayName"]["LocalizedLabels"][0]["Label"]
self.assertEqual(label_value, "new_TestTable")

def test_display_name_empty_string_raises(self):
"""_create_table raises TypeError when display_name is an empty string."""
self._setup_for_create()
with self.assertRaises(TypeError):
self.od._create_table("new_TestTable", {}, display_name="")

def test_display_name_whitespace_raises(self):
"""_create_table raises TypeError when display_name is whitespace only."""
self._setup_for_create()
with self.assertRaises(TypeError):
self.od._create_table("new_TestTable", {}, display_name=" ")

def test_display_name_non_string_raises(self):
"""_create_table raises TypeError when display_name is not a string."""
self._setup_for_create()
with self.assertRaises(TypeError):
self.od._create_table("new_TestTable", {}, display_name=123)


class TestCreateColumns(unittest.TestCase):
"""Unit tests for _ODataClient._create_columns."""
Expand Down Expand Up @@ -2813,5 +2847,132 @@ def test_url_contains_upsert_multiple_action(self):
self.assertEqual(req.method, "POST")


class TestBuildCreateEntity(unittest.TestCase):
"""Unit tests for _ODataClient._build_create_entity (batch deferred build)."""

def setUp(self):
self.od = _make_odata_client()

def _body(self, **kwargs):
req = self.od._build_create_entity("new_TestTable", {}, **kwargs)
return json.loads(req.body)

def test_display_name_used_in_payload_when_provided(self):
"""_build_create_entity uses the provided display_name in DisplayName."""
body = self._body(display_name="Test Table")
self.assertEqual(body["DisplayName"]["LocalizedLabels"][0]["Label"], "Test Table")

def test_display_name_defaults_to_schema_name(self):
"""_build_create_entity falls back to table schema name when display_name is omitted."""
body = self._body()
self.assertEqual(body["DisplayName"]["LocalizedLabels"][0]["Label"], "new_TestTable")

def test_display_collection_name_derived_from_display_name(self):
"""_build_create_entity appends 's' to display_name for DisplayCollectionName."""
body = self._body(display_name="Test Table")
self.assertEqual(body["DisplayCollectionName"]["LocalizedLabels"][0]["Label"], "Test Tables")

def test_display_name_empty_string_raises(self):
"""_build_create_entity raises TypeError when display_name is an empty string."""
with self.assertRaises(TypeError):
self.od._build_create_entity("new_TestTable", {}, display_name="")

def test_display_name_whitespace_raises(self):
"""_build_create_entity raises TypeError when display_name is whitespace only."""
with self.assertRaises(TypeError):
self.od._build_create_entity("new_TestTable", {}, display_name=" ")

def test_display_name_non_string_raises(self):
"""_build_create_entity raises TypeError when display_name is not a string."""
with self.assertRaises(TypeError):
self.od._build_create_entity("new_TestTable", {}, display_name=123)

# --- HTTP request structure -------------------------------------------

def test_returns_post_request(self):
"""_build_create_entity returns a POST _RawRequest."""
req = self.od._build_create_entity("new_TestTable", {})
self.assertEqual(req.method, "POST")

def test_url_targets_entity_definitions(self):
"""_build_create_entity URL ends with /EntityDefinitions."""
req = self.od._build_create_entity("new_TestTable", {})
self.assertTrue(req.url.endswith("/EntityDefinitions"))

def test_solution_appended_to_url(self):
"""_build_create_entity appends SolutionUniqueName to URL when solution is given."""
req = self.od._build_create_entity("new_TestTable", {}, solution="MySolution")
self.assertIn("SolutionUniqueName=MySolution", req.url)

def test_no_solution_no_query_string(self):
"""_build_create_entity URL has no query string when solution is omitted."""
req = self.od._build_create_entity("new_TestTable", {})
self.assertNotIn("?", req.url)

# --- Payload structure ------------------------------------------------

def test_schema_name_in_payload(self):
"""_build_create_entity sets SchemaName in the payload."""
body = self._body()
self.assertEqual(body["SchemaName"], "new_TestTable")

def test_static_payload_fields(self):
"""_build_create_entity sets fixed metadata fields correctly."""
body = self._body()
self.assertEqual(body["OwnershipType"], "UserOwned")
self.assertFalse(body["HasActivities"])
self.assertFalse(body["IsActivity"])
self.assertTrue(body["HasNotes"])

def test_description_uses_label(self):
"""_build_create_entity Description reflects the display label."""
body = self._body(display_name="My Table")
label = body["Description"]["LocalizedLabels"][0]["Label"]
self.assertIn("My Table", label)

# --- Primary column derivation ----------------------------------------

def test_primary_column_derived_from_table_prefix(self):
"""Primary column SchemaName uses table prefix when no primary_column given."""
body = self._body()
attrs = body["Attributes"]
primary = next(a for a in attrs if a.get("IsPrimaryName"))
self.assertEqual(primary["SchemaName"], "new_Name")

def test_primary_column_explicit(self):
"""_build_create_entity uses explicit primary_column when provided."""
req = self.od._build_create_entity("new_TestTable", {}, primary_column="new_CustomName")
body = json.loads(req.body)
attrs = body["Attributes"]
primary = next(a for a in attrs if a.get("IsPrimaryName"))
self.assertEqual(primary["SchemaName"], "new_CustomName")

def test_primary_column_derived_no_prefix(self):
"""Primary column defaults to 'new_Name' when table has no underscore."""
req = self.od._build_create_entity("TestTable", {})
body = json.loads(req.body)
primary = next(a for a in body["Attributes"] if a.get("IsPrimaryName"))
self.assertEqual(primary["SchemaName"], "new_Name")

# --- Column inclusion -------------------------------------------------

def test_columns_included_in_attributes(self):
"""_build_create_entity includes provided columns in Attributes."""
body = (
self._body.__func__(self, **{})
if False
else json.loads(self.od._build_create_entity("new_TestTable", {"new_Price": "decimal"}).body)
)
schemas = [a["SchemaName"] for a in body["Attributes"]]
self.assertIn("new_Price", schemas)

def test_unsupported_column_type_raises(self):
"""_build_create_entity raises ValidationError for unsupported column type."""
from PowerPlatform.Dataverse.core.errors import ValidationError

with self.assertRaises(ValidationError):
self.od._build_create_entity("new_TestTable", {"new_Bad": "unsupported_type"})


if __name__ == "__main__":
unittest.main()
1 change: 1 addition & 0 deletions tests/unit/test_client_deprecations.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ def test_create_table_warns(self):
{"new_Price": "decimal"},
"MySolution",
"new_ProductName",
None,
)
self.assertEqual(result["table_schema_name"], "new_Product")
self.assertEqual(result["columns_created"], ["new_Price"])
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/test_tables_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,34 @@ def test_create(self):
columns,
"MySolution",
"new_ProductName",
None,
)
self.assertIsInstance(result, TableInfo)
self.assertEqual(result.schema_name, "new_Product")
self.assertEqual(result["table_schema_name"], "new_Product")
self.assertEqual(result["entity_set_name"], "new_products")

def test_create_with_display_name(self):
"""create() should forward display_name to _create_table."""
raw = {
"table_schema_name": "new_Product",
"entity_set_name": "new_products",
"table_logical_name": "new_product",
"metadata_id": "meta-guid-1",
"columns_created": [],
}
self.client._odata._create_table.return_value = raw

self.client.tables.create("new_Product", {}, display_name="Product")

self.client._odata._create_table.assert_called_once_with(
"new_Product",
{},
None,
None,
"Product",
)

# ------------------------------------------------------------------ delete

def test_delete(self):
Expand Down
Loading