Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 40 additions & 23 deletions src/PowerPlatform/Dataverse/data/_odata.py
Original file line number Diff line number Diff line change
Expand Up @@ -1190,18 +1190,22 @@ def _create_entity(
attributes: List[Dict[str, Any]],
solution_unique_name: Optional[str] = None,
) -> Dict[str, Any]:
url = f"{self.api}/EntityDefinitions"
url = f"{self.api}/CreateEntities"
payload = {
"@odata.type": "Microsoft.Dynamics.CRM.EntityMetadata",
"SchemaName": table_schema_name,
"DisplayName": self._label(display_name),
"DisplayCollectionName": self._label(display_name + "s"),
"Description": self._label(f"Custom entity for {display_name}"),
"OwnershipType": "UserOwned",
"HasActivities": False,
"HasNotes": True,
"IsActivity": False,
"Attributes": attributes,
"Entities": [
{
"@odata.type": "Microsoft.Dynamics.CRM.ComplexEntityMetadata",
"SchemaName": table_schema_name,
"DisplayName": self._label(display_name),
"DisplayCollectionName": self._label(display_name + "s"),
"Description": self._label(f"Custom entity for {display_name}"),
"OwnershipType": "UserOwned",
"HasActivities": False,
"HasNotes": True,
"IsActivity": False,
"Attributes": attributes,
}
]
}
params = None
if solution_unique_name:
Expand Down Expand Up @@ -1577,8 +1581,20 @@ def _convert_labels_to_ints(self, table_schema_name: str, record: Dict[str, Any]
return resolved_record

def _attribute_payload(
self, column_schema_name: str, dtype: Any, *, is_primary_name: bool = False
self,
column_schema_name: str,
dtype: Any,
*,
is_primary_name: bool = False,
complex: bool = False,
) -> Optional[Dict[str, Any]]:
"""Build attribute metadata payload for a column.

:param complex: When ``True``, emit ``Complex*AttributeMetadata`` types
required by the ``CreateEntities`` action. When ``False`` (default),
emit the standard ``*AttributeMetadata`` types used by the
``EntityDefinitions/{id}/Attributes`` endpoint.
"""
# Enum-based local option set support
if isinstance(dtype, type) and issubclass(dtype, Enum):
return self._enum_optionset_payload(column_schema_name, dtype, is_primary_name=is_primary_name)
Comment on lines 1598 to 1600
Expand All @@ -1588,9 +1604,10 @@ def _attribute_payload(
)
dtype_l = dtype.lower().strip()
label = column_schema_name.split("_")[-1]
prefix = "Microsoft.Dynamics.CRM.Complex" if complex else "Microsoft.Dynamics.CRM."
if dtype_l in ("string", "text"):
return {
"@odata.type": "Microsoft.Dynamics.CRM.StringAttributeMetadata",
"@odata.type": f"{prefix}StringAttributeMetadata",
"SchemaName": column_schema_name,
"DisplayName": self._label(label),
"RequiredLevel": {"Value": "None"},
Expand All @@ -1600,7 +1617,7 @@ def _attribute_payload(
}
if dtype_l in ("memo", "multiline"):
return {
"@odata.type": "Microsoft.Dynamics.CRM.MemoAttributeMetadata",
"@odata.type": f"{prefix}MemoAttributeMetadata",
"SchemaName": column_schema_name,
"DisplayName": self._label(label),
"RequiredLevel": {"Value": "None"},
Expand All @@ -1610,7 +1627,7 @@ def _attribute_payload(
}
if dtype_l in ("int", "integer"):
return {
"@odata.type": "Microsoft.Dynamics.CRM.IntegerAttributeMetadata",
"@odata.type": f"{prefix}IntegerAttributeMetadata",
"SchemaName": column_schema_name,
"DisplayName": self._label(label),
"RequiredLevel": {"Value": "None"},
Expand All @@ -1620,7 +1637,7 @@ def _attribute_payload(
}
if dtype_l in ("decimal", "money"):
return {
"@odata.type": "Microsoft.Dynamics.CRM.DecimalAttributeMetadata",
"@odata.type": f"{prefix}DecimalAttributeMetadata",
"SchemaName": column_schema_name,
"DisplayName": self._label(label),
"RequiredLevel": {"Value": "None"},
Expand All @@ -1630,7 +1647,7 @@ def _attribute_payload(
}
if dtype_l in ("float", "double"):
return {
"@odata.type": "Microsoft.Dynamics.CRM.DoubleAttributeMetadata",
"@odata.type": f"{prefix}DoubleAttributeMetadata",
"SchemaName": column_schema_name,
"DisplayName": self._label(label),
"RequiredLevel": {"Value": "None"},
Expand All @@ -1640,7 +1657,7 @@ def _attribute_payload(
}
if dtype_l in ("datetime", "date"):
return {
"@odata.type": "Microsoft.Dynamics.CRM.DateTimeAttributeMetadata",
"@odata.type": f"{prefix}DateTimeAttributeMetadata",
"SchemaName": column_schema_name,
"DisplayName": self._label(label),
"RequiredLevel": {"Value": "None"},
Expand All @@ -1649,12 +1666,12 @@ def _attribute_payload(
}
if dtype_l in ("bool", "boolean"):
return {
"@odata.type": "Microsoft.Dynamics.CRM.BooleanAttributeMetadata",
"@odata.type": f"{prefix}BooleanAttributeMetadata",
"SchemaName": column_schema_name,
"DisplayName": self._label(label),
"RequiredLevel": {"Value": "None"},
"OptionSet": {
"@odata.type": "Microsoft.Dynamics.CRM.BooleanOptionSetMetadata",
"@odata.type": f"{prefix}BooleanOptionSetMetadata",
"TrueOption": {
"Value": 1,
"Label": self._label("True"),
Expand All @@ -1668,7 +1685,7 @@ def _attribute_payload(
}
if dtype_l == "file":
return {
"@odata.type": "Microsoft.Dynamics.CRM.FileAttributeMetadata",
"@odata.type": f"{prefix}FileAttributeMetadata",
"SchemaName": column_schema_name,
"DisplayName": self._label(label),
"RequiredLevel": {"Value": "None"},
Expand Down Expand Up @@ -1901,9 +1918,9 @@ def _create_table(
)

attributes: List[Dict[str, Any]] = []
attributes.append(self._attribute_payload(primary_attr_schema, "string", is_primary_name=True))
attributes.append(self._attribute_payload(primary_attr_schema, "string", is_primary_name=True, complex=True))
for col_name, dtype in schema.items():
payload = self._attribute_payload(col_name, dtype)
payload = self._attribute_payload(col_name, dtype, complex=True)
if not payload:
raise ValueError(f"Unsupported column type '{dtype}' for '{col_name}'.")
attributes.append(payload)
Expand Down
52 changes: 49 additions & 3 deletions tests/unit/data/test_odata_internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -1518,6 +1518,11 @@ def test_int_dtype(self):
result = self.od._attribute_payload("new_Count", "int")
self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.IntegerAttributeMetadata")

def test_complex_int_dtype(self):
"""'int' produces ComplexIntegerAttributeMetadata."""
result = self.od._attribute_payload("new_Count", "int", complex=True)
self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexIntegerAttributeMetadata")

def test_integer_dtype_alias(self):
"""'integer' is an alias for 'int'."""
result = self.od._attribute_payload("new_Count", "integer")
Expand All @@ -1528,6 +1533,11 @@ def test_decimal_dtype(self):
result = self.od._attribute_payload("new_Price", "decimal")
self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.DecimalAttributeMetadata")

def test_complex_decimal_dtype(self):
"""'decimal' produces ComplexDecimalAttributeMetadata."""
result = self.od._attribute_payload("new_Price", "decimal", complex=True)
self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexDecimalAttributeMetadata")

def test_money_dtype_alias(self):
"""'money' is an alias for 'decimal'."""
result = self.od._attribute_payload("new_Revenue", "money")
Expand All @@ -1538,6 +1548,11 @@ def test_float_dtype(self):
result = self.od._attribute_payload("new_Score", "float")
self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.DoubleAttributeMetadata")

def test_complex_float_dtype(self):
"""'float' produces ComplexDoubleAttributeMetadata."""
result = self.od._attribute_payload("new_Score", "float", complex=True)
self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexDoubleAttributeMetadata")

def test_double_dtype_alias(self):
"""'double' is an alias for 'float'."""
result = self.od._attribute_payload("new_Score", "double")
Expand All @@ -1548,6 +1563,11 @@ def test_datetime_dtype(self):
result = self.od._attribute_payload("new_CreatedDate", "datetime")
self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.DateTimeAttributeMetadata")

def test_complex_datetime_dtype(self):
"""'datetime' produces ComplexDateTimeAttributeMetadata."""
result = self.od._attribute_payload("new_CreatedDate", "datetime", complex=True)
self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexDateTimeAttributeMetadata")

def test_date_dtype_alias(self):
"""'date' is an alias for 'datetime'."""
result = self.od._attribute_payload("new_BirthDate", "date")
Expand All @@ -1558,6 +1578,11 @@ def test_bool_dtype(self):
result = self.od._attribute_payload("new_IsActive", "bool")
self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.BooleanAttributeMetadata")

def test_complex_bool_dtype(self):
"""'bool' produces ComplexBooleanAttributeMetadata."""
result = self.od._attribute_payload("new_IsActive", "bool", complex=True)
self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexBooleanAttributeMetadata")

def test_boolean_dtype_alias(self):
"""'boolean' is an alias for 'bool'."""
result = self.od._attribute_payload("new_IsActive", "boolean")
Expand All @@ -1568,6 +1593,11 @@ def test_file_dtype(self):
result = self.od._attribute_payload("new_Attachment", "file")
self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.FileAttributeMetadata")

def test_complex_file_dtype(self):
"""'file' produces ComplexFileAttributeMetadata."""
result = self.od._attribute_payload("new_Attachment", "file", complex=True)
self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexFileAttributeMetadata")

def test_non_string_dtype_raises_value_error(self):
"""Non-string dtype raises ValueError."""
with self.assertRaises(ValueError):
Expand All @@ -1582,6 +1612,15 @@ def test_memo_type(self):
self.assertEqual(result["FormatName"], {"Value": "Text"})
self.assertNotIn("IsPrimaryName", result)

def test_complex_memo_type(self):
"""'memo' produces ComplexMemoAttributeMetadata with MaxLength 4000."""
result = self.od._attribute_payload("new_Notes", "memo", complex=True)
self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexMemoAttributeMetadata")
self.assertEqual(result["SchemaName"], "new_Notes")
self.assertEqual(result["MaxLength"], 4000)
self.assertEqual(result["FormatName"], {"Value": "Text"})
self.assertNotIn("IsPrimaryName", result)

def test_multiline_alias(self):
"""'multiline' produces identical payload to 'memo'."""
memo_result = self.od._attribute_payload("new_Description", "memo")
Expand All @@ -1595,6 +1634,13 @@ def test_string_type_max_length(self):
self.assertEqual(result["MaxLength"], 200)
self.assertEqual(result["FormatName"], {"Value": "Text"})

def test_complex_string_type_max_length(self):
"""'string' produces ComplexStringAttributeMetadata with MaxLength 200."""
result = self.od._attribute_payload("new_Title", "string", complex=True)
self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexStringAttributeMetadata")
self.assertEqual(result["MaxLength"], 200)
self.assertEqual(result["FormatName"], {"Value": "Text"})

def test_unsupported_type_returns_none(self):
"""An unknown type string should return None."""
result = self.od._attribute_payload("new_Col", "unknown_type")
Expand Down Expand Up @@ -1819,7 +1865,7 @@ def test_primary_column_schema_name_used_when_provided(self):
self._setup_for_create()
self.od._create_table("new_TestTable", {}, primary_column_schema_name="new_CustomName")
post_json = self.od._request.call_args.kwargs["json"]
attrs = post_json["Attributes"]
attrs = post_json["Entities"][0]["Attributes"]
primary_attr = next((a for a in attrs if a.get("IsPrimaryName")), None)
self.assertIsNotNone(primary_attr)
self.assertEqual(primary_attr["SchemaName"], "new_CustomName")
Expand All @@ -1829,15 +1875,15 @@ def test_display_name_used_in_payload_when_provided(self):
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"]
label_value = post_json["Entities"][0]["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"]
label_value = post_json["Entities"][0]["DisplayName"]["LocalizedLabels"][0]["Label"]
self.assertEqual(label_value, "new_TestTable")

def test_display_name_empty_string_raises(self):
Expand Down
Loading