diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index b6cdf29a..d538025e 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -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: @@ -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) @@ -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"}, @@ -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"}, @@ -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"}, @@ -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"}, @@ -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"}, @@ -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"}, @@ -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"), @@ -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"}, @@ -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) @@ -2330,14 +2347,14 @@ def _build_create_entity( primary_column: Optional[str] = None, display_name: Optional[str] = None, ) -> _RawRequest: - """Build an EntityDefinitions POST request without sending it.""" + """Build an CreateEntities POST request without sending it.""" if primary_column: primary_attr = primary_column else: primary_attr = f"{table.split('_', 1)[0]}_Name" if "_" in table else "new_Name" - attributes = [self._attribute_payload(primary_attr, "string", is_primary_name=True)] + attributes = [self._attribute_payload(primary_attr, "string", is_primary_name=True, complex=True)] for col_name, dtype in columns.items(): - attr = self._attribute_payload(col_name, dtype) + attr = self._attribute_payload(col_name, dtype, complex=True) if not attr: raise ValidationError( f"Unsupported column type '{dtype}' for column '{col_name}'.", @@ -2349,18 +2366,22 @@ def _build_create_entity( 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(label), - "DisplayCollectionName": self._label(label + "s"), - "Description": self._label(f"Custom entity for {label}"), - "OwnershipType": "UserOwned", - "HasActivities": False, - "HasNotes": True, - "IsActivity": False, - "Attributes": attributes, + "Entities": [ + { + "@odata.type": "Microsoft.Dynamics.CRM.ComplexEntityMetadata", + "SchemaName": table, + "DisplayName": self._label(label), + "DisplayCollectionName": self._label(label + "s"), + "Description": self._label(f"Custom entity for {label}"), + "OwnershipType": "UserOwned", + "HasActivities": False, + "HasNotes": True, + "IsActivity": False, + "Attributes": attributes, + } + ] } - url = f"{self.api}/EntityDefinitions" + url = f"{self.api}/CreateEntities" if solution: url += f"?SolutionUniqueName={solution}" return _RawRequest( diff --git a/tests/unit/data/test_odata_internal.py b/tests/unit/data/test_odata_internal.py index f392ce20..5c9b95f6 100644 --- a/tests/unit/data/test_odata_internal.py +++ b/tests/unit/data/test_odata_internal.py @@ -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") @@ -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") @@ -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") @@ -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") @@ -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") @@ -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): @@ -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") @@ -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") @@ -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") @@ -1829,7 +1875,7 @@ 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): @@ -1837,7 +1883,7 @@ def test_display_name_defaults_to_schema_name(self): 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):