From fc83332d3308cced43a8ae8ea303ea6da8b67687 Mon Sep 17 00:00:00 2001 From: vrathee-msft Date: Thu, 14 May 2026 18:01:07 -0700 Subject: [PATCH 01/17] Move to Create Entities API for table creation --- src/PowerPlatform/Dataverse/data/_odata.py | 24 +++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index b6cdf29a..f4e872f5 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -1190,9 +1190,9 @@ def _create_entity( attributes: List[Dict[str, Any]], solution_unique_name: Optional[str] = None, ) -> Dict[str, Any]: - url = f"{self.api}/EntityDefinitions" - payload = { - "@odata.type": "Microsoft.Dynamics.CRM.EntityMetadata", + url = f"{self.api}/CreateEntities" + payload = {"Entities" :[{ + "@odata.type": "Microsoft.Dynamics.CRM.ComplexEntityMetadata", "SchemaName": table_schema_name, "DisplayName": self._label(display_name), "DisplayCollectionName": self._label(display_name + "s"), @@ -1202,7 +1202,7 @@ def _create_entity( "HasNotes": True, "IsActivity": False, "Attributes": attributes, - } + }]} params = None if solution_unique_name: params = {"SolutionUniqueName": solution_unique_name} @@ -1590,7 +1590,7 @@ def _attribute_payload( label = column_schema_name.split("_")[-1] if dtype_l in ("string", "text"): return { - "@odata.type": "Microsoft.Dynamics.CRM.StringAttributeMetadata", + "@odata.type": "Microsoft.Dynamics.CRM.ComplexStringAttributeMetadata", "SchemaName": column_schema_name, "DisplayName": self._label(label), "RequiredLevel": {"Value": "None"}, @@ -1600,7 +1600,7 @@ def _attribute_payload( } if dtype_l in ("memo", "multiline"): return { - "@odata.type": "Microsoft.Dynamics.CRM.MemoAttributeMetadata", + "@odata.type": "Microsoft.Dynamics.CRM.ComplexMemoAttributeMetadata", "SchemaName": column_schema_name, "DisplayName": self._label(label), "RequiredLevel": {"Value": "None"}, @@ -1610,7 +1610,7 @@ def _attribute_payload( } if dtype_l in ("int", "integer"): return { - "@odata.type": "Microsoft.Dynamics.CRM.IntegerAttributeMetadata", + "@odata.type": "Microsoft.Dynamics.CRM.ComplexIntegerAttributeMetadata", "SchemaName": column_schema_name, "DisplayName": self._label(label), "RequiredLevel": {"Value": "None"}, @@ -1620,7 +1620,7 @@ def _attribute_payload( } if dtype_l in ("decimal", "money"): return { - "@odata.type": "Microsoft.Dynamics.CRM.DecimalAttributeMetadata", + "@odata.type": "Microsoft.Dynamics.CRM.ComplexDecimalAttributeMetadata", "SchemaName": column_schema_name, "DisplayName": self._label(label), "RequiredLevel": {"Value": "None"}, @@ -1640,7 +1640,7 @@ def _attribute_payload( } if dtype_l in ("datetime", "date"): return { - "@odata.type": "Microsoft.Dynamics.CRM.DateTimeAttributeMetadata", + "@odata.type": "Microsoft.Dynamics.CRM.ComplexDateTimeAttributeMetadata", "SchemaName": column_schema_name, "DisplayName": self._label(label), "RequiredLevel": {"Value": "None"}, @@ -1649,12 +1649,12 @@ def _attribute_payload( } if dtype_l in ("bool", "boolean"): return { - "@odata.type": "Microsoft.Dynamics.CRM.BooleanAttributeMetadata", + "@odata.type": "Microsoft.Dynamics.CRM.ComplexBooleanAttributeMetadata", "SchemaName": column_schema_name, "DisplayName": self._label(label), "RequiredLevel": {"Value": "None"}, "OptionSet": { - "@odata.type": "Microsoft.Dynamics.CRM.BooleanOptionSetMetadata", + "@odata.type": "Microsoft.Dynamics.CRM.ComplexBooleanOptionSetMetadata", "TrueOption": { "Value": 1, "Label": self._label("True"), @@ -1668,7 +1668,7 @@ def _attribute_payload( } if dtype_l == "file": return { - "@odata.type": "Microsoft.Dynamics.CRM.FileAttributeMetadata", + "@odata.type": "Microsoft.Dynamics.CRM.ComplexFileAttributeMetadata", "SchemaName": column_schema_name, "DisplayName": self._label(label), "RequiredLevel": {"Value": "None"}, From 7244b0628992d55394eb527b5e34c2fb60e5e51d Mon Sep 17 00:00:00 2001 From: vrathee-msft Date: Thu, 14 May 2026 18:22:08 -0700 Subject: [PATCH 02/17] fix unit test after moving to CreateEntitiesAPI --- src/PowerPlatform/Dataverse/data/_odata.py | 2 +- tests/unit/data/test_odata_internal.py | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index f4e872f5..fabe08c6 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -1630,7 +1630,7 @@ def _attribute_payload( } if dtype_l in ("float", "double"): return { - "@odata.type": "Microsoft.Dynamics.CRM.DoubleAttributeMetadata", + "@odata.type": "Microsoft.Dynamics.CRM.ComplexDoubleAttributeMetadata", "SchemaName": column_schema_name, "DisplayName": self._label(label), "RequiredLevel": {"Value": "None"}, diff --git a/tests/unit/data/test_odata_internal.py b/tests/unit/data/test_odata_internal.py index f392ce20..42297f99 100644 --- a/tests/unit/data/test_odata_internal.py +++ b/tests/unit/data/test_odata_internal.py @@ -1516,7 +1516,7 @@ def setUp(self): def test_int_dtype(self): """'int' produces IntegerAttributeMetadata.""" result = self.od._attribute_payload("new_Count", "int") - self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.IntegerAttributeMetadata") + self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexIntegerAttributeMetadata") def test_integer_dtype_alias(self): """'integer' is an alias for 'int'.""" @@ -1526,7 +1526,7 @@ def test_integer_dtype_alias(self): def test_decimal_dtype(self): """'decimal' produces DecimalAttributeMetadata.""" result = self.od._attribute_payload("new_Price", "decimal") - self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.DecimalAttributeMetadata") + self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexDecimalAttributeMetadata") def test_money_dtype_alias(self): """'money' is an alias for 'decimal'.""" @@ -1536,7 +1536,7 @@ def test_money_dtype_alias(self): def test_float_dtype(self): """'float' produces DoubleAttributeMetadata.""" result = self.od._attribute_payload("new_Score", "float") - self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.DoubleAttributeMetadata") + self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexDoubleAttributeMetadata") def test_double_dtype_alias(self): """'double' is an alias for 'float'.""" @@ -1546,7 +1546,7 @@ def test_double_dtype_alias(self): def test_datetime_dtype(self): """'datetime' produces DateTimeAttributeMetadata.""" result = self.od._attribute_payload("new_CreatedDate", "datetime") - self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.DateTimeAttributeMetadata") + self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexDateTimeAttributeMetadata") def test_date_dtype_alias(self): """'date' is an alias for 'datetime'.""" @@ -1556,7 +1556,7 @@ def test_date_dtype_alias(self): def test_bool_dtype(self): """'bool' produces BooleanAttributeMetadata.""" result = self.od._attribute_payload("new_IsActive", "bool") - self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.BooleanAttributeMetadata") + self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexBooleanAttributeMetadata") def test_boolean_dtype_alias(self): """'boolean' is an alias for 'bool'.""" @@ -1566,7 +1566,7 @@ def test_boolean_dtype_alias(self): def test_file_dtype(self): """'file' produces FileAttributeMetadata.""" result = self.od._attribute_payload("new_Attachment", "file") - self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.FileAttributeMetadata") + self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexFileAttributeMetadata") def test_non_string_dtype_raises_value_error(self): """Non-string dtype raises ValueError.""" @@ -1576,7 +1576,7 @@ def test_non_string_dtype_raises_value_error(self): def test_memo_type(self): """'memo' produces MemoAttributeMetadata with MaxLength 4000.""" result = self.od._attribute_payload("new_Notes", "memo") - self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.MemoAttributeMetadata") + 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"}) @@ -1591,7 +1591,7 @@ def test_multiline_alias(self): def test_string_type_max_length(self): """'string' produces StringAttributeMetadata with MaxLength 200.""" result = self.od._attribute_payload("new_Title", "string") - self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.StringAttributeMetadata") + self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexStringAttributeMetadata") self.assertEqual(result["MaxLength"], 200) self.assertEqual(result["FormatName"], {"Value": "Text"}) @@ -1819,7 +1819,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 +1829,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 +1837,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): From 247463677417c77da5495b69331d2e1720e82085 Mon Sep 17 00:00:00 2001 From: vrathee-msft Date: Thu, 14 May 2026 18:28:16 -0700 Subject: [PATCH 03/17] fix formatting --- src/PowerPlatform/Dataverse/data/_odata.py | 28 ++++++++++++---------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index fabe08c6..f05171e9 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -1191,18 +1191,22 @@ def _create_entity( solution_unique_name: Optional[str] = None, ) -> Dict[str, Any]: url = f"{self.api}/CreateEntities" - payload = {"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, - }]} + payload = { + "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: params = {"SolutionUniqueName": solution_unique_name} From dc555dafa1cd72b9d5a1bf63e51af99c566bcba8 Mon Sep 17 00:00:00 2001 From: vrathee-msft Date: Fri, 15 May 2026 13:37:02 -0700 Subject: [PATCH 04/17] add optional support for complex types for createentties in attribute metadata payload method --- src/PowerPlatform/Dataverse/data/_odata.py | 39 +++++++++++++++------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index f05171e9..4287d3b5 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -1581,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) @@ -1592,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.ComplexStringAttributeMetadata", + "@odata.type": f"{prefix}StringAttributeMetadata", "SchemaName": column_schema_name, "DisplayName": self._label(label), "RequiredLevel": {"Value": "None"}, @@ -1604,7 +1617,7 @@ def _attribute_payload( } if dtype_l in ("memo", "multiline"): return { - "@odata.type": "Microsoft.Dynamics.CRM.ComplexMemoAttributeMetadata", + "@odata.type": f"{prefix}MemoAttributeMetadata", "SchemaName": column_schema_name, "DisplayName": self._label(label), "RequiredLevel": {"Value": "None"}, @@ -1614,7 +1627,7 @@ def _attribute_payload( } if dtype_l in ("int", "integer"): return { - "@odata.type": "Microsoft.Dynamics.CRM.ComplexIntegerAttributeMetadata", + "@odata.type": f"{prefix}IntegerAttributeMetadata", "SchemaName": column_schema_name, "DisplayName": self._label(label), "RequiredLevel": {"Value": "None"}, @@ -1624,7 +1637,7 @@ def _attribute_payload( } if dtype_l in ("decimal", "money"): return { - "@odata.type": "Microsoft.Dynamics.CRM.ComplexDecimalAttributeMetadata", + "@odata.type": f"{prefix}DecimalAttributeMetadata", "SchemaName": column_schema_name, "DisplayName": self._label(label), "RequiredLevel": {"Value": "None"}, @@ -1634,7 +1647,7 @@ def _attribute_payload( } if dtype_l in ("float", "double"): return { - "@odata.type": "Microsoft.Dynamics.CRM.ComplexDoubleAttributeMetadata", + "@odata.type": f"{prefix}DoubleAttributeMetadata", "SchemaName": column_schema_name, "DisplayName": self._label(label), "RequiredLevel": {"Value": "None"}, @@ -1644,7 +1657,7 @@ def _attribute_payload( } if dtype_l in ("datetime", "date"): return { - "@odata.type": "Microsoft.Dynamics.CRM.ComplexDateTimeAttributeMetadata", + "@odata.type": f"{prefix}DateTimeAttributeMetadata", "SchemaName": column_schema_name, "DisplayName": self._label(label), "RequiredLevel": {"Value": "None"}, @@ -1653,12 +1666,12 @@ def _attribute_payload( } if dtype_l in ("bool", "boolean"): return { - "@odata.type": "Microsoft.Dynamics.CRM.ComplexBooleanAttributeMetadata", + "@odata.type": f"{prefix}BooleanAttributeMetadata", "SchemaName": column_schema_name, "DisplayName": self._label(label), "RequiredLevel": {"Value": "None"}, "OptionSet": { - "@odata.type": "Microsoft.Dynamics.CRM.ComplexBooleanOptionSetMetadata", + "@odata.type": f"{prefix}BooleanOptionSetMetadata", "TrueOption": { "Value": 1, "Label": self._label("True"), @@ -1672,7 +1685,7 @@ def _attribute_payload( } if dtype_l == "file": return { - "@odata.type": "Microsoft.Dynamics.CRM.ComplexFileAttributeMetadata", + "@odata.type": f"{prefix}FileAttributeMetadata", "SchemaName": column_schema_name, "DisplayName": self._label(label), "RequiredLevel": {"Value": "None"}, @@ -1905,9 +1918,11 @@ 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) From 7b92195ccad5631034e88e5f58a84fca947115c3 Mon Sep 17 00:00:00 2001 From: vrathee-msft Date: Fri, 15 May 2026 13:52:54 -0700 Subject: [PATCH 05/17] fix for unit test --- tests/unit/data/test_odata_internal.py | 47 ++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/unit/data/test_odata_internal.py b/tests/unit/data/test_odata_internal.py index 42297f99..1d7ba84b 100644 --- a/tests/unit/data/test_odata_internal.py +++ b/tests/unit/data/test_odata_internal.py @@ -1516,6 +1516,11 @@ def setUp(self): def test_int_dtype(self): """'int' produces IntegerAttributeMetadata.""" 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 IntegerAttributeMetadata.""" + 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): @@ -1526,6 +1531,11 @@ def test_integer_dtype_alias(self): def test_decimal_dtype(self): """'decimal' produces DecimalAttributeMetadata.""" 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 DecimalAttributeMetadata.""" + 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): @@ -1536,6 +1546,11 @@ def test_money_dtype_alias(self): def test_float_dtype(self): """'float' produces DoubleAttributeMetadata.""" 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 DoubleAttributeMetadata.""" + 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): @@ -1546,8 +1561,14 @@ def test_double_dtype_alias(self): def test_datetime_dtype(self): """'datetime' produces DateTimeAttributeMetadata.""" 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 DateTimeAttributeMetadata.""" + 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") @@ -1556,6 +1577,11 @@ def test_date_dtype_alias(self): def test_bool_dtype(self): """'bool' produces BooleanAttributeMetadata.""" 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 BooleanAttributeMetadata.""" + 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): @@ -1566,6 +1592,11 @@ def test_boolean_dtype_alias(self): def test_file_dtype(self): """'file' produces FileAttributeMetadata.""" 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 FileAttributeMetadata.""" + 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): @@ -1576,6 +1607,15 @@ def test_non_string_dtype_raises_value_error(self): def test_memo_type(self): """'memo' produces MemoAttributeMetadata with MaxLength 4000.""" result = self.od._attribute_payload("new_Notes", "memo") + self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.MemoAttributeMetadata") + self.assertEqual(result["SchemaName"], "new_Notes") + self.assertEqual(result["MaxLength"], 4000) + self.assertEqual(result["FormatName"], {"Value": "Text"}) + self.assertNotIn("IsPrimaryName", result) + + def test_complex_memo_type(self): + """'memo' produces MemoAttributeMetadata 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) @@ -1591,6 +1631,13 @@ def test_multiline_alias(self): def test_string_type_max_length(self): """'string' produces StringAttributeMetadata with MaxLength 200.""" result = self.od._attribute_payload("new_Title", "string") + self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.StringAttributeMetadata") + self.assertEqual(result["MaxLength"], 200) + self.assertEqual(result["FormatName"], {"Value": "Text"}) + + def test_complex_string_type_max_length(self): + """'string' produces StringAttributeMetadata 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"}) From b391f0a5ceb4d5298d521273e0d9468fb8c473bb Mon Sep 17 00:00:00 2001 From: vrathee-msft Date: Fri, 15 May 2026 13:56:45 -0700 Subject: [PATCH 06/17] code comments fix --- tests/unit/data/test_odata_internal.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/unit/data/test_odata_internal.py b/tests/unit/data/test_odata_internal.py index 1d7ba84b..63e22416 100644 --- a/tests/unit/data/test_odata_internal.py +++ b/tests/unit/data/test_odata_internal.py @@ -1519,7 +1519,7 @@ def test_int_dtype(self): self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.IntegerAttributeMetadata") def test_complex_int_dtype(self): - """'int' produces IntegerAttributeMetadata.""" + """'int' produces ComplexIntegerAttributeMetadata.""" result = self.od._attribute_payload("new_Count", "int", complex=True) self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexIntegerAttributeMetadata") @@ -1534,7 +1534,7 @@ def test_decimal_dtype(self): self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.DecimalAttributeMetadata") def test_complex_decimal_dtype(self): - """'decimal' produces DecimalAttributeMetadata.""" + """'decimal' produces ComplexDecimalAttributeMetadata.""" result = self.od._attribute_payload("new_Price", "decimal", complex=True) self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexDecimalAttributeMetadata") @@ -1549,7 +1549,7 @@ def test_float_dtype(self): self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.DoubleAttributeMetadata") def test_complex_float_dtype(self): - """'float' produces DoubleAttributeMetadata.""" + """'float' produces ComplexDoubleAttributeMetadata.""" result = self.od._attribute_payload("new_Score", "float", complex=True) self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexDoubleAttributeMetadata") @@ -1564,7 +1564,7 @@ def test_datetime_dtype(self): self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.DateTimeAttributeMetadata") def test_complex_datetime_dtype(self): - """'datetime' produces DateTimeAttributeMetadata.""" + """'datetime' produces ComplexDateTimeAttributeMetadata.""" result = self.od._attribute_payload("new_CreatedDate", "datetime", complex=True) self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexDateTimeAttributeMetadata") @@ -1580,7 +1580,7 @@ def test_bool_dtype(self): self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.BooleanAttributeMetadata") def test_complex_bool_dtype(self): - """'bool' produces BooleanAttributeMetadata.""" + """'bool' produces ComplexBooleanAttributeMetadata.""" result = self.od._attribute_payload("new_IsActive", "bool", complex=True) self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexBooleanAttributeMetadata") @@ -1595,7 +1595,7 @@ def test_file_dtype(self): self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.FileAttributeMetadata") def test_complex_file_dtype(self): - """'file' produces FileAttributeMetadata.""" + """'file' produces ComplexFileAttributeMetadata.""" result = self.od._attribute_payload("new_Attachment", "file", complex=True) self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexFileAttributeMetadata") @@ -1614,7 +1614,7 @@ def test_memo_type(self): self.assertNotIn("IsPrimaryName", result) def test_complex_memo_type(self): - """'memo' produces MemoAttributeMetadata with MaxLength 4000.""" + """'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") @@ -1636,7 +1636,7 @@ def test_string_type_max_length(self): self.assertEqual(result["FormatName"], {"Value": "Text"}) def test_complex_string_type_max_length(self): - """'string' produces StringAttributeMetadata with MaxLength 200.""" + """'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) From ce13ba20a43a05a38aaa90802c349039c822b5d1 Mon Sep 17 00:00:00 2001 From: vrathee-msft Date: Fri, 15 May 2026 14:26:54 -0700 Subject: [PATCH 07/17] black formatter error --- src/PowerPlatform/Dataverse/data/_odata.py | 4 +--- tests/unit/data/test_odata_internal.py | 7 +++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index 4287d3b5..7da973e0 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -1918,9 +1918,7 @@ def _create_table( ) attributes: List[Dict[str, Any]] = [] - attributes.append( - self._attribute_payload(primary_attr_schema, "string", is_primary_name=True, complex=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, complex=True) if not payload: diff --git a/tests/unit/data/test_odata_internal.py b/tests/unit/data/test_odata_internal.py index 63e22416..5c9b95f6 100644 --- a/tests/unit/data/test_odata_internal.py +++ b/tests/unit/data/test_odata_internal.py @@ -1517,7 +1517,7 @@ def test_int_dtype(self): """'int' produces IntegerAttributeMetadata.""" 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) @@ -1568,7 +1568,6 @@ def test_complex_datetime_dtype(self): 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") @@ -1578,7 +1577,7 @@ def test_bool_dtype(self): """'bool' produces BooleanAttributeMetadata.""" 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) @@ -1634,7 +1633,7 @@ def test_string_type_max_length(self): self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.StringAttributeMetadata") 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) From 61a709541f73ffbaf567ed403f6c5e5d404d8f7b Mon Sep 17 00:00:00 2001 From: vrathee-msft Date: Mon, 18 May 2026 07:55:52 -0700 Subject: [PATCH 08/17] use createentities api for batch request --- src/PowerPlatform/Dataverse/data/_odata.py | 32 ++++++++++++---------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index 7da973e0..d538025e 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -2347,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}'.", @@ -2366,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( From 2d8fabbea0b710ddfcba4032854c1bac7203a19c Mon Sep 17 00:00:00 2001 From: vrathee-msft Date: Mon, 18 May 2026 14:24:32 -0700 Subject: [PATCH 09/17] fix unit test after moving to CreateEntitiesAPI --- tests/unit/data/test_odata_internal.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/unit/data/test_odata_internal.py b/tests/unit/data/test_odata_internal.py index 5c9b95f6..3a418b67 100644 --- a/tests/unit/data/test_odata_internal.py +++ b/tests/unit/data/test_odata_internal.py @@ -2901,7 +2901,7 @@ def setUp(self): def _body(self, **kwargs): req = self.od._build_create_entity("new_TestTable", {}, **kwargs) - return json.loads(req.body) + return json.loads(req.body)["Entities"][0] def test_display_name_used_in_payload_when_provided(self): """_build_create_entity uses the provided display_name in DisplayName.""" @@ -2941,9 +2941,9 @@ def test_returns_post_request(self): self.assertEqual(req.method, "POST") def test_url_targets_entity_definitions(self): - """_build_create_entity URL ends with /EntityDefinitions.""" + """_build_create_entity URL ends with /CreateEntities.""" req = self.od._build_create_entity("new_TestTable", {}) - self.assertTrue(req.url.endswith("/EntityDefinitions")) + self.assertTrue(req.url.endswith("/CreateEntities")) def test_solution_appended_to_url(self): """_build_create_entity appends SolutionUniqueName to URL when solution is given.""" @@ -2988,7 +2988,7 @@ def test_primary_column_derived_from_table_prefix(self): 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) + body = json.loads(req.body)["Entities"][0] attrs = body["Attributes"] primary = next(a for a in attrs if a.get("IsPrimaryName")) self.assertEqual(primary["SchemaName"], "new_CustomName") @@ -2996,7 +2996,7 @@ def test_primary_column_explicit(self): 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) + body = json.loads(req.body)["Entities"][0] primary = next(a for a in body["Attributes"] if a.get("IsPrimaryName")) self.assertEqual(primary["SchemaName"], "new_Name") @@ -3007,7 +3007,7 @@ def test_columns_included_in_attributes(self): body = ( self._body.__func__(self, **{}) if False - else json.loads(self.od._build_create_entity("new_TestTable", {"new_Price": "decimal"}).body) + else json.loads(self.od._build_create_entity("new_TestTable", {"new_Price": "decimal"}).body)["Entities"][0] ) schemas = [a["SchemaName"] for a in body["Attributes"]] self.assertIn("new_Price", schemas) From 2f419c5c28c1c5761df8040548249df5b07a5294 Mon Sep 17 00:00:00 2001 From: vrathee-msft Date: Mon, 18 May 2026 16:38:14 -0700 Subject: [PATCH 10/17] fixes for copilot reviews and add complex support for picklist attribute type --- src/PowerPlatform/Dataverse/data/_odata.py | 24 +++++++++++++++++----- tests/unit/data/test_odata_internal.py | 16 +++++++-------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index d538025e..0caf3880 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -1364,7 +1364,12 @@ def _build_localizedlabels_payload(self, translations: Dict[int, str]) -> Dict[s } def _enum_optionset_payload( - self, column_schema_name: str, enum_cls: type[Enum], is_primary_name: bool = False + self, + column_schema_name: str, + enum_cls: type[Enum], + is_primary_name: bool = False, + *, + complex: bool = False, ) -> Dict[str, Any]: """Create local (IsGlobal=False) PicklistAttributeMetadata from an Enum subclass. @@ -1375,7 +1380,14 @@ def _enum_optionset_payload( Keys inside per-language dict may be either enum member objects or their names. If a language lacks a label for a member, member.name is used as fallback. The client's configured language code is always ensured to exist. + + :param complex: When ``True``, emit ``ComplexPicklistAttributeMetadata`` / + ``ComplexOptionSetMetadata`` types required by the ``CreateEntities`` + action. When ``False`` (default), emit the standard types used by the + ``EntityDefinitions/{id}/Attributes`` endpoint. """ + prefix = "Microsoft.Dynamics.CRM.Complex" if complex else "Microsoft.Dynamics.CRM." + all_member_items = list(enum_cls.__members__.items()) if not all_member_items: raise ValueError(f"Enum {enum_cls.__name__} has no members") @@ -1436,7 +1448,7 @@ def _enum_optionset_payload( per_lang[lang] = label_text options.append( { - "@odata.type": "Microsoft.Dynamics.CRM.OptionMetadata", + "@odata.type": f"{prefix}.OptionMetadata", "Value": m.value, "Label": self._build_localizedlabels_payload(per_lang), } @@ -1444,13 +1456,13 @@ def _enum_optionset_payload( attr_label = column_schema_name.split("_")[-1] return { - "@odata.type": "Microsoft.Dynamics.CRM.PicklistAttributeMetadata", + "@odata.type": f"{prefix}PicklistAttributeMetadata", "SchemaName": column_schema_name, "DisplayName": self._label(attr_label), "RequiredLevel": {"Value": "None"}, "IsPrimaryName": bool(is_primary_name), "OptionSet": { - "@odata.type": "Microsoft.Dynamics.CRM.OptionSetMetadata", + "@odata.type": f"{prefix}OptionSetMetadata", "IsGlobal": False, "Options": options, }, @@ -1597,7 +1609,9 @@ def _attribute_payload( """ # 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) + return self._enum_optionset_payload( + column_schema_name, dtype, is_primary_name=is_primary_name, complex=complex + ) if not isinstance(dtype, str): raise ValueError( f"Unsupported column spec type for '{column_schema_name}': {type(dtype)} (expected str or Enum subclass)" diff --git a/tests/unit/data/test_odata_internal.py b/tests/unit/data/test_odata_internal.py index 3a418b67..f13588de 100644 --- a/tests/unit/data/test_odata_internal.py +++ b/tests/unit/data/test_odata_internal.py @@ -1519,7 +1519,7 @@ def test_int_dtype(self): self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.IntegerAttributeMetadata") def test_complex_int_dtype(self): - """'int' produces ComplexIntegerAttributeMetadata.""" + """'int' with complex=True produces ComplexIntegerAttributeMetadata.""" result = self.od._attribute_payload("new_Count", "int", complex=True) self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexIntegerAttributeMetadata") @@ -1534,7 +1534,7 @@ def test_decimal_dtype(self): self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.DecimalAttributeMetadata") def test_complex_decimal_dtype(self): - """'decimal' produces ComplexDecimalAttributeMetadata.""" + """'decimal' with complex=True produces ComplexDecimalAttributeMetadata.""" result = self.od._attribute_payload("new_Price", "decimal", complex=True) self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexDecimalAttributeMetadata") @@ -1549,7 +1549,7 @@ def test_float_dtype(self): self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.DoubleAttributeMetadata") def test_complex_float_dtype(self): - """'float' produces ComplexDoubleAttributeMetadata.""" + """'float' with complex=True produces ComplexDoubleAttributeMetadata.""" result = self.od._attribute_payload("new_Score", "float", complex=True) self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexDoubleAttributeMetadata") @@ -1564,7 +1564,7 @@ def test_datetime_dtype(self): self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.DateTimeAttributeMetadata") def test_complex_datetime_dtype(self): - """'datetime' produces ComplexDateTimeAttributeMetadata.""" + """'datetime' with complex=True produces ComplexDateTimeAttributeMetadata.""" result = self.od._attribute_payload("new_CreatedDate", "datetime", complex=True) self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexDateTimeAttributeMetadata") @@ -1579,7 +1579,7 @@ def test_bool_dtype(self): self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.BooleanAttributeMetadata") def test_complex_bool_dtype(self): - """'bool' produces ComplexBooleanAttributeMetadata.""" + """'bool' with complex=True produces ComplexBooleanAttributeMetadata.""" result = self.od._attribute_payload("new_IsActive", "bool", complex=True) self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexBooleanAttributeMetadata") @@ -1594,7 +1594,7 @@ def test_file_dtype(self): self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.FileAttributeMetadata") def test_complex_file_dtype(self): - """'file' produces ComplexFileAttributeMetadata.""" + """'file' with complex=True produces ComplexFileAttributeMetadata.""" result = self.od._attribute_payload("new_Attachment", "file", complex=True) self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.ComplexFileAttributeMetadata") @@ -1613,7 +1613,7 @@ def test_memo_type(self): self.assertNotIn("IsPrimaryName", result) def test_complex_memo_type(self): - """'memo' produces ComplexMemoAttributeMetadata with MaxLength 4000.""" + """'memo' with complex=True 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") @@ -1635,7 +1635,7 @@ def test_string_type_max_length(self): self.assertEqual(result["FormatName"], {"Value": "Text"}) def test_complex_string_type_max_length(self): - """'string' produces ComplexStringAttributeMetadata with MaxLength 200.""" + """'string' with complex=True 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) From 1b83f2984bdebaafe8ff482a2ab895ad0b8ea585 Mon Sep 17 00:00:00 2001 From: Vikas Rathee Date: Mon, 18 May 2026 22:20:31 -0700 Subject: [PATCH 11/17] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/unit/data/test_odata_internal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/data/test_odata_internal.py b/tests/unit/data/test_odata_internal.py index f13588de..ff738528 100644 --- a/tests/unit/data/test_odata_internal.py +++ b/tests/unit/data/test_odata_internal.py @@ -2940,7 +2940,7 @@ def test_returns_post_request(self): req = self.od._build_create_entity("new_TestTable", {}) self.assertEqual(req.method, "POST") - def test_url_targets_entity_definitions(self): + def test_url_targets_create_entities(self): """_build_create_entity URL ends with /CreateEntities.""" req = self.od._build_create_entity("new_TestTable", {}) self.assertTrue(req.url.endswith("/CreateEntities")) From 3eafafe40fade01e084e5d9f0d702e70fb35c8ac Mon Sep 17 00:00:00 2001 From: vrathee-msft Date: Tue, 19 May 2026 08:00:39 -0700 Subject: [PATCH 12/17] fix typo --- src/PowerPlatform/Dataverse/data/_odata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index 0caf3880..bdf4ee81 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -1448,7 +1448,7 @@ def _enum_optionset_payload( per_lang[lang] = label_text options.append( { - "@odata.type": f"{prefix}.OptionMetadata", + "@odata.type": f"{prefix}OptionMetadata", "Value": m.value, "Label": self._build_localizedlabels_payload(per_lang), } From 94f6d8ad63c91a8f4e88d096b3e4a0244f06b8fd Mon Sep 17 00:00:00 2001 From: vrathee-msft Date: Tue, 19 May 2026 19:44:52 -0700 Subject: [PATCH 13/17] missing import --- src/PowerPlatform/Dataverse/data/_odata.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index be005641..23abc0fd 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -19,6 +19,7 @@ from urllib.parse import quote as _url_quote from ..core._http import _HttpClient +from ..common.constants import CASCADE_BEHAVIOR_REMOVE_LINK from ._upload import _FileUploadMixin from ._relationships import _RelationshipOperationsMixin from ..core.errors import * From bc55de1a410159000e970b82e0ea34de658aef72 Mon Sep 17 00:00:00 2001 From: vrathee-msft Date: Tue, 19 May 2026 21:03:42 -0700 Subject: [PATCH 14/17] fix conflicts after refactoring --- src/PowerPlatform/Dataverse/data/_odata.py | 51 +++++----------------- 1 file changed, 11 insertions(+), 40 deletions(-) diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index 23abc0fd..d6d21719 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -13,6 +13,7 @@ import unicodedata import time import json +import re import warnings from datetime import datetime, timezone @@ -2164,47 +2165,17 @@ def _build_lookup_field_models( cascade_delete: str = CASCADE_BEHAVIOR_REMOVE_LINK, language_code: int = 1033, ) -> tuple: - """Build a (lookup, relationship) pair for a lookup field creation. - - Returns ``(LookupAttributeMetadata, OneToManyRelationshipMetadata)``. - Used by both the batch resolver and ``TableOperations.create_lookup_field`` - to avoid duplicating the metadata assembly logic. - - Note: ``referencing_table`` and ``referenced_table`` are lowercased - automatically because Dataverse stores entity logical names in - lowercase. ``lookup_field_name`` is kept as-is (it is a SchemaName). - """ - # Dataverse logical names are always lowercase. Callers may pass - # SchemaName-cased values (e.g. "new_SQLTeam"); normalise here so - # the relationship metadata uses valid logical names. - referencing_lower = referencing_table.lower() - referenced_lower = referenced_table.lower() - - lookup = LookupAttributeMetadata( - schema_name=lookup_field_name, - display_name=Label( - localized_labels=[ - LocalizedLabel( - label=display_name or referenced_table, - language_code=language_code, - ) - ] - ), - required_level="ApplicationRequired" if required else "None", - ) - if description: - lookup.description = Label( - localized_labels=[LocalizedLabel(label=description, language_code=language_code)] - ) - rel_name = f"{referenced_lower}_{referencing_lower}_{lookup_field_name}" - relationship = OneToManyRelationshipMetadata( - schema_name=rel_name, - referenced_entity=referenced_lower, - referencing_entity=referencing_lower, - referenced_attribute=f"{referenced_lower}id", - cascade_configuration=CascadeConfiguration(delete=cascade_delete), + """Delegate to the base implementation (kept here for subclass discoverability).""" + return _ODataBase._build_lookup_field_models( + referencing_table, + lookup_field_name, + referenced_table, + display_name=display_name, + description=description, + required=required, + cascade_delete=cascade_delete, + language_code=language_code, ) - return lookup, relationship def _build_create_relationship( self, From 2a01ed2897ee6f1b70c4301f108cc1faf8880f6e Mon Sep 17 00:00:00 2001 From: vrathee-msft Date: Tue, 19 May 2026 22:55:45 -0700 Subject: [PATCH 15/17] fix corrupted merge conflicts --- src/PowerPlatform/Dataverse/data/_odata.py | 436 ------------------ .../Dataverse/data/_odata_base.py | 89 ++-- 2 files changed, 60 insertions(+), 465 deletions(-) diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index d6d21719..bc92b973 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -13,14 +13,12 @@ import unicodedata import time import json -import re import warnings from datetime import datetime, timezone from urllib.parse import quote as _url_quote from ..core._http import _HttpClient -from ..common.constants import CASCADE_BEHAVIOR_REMOVE_LINK from ._upload import _FileUploadMixin from ._relationships import _RelationshipOperationsMixin from ..core.errors import * @@ -965,148 +963,6 @@ def _wait_for_attribute_visibility( f"after {total_wait} seconds (exhausted all retries)." ) from last_error - # ---------------------- Enum / Option Set helpers ------------------ - def _build_localizedlabels_payload(self, translations: Dict[int, str]) -> Dict[str, Any]: - """Build a Dataverse Label object from {: } entries. - - Ensures at least one localized label. Does not deduplicate language codes; last wins. - """ - locs: List[Dict[str, Any]] = [] - for lang, text in translations.items(): - if not isinstance(lang, int): - raise ValueError(f"Language code '{lang}' must be int") - if not isinstance(text, str) or not text.strip(): - raise ValueError(f"Label for lang {lang} must be non-empty string") - locs.append( - { - "@odata.type": "Microsoft.Dynamics.CRM.LocalizedLabel", - "Label": text, - "LanguageCode": lang, - } - ) - if not locs: - raise ValueError("At least one translation required") - return { - "@odata.type": "Microsoft.Dynamics.CRM.Label", - "LocalizedLabels": locs, - } - - def _enum_optionset_payload( - self, - column_schema_name: str, - enum_cls: type[Enum], - is_primary_name: bool = False, - *, - complex: bool = False, - ) -> Dict[str, Any]: - """Create local (IsGlobal=False) PicklistAttributeMetadata from an Enum subclass. - - Supports translation mapping via optional class attribute `__labels__`: - __labels__ = { 1033: { "Active": "Active", "Inactive": "Inactive" }, - 1036: { "Active": "Actif", "Inactive": "Inactif" } } - - Keys inside per-language dict may be either enum member objects or their names. - If a language lacks a label for a member, member.name is used as fallback. - The client's configured language code is always ensured to exist. - - :param complex: When ``True``, emit ``ComplexPicklistAttributeMetadata`` / - ``ComplexOptionSetMetadata`` types required by the ``CreateEntities`` - action. When ``False`` (default), emit the standard types used by the - ``EntityDefinitions/{id}/Attributes`` endpoint. - """ - prefix = "Microsoft.Dynamics.CRM.Complex" if complex else "Microsoft.Dynamics.CRM." - - all_member_items = list(enum_cls.__members__.items()) - if not all_member_items: - raise ValueError(f"Enum {enum_cls.__name__} has no members") - - # Duplicate detection - value_to_first_name: Dict[int, str] = {} - for name, member in all_member_items: - val = getattr(member, "value", None) - # Defer non-int validation to later loop for consistency - if val in value_to_first_name and value_to_first_name[val] != name: - raise ValueError( - f"Duplicate enum value {val} in {enum_cls.__name__} (names: {value_to_first_name[val]}, {name})" - ) - value_to_first_name[val] = name - - members = list(enum_cls) - # Validate integer values - for m in members: - if not isinstance(m.value, int): - raise ValueError(f"Enum member '{m.name}' has non-int value '{m.value}' (only int values supported)") - - raw_labels = getattr(enum_cls, "__labels__", None) - labels_by_lang: Dict[int, Dict[str, str]] = {} - if raw_labels is not None: - if not isinstance(raw_labels, dict): - raise ValueError("__labels__ must be a dict {lang:int -> {member: label}}") - # Build a helper map for value -> member name to resolve raw int keys - value_to_name = {m.value: m.name for m in members} - for lang, mapping in raw_labels.items(): - if not isinstance(lang, int): - raise ValueError("Language codes in __labels__ must be ints") - if not isinstance(mapping, dict): - raise ValueError(f"__labels__[{lang}] must be a dict of member names to strings") - labels_by_lang.setdefault(lang, {}) - for k, v in mapping.items(): - # Accept enum member object, its name, or raw int value (from class body reference) - if isinstance(k, enum_cls): - member_name = k.name - elif isinstance(k, int): - member_name = value_to_name.get(k) - if member_name is None: - raise ValueError(f"__labels__[{lang}] has int key {k} not matching any enum value") - else: - member_name = str(k) - if not isinstance(v, str) or not v.strip(): - raise ValueError(f"Label for {member_name} lang {lang} must be non-empty string") - labels_by_lang[lang][member_name] = v - - config_lang = int(self.config.language_code) - # Ensure config language appears (fallback to names) - all_langs = set(labels_by_lang.keys()) | {config_lang} - - options: List[Dict[str, Any]] = [] - for m in sorted(members, key=lambda x: x.value): - per_lang: Dict[int, str] = {} - for lang in all_langs: - label_text = labels_by_lang.get(lang, {}).get(m.name, m.name) - per_lang[lang] = label_text - options.append( - { - "@odata.type": f"{prefix}OptionMetadata", - "Value": m.value, - "Label": self._build_localizedlabels_payload(per_lang), - } - ) - - attr_label = column_schema_name.split("_")[-1] - return { - "@odata.type": f"{prefix}PicklistAttributeMetadata", - "SchemaName": column_schema_name, - "DisplayName": self._label(attr_label), - "RequiredLevel": {"Value": "None"}, - "IsPrimaryName": bool(is_primary_name), - "OptionSet": { - "@odata.type": f"{prefix}OptionSetMetadata", - "IsGlobal": False, - "Options": options, - }, - } - - def _normalize_picklist_label(self, label: str) -> str: - """Normalize a label for case / diacritic insensitive comparison.""" - if not isinstance(label, str): - return "" - # Strip accents - norm = unicodedata.normalize("NFD", label) - norm = "".join(c for c in norm if unicodedata.category(c) != "Mn") - # Collapse whitespace, lowercase - norm = re.sub(r"\s+", " ", norm).strip().lower() - return norm - def _request_metadata_with_retry(self, method: str, url: str, **kwargs): """Fetch metadata with retries on transient errors.""" max_attempts = 5 @@ -1220,120 +1076,6 @@ def _convert_labels_to_ints(self, table_schema_name: str, record: Dict[str, Any] resolved_record[k] = val return resolved_record - def _attribute_payload( - 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, complex=complex - ) - if not isinstance(dtype, str): - raise ValueError( - f"Unsupported column spec type for '{column_schema_name}': {type(dtype)} (expected str or Enum subclass)" - ) - 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": f"{prefix}StringAttributeMetadata", - "SchemaName": column_schema_name, - "DisplayName": self._label(label), - "RequiredLevel": {"Value": "None"}, - "MaxLength": 200, - "FormatName": {"Value": "Text"}, - "IsPrimaryName": bool(is_primary_name), - } - if dtype_l in ("memo", "multiline"): - return { - "@odata.type": f"{prefix}MemoAttributeMetadata", - "SchemaName": column_schema_name, - "DisplayName": self._label(label), - "RequiredLevel": {"Value": "None"}, - "MaxLength": 4000, - "FormatName": {"Value": "Text"}, - "ImeMode": "Auto", - } - if dtype_l in ("int", "integer"): - return { - "@odata.type": f"{prefix}IntegerAttributeMetadata", - "SchemaName": column_schema_name, - "DisplayName": self._label(label), - "RequiredLevel": {"Value": "None"}, - "Format": "None", - "MinValue": -2147483648, - "MaxValue": 2147483647, - } - if dtype_l in ("decimal", "money"): - return { - "@odata.type": f"{prefix}DecimalAttributeMetadata", - "SchemaName": column_schema_name, - "DisplayName": self._label(label), - "RequiredLevel": {"Value": "None"}, - "MinValue": -100000000000.0, - "MaxValue": 100000000000.0, - "Precision": 2, - } - if dtype_l in ("float", "double"): - return { - "@odata.type": f"{prefix}DoubleAttributeMetadata", - "SchemaName": column_schema_name, - "DisplayName": self._label(label), - "RequiredLevel": {"Value": "None"}, - "MinValue": -100000000000.0, - "MaxValue": 100000000000.0, - "Precision": 2, - } - if dtype_l in ("datetime", "date"): - return { - "@odata.type": f"{prefix}DateTimeAttributeMetadata", - "SchemaName": column_schema_name, - "DisplayName": self._label(label), - "RequiredLevel": {"Value": "None"}, - "Format": "DateOnly", - "ImeMode": "Inactive", - } - if dtype_l in ("bool", "boolean"): - return { - "@odata.type": f"{prefix}BooleanAttributeMetadata", - "SchemaName": column_schema_name, - "DisplayName": self._label(label), - "RequiredLevel": {"Value": "None"}, - "OptionSet": { - "@odata.type": f"{prefix}BooleanOptionSetMetadata", - "TrueOption": { - "Value": 1, - "Label": self._label("True"), - }, - "FalseOption": { - "Value": 0, - "Label": self._label("False"), - }, - "IsGlobal": False, - }, - } - if dtype_l == "file": - return { - "@odata.type": f"{prefix}FileAttributeMetadata", - "SchemaName": column_schema_name, - "DisplayName": self._label(label), - "RequiredLevel": {"Value": "None"}, - } - return None - def _get_table_info(self, table_schema_name: str) -> Optional[Dict[str, Any]]: """Return basic metadata for a custom table if it exists. @@ -2032,184 +1774,6 @@ def _build_list( headers = {"Prefer": ",".join(prefer_parts)} if prefer_parts else None return _RawRequest(method="GET", url=url, headers=headers) - def _build_create_entity( - self, - table: str, - columns: Dict[str, Any], - solution: Optional[str] = None, - primary_column: Optional[str] = None, - display_name: Optional[str] = None, - ) -> _RawRequest: - """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, complex=True)] - for col_name, dtype in columns.items(): - attr = self._attribute_payload(col_name, dtype, complex=True) - if not attr: - raise ValidationError( - f"Unsupported column type '{dtype}' for column '{col_name}'.", - 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 = { - "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}/CreateEntities" - if solution: - url += f"?SolutionUniqueName={solution}" - return _RawRequest( - method="POST", - url=url, - body=json.dumps(body, ensure_ascii=False), - ) - - def _build_delete_entity(self, metadata_id: str) -> _RawRequest: - """Build an EntityDefinitions DELETE request without sending it.""" - return _RawRequest( - method="DELETE", - url=f"{self.api}/EntityDefinitions({metadata_id})", - headers={"If-Match": "*"}, - ) - - def _build_get_entity(self, table: str) -> _RawRequest: - """Build an EntityDefinitions GET request without sending it.""" - logical = self._escape_odata_quotes(table.lower()) - return _RawRequest( - method="GET", - url=( - f"{self.api}/EntityDefinitions" - f"?$select=MetadataId,LogicalName,SchemaName,EntitySetName,PrimaryNameAttribute,PrimaryIdAttribute" - f"&$filter=LogicalName eq '{logical}'" - ), - ) - - def _build_list_entities( - self, - *, - filter: Optional[str] = None, - select: Optional[List[str]] = None, - ) -> _RawRequest: - """Build an EntityDefinitions list GET request without sending it.""" - base_filter = "IsPrivate eq false" - if filter: - combined_filter = f"{base_filter} and ({filter})" - else: - combined_filter = base_filter - url = f"{self.api}/EntityDefinitions?$filter={combined_filter}" - if select is not None and isinstance(select, str): - raise TypeError("select must be a list of property names, not a bare string") - if select: - url += "&$select=" + ",".join(select) - return _RawRequest(method="GET", url=url) - - def _build_create_column( - self, - entity_metadata_id: str, - col_name: str, - dtype: Any, - ) -> _RawRequest: - """Build an Attributes POST request for one column without sending it.""" - attr = self._attribute_payload(col_name, dtype) - if not attr: - raise ValidationError( - f"Unsupported column type '{dtype}' for column '{col_name}'.", - subcode=VALIDATION_UNSUPPORTED_COLUMN_TYPE, - ) - return _RawRequest( - method="POST", - url=f"{self.api}/EntityDefinitions({entity_metadata_id})/Attributes", - body=json.dumps(attr, ensure_ascii=False), - ) - - def _build_delete_column( - self, - entity_metadata_id: str, - col_metadata_id: str, - ) -> _RawRequest: - """Build an Attributes DELETE request for one column without sending it.""" - return _RawRequest( - method="DELETE", - url=f"{self.api}/EntityDefinitions({entity_metadata_id})/Attributes({col_metadata_id})", - headers={"If-Match": "*"}, - ) - - @staticmethod - def _build_lookup_field_models( - referencing_table: str, - lookup_field_name: str, - referenced_table: str, - *, - display_name: Optional[str] = None, - description: Optional[str] = None, - required: bool = False, - cascade_delete: str = CASCADE_BEHAVIOR_REMOVE_LINK, - language_code: int = 1033, - ) -> tuple: - """Delegate to the base implementation (kept here for subclass discoverability).""" - return _ODataBase._build_lookup_field_models( - referencing_table, - lookup_field_name, - referenced_table, - display_name=display_name, - description=description, - required=required, - cascade_delete=cascade_delete, - language_code=language_code, - ) - - def _build_create_relationship( - self, - body: Dict[str, Any], - *, - solution: Optional[str] = None, - ) -> _RawRequest: - """Build a RelationshipDefinitions POST request without sending it.""" - headers: Dict[str, str] = {} - if solution: - headers["MSCRM.SolutionUniqueName"] = solution - return _RawRequest( - method="POST", - url=f"{self.api}/RelationshipDefinitions", - body=json.dumps(body, ensure_ascii=False), - headers=headers or None, - ) - - def _build_delete_relationship(self, relationship_id: str) -> _RawRequest: - """Build a RelationshipDefinitions DELETE request without sending it.""" - return _RawRequest( - method="DELETE", - url=f"{self.api}/RelationshipDefinitions({relationship_id})", - headers={"If-Match": "*"}, - ) - - def _build_get_relationship(self, schema_name: str) -> _RawRequest: - """Build a RelationshipDefinitions GET request without sending it.""" - escaped = self._escape_odata_quotes(schema_name) - return _RawRequest( - method="GET", - url=f"{self.api}/RelationshipDefinitions?$filter=SchemaName eq '{escaped}'", - ) - def _build_sql(self, sql: str) -> _RawRequest: """Build a SQL query GET request without sending it. diff --git a/src/PowerPlatform/Dataverse/data/_odata_base.py b/src/PowerPlatform/Dataverse/data/_odata_base.py index 34ff7c55..1b816b9e 100644 --- a/src/PowerPlatform/Dataverse/data/_odata_base.py +++ b/src/PowerPlatform/Dataverse/data/_odata_base.py @@ -330,7 +330,12 @@ def _build_localizedlabels_payload(self, translations: Dict[int, str]) -> Dict[s } def _enum_optionset_payload( - self, column_schema_name: str, enum_cls: type[Enum], is_primary_name: bool = False + self, + column_schema_name: str, + enum_cls: type[Enum], + is_primary_name: bool = False, + *, + complex: bool = False, ) -> Dict[str, Any]: """Create local (IsGlobal=False) PicklistAttributeMetadata from an Enum subclass. @@ -341,7 +346,14 @@ def _enum_optionset_payload( Keys inside per-language dict may be either enum member objects or their names. If a language lacks a label for a member, member.name is used as fallback. The client's configured language code is always ensured to exist. + + :param complex: When ``True``, emit ``ComplexPicklistAttributeMetadata`` / + ``ComplexOptionSetMetadata`` types required by the ``CreateEntities`` + action. When ``False`` (default), emit the standard types used by the + ``EntityDefinitions/{id}/Attributes`` endpoint. """ + prefix = "Microsoft.Dynamics.CRM.Complex" if complex else "Microsoft.Dynamics.CRM." + all_member_items = list(enum_cls.__members__.items()) if not all_member_items: raise ValueError(f"Enum {enum_cls.__name__} has no members") @@ -402,7 +414,7 @@ def _enum_optionset_payload( per_lang[lang] = label_text options.append( { - "@odata.type": "Microsoft.Dynamics.CRM.OptionMetadata", + "@odata.type": f"{prefix}OptionMetadata", "Value": m.value, "Label": self._build_localizedlabels_payload(per_lang), } @@ -410,33 +422,48 @@ def _enum_optionset_payload( attr_label = column_schema_name.split("_")[-1] return { - "@odata.type": "Microsoft.Dynamics.CRM.PicklistAttributeMetadata", + "@odata.type": f"{prefix}PicklistAttributeMetadata", "SchemaName": column_schema_name, "DisplayName": self._label(attr_label), "RequiredLevel": {"Value": "None"}, "IsPrimaryName": bool(is_primary_name), "OptionSet": { - "@odata.type": "Microsoft.Dynamics.CRM.OptionSetMetadata", + "@odata.type": f"{prefix}OptionSetMetadata", "IsGlobal": False, "Options": options, }, } 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) + return self._enum_optionset_payload( + column_schema_name, dtype, is_primary_name=is_primary_name, complex=complex + ) if not isinstance(dtype, str): raise ValueError( f"Unsupported column spec type for '{column_schema_name}': {type(dtype)} (expected str or Enum subclass)" ) 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"}, @@ -446,7 +473,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"}, @@ -456,7 +483,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"}, @@ -466,7 +493,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"}, @@ -476,7 +503,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"}, @@ -486,7 +513,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"}, @@ -495,12 +522,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"), @@ -514,7 +541,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"}, @@ -533,14 +560,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}'.", @@ -552,18 +579,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( From 389a47f37ed5148e5c7503ecca0677e365bacffb Mon Sep 17 00:00:00 2001 From: vrathee-msft Date: Wed, 20 May 2026 11:15:54 -0700 Subject: [PATCH 16/17] picklist attribute type fix --- src/PowerPlatform/Dataverse/data/_odata_base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/PowerPlatform/Dataverse/data/_odata_base.py b/src/PowerPlatform/Dataverse/data/_odata_base.py index 1b816b9e..700c5a42 100644 --- a/src/PowerPlatform/Dataverse/data/_odata_base.py +++ b/src/PowerPlatform/Dataverse/data/_odata_base.py @@ -414,7 +414,9 @@ def _enum_optionset_payload( per_lang[lang] = label_text options.append( { - "@odata.type": f"{prefix}OptionMetadata", + # OptionMetadata has no "Complex" variant in the EDM model - + # only the outer PicklistAttributeMetadata/OptionSetMetadata do. + "@odata.type": "Microsoft.Dynamics.CRM.OptionMetadata", "Value": m.value, "Label": self._build_localizedlabels_payload(per_lang), } From e52b5dd85ed3d1a732f47708ae46265331f30b03 Mon Sep 17 00:00:00 2001 From: vrathee-msft Date: Wed, 20 May 2026 12:24:47 -0700 Subject: [PATCH 17/17] add picklist attribute type table creation in functional tests --- examples/basic/functional_testing.py | 166 ++++++++++++++++++++++++++- 1 file changed, 164 insertions(+), 2 deletions(-) diff --git a/examples/basic/functional_testing.py b/examples/basic/functional_testing.py index ddebd362..e482f4a1 100644 --- a/examples/basic/functional_testing.py +++ b/examples/basic/functional_testing.py @@ -27,6 +27,7 @@ import sys import time +from enum import Enum from typing import Optional, Dict, Any from datetime import datetime @@ -909,7 +910,12 @@ def test_batch_all_operations(client: DataverseClient, table_info: Dict[str, Any pass -def cleanup_test_data(client: DataverseClient, table_info: Dict[str, Any], record_id: str) -> None: +def cleanup_test_data( + client: DataverseClient, + table_info: Dict[str, Any], + record_id: str, + picklist_table_schema_name: Optional[str] = None, +) -> None: """Clean up test data.""" print("\n-> Cleanup") print("=" * 50) @@ -979,6 +985,46 @@ def cleanup_test_data(client: DataverseClient, table_info: Dict[str, Any], recor else: print("Test table kept for future testing") + # --- Picklist test table cleanup --- + if picklist_table_schema_name: + picklist_cleanup = ( + input(f"Do you want to delete the picklist test table '{picklist_table_schema_name}'? (y/N): ") + .strip() + .lower() + ) + if picklist_cleanup in ["y", "yes"]: + for attempt in range(1, retries + 1): + try: + client.tables.delete(picklist_table_schema_name) + print(f"[OK] Picklist test table '{picklist_table_schema_name}' deleted successfully") + break + except HttpError as err: + status = getattr(err, "status_code", None) + if status == 404: + if _table_still_exists(client, picklist_table_schema_name): + if attempt < retries: + print( + f" Picklist table delete retry {attempt}/{retries} after metadata 404 ({err}). Waiting {delay_seconds}s..." + ) + time.sleep(delay_seconds) + continue + print(f"[WARN] Failed to delete picklist test table due to metadata delay: {err}") + break + print("[OK] Picklist test table deleted successfully (404 reported).") + break + if attempt < retries: + print( + f" Picklist table delete retry {attempt}/{retries} after error ({err}). Waiting {delay_seconds}s..." + ) + time.sleep(delay_seconds) + continue + print(f"[WARN] Failed to delete picklist test table: {err}") + except Exception as e: + print(f"[WARN] Failed to delete picklist test table: {e}") + break + else: + print("Picklist test table kept for future testing") + def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)): """Retry helper with exponential backoff for metadata propagation delays.""" @@ -1260,6 +1306,118 @@ def _get_or_create(schema, columns, label): print(f" [WARN] Could not delete {tbl}: {e}") +def test_picklist_table(client: DataverseClient) -> str: + """Create a table with a local picklist column and write/read records. + + Demonstrates: + - Defining a local OptionSet via an ``Enum`` subclass passed as the column ``dtype``. + - Optional multi-language labels via the ``__labels__`` class attribute. + - Writing records using either the enum member's integer value OR its label. + - Reading the integer value back, and the formatted label via + ``include_annotations="OData.Community.Display.V1.FormattedValue"``. + + Returns the schema name of the table so the caller can clean it up later. + """ + print("\n-> Picklist Column Test") + print("=" * 50) + + table_schema_name = "test_PicklistAttribute" + + # Define a local option set as an Enum. Optional __labels__ provides + # display labels per language code (1033 = English, 1036 = French). + class TaskStatus(Enum): + NotStarted = 1 + InProgress = 2 + Completed = 3 + Cancelled = 4 + + __labels__ = { + 1033: { + "NotStarted": "Not Started", + "InProgress": "In Progress", + "Completed": "Completed", + "Cancelled": "Cancelled", + }, + 1036: { + "NotStarted": "Non commencé", + "InProgress": "En cours", + "Completed": "Terminé", + "Cancelled": "Annulé", + }, + } + + record_id: Optional[str] = None + try: + # Drop any leftover table from a prior failed run so this example is idempotent. + try: + existing = client.tables.get(table_schema_name) + if existing: + print(f" Removing leftover '{table_schema_name}' from a previous run...") + client.tables.delete(table_schema_name) + except Exception: + pass + + print(f"Creating table '{table_schema_name}' with a picklist column 'test_status'...") + + client.tables.create( + table_schema_name, + primary_column="test_name", + columns={ + "test_status": TaskStatus, # Enum subclass => local picklist + "test_notes": "string", + }, + ) + + table_info = wait_for_table_metadata(client, table_schema_name) + print(f"[OK] Picklist table ready: entity_set='{table_info.get('entity_set_name')}'") + + # --- Insert one record using the enum's integer value --- + rec_by_int = { + "test_name": f"Picklist Int {datetime.now().strftime('%H:%M:%S')}", + "test_status": TaskStatus.InProgress.value, # integer 2 + "test_notes": "Created using TaskStatus.InProgress.value", + } + record_id = client.records.create(table_schema_name, rec_by_int) + print(f"[OK] Created record by int value: {record_id} (status={TaskStatus.InProgress.value})") + + # --- Insert another record using the picklist label (SDK resolves label -> int) --- + rec_by_label = { + "test_name": f"Picklist Label {datetime.now().strftime('%H:%M:%S')}", + "test_status": "Completed", # resolved via label cache to int 3 + "test_notes": "Created using label string 'Completed'", + } + record_id_2 = client.records.create(table_schema_name, rec_by_label) + print(f"[OK] Created record by label: {record_id_2} (status='Completed' -> 3)") + + # --- Read back including the FormattedValue annotation --- + annotation = "OData.Community.Display.V1.FormattedValue" + retrieved = client.records.retrieve( + table_schema_name, + record_id, + select=["test_name", "test_status"], + include_annotations=annotation, + ) + status_int = retrieved.get("test_status") + status_label = retrieved.get(f"test_status@{annotation}") + print(f" Retrieved: test_status={status_int}, formatted='{status_label}'") + assert status_int == TaskStatus.InProgress.value, f"expected {TaskStatus.InProgress.value}, got {status_int}" + + # --- List records, filtering by the picklist column --- + completed = client.records.list( + table_schema_name, + select=["test_name", "test_status"], + filter=f"test_status eq {TaskStatus.Completed.value}", + include_annotations=annotation, + ) + print(f"[OK] Query by picklist value found {len(completed)} 'Completed' record(s).") + + except HttpError as e: + print(f"[ERR] HTTP error during picklist test: {e}") + raise + + return table_schema_name + + def _table_still_exists(client: DataverseClient, table_schema_name: Optional[str]) -> bool: if not table_schema_name: return False @@ -1304,6 +1462,9 @@ def main(): # Test querying test_query_records(client, table_info) + # Test picklist (local OptionSet) column creation, write, read + picklist_table_info = test_picklist_table(client) + # Test relationships test_relationships(client) @@ -1321,13 +1482,14 @@ def main(): print("[OK] Record Creation: Success") print("[OK] Record Reading: Success") print("[OK] Record Querying: Success") + print("[OK] Picklist Column: Success") print("[OK] Relationship Operations: Success") print("[OK] SQL Encoding: Success") print("[OK] Batch Operations: Success") print("\nYour PowerPlatform Dataverse Client SDK is fully functional!") # Cleanup - cleanup_test_data(client, table_info, record_id) + cleanup_test_data(client, table_info, record_id, picklist_table_info) except KeyboardInterrupt: print("\n\n[WARN] Test interrupted by user")