From fa9dac0892a53077e4cb9b8e67c127c77470267b Mon Sep 17 00:00:00 2001 From: maksii <1761348+maksii@users.noreply.github.com> Date: Sat, 28 Feb 2026 14:24:29 +0200 Subject: [PATCH 1/6] Add extended table metadata retrieval and models for columns and option sets - Implemented methods to fetch detailed metadata for tables, including columns and relationships. - Introduced `ColumnMetadata`, `OptionItem`, and `OptionSetInfo` models to represent column and option set data structures. - Updated `get` method in `TableOperations` to support optional parameters for including columns and relationships in the response. - Enhanced tests to cover new functionality and ensure backward compatibility. This update improves the SDK's ability to interact with Dataverse metadata, providing richer data for developers. --- .claude/skills/dataverse-sdk-use/SKILL.md | 57 ++++ README.md | 34 +++ .../Dataverse/common/constants.py | 27 ++ src/PowerPlatform/Dataverse/data/_odata.py | 271 +++++++++++++++++ .../Dataverse/models/metadata.py | 205 +++++++++++++ .../Dataverse/operations/tables.py | 279 +++++++++++++++++- tests/unit/models/test_metadata.py | 181 ++++++++++++ tests/unit/test_tables_operations.py | 256 ++++++++++++++++ 8 files changed, 1306 insertions(+), 4 deletions(-) create mode 100644 src/PowerPlatform/Dataverse/models/metadata.py create mode 100644 tests/unit/models/test_metadata.py diff --git a/.claude/skills/dataverse-sdk-use/SKILL.md b/.claude/skills/dataverse-sdk-use/SKILL.md index c2010a70..6dc77897 100644 --- a/.claude/skills/dataverse-sdk-use/SKILL.md +++ b/.claude/skills/dataverse-sdk-use/SKILL.md @@ -223,6 +223,63 @@ for table in tables: print(table) ``` +#### Get Extended Table Metadata +```python +# Get table with column metadata +info = client.tables.get("account", include_columns=True) +for col in info["columns"]: + print(f"{col.logical_name} ({col.attribute_type})") + +# Get table with relationship metadata +info = client.tables.get("account", include_relationships=True) + +# Get specific entity properties +info = client.tables.get("account", select=["DisplayName", "Description"]) +``` + +#### List Columns +```python +from PowerPlatform.Dataverse.models.metadata import ColumnMetadata + +columns = client.tables.get_columns("account") +for col in columns: + print(f"{col.schema_name}: {col.attribute_type} (required: {col.required_level})") + +# Filter to specific column types (OData syntax, fully-qualified enum) +picklists = client.tables.get_columns( + "account", + filter="AttributeType eq Microsoft.Dynamics.CRM.AttributeTypeCode'Picklist'", +) +``` + +#### Get Single Column +```python +col = client.tables.get_column("account", "emailaddress1") +if col: + print(f"Type: {col.attribute_type}, Required: {col.required_level}") +``` + +#### Get Column Options (Picklist/Choice Values) +```python +from PowerPlatform.Dataverse.models.metadata import OptionSetInfo + +options = client.tables.get_column_options("account", "accountcategorycode") +if options: + for opt in options.options: + print(f" Value={opt.value}, Label={opt.label}") +``` + +#### List Table Relationships +```python +# All relationships +rels = client.tables.list_relationships("account") + +# Specific type: "one_to_many" / "1:N", "many_to_one" / "N:1", "many_to_many" / "N:N" +rels = client.tables.list_relationships("account", relationship_type="one_to_many") +for rel in rels: + print(f"{rel['SchemaName']}: {rel.get('ReferencingEntity')}") +``` + #### Delete Tables ```python client.tables.delete("new_Product") diff --git a/README.md b/README.md index a83dcf9b..f70c5e4a 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,40 @@ client.tables.remove_columns("new_Product", ["new_Category"]) client.tables.delete("new_Product") ``` +```python +# Get extended table metadata with columns +info = client.tables.get("account", include_columns=True) +for col in info["columns"]: + print(f"{col.logical_name} ({col.attribute_type})") + +# Get extended table metadata with relationships +info = client.tables.get("account", include_relationships=True) +for rel in info.get("one_to_many_relationships", []): + print(rel["SchemaName"]) + +# Get specific entity properties +info = client.tables.get("account", select=["DisplayName", "Description"]) + +# List all columns of a table +columns = client.tables.get_columns("account") +for col in columns: + print(f"{col.schema_name}: {col.attribute_type} (required: {col.required_level})") + +# Get a specific column's metadata +col = client.tables.get_column("account", "emailaddress1") +if col: + print(f"Type: {col.attribute_type}, Required: {col.required_level}") + +# Get picklist/choice column options +options = client.tables.get_column_options("account", "accountcategorycode") +if options: + for opt in options.options: + print(f" {opt.value}: {opt.label}") + +# List relationships for a table +rels = client.tables.list_relationships("account", relationship_type="one_to_many") +``` + > **Important**: All custom column names must include the customization prefix value (e.g., `"new_"`). > This ensures explicit, predictable naming and aligns with Dataverse metadata requirements. diff --git a/src/PowerPlatform/Dataverse/common/constants.py b/src/PowerPlatform/Dataverse/common/constants.py index c18a74f8..33724679 100644 --- a/src/PowerPlatform/Dataverse/common/constants.py +++ b/src/PowerPlatform/Dataverse/common/constants.py @@ -29,3 +29,30 @@ CASCADE_BEHAVIOR_RESTRICT = "Restrict" """Prevent the referenced table record from being deleted when referencing table records exist.""" + +# AttributeMetadata derived type OData identifiers +# Used when casting Attributes collection to a specific derived type in Web API URLs +ODATA_TYPE_PICKLIST_ATTRIBUTE = "Microsoft.Dynamics.CRM.PicklistAttributeMetadata" +ODATA_TYPE_BOOLEAN_ATTRIBUTE = "Microsoft.Dynamics.CRM.BooleanAttributeMetadata" +ODATA_TYPE_MULTISELECT_PICKLIST_ATTRIBUTE = "Microsoft.Dynamics.CRM.MultiSelectPicklistAttributeMetadata" +ODATA_TYPE_STRING_ATTRIBUTE = "Microsoft.Dynamics.CRM.StringAttributeMetadata" +ODATA_TYPE_INTEGER_ATTRIBUTE = "Microsoft.Dynamics.CRM.IntegerAttributeMetadata" +ODATA_TYPE_DECIMAL_ATTRIBUTE = "Microsoft.Dynamics.CRM.DecimalAttributeMetadata" +ODATA_TYPE_DOUBLE_ATTRIBUTE = "Microsoft.Dynamics.CRM.DoubleAttributeMetadata" +ODATA_TYPE_MONEY_ATTRIBUTE = "Microsoft.Dynamics.CRM.MoneyAttributeMetadata" +ODATA_TYPE_DATETIME_ATTRIBUTE = "Microsoft.Dynamics.CRM.DateTimeAttributeMetadata" +ODATA_TYPE_MEMO_ATTRIBUTE = "Microsoft.Dynamics.CRM.MemoAttributeMetadata" +ODATA_TYPE_FILE_ATTRIBUTE = "Microsoft.Dynamics.CRM.FileAttributeMetadata" + +# Attribute type code values returned in the AttributeType property of attribute metadata +ATTRIBUTE_TYPE_PICKLIST = "Picklist" +ATTRIBUTE_TYPE_BOOLEAN = "Boolean" +ATTRIBUTE_TYPE_STRING = "String" +ATTRIBUTE_TYPE_INTEGER = "Integer" +ATTRIBUTE_TYPE_DECIMAL = "Decimal" +ATTRIBUTE_TYPE_DOUBLE = "Double" +ATTRIBUTE_TYPE_MONEY = "Money" +ATTRIBUTE_TYPE_DATETIME = "DateTime" +ATTRIBUTE_TYPE_MEMO = "Memo" +ATTRIBUTE_TYPE_LOOKUP = "Lookup" +ATTRIBUTE_TYPE_UNIQUEIDENTIFIER = "Uniqueidentifier" diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index eb341f22..fe96308a 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -34,6 +34,11 @@ METADATA_COLUMN_NOT_FOUND, VALIDATION_UNSUPPORTED_CACHE_KIND, ) +from ..common.constants import ( + ODATA_TYPE_BOOLEAN_ATTRIBUTE, + ODATA_TYPE_MULTISELECT_PICKLIST_ATTRIBUTE, + ODATA_TYPE_PICKLIST_ATTRIBUTE, +) from .. import __version__ as _SDK_VERSION @@ -80,6 +85,15 @@ def build( class _ODataClient(_FileUploadMixin, _RelationshipOperationsMixin): """Dataverse Web API client: CRUD, SQL-over-API, and table metadata helpers.""" + _RELATIONSHIP_TYPE_MAP = { + "one_to_many": "/OneToManyRelationships", + "1:N": "/OneToManyRelationships", + "many_to_one": "/ManyToOneRelationships", + "N:1": "/ManyToOneRelationships", + "many_to_many": "/ManyToManyRelationships", + "N:N": "/ManyToManyRelationships", + } + @staticmethod def _escape_odata_quotes(value: str) -> str: """Escape single quotes for OData queries (by doubling them).""" @@ -1463,6 +1477,263 @@ def _list_tables( r = self._request("get", url, params=params) return r.json().get("value", []) + # ------------------------------------------------------------------------- + # Extended table metadata (columns, relationships, option sets) + # ------------------------------------------------------------------------- + + def _get_table_metadata( + self, + table_schema_name: str, + select: Optional[List[str]] = None, + include_attributes: bool = False, + include_one_to_many: bool = False, + include_many_to_one: bool = False, + include_many_to_many: bool = False, + ) -> Optional[Dict[str, Any]]: + """Retrieve rich table metadata using EntityDefinitions with optional $select and $expand. + + :param table_schema_name: Schema name of the table. + :type table_schema_name: ``str`` + :param select: Optional list of PascalCase property names to project. + :type select: ``list[str]`` or ``None`` + :param include_attributes: Expand ``Attributes`` collection. + :type include_attributes: ``bool`` + :param include_one_to_many: Expand ``OneToManyRelationships``. + :type include_one_to_many: ``bool`` + :param include_many_to_one: Expand ``ManyToOneRelationships``. + :type include_many_to_one: ``bool`` + :param include_many_to_many: Expand ``ManyToManyRelationships``. + :type include_many_to_many: ``bool`` + + :return: Raw entity metadata dict, or ``None`` if not found (404). + :rtype: ``dict[str, Any]`` | ``None`` + + :raises HttpError: If the request fails (non-404). + """ + logical_lower = table_schema_name.lower() + logical_escaped = self._escape_odata_quotes(logical_lower) + url = f"{self.api}/EntityDefinitions(LogicalName='{logical_escaped}')" + + params: Dict[str, str] = {} + 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: + base_fields = {"MetadataId", "LogicalName", "SchemaName", "EntitySetName"} + merged = list(base_fields | set(select)) + params["$select"] = ",".join(merged) + expand_parts: List[str] = [] + if include_attributes: + expand_parts.append("Attributes") + if include_one_to_many: + expand_parts.append("OneToManyRelationships") + if include_many_to_one: + expand_parts.append("ManyToOneRelationships") + if include_many_to_many: + expand_parts.append("ManyToManyRelationships") + if expand_parts: + params["$expand"] = ",".join(expand_parts) + + try: + r = self._request("get", url, params=params) + return r.json() + except HttpError as e: + if e.status_code == 404: + return None + raise + + def _get_table_columns( + self, + table_schema_name: str, + select: Optional[List[str]] = None, + filter: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Get all columns/attributes for a table using the Attributes collection endpoint. + + :param table_schema_name: Schema name of the table. + :type table_schema_name: ``str`` + :param select: Optional list of PascalCase attribute property names to project. + :type select: ``list[str]`` or ``None`` + :param filter: Optional OData $filter expression for attributes. + :type filter: ``str`` or ``None`` + + :return: List of raw attribute metadata dicts. + :rtype: ``list[dict[str, Any]]`` + + :raises HttpError: If the request fails. + """ + logical_lower = table_schema_name.lower() + logical_escaped = self._escape_odata_quotes(logical_lower) + url = f"{self.api}/EntityDefinitions(LogicalName='{logical_escaped}')/Attributes" + + params: Dict[str, str] = {} + 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: + params["$select"] = ",".join(select) + else: + params["$select"] = ",".join( + [ + "LogicalName", + "SchemaName", + "DisplayName", + "AttributeType", + "AttributeTypeName", + "IsCustomAttribute", + "IsPrimaryId", + "IsPrimaryName", + "RequiredLevel", + "IsValidForCreate", + "IsValidForUpdate", + "IsValidForRead", + "MetadataId", + ] + ) + if filter: + params["$filter"] = filter + + r = self._request("get", url, params=params) + return r.json().get("value", []) + + def _get_table_column( + self, + table_schema_name: str, + column_logical_name: str, + select: Optional[List[str]] = None, + ) -> Optional[Dict[str, Any]]: + """Get metadata for a single specific column using the alternate key pattern. + + :param table_schema_name: Schema name of the table. + :type table_schema_name: ``str`` + :param column_logical_name: Logical name of the column. + :type column_logical_name: ``str`` + :param select: Optional list of PascalCase attribute property names to project. + :type select: ``list[str]`` or ``None`` + + :return: Raw attribute metadata dict, or ``None`` if not found (404). + :rtype: ``dict[str, Any]`` | ``None`` + + :raises HttpError: If the request fails (non-404). + """ + table_lower = self._escape_odata_quotes(table_schema_name.lower()) + column_lower = self._escape_odata_quotes(column_logical_name.lower()) + url = f"{self.api}/EntityDefinitions(LogicalName='{table_lower}')/Attributes(LogicalName='{column_lower}')" + + params: Dict[str, str] = {} + 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: + params["$select"] = ",".join(select) + + try: + r = self._request("get", url, params=params) + return r.json() + except HttpError as e: + if e.status_code == 404: + return None + raise + + def _get_column_optionset( + self, + table_schema_name: str, + column_logical_name: str, + ) -> Optional[Dict[str, Any]]: + """Get the option set definition for a Picklist, MultiSelectPicklist, or Boolean column. + + :param table_schema_name: Schema name of the table. + :type table_schema_name: ``str`` + :param column_logical_name: Logical name of the column. + :type column_logical_name: ``str`` + + :return: Raw option set metadata dict, or ``None`` if not found or not an option-set column. + :rtype: ``dict[str, Any]`` | ``None`` + + :raises HttpError: If the request fails (non-400/404). + """ + table_lower = self._escape_odata_quotes(table_schema_name.lower()) + column_lower = self._escape_odata_quotes(column_logical_name.lower()) + base = f"{self.api}/EntityDefinitions(LogicalName='{table_lower}')/Attributes(LogicalName='{column_lower}')" + + params = {"$select": "LogicalName", "$expand": "OptionSet,GlobalOptionSet"} + + for cast_type in [ + ODATA_TYPE_PICKLIST_ATTRIBUTE, + ODATA_TYPE_BOOLEAN_ATTRIBUTE, + ODATA_TYPE_MULTISELECT_PICKLIST_ATTRIBUTE, + ]: + url = f"{base}/{cast_type}" + try: + r = self._request("get", url, params=params) + data = r.json() + option_set = data.get("OptionSet") + if option_set is None: + option_set = data.get("GlobalOptionSet") + if option_set is not None: + return option_set + except HttpError as e: + if e.status_code not in (400, 404): + raise + + return None + + def _list_table_relationships( + self, + table_schema_name: str, + relationship_type: Optional[str] = None, + select: Optional[List[str]] = None, + ) -> List[Dict[str, Any]]: + """List relationship metadata for a table, optionally filtered by type. + + :param table_schema_name: Schema name of the table. + :type table_schema_name: ``str`` + :param relationship_type: Optional filter (e.g. ``"one_to_many"``, ``"1:N"``, ``"N:1"``). + :type relationship_type: ``str`` or ``None`` + :param select: Optional list of PascalCase property names to project. + :type select: ``list[str]`` or ``None`` + + :return: List of raw relationship metadata dicts. + :rtype: ``list[dict[str, Any]]`` + + :raises ValueError: If ``relationship_type`` is invalid. + :raises HttpError: If the request fails. + """ + table_lower = self._escape_odata_quotes(table_schema_name.lower()) + base_url = f"{self.api}/EntityDefinitions(LogicalName='{table_lower}')" + + params: Dict[str, str] = {} + 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: + params["$select"] = ",".join(select) + + if relationship_type is not None: + sub_path = self._RELATIONSHIP_TYPE_MAP.get(relationship_type) + if sub_path is None: + raise ValueError( + f"Invalid relationship_type: {relationship_type!r}. " + f"Valid values: {list(self._RELATIONSHIP_TYPE_MAP.keys())} or None for all." + ) + url = f"{base_url}{sub_path}" + r = self._request("get", url, params=params) + results = r.json().get("value", []) + type_tag = sub_path.strip("/").replace("Relationships", "") + for item in results: + item["_relationship_type"] = type_tag + return results + + all_results: List[Dict[str, Any]] = [] + for sub_path, type_tag in [ + ("/OneToManyRelationships", "OneToMany"), + ("/ManyToOneRelationships", "ManyToOne"), + ("/ManyToManyRelationships", "ManyToMany"), + ]: + url = f"{base_url}{sub_path}" + r = self._request("get", url, params=params) + items = r.json().get("value", []) + for item in items: + item["_relationship_type"] = type_tag + all_results.extend(items) + return all_results + def _delete_table(self, table_schema_name: str) -> None: """Delete a table by schema name. diff --git a/src/PowerPlatform/Dataverse/models/metadata.py b/src/PowerPlatform/Dataverse/models/metadata.py new file mode 100644 index 00000000..5289abc9 --- /dev/null +++ b/src/PowerPlatform/Dataverse/models/metadata.py @@ -0,0 +1,205 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Metadata models for table column and option set definitions.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +__all__ = ["ColumnMetadata", "OptionItem", "OptionSetInfo"] + + +@dataclass +class ColumnMetadata: + """ + Metadata for a single table column (attribute). + + :param logical_name: Logical name of the column (e.g., ``"emailaddress1"``). + :type logical_name: :class:`str` + :param schema_name: Schema name of the column (e.g., ``"EMailAddress1"``). + :type schema_name: :class:`str` + :param display_name: Localized display name, or ``None`` if not available. + :type display_name: :class:`str` or None + :param attribute_type: Attribute type (e.g., ``"String"``, ``"Picklist"``). + :type attribute_type: :class:`str` + :param attribute_type_name: Attribute type name (e.g., ``"StringType"``). + :type attribute_type_name: :class:`str` or None + :param is_custom_attribute: Whether the column is custom. + :type is_custom_attribute: :class:`bool` + :param is_primary_id: Whether this is the primary ID column. + :type is_primary_id: :class:`bool` + :param is_primary_name: Whether this is the primary name column. + :type is_primary_name: :class:`bool` + :param required_level: Required level (e.g., ``"None"``, ``"SystemRequired"``). + :type required_level: :class:`str` or None + :param is_valid_for_create: Whether valid for create operations. + :type is_valid_for_create: :class:`bool` + :param is_valid_for_update: Whether valid for update operations. + :type is_valid_for_update: :class:`bool` + :param is_valid_for_read: Whether valid for read operations. + :type is_valid_for_read: :class:`bool` + :param metadata_id: GUID of the attribute metadata. + :type metadata_id: :class:`str` or None + """ + + logical_name: str = "" + schema_name: str = "" + display_name: Optional[str] = None + attribute_type: str = "" + attribute_type_name: Optional[str] = None + is_custom_attribute: bool = False + is_primary_id: bool = False + is_primary_name: bool = False + required_level: Optional[str] = None + is_valid_for_create: bool = False + is_valid_for_update: bool = False + is_valid_for_read: bool = False + metadata_id: Optional[str] = None + + @classmethod + def from_api_response(cls, data: Dict[str, Any]) -> ColumnMetadata: + """Create a ``ColumnMetadata`` from a raw Web API attribute response. + + :param data: Raw JSON dict from the Dataverse Web API. + :type data: :class:`dict` + :return: Parsed column metadata instance. + :rtype: :class:`ColumnMetadata` + """ + display_name = None + dn = data.get("DisplayName") + if isinstance(dn, dict): + ull = dn.get("UserLocalizedLabel") + if isinstance(ull, dict): + display_name = ull.get("Label") + + attribute_type_name = None + atn = data.get("AttributeTypeName") + if isinstance(atn, dict): + attribute_type_name = atn.get("Value") + + required_level = None + rl = data.get("RequiredLevel") + if isinstance(rl, dict): + required_level = rl.get("Value") + + return cls( + logical_name=data.get("LogicalName", ""), + schema_name=data.get("SchemaName", ""), + display_name=display_name, + attribute_type=data.get("AttributeType", ""), + attribute_type_name=attribute_type_name, + is_custom_attribute=data.get("IsCustomAttribute", False), + is_primary_id=data.get("IsPrimaryId", False), + is_primary_name=data.get("IsPrimaryName", False), + required_level=required_level, + is_valid_for_create=data.get("IsValidForCreate", False), + is_valid_for_update=data.get("IsValidForUpdate", False), + is_valid_for_read=data.get("IsValidForRead", False), + metadata_id=data.get("MetadataId"), + ) + + +@dataclass +class OptionItem: + """ + A single option/choice value in an option set. + + :param value: Numeric option value. + :type value: :class:`int` + :param label: Localized display text, or ``None`` if not available. + :type label: :class:`str` or None + """ + + value: int = 0 + label: Optional[str] = None + + @classmethod + def from_api_response(cls, data: Dict[str, Any]) -> OptionItem: + """Create an ``OptionItem`` from a raw Web API option response. + + :param data: Raw JSON dict for a single option/choice value. + :type data: :class:`dict` + :return: Parsed option item. + :rtype: :class:`OptionItem` + """ + label = None + lbl = data.get("Label") + if isinstance(lbl, dict): + ull = lbl.get("UserLocalizedLabel") + if isinstance(ull, dict): + label = ull.get("Label") + return cls(value=data.get("Value", 0), label=label) + + +@dataclass +class OptionSetInfo: + """ + Option set definition including all option values. + + .. note:: + For Boolean option sets, options are ordered as + ``[FalseOption, TrueOption]``. Use :attr:`OptionItem.value` to + distinguish rather than relying on list index. + + :param name: Option set name. + :type name: :class:`str` or None + :param display_name: Localized display name. + :type display_name: :class:`str` or None + :param is_global: Whether this is a global option set. + :type is_global: :class:`bool` + :param option_set_type: Type (e.g., ``"Picklist"`` or ``"Boolean"``). + :type option_set_type: :class:`str` or None + :param options: List of option items. + :type options: :class:`list` of :class:`OptionItem` + :param metadata_id: GUID of the option set metadata. + :type metadata_id: :class:`str` or None + """ + + name: Optional[str] = None + display_name: Optional[str] = None + is_global: bool = False + option_set_type: Optional[str] = None + options: List[OptionItem] = field(default_factory=list) + metadata_id: Optional[str] = None + + @classmethod + def from_api_response(cls, data: Dict[str, Any]) -> OptionSetInfo: + """Create an ``OptionSetInfo`` from a raw Web API option set response. + + Handles both picklist-style (``Options`` array) and boolean-style + (``TrueOption``/``FalseOption``) option sets. + + :param data: Raw JSON dict from the Dataverse Web API. + :type data: :class:`dict` + :return: Parsed option set info. + :rtype: :class:`OptionSetInfo` + """ + display_name = None + dn = data.get("DisplayName") + if isinstance(dn, dict): + ull = dn.get("UserLocalizedLabel") + if isinstance(ull, dict): + display_name = ull.get("Label") + + options: List[OptionItem] = [] + raw_options = data.get("Options") + if isinstance(raw_options, list): + options = [OptionItem.from_api_response(o) for o in raw_options] + else: + false_opt = data.get("FalseOption") + true_opt = data.get("TrueOption") + if isinstance(false_opt, dict): + options.append(OptionItem.from_api_response(false_opt)) + if isinstance(true_opt, dict): + options.append(OptionItem.from_api_response(true_opt)) + + return cls( + name=data.get("Name"), + display_name=display_name, + is_global=data.get("IsGlobal", False), + option_set_type=data.get("OptionSetType"), + options=options, + metadata_id=data.get("MetadataId"), + ) diff --git a/src/PowerPlatform/Dataverse/operations/tables.py b/src/PowerPlatform/Dataverse/operations/tables.py index 6d71d929..a95f0e5c 100644 --- a/src/PowerPlatform/Dataverse/operations/tables.py +++ b/src/PowerPlatform/Dataverse/operations/tables.py @@ -15,6 +15,7 @@ RelationshipInfo, ) from ..models.labels import Label, LocalizedLabel +from ..models.metadata import ColumnMetadata, OptionSetInfo from ..common.constants import CASCADE_BEHAVIOR_REMOVE_LINK if TYPE_CHECKING: @@ -155,27 +156,297 @@ def delete(self, table: str) -> None: # -------------------------------------------------------------------- get - def get(self, table: str) -> Optional[Dict[str, Any]]: - """Get basic metadata for a table if it exists. + def get( + self, + table: str, + *, + select: Optional[List[str]] = None, + include_columns: bool = False, + include_relationships: bool = False, + ) -> Optional[Dict[str, Any]]: + """Get basic or extended metadata for a table if it exists. + + When no extra parameters are passed, returns the same lightweight + result as before (backward compatible). Use optional parameters to + request richer metadata including columns and relationships. :param table: Schema name of the table (e.g. ``"new_MyTestTable"`` or ``"account"``). :type table: :class:`str` + :param select: Optional list of PascalCase EntityDefinition property + names to include (e.g. ``["DisplayName", "Description"]``). + :type select: :class:`list` of :class:`str` or None + :param include_columns: If ``True``, expands and returns all column + metadata as :class:`~PowerPlatform.Dataverse.models.metadata.ColumnMetadata` + instances in the ``columns`` key. + :type include_columns: :class:`bool` + :param include_relationships: If ``True``, expands and returns + ``one_to_many_relationships``, ``many_to_one_relationships``, and + ``many_to_many_relationships``. + :type include_relationships: :class:`bool` :return: Dictionary containing ``table_schema_name``, ``table_logical_name``, ``entity_set_name``, and ``metadata_id``. + When extended params are used, may also include ``columns``, + ``one_to_many_relationships``, ``many_to_one_relationships``, + ``many_to_many_relationships``, and any extra selected properties. Returns None if the table is not found. :rtype: :class:`dict` or None Example:: + # Basic usage (unchanged) info = client.tables.get("new_MyTestTable") if info: print(f"Logical name: {info['table_logical_name']}") - print(f"Entity set: {info['entity_set_name']}") + + # Extended with columns + info = client.tables.get("account", include_columns=True) + for col in info.get("columns", []): + print(f"{col.logical_name} ({col.attribute_type})") + + # Extended with relationships + info = client.tables.get("account", include_relationships=True) + """ + # When no extra parameters are passed, use the original lightweight lookup. + # This ensures backward compatibility -- existing callers get identical behavior. + if not include_columns and not include_relationships and select is None: + with self._client._scoped_odata() as od: + return od._get_table_info(table) + + # Extended metadata retrieval when any extra parameter is used + with self._client._scoped_odata() as od: + raw = od._get_table_metadata( + table, + select=select, + include_attributes=include_columns, + include_one_to_many=include_relationships, + include_many_to_one=include_relationships, + include_many_to_many=include_relationships, + ) + if raw is None: + return None + + # Build result dict starting with the standard 4 fields + result: Dict[str, Any] = { + "table_schema_name": raw.get("SchemaName", table), + "table_logical_name": raw.get("LogicalName"), + "entity_set_name": raw.get("EntitySetName"), + "metadata_id": raw.get("MetadataId"), + "columns_created": [], + } + + # Include any extra selected entity properties + if select: + for prop in select: + if prop not in ("SchemaName", "LogicalName", "EntitySetName", "MetadataId"): + result[prop] = raw.get(prop) + + # Convert expanded Attributes into ColumnMetadata instances + if include_columns and "Attributes" in raw: + result["columns"] = [ColumnMetadata.from_api_response(a) for a in raw["Attributes"]] + + # Include expanded relationship collections as raw dicts + if include_relationships: + if "OneToManyRelationships" in raw: + result["one_to_many_relationships"] = raw["OneToManyRelationships"] + if "ManyToOneRelationships" in raw: + result["many_to_one_relationships"] = raw["ManyToOneRelationships"] + if "ManyToManyRelationships" in raw: + result["many_to_many_relationships"] = raw["ManyToManyRelationships"] + + return result + + # -------------------------------------------------------------- get_columns + + def get_columns( + self, + table: str, + *, + select: Optional[List[str]] = None, + filter: Optional[str] = None, + ) -> List[ColumnMetadata]: + """Get column (attribute) metadata for a table. + + Returns a list of :class:`~PowerPlatform.Dataverse.models.metadata.ColumnMetadata` + instances representing each column in the table. + + :param table: Schema name of the table (e.g. ``"account"`` + or ``"new_MyTestTable"``). + :type table: :class:`str` + :param select: Optional list of attribute metadata property names to + include (PascalCase, e.g. ``["LogicalName", "AttributeType"]``). + When ``None``, returns a default set of useful properties. + :type select: :class:`list` of :class:`str` or None + :param filter: Optional OData ``$filter`` expression. Column names in + filter expressions must use PascalCase metadata property names. + + .. note:: + Enum values in filters must use fully-qualified type names, + e.g. ``"AttributeType eq Microsoft.Dynamics.CRM.AttributeTypeCode'Picklist'"``. + + .. note:: + The ``filter`` expression is passed directly to the Web API. + If constructing filters from external input, ensure values are + properly escaped (single quotes doubled) to avoid malformed queries. + + :type filter: :class:`str` or None + + :return: List of column metadata. + :rtype: :class:`list` of :class:`~PowerPlatform.Dataverse.models.metadata.ColumnMetadata` + + :raises ~PowerPlatform.Dataverse.core.errors.HttpError: + If the Web API request fails. + + Example:: + + # Get all columns + columns = client.tables.get_columns("account") + for col in columns: + print(f"{col.logical_name}: {col.attribute_type}") + + # Filter to only picklist columns + picklists = client.tables.get_columns( + "account", + filter="AttributeType eq Microsoft.Dynamics.CRM.AttributeTypeCode'Picklist'", + ) + """ + with self._client._scoped_odata() as od: + raw_list = od._get_table_columns(table, select=select, filter=filter) + return [ColumnMetadata.from_api_response(item) for item in raw_list] + + # --------------------------------------------------------------- get_column + + def get_column( + self, + table: str, + column: str, + *, + select: Optional[List[str]] = None, + ) -> Optional[ColumnMetadata]: + """Get metadata for a single column by logical name. + + :param table: Schema name of the table (e.g. ``"account"``). + :type table: :class:`str` + :param column: Logical name of the column (e.g. ``"emailaddress1"``). + :type column: :class:`str` + :param select: Optional list of attribute metadata property names to + include (PascalCase). When ``None``, returns all properties. + :type select: :class:`list` of :class:`str` or None + + :return: Column metadata, or ``None`` if the column is not found. + :rtype: :class:`~PowerPlatform.Dataverse.models.metadata.ColumnMetadata` + or None + + :raises ~PowerPlatform.Dataverse.core.errors.HttpError: + If the Web API request fails (other than 404). + + Example:: + + col = client.tables.get_column("account", "emailaddress1") + if col: + print(f"Type: {col.attribute_type}, Required: {col.required_level}") + """ + with self._client._scoped_odata() as od: + raw = od._get_table_column(table, column, select=select) + if raw is None: + return None + return ColumnMetadata.from_api_response(raw) + + # -------------------------------------------------------- get_column_options + + def get_column_options( + self, + table: str, + column: str, + ) -> Optional[OptionSetInfo]: + """Get option set values for a Picklist, MultiSelect, or Boolean column. + + This method retrieves the available choices for a column that uses an + option set. For Picklist and MultiSelect columns, the options are the + defined choice values. For Boolean columns, the result contains the + True and False option labels. + + :param table: Schema name of the table (e.g. ``"account"``). + :type table: :class:`str` + :param column: Logical name of the column (e.g. + ``"accountcategorycode"``). + :type column: :class:`str` + + :return: Option set information with available choices, or ``None`` if + the column is not a choice/boolean type. + :rtype: :class:`~PowerPlatform.Dataverse.models.metadata.OptionSetInfo` + or None + + :raises ~PowerPlatform.Dataverse.core.errors.HttpError: + If the Web API request fails (other than expected type mismatches). + + Example:: + + options = client.tables.get_column_options("account", "accountcategorycode") + if options: + for opt in options.options: + print(f" {opt.value}: {opt.label}") """ with self._client._scoped_odata() as od: - return od._get_table_info(table) + raw = od._get_column_optionset(table, column) + if raw is None: + return None + return OptionSetInfo.from_api_response(raw) + + # -------------------------------------------------------- list_relationships + + def list_relationships( + self, + table: str, + *, + relationship_type: Optional[str] = None, + select: Optional[List[str]] = None, + ) -> List[Dict[str, Any]]: + """List relationship metadata for a table. + + Returns relationship definitions from the Web API. Each dict in the + result has a ``_relationship_type`` key added by the SDK with value + ``"OneToMany"``, ``"ManyToOne"``, or ``"ManyToMany"`` to identify the + relationship category. + + :param table: Schema name of the table (e.g. ``"account"``). + :type table: :class:`str` + :param relationship_type: Filter by relationship type. Valid values: + ``"one_to_many"`` (or ``"1:N"``), ``"many_to_one"`` + (or ``"N:1"``), ``"many_to_many"`` (or ``"N:N"``), or + ``None`` (default) to return all types. + :type relationship_type: :class:`str` or None + :param select: Optional list of relationship property names to include + (PascalCase, e.g. ``["SchemaName", "ReferencedEntity"]``). + :type select: :class:`list` of :class:`str` or None + + :return: List of relationship metadata dictionaries from the Web API. + :rtype: :class:`list` of :class:`dict` + + :raises ValueError: If ``relationship_type`` is not a valid value. + :raises ~PowerPlatform.Dataverse.core.errors.HttpError: + If the Web API request fails. + + Example:: + + # All relationships + rels = client.tables.list_relationships("account") + + # Only one-to-many + rels = client.tables.list_relationships( + "account", + relationship_type="one_to_many", + ) + for rel in rels: + print(f"{rel['SchemaName']}: {rel.get('ReferencingEntity')}") + """ + with self._client._scoped_odata() as od: + return od._list_table_relationships( + table, + relationship_type=relationship_type, + select=select, + ) # ------------------------------------------------------------------- list diff --git a/tests/unit/models/test_metadata.py b/tests/unit/models/test_metadata.py new file mode 100644 index 00000000..0b128a59 --- /dev/null +++ b/tests/unit/models/test_metadata.py @@ -0,0 +1,181 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Tests for metadata models.""" + +from PowerPlatform.Dataverse.models.metadata import ( + ColumnMetadata, + OptionItem, + OptionSetInfo, +) + + +class TestColumnMetadata: + """Tests for ColumnMetadata.""" + + def test_from_api_response_full(self): + """Test full API response maps all 13 fields correctly.""" + data = { + "@odata.type": "#Microsoft.Dynamics.CRM.StringAttributeMetadata", + "LogicalName": "emailaddress1", + "SchemaName": "EMailAddress1", + "DisplayName": { + "UserLocalizedLabel": {"Label": "Email", "LanguageCode": 1033}, + }, + "AttributeType": "String", + "AttributeTypeName": {"Value": "StringType"}, + "IsCustomAttribute": False, + "IsPrimaryId": False, + "IsPrimaryName": False, + "RequiredLevel": {"Value": "None"}, + "IsValidForCreate": True, + "IsValidForUpdate": True, + "IsValidForRead": True, + "MetadataId": "def-456", + } + col = ColumnMetadata.from_api_response(data) + assert col.logical_name == "emailaddress1" + assert col.schema_name == "EMailAddress1" + assert col.display_name == "Email" + assert col.attribute_type == "String" + assert col.attribute_type_name == "StringType" + assert col.is_custom_attribute is False + assert col.is_primary_id is False + assert col.is_primary_name is False + assert col.required_level == "None" + assert col.is_valid_for_create is True + assert col.is_valid_for_update is True + assert col.is_valid_for_read is True + assert col.metadata_id == "def-456" + + def test_from_api_response_minimal(self): + """Test minimal dict with only LogicalName and SchemaName uses defaults.""" + data = {"LogicalName": "name", "SchemaName": "Name"} + col = ColumnMetadata.from_api_response(data) + assert col.logical_name == "name" + assert col.schema_name == "Name" + assert col.display_name is None + assert col.attribute_type == "" + assert col.attribute_type_name is None + assert col.is_custom_attribute is False + assert col.is_primary_id is False + assert col.is_primary_name is False + assert col.required_level is None + assert col.is_valid_for_create is False + assert col.is_valid_for_update is False + assert col.is_valid_for_read is False + assert col.metadata_id is None + + def test_display_name_nested_none(self): + """Test DisplayName exists but UserLocalizedLabel is None.""" + data = { + "LogicalName": "col", + "SchemaName": "Col", + "DisplayName": {"UserLocalizedLabel": None}, + } + col = ColumnMetadata.from_api_response(data) + assert col.display_name is None + + def test_display_name_missing_entirely(self): + """Test dict without DisplayName key.""" + data = {"LogicalName": "col", "SchemaName": "Col"} + col = ColumnMetadata.from_api_response(data) + assert col.display_name is None + + def test_required_level_extraction(self): + """Test RequiredLevel.Value is extracted correctly.""" + data = { + "LogicalName": "col", + "SchemaName": "Col", + "RequiredLevel": {"Value": "ApplicationRequired"}, + } + col = ColumnMetadata.from_api_response(data) + assert col.required_level == "ApplicationRequired" + + +class TestOptionItem: + """Tests for OptionItem.""" + + def test_from_api_response(self): + """Test option with Value and Label.""" + data = { + "Value": 1, + "Label": { + "UserLocalizedLabel": {"Label": "Preferred Customer", "LanguageCode": 1033}, + }, + } + opt = OptionItem.from_api_response(data) + assert opt.value == 1 + assert opt.label == "Preferred Customer" + + def test_from_api_response_no_label(self): + """Test option with Value but Label.UserLocalizedLabel is None.""" + data = {"Value": 2, "Label": {"UserLocalizedLabel": None}} + opt = OptionItem.from_api_response(data) + assert opt.value == 2 + assert opt.label is None + + +class TestOptionSetInfo: + """Tests for OptionSetInfo.""" + + def test_from_api_response_picklist(self): + """Test picklist-style OptionSet with Options array.""" + data = { + "Name": "account_accountcategorycode", + "DisplayName": {"UserLocalizedLabel": {"Label": "Category", "LanguageCode": 1033}}, + "IsGlobal": False, + "OptionSetType": "Picklist", + "Options": [ + {"Value": 1, "Label": {"UserLocalizedLabel": {"Label": "Preferred Customer"}}}, + {"Value": 2, "Label": {"UserLocalizedLabel": {"Label": "Standard"}}}, + ], + "MetadataId": "meta-guid", + } + opt_set = OptionSetInfo.from_api_response(data) + assert opt_set.option_set_type == "Picklist" + assert opt_set.name == "account_accountcategorycode" + assert opt_set.display_name == "Category" + assert opt_set.is_global is False + assert len(opt_set.options) == 2 + assert opt_set.options[0].value == 1 + assert opt_set.options[0].label == "Preferred Customer" + assert opt_set.options[1].value == 2 + assert opt_set.options[1].label == "Standard" + assert opt_set.metadata_id == "meta-guid" + + def test_from_api_response_boolean(self): + """Test boolean-style OptionSet with TrueOption and FalseOption.""" + data = { + "OptionSetType": "Boolean", + "TrueOption": {"Value": 1, "Label": {"UserLocalizedLabel": {"Label": "Do Not Allow"}}}, + "FalseOption": {"Value": 0, "Label": {"UserLocalizedLabel": {"Label": "Allow"}}}, + } + opt_set = OptionSetInfo.from_api_response(data) + assert opt_set.option_set_type == "Boolean" + assert len(opt_set.options) == 2 + values = [o.value for o in opt_set.options] + assert 0 in values + assert 1 in values + labels = {o.value: o.label for o in opt_set.options} + assert labels[0] == "Allow" + assert labels[1] == "Do Not Allow" + + def test_from_api_response_empty_options(self): + """Test OptionSet with empty Options array.""" + data = {"Options": [], "OptionSetType": "Picklist"} + opt_set = OptionSetInfo.from_api_response(data) + assert opt_set.options == [] + assert opt_set.option_set_type == "Picklist" + + def test_from_api_response_global_optionset(self): + """Test OptionSet with IsGlobal True.""" + data = { + "Name": "global_options", + "IsGlobal": True, + "Options": [], + "OptionSetType": "Picklist", + } + opt_set = OptionSetInfo.from_api_response(data) + assert opt_set.is_global is True + assert opt_set.name == "global_options" diff --git a/tests/unit/test_tables_operations.py b/tests/unit/test_tables_operations.py index c2d8bede..8644ad4b 100644 --- a/tests/unit/test_tables_operations.py +++ b/tests/unit/test_tables_operations.py @@ -7,6 +7,7 @@ from azure.core.credentials import TokenCredential from PowerPlatform.Dataverse.client import DataverseClient +from PowerPlatform.Dataverse.models.metadata import ColumnMetadata, OptionSetInfo from PowerPlatform.Dataverse.models.relationship import RelationshipInfo from PowerPlatform.Dataverse.operations.tables import TableOperations @@ -89,6 +90,261 @@ def test_get_returns_none(self): self.client._odata._get_table_info.assert_called_once_with("nonexistent_Table") self.assertIsNone(result) + def test_get_basic_unchanged(self): + """get() with no extra args should use _get_table_info (backward compatibility).""" + expected_info = { + "table_schema_name": "account", + "table_logical_name": "account", + "entity_set_name": "accounts", + "metadata_id": "meta-guid-1", + } + self.client._odata._get_table_info.return_value = expected_info + + result = self.client.tables.get("account") + + self.client._odata._get_table_info.assert_called_once_with("account") + self.client._odata._get_table_metadata.assert_not_called() + self.assertEqual(result, expected_info) + + def test_get_with_include_columns(self): + """get(include_columns=True) should call _get_table_metadata and return columns.""" + raw = { + "SchemaName": "Account", + "LogicalName": "account", + "EntitySetName": "accounts", + "MetadataId": "meta-guid", + "Attributes": [ + {"LogicalName": "name", "SchemaName": "Name", "AttributeType": "String"}, + ], + } + self.client._odata._get_table_metadata.return_value = raw + + result = self.client.tables.get("account", include_columns=True) + + self.client._odata._get_table_metadata.assert_called_once_with( + "account", + select=None, + include_attributes=True, + include_one_to_many=False, + include_many_to_one=False, + include_many_to_many=False, + ) + self.assertIn("columns", result) + self.assertEqual(len(result["columns"]), 1) + self.assertIsInstance(result["columns"][0], ColumnMetadata) + self.assertEqual(result["columns"][0].logical_name, "name") + self.assertEqual(result["columns"][0].attribute_type, "String") + + def test_get_with_include_relationships(self): + """get(include_relationships=True) should return relationship arrays.""" + raw = { + "SchemaName": "Account", + "LogicalName": "account", + "EntitySetName": "accounts", + "MetadataId": "meta-guid", + "OneToManyRelationships": [{"SchemaName": "account_tasks", "ReferencingEntity": "task"}], + "ManyToOneRelationships": [], + "ManyToManyRelationships": [], + } + self.client._odata._get_table_metadata.return_value = raw + + result = self.client.tables.get("account", include_relationships=True) + + self.client._odata._get_table_metadata.assert_called_once_with( + "account", + select=None, + include_attributes=False, + include_one_to_many=True, + include_many_to_one=True, + include_many_to_many=True, + ) + self.assertIn("one_to_many_relationships", result) + self.assertEqual(len(result["one_to_many_relationships"]), 1) + self.assertEqual(result["one_to_many_relationships"][0]["SchemaName"], "account_tasks") + self.assertIn("many_to_one_relationships", result) + self.assertIn("many_to_many_relationships", result) + + def test_get_with_select(self): + """get(select=[...]) should pass select and include extra properties in result.""" + raw = { + "SchemaName": "Account", + "LogicalName": "account", + "EntitySetName": "accounts", + "MetadataId": "meta-guid", + "DisplayName": {"UserLocalizedLabel": {"Label": "Account"}}, + "Description": {"UserLocalizedLabel": {"Label": "Business account"}}, + } + self.client._odata._get_table_metadata.return_value = raw + + result = self.client.tables.get("account", select=["DisplayName", "Description"]) + + self.client._odata._get_table_metadata.assert_called_once_with( + "account", + select=["DisplayName", "Description"], + include_attributes=False, + include_one_to_many=False, + include_many_to_one=False, + include_many_to_many=False, + ) + self.assertIn("DisplayName", result) + self.assertIn("Description", result) + + def test_get_extended_returns_none(self): + """get(include_columns=True) should return None when table not found.""" + self.client._odata._get_table_metadata.return_value = None + + result = self.client.tables.get("nonexistent", include_columns=True) + + self.assertIsNone(result) + + def test_get_columns(self): + """get_columns() should return list of ColumnMetadata.""" + raw_list = [ + {"LogicalName": "name", "SchemaName": "Name", "AttributeType": "String"}, + {"LogicalName": "emailaddress1", "SchemaName": "EMailAddress1", "AttributeType": "String"}, + ] + self.client._odata._get_table_columns.return_value = raw_list + + result = self.client.tables.get_columns("account") + + self.client._odata._get_table_columns.assert_called_once_with( + "account", + select=None, + filter=None, + ) + self.assertEqual(len(result), 2) + self.assertIsInstance(result[0], ColumnMetadata) + self.assertIsInstance(result[1], ColumnMetadata) + self.assertEqual(result[0].logical_name, "name") + self.assertEqual(result[1].logical_name, "emailaddress1") + + def test_get_columns_with_filter(self): + """get_columns(filter=...) should pass filter to _get_table_columns.""" + filter_expr = "AttributeType eq Microsoft.Dynamics.CRM.AttributeTypeCode'Picklist'" + self.client._odata._get_table_columns.return_value = [] + + self.client.tables.get_columns("account", filter=filter_expr) + + self.client._odata._get_table_columns.assert_called_once_with( + "account", + select=None, + filter=filter_expr, + ) + + def test_get_column_found(self): + """get_column() should return ColumnMetadata when column exists.""" + raw = {"LogicalName": "emailaddress1", "SchemaName": "EMailAddress1", "AttributeType": "String"} + self.client._odata._get_table_column.return_value = raw + + result = self.client.tables.get_column("account", "emailaddress1") + + self.client._odata._get_table_column.assert_called_once_with( + "account", + "emailaddress1", + select=None, + ) + self.assertIsInstance(result, ColumnMetadata) + self.assertEqual(result.logical_name, "emailaddress1") + + def test_get_column_not_found(self): + """get_column() should return None when column not found.""" + self.client._odata._get_table_column.return_value = None + + result = self.client.tables.get_column("account", "nonexistent_col") + + self.assertIsNone(result) + + def test_get_column_options_picklist(self): + """get_column_options() should return OptionSetInfo for picklist column.""" + raw_optionset = { + "Name": "account_accountcategorycode", + "OptionSetType": "Picklist", + "Options": [ + {"Value": 1, "Label": {"UserLocalizedLabel": {"Label": "Preferred Customer"}}}, + {"Value": 2, "Label": {"UserLocalizedLabel": {"Label": "Standard"}}}, + ], + } + self.client._odata._get_column_optionset.return_value = raw_optionset + + result = self.client.tables.get_column_options("account", "accountcategorycode") + + self.client._odata._get_column_optionset.assert_called_once_with("account", "accountcategorycode") + self.assertIsInstance(result, OptionSetInfo) + self.assertEqual(len(result.options), 2) + self.assertEqual(result.options[0].value, 1) + self.assertEqual(result.options[0].label, "Preferred Customer") + + def test_get_column_options_not_picklist(self): + """get_column_options() should return None for non-choice column.""" + self.client._odata._get_column_optionset.return_value = None + + result = self.client.tables.get_column_options("account", "name") + + self.assertIsNone(result) + + def test_list_relationships_all(self): + """list_relationships() with no type should return all relationship types.""" + expected = [ + {"SchemaName": "account_tasks", "_relationship_type": "OneToMany"}, + {"SchemaName": "account_primarycontact", "_relationship_type": "ManyToOne"}, + ] + self.client._odata._list_table_relationships.return_value = expected + + result = self.client.tables.list_relationships("account") + + self.client._odata._list_table_relationships.assert_called_once_with( + "account", + relationship_type=None, + select=None, + ) + self.assertEqual(result, expected) + + def test_list_relationships_filtered(self): + """list_relationships(relationship_type=...) should pass type filter.""" + expected = [{"SchemaName": "account_tasks", "_relationship_type": "OneToMany"}] + self.client._odata._list_table_relationships.return_value = expected + + result = self.client.tables.list_relationships("account", relationship_type="one_to_many") + + self.client._odata._list_table_relationships.assert_called_once_with( + "account", + relationship_type="one_to_many", + select=None, + ) + self.assertEqual(result, expected) + + def test_get_select_bare_string_raises(self): + """get() with select as bare string should raise TypeError.""" + self.client._odata._get_table_metadata.side_effect = TypeError( + "select must be a list of property names, not a bare string" + ) + with self.assertRaises(TypeError): + self.client.tables.get("account", select="DisplayName") + + def test_get_columns_select_bare_string_raises(self): + """get_columns() with select as bare string should raise TypeError.""" + self.client._odata._get_table_columns.side_effect = TypeError( + "select must be a list of property names, not a bare string" + ) + with self.assertRaises(TypeError): + self.client.tables.get_columns("account", select="LogicalName") + + def test_get_column_select_bare_string_raises(self): + """get_column() with select as bare string should raise TypeError.""" + self.client._odata._get_table_column.side_effect = TypeError( + "select must be a list of property names, not a bare string" + ) + with self.assertRaises(TypeError): + self.client.tables.get_column("account", "name", select="LogicalName") + + def test_list_relationships_select_bare_string_raises(self): + """list_relationships() should raise TypeError on bare string select.""" + self.client._odata._list_table_relationships.side_effect = TypeError( + "select must be a list of property names, not a bare string" + ) + with self.assertRaises(TypeError): + self.client.tables.list_relationships("account", select="SchemaName") + # ------------------------------------------------------------------- list def test_list(self): From f3c3efb68f68b0c5f63ccff042c7a48ecfc8f523 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:25:56 +0000 Subject: [PATCH 2/6] Initial plan From 92ca6bd53e2e1f0cc8608e8436b402ca030da5cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:30:25 +0000 Subject: [PATCH 3/6] Fix get() result: remove columns_created, reduce cyclomatic complexity Co-authored-by: maksii <1761348+maksii@users.noreply.github.com> --- src/PowerPlatform/Dataverse/operations/tables.py | 14 +++++++------- tests/unit/test_tables_operations.py | 1 + 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/PowerPlatform/Dataverse/operations/tables.py b/src/PowerPlatform/Dataverse/operations/tables.py index a95f0e5c..ec2093f3 100644 --- a/src/PowerPlatform/Dataverse/operations/tables.py +++ b/src/PowerPlatform/Dataverse/operations/tables.py @@ -233,7 +233,6 @@ def get( "table_logical_name": raw.get("LogicalName"), "entity_set_name": raw.get("EntitySetName"), "metadata_id": raw.get("MetadataId"), - "columns_created": [], } # Include any extra selected entity properties @@ -248,12 +247,13 @@ def get( # Include expanded relationship collections as raw dicts if include_relationships: - if "OneToManyRelationships" in raw: - result["one_to_many_relationships"] = raw["OneToManyRelationships"] - if "ManyToOneRelationships" in raw: - result["many_to_one_relationships"] = raw["ManyToOneRelationships"] - if "ManyToManyRelationships" in raw: - result["many_to_many_relationships"] = raw["ManyToManyRelationships"] + for raw_key, result_key in ( + ("OneToManyRelationships", "one_to_many_relationships"), + ("ManyToOneRelationships", "many_to_one_relationships"), + ("ManyToManyRelationships", "many_to_many_relationships"), + ): + if raw_key in raw: + result[result_key] = raw[raw_key] return result diff --git a/tests/unit/test_tables_operations.py b/tests/unit/test_tables_operations.py index 8644ad4b..8070a438 100644 --- a/tests/unit/test_tables_operations.py +++ b/tests/unit/test_tables_operations.py @@ -130,6 +130,7 @@ def test_get_with_include_columns(self): include_many_to_many=False, ) self.assertIn("columns", result) + self.assertNotIn("columns_created", result) self.assertEqual(len(result["columns"]), 1) self.assertIsInstance(result["columns"][0], ColumnMetadata) self.assertEqual(result["columns"][0].logical_name, "name") From 53220837a54f0b5c2c4aa3d77810a882591c87c2 Mon Sep 17 00:00:00 2001 From: maksii <1761348+maksii@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:26:14 +0200 Subject: [PATCH 4/6] Add test data fixtures and enhance unit tests for metadata operations - Introduced new test data fixtures in `tests/fixtures/test_data.py` for various metadata attributes, including columns, option sets, and relationships. - Updated `tests/unit/models/test_metadata.py` to utilize the new fixtures for testing `ColumnMetadata` and `OptionSetInfo` classes, improving test coverage for primary name columns, picklist columns, and status/state option sets. - Enhanced `tests/unit/test_tables_operations.py` to incorporate fixtures for table operations, ensuring accurate testing of column retrieval, relationship listing, and table metadata. - Refactored existing tests to replace hardcoded data with structured test data from the new fixtures, promoting maintainability and clarity in test cases. --- .../Dataverse/common/constants.py | 2 + src/PowerPlatform/Dataverse/data/_odata.py | 4 + tests/fixtures/__init__.py | 2 + tests/fixtures/test_data.py | 534 +++++++++++++++++- tests/unit/models/test_metadata.py | 158 ++++-- tests/unit/test_tables_operations.py | 204 +++++-- 6 files changed, 804 insertions(+), 100 deletions(-) create mode 100644 tests/fixtures/__init__.py diff --git a/src/PowerPlatform/Dataverse/common/constants.py b/src/PowerPlatform/Dataverse/common/constants.py index 33724679..552da5a7 100644 --- a/src/PowerPlatform/Dataverse/common/constants.py +++ b/src/PowerPlatform/Dataverse/common/constants.py @@ -35,6 +35,8 @@ ODATA_TYPE_PICKLIST_ATTRIBUTE = "Microsoft.Dynamics.CRM.PicklistAttributeMetadata" ODATA_TYPE_BOOLEAN_ATTRIBUTE = "Microsoft.Dynamics.CRM.BooleanAttributeMetadata" ODATA_TYPE_MULTISELECT_PICKLIST_ATTRIBUTE = "Microsoft.Dynamics.CRM.MultiSelectPicklistAttributeMetadata" +ODATA_TYPE_STATUS_ATTRIBUTE = "Microsoft.Dynamics.CRM.StatusAttributeMetadata" +ODATA_TYPE_STATE_ATTRIBUTE = "Microsoft.Dynamics.CRM.StateAttributeMetadata" ODATA_TYPE_STRING_ATTRIBUTE = "Microsoft.Dynamics.CRM.StringAttributeMetadata" ODATA_TYPE_INTEGER_ATTRIBUTE = "Microsoft.Dynamics.CRM.IntegerAttributeMetadata" ODATA_TYPE_DECIMAL_ATTRIBUTE = "Microsoft.Dynamics.CRM.DecimalAttributeMetadata" diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index fe96308a..ca1ba5ac 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -38,6 +38,8 @@ ODATA_TYPE_BOOLEAN_ATTRIBUTE, ODATA_TYPE_MULTISELECT_PICKLIST_ATTRIBUTE, ODATA_TYPE_PICKLIST_ATTRIBUTE, + ODATA_TYPE_STATE_ATTRIBUTE, + ODATA_TYPE_STATUS_ATTRIBUTE, ) from .. import __version__ as _SDK_VERSION @@ -1659,6 +1661,8 @@ def _get_column_optionset( ODATA_TYPE_PICKLIST_ATTRIBUTE, ODATA_TYPE_BOOLEAN_ATTRIBUTE, ODATA_TYPE_MULTISELECT_PICKLIST_ATTRIBUTE, + ODATA_TYPE_STATUS_ATTRIBUTE, + ODATA_TYPE_STATE_ATTRIBUTE, ]: url = f"{base}/{cast_type}" try: diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 00000000..9a045456 --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. diff --git a/tests/fixtures/test_data.py b/tests/fixtures/test_data.py index 20e2f17a..f222d966 100644 --- a/tests/fixtures/test_data.py +++ b/tests/fixtures/test_data.py @@ -12,16 +12,80 @@ SAMPLE_ENTITY_METADATA = { "value": [ { + "MetadataId": "70816501-edb9-4740-a16c-6a5efbc05d84", "LogicalName": "account", + "SchemaName": "Account", "EntitySetName": "accounts", "PrimaryIdAttribute": "accountid", - "DisplayName": {"UserLocalizedLabel": {"Label": "Account"}}, + "DisplayName": { + "LocalizedLabels": [ + { + "Label": "Account", + "LanguageCode": 1033, + "IsManaged": True, + "MetadataId": "2a4901bf-2241-db11-898a-0007e9e17ebd", + }, + ], + "UserLocalizedLabel": { + "Label": "Account", + "LanguageCode": 1033, + "IsManaged": True, + "MetadataId": "2a4901bf-2241-db11-898a-0007e9e17ebd", + }, + }, + "Description": { + "LocalizedLabels": [ + { + "Label": "Business that represents a customer or potential customer. The company that is billed in business transactions.", + "LanguageCode": 1033, + "IsManaged": True, + "MetadataId": "294901bf-2241-db11-898a-0007e9e17ebd", + }, + ], + "UserLocalizedLabel": { + "Label": "Business that represents a customer or potential customer. The company that is billed in business transactions.", + "LanguageCode": 1033, + "IsManaged": True, + "MetadataId": "294901bf-2241-db11-898a-0007e9e17ebd", + }, + }, }, { + "MetadataId": "608861bc-50a4-4c5f-a02c-21fe1943e2cf", "LogicalName": "contact", + "SchemaName": "Contact", "EntitySetName": "contacts", "PrimaryIdAttribute": "contactid", - "DisplayName": {"UserLocalizedLabel": {"Label": "Contact"}}, + "DisplayName": { + "LocalizedLabels": [ + { + "Label": "Contact", + "LanguageCode": 1033, + "IsManaged": True, + "MetadataId": "3a4901bf-2241-db11-898a-0007e9e17ebd", + }, + ], + "UserLocalizedLabel": { + "Label": "Contact", + "LanguageCode": 1033, + "IsManaged": True, + "MetadataId": "3a4901bf-2241-db11-898a-0007e9e17ebd", + }, + }, + "Description": { + "LocalizedLabels": [ + { + "Label": "Person with whom a business unit has a relationship, such as customer, supplier, and colleague.", + "LanguageCode": 1033, + "IsManaged": True, + }, + ], + "UserLocalizedLabel": { + "Label": "Person with whom a business unit has a relationship, such as customer, supplier, and colleague.", + "LanguageCode": 1033, + "IsManaged": True, + }, + }, }, ] } @@ -52,3 +116,469 @@ # Sample SQL query results SAMPLE_SQL_RESPONSE = {"value": [{"name": "Account 1", "revenue": 1000000}, {"name": "Account 2", "revenue": 2000000}]} + + +# --------------------------------------------------------------------------- +# Column attribute metadata samples +# (Realistic responses from Dataverse Web API, used across unit tests) +# --------------------------------------------------------------------------- + +ACCOUNT_NAME_COLUMN = { + "@odata.type": "#Microsoft.Dynamics.CRM.StringAttributeMetadata", + "LogicalName": "name", + "SchemaName": "Name", + "AttributeType": "String", + "AttributeTypeName": {"Value": "StringType"}, + "IsCustomAttribute": False, + "IsPrimaryId": False, + "IsPrimaryName": True, + "IsValidForCreate": True, + "IsValidForUpdate": True, + "IsValidForRead": True, + "RequiredLevel": { + "Value": "ApplicationRequired", + "CanBeChanged": True, + "ManagedPropertyLogicalName": "canmodifyrequirementlevelsettings", + }, + "MetadataId": "a1965545-44bc-4b7b-b1ae-93074d0e3f2a", + "DisplayName": { + "LocalizedLabels": [ + { + "Label": "Account Name", + "LanguageCode": 1033, + "IsManaged": True, + "MetadataId": "ea34ed00-2341-db11-898a-0007e9e17ebd", + } + ], + "UserLocalizedLabel": { + "Label": "Account Name", + "LanguageCode": 1033, + "IsManaged": True, + "MetadataId": "ea34ed00-2341-db11-898a-0007e9e17ebd", + }, + }, + "Description": { + "LocalizedLabels": [ + { + "Label": "Type the company or business name.", + "LanguageCode": 1033, + "IsManaged": True, + } + ], + "UserLocalizedLabel": { + "Label": "Type the company or business name.", + "LanguageCode": 1033, + "IsManaged": True, + }, + }, +} + +EMAILADDRESS1_COLUMN = { + "@odata.type": "#Microsoft.Dynamics.CRM.StringAttributeMetadata", + "LogicalName": "emailaddress1", + "SchemaName": "EMailAddress1", + "AttributeType": "String", + "AttributeTypeName": {"Value": "StringType"}, + "IsCustomAttribute": False, + "IsPrimaryId": False, + "IsPrimaryName": False, + "IsValidForCreate": True, + "IsValidForUpdate": True, + "IsValidForRead": True, + "RequiredLevel": { + "Value": "None", + "CanBeChanged": True, + "ManagedPropertyLogicalName": "canmodifyrequirementlevelsettings", + }, + "MetadataId": "024a2ee3-b983-4fd8-8991-f8d548a227e0", + "DisplayName": { + "LocalizedLabels": [ + { + "Label": "Email", + "LanguageCode": 1033, + "IsManaged": True, + "MetadataId": "54c04ee3-b983-4fd8-8991-f8d548a227e0", + } + ], + "UserLocalizedLabel": { + "Label": "Email", + "LanguageCode": 1033, + "IsManaged": True, + "MetadataId": "54c04ee3-b983-4fd8-8991-f8d548a227e0", + }, + }, + "Description": { + "LocalizedLabels": [ + { + "Label": "Type the primary email address for the contact.", + "LanguageCode": 1033, + "IsManaged": True, + } + ], + "UserLocalizedLabel": { + "Label": "Type the primary email address for the contact.", + "LanguageCode": 1033, + "IsManaged": True, + }, + }, +} + +PICKLIST_COLUMN = { + "@odata.type": "#Microsoft.Dynamics.CRM.PicklistAttributeMetadata", + "LogicalName": "accountcategorycode", + "SchemaName": "AccountCategoryCode", + "AttributeType": "Picklist", + "AttributeTypeName": {"Value": "PicklistType"}, + "IsCustomAttribute": False, + "IsPrimaryId": False, + "IsPrimaryName": False, + "IsValidForCreate": True, + "IsValidForUpdate": True, + "IsValidForRead": True, + "RequiredLevel": { + "Value": "None", + "CanBeChanged": True, + "ManagedPropertyLogicalName": "canmodifyrequirementlevelsettings", + }, + "MetadataId": "118771ca-6fb9-4f60-8fd4-99b6124b63ad", + "DisplayName": { + "LocalizedLabels": [{"Label": "Category", "LanguageCode": 1033, "IsManaged": True}], + "UserLocalizedLabel": {"Label": "Category", "LanguageCode": 1033, "IsManaged": True}, + }, +} + +STATUS_COLUMN = { + "@odata.type": "#Microsoft.Dynamics.CRM.StatusAttributeMetadata", + "LogicalName": "statuscode", + "SchemaName": "StatusCode", + "AttributeType": "Status", + "AttributeTypeName": {"Value": "StatusType"}, + "IsCustomAttribute": False, + "IsPrimaryId": False, + "IsPrimaryName": False, + "IsValidForCreate": True, + "IsValidForUpdate": True, + "IsValidForRead": True, + "RequiredLevel": { + "Value": "None", + "CanBeChanged": True, + "ManagedPropertyLogicalName": "canmodifyrequirementlevelsettings", + }, + "MetadataId": "f99371c3-b1e1-4645-b2c3-c3db0f59ecf0", + "DisplayName": { + "LocalizedLabels": [{"Label": "Status Reason", "LanguageCode": 1033, "IsManaged": True}], + "UserLocalizedLabel": {"Label": "Status Reason", "LanguageCode": 1033, "IsManaged": True}, + }, +} + +STATE_COLUMN = { + "@odata.type": "#Microsoft.Dynamics.CRM.StateAttributeMetadata", + "LogicalName": "statecode", + "SchemaName": "StateCode", + "AttributeType": "State", + "AttributeTypeName": {"Value": "StateType"}, + "IsCustomAttribute": False, + "IsPrimaryId": False, + "IsPrimaryName": False, + "IsValidForCreate": False, + "IsValidForUpdate": True, + "IsValidForRead": True, + "RequiredLevel": { + "Value": "SystemRequired", + "CanBeChanged": False, + "ManagedPropertyLogicalName": "canmodifyrequirementlevelsettings", + }, + "MetadataId": "cdc3895a-7539-41e9-966b-3f9ef805aefd", + "DisplayName": { + "LocalizedLabels": [{"Label": "Status", "LanguageCode": 1033, "IsManaged": True}], + "UserLocalizedLabel": {"Label": "Status", "LanguageCode": 1033, "IsManaged": True}, + }, +} + +UNIQUEID_COLUMN = { + "LogicalName": "accountid", + "SchemaName": "AccountId", + "AttributeType": "Uniqueidentifier", + "AttributeTypeName": {"Value": "UniqueidentifierType"}, + "IsCustomAttribute": False, + "IsPrimaryId": True, + "IsPrimaryName": False, + "IsValidForCreate": True, + "IsValidForUpdate": False, + "IsValidForRead": True, + "RequiredLevel": {"Value": "SystemRequired"}, + "MetadataId": "f8cd5db9-cee8-4845-8cdd-cd4f504957e7", + "DisplayName": { + "LocalizedLabels": [{"Label": "Account", "LanguageCode": 1033, "IsManaged": True}], + "UserLocalizedLabel": {"Label": "Account", "LanguageCode": 1033, "IsManaged": True}, + }, +} + +# Reference to the first Account entity from SAMPLE_ENTITY_METADATA, +# with full DisplayName and Description (used in table-get-with-select tests). +ACCOUNT_TABLE_FULL = {k: v for k, v in SAMPLE_ENTITY_METADATA["value"][0].items() if k != "PrimaryIdAttribute"} + + +# --------------------------------------------------------------------------- +# OptionSet metadata samples +# --------------------------------------------------------------------------- + +PICKLIST_OPTIONSET = { + "MetadataId": "b994cdd8-5ce9-4ab9-bdd3-8888ebdb0407", + "HasChanged": None, + "IsCustomOptionSet": False, + "IsGlobal": False, + "IsManaged": True, + "Name": "account_accountcategorycode", + "OptionSetType": "Picklist", + "DisplayName": { + "LocalizedLabels": [ + { + "Label": "Category", + "LanguageCode": 1033, + "IsManaged": True, + "MetadataId": "d8a3356a-6d26-4f0e-b89e-8b73f25ed57b", + } + ], + "UserLocalizedLabel": { + "Label": "Category", + "LanguageCode": 1033, + "IsManaged": True, + "MetadataId": "d8a3356a-6d26-4f0e-b89e-8b73f25ed57b", + }, + }, + "Description": { + "LocalizedLabels": [ + { + "Label": "Drop-down list for selecting the category of the account.", + "LanguageCode": 1033, + "IsManaged": True, + } + ], + "UserLocalizedLabel": { + "Label": "Drop-down list for selecting the category of the account.", + "LanguageCode": 1033, + "IsManaged": True, + }, + }, + "Options": [ + { + "Value": 1, + "Color": None, + "IsManaged": True, + "ExternalValue": None, + "ParentValues": [], + "Tag": None, + "IsHidden": False, + "Label": { + "LocalizedLabels": [ + { + "Label": "Preferred Customer", + "LanguageCode": 1033, + "IsManaged": True, + "MetadataId": "0bd8a218-2341-db11-898a-0007e9e17ebd", + } + ], + "UserLocalizedLabel": { + "Label": "Preferred Customer", + "LanguageCode": 1033, + "IsManaged": True, + "MetadataId": "0bd8a218-2341-db11-898a-0007e9e17ebd", + }, + }, + "Description": {"LocalizedLabels": [], "UserLocalizedLabel": None}, + }, + { + "Value": 2, + "Color": None, + "IsManaged": True, + "ExternalValue": None, + "ParentValues": [], + "Tag": None, + "IsHidden": False, + "Label": { + "LocalizedLabels": [ + { + "Label": "Standard", + "LanguageCode": 1033, + "IsManaged": True, + "MetadataId": "0dd8a218-2341-db11-898a-0007e9e17ebd", + } + ], + "UserLocalizedLabel": { + "Label": "Standard", + "LanguageCode": 1033, + "IsManaged": True, + "MetadataId": "0dd8a218-2341-db11-898a-0007e9e17ebd", + }, + }, + "Description": {"LocalizedLabels": [], "UserLocalizedLabel": None}, + }, + ], +} + +STATUS_OPTIONSET = { + "MetadataId": "75ad977d-6f28-4c5c-ae44-7816d366ba21", + "HasChanged": None, + "IsCustomOptionSet": False, + "IsGlobal": False, + "IsManaged": True, + "Name": "account_statuscode", + "OptionSetType": "Status", + "DisplayName": { + "LocalizedLabels": [{"Label": "Status Reason", "LanguageCode": 1033, "IsManaged": True}], + "UserLocalizedLabel": {"Label": "Status Reason", "LanguageCode": 1033, "IsManaged": True}, + }, + "Options": [ + { + "@odata.type": "#Microsoft.Dynamics.CRM.StatusOptionMetadata", + "Value": 1, + "Color": None, + "IsManaged": True, + "State": 0, + "TransitionData": None, + "Label": { + "LocalizedLabels": [{"Label": "Active", "LanguageCode": 1033, "IsManaged": True}], + "UserLocalizedLabel": {"Label": "Active", "LanguageCode": 1033, "IsManaged": True}, + }, + "Description": {"LocalizedLabels": [], "UserLocalizedLabel": None}, + }, + { + "@odata.type": "#Microsoft.Dynamics.CRM.StatusOptionMetadata", + "Value": 2, + "Color": None, + "IsManaged": True, + "State": 1, + "TransitionData": None, + "Label": { + "LocalizedLabels": [{"Label": "Inactive", "LanguageCode": 1033, "IsManaged": True}], + "UserLocalizedLabel": {"Label": "Inactive", "LanguageCode": 1033, "IsManaged": True}, + }, + "Description": {"LocalizedLabels": [], "UserLocalizedLabel": None}, + }, + ], +} + +STATE_OPTIONSET = { + "MetadataId": "88fa5ad0-2a4b-4281-ac9c-b4e71fb77920", + "HasChanged": None, + "IsCustomOptionSet": False, + "IsGlobal": False, + "IsManaged": True, + "Name": "contact_statecode", + "OptionSetType": "State", + "DisplayName": { + "LocalizedLabels": [{"Label": "Status", "LanguageCode": 1033, "IsManaged": True}], + "UserLocalizedLabel": {"Label": "Status", "LanguageCode": 1033, "IsManaged": True}, + }, + "Options": [ + { + "@odata.type": "#Microsoft.Dynamics.CRM.StateOptionMetadata", + "Value": 0, + "Color": None, + "IsManaged": True, + "DefaultStatus": 1, + "InvariantName": "Active", + "Label": { + "LocalizedLabels": [{"Label": "Active", "LanguageCode": 1033, "IsManaged": True}], + "UserLocalizedLabel": {"Label": "Active", "LanguageCode": 1033, "IsManaged": True}, + }, + "Description": {"LocalizedLabels": [], "UserLocalizedLabel": None}, + }, + { + "@odata.type": "#Microsoft.Dynamics.CRM.StateOptionMetadata", + "Value": 1, + "Color": None, + "IsManaged": True, + "DefaultStatus": 2, + "InvariantName": "Inactive", + "Label": { + "LocalizedLabels": [{"Label": "Inactive", "LanguageCode": 1033, "IsManaged": True}], + "UserLocalizedLabel": {"Label": "Inactive", "LanguageCode": 1033, "IsManaged": True}, + }, + "Description": {"LocalizedLabels": [], "UserLocalizedLabel": None}, + }, + ], +} + +BOOLEAN_OPTIONSET = { + "MetadataId": "0fe276ef-76e9-4121-b570-a09edbf92ab3", + "HasChanged": None, + "IsCustomOptionSet": False, + "IsGlobal": False, + "IsManaged": True, + "Name": "contact_donotphone", + "OptionSetType": "Boolean", + "DisplayName": { + "LocalizedLabels": [{"Label": "Do not allow Phone Calls", "LanguageCode": 1033, "IsManaged": True}], + "UserLocalizedLabel": {"Label": "Do not allow Phone Calls", "LanguageCode": 1033, "IsManaged": True}, + }, + "TrueOption": { + "Value": 1, + "Color": None, + "IsManaged": True, + "ExternalValue": None, + "ParentValues": [], + "Label": { + "LocalizedLabels": [{"Label": "Do Not Allow", "LanguageCode": 1033, "IsManaged": True}], + "UserLocalizedLabel": {"Label": "Do Not Allow", "LanguageCode": 1033, "IsManaged": True}, + }, + "Description": {"LocalizedLabels": [], "UserLocalizedLabel": None}, + }, + "FalseOption": { + "Value": 0, + "Color": None, + "IsManaged": True, + "ExternalValue": None, + "ParentValues": [], + "Label": { + "LocalizedLabels": [{"Label": "Allow", "LanguageCode": 1033, "IsManaged": True}], + "UserLocalizedLabel": {"Label": "Allow", "LanguageCode": 1033, "IsManaged": True}, + }, + "Description": {"LocalizedLabels": [], "UserLocalizedLabel": None}, + }, +} + + +# --------------------------------------------------------------------------- +# Relationship metadata samples +# --------------------------------------------------------------------------- + +ACCOUNT_CHATS_RELATIONSHIP = { + "MetadataId": "4c731d0a-8713-f111-8341-7ced8d40bc10", + "SchemaName": "account_chats", + "ReferencedAttribute": "accountid", + "ReferencedEntity": "account", + "ReferencingAttribute": "regardingobjectid", + "ReferencingEntity": "chat", + "RelationshipType": "OneToManyRelationship", + "IsCustomRelationship": False, + "IsManaged": False, + "CascadeConfiguration": { + "Assign": "Cascade", + "Delete": "Cascade", + "Merge": "Cascade", + "Reparent": "Cascade", + "Share": "Cascade", + "Unshare": "Cascade", + }, +} + + +# --------------------------------------------------------------------------- +# Table list entry fixtures (commonly used in list tests) +# --------------------------------------------------------------------------- + +ACCOUNT_TABLE_ENTRY = { + "MetadataId": "70816501-edb9-4740-a16c-6a5efbc05d84", + "LogicalName": "account", + "SchemaName": "Account", + "EntitySetName": "accounts", +} + +CONTACT_TABLE_ENTRY = { + "MetadataId": "608861bc-50a4-4c5f-a02c-21fe1943e2cf", + "LogicalName": "contact", + "SchemaName": "Contact", + "EntitySetName": "contacts", +} diff --git a/tests/unit/models/test_metadata.py b/tests/unit/models/test_metadata.py index 0b128a59..ca504ca0 100644 --- a/tests/unit/models/test_metadata.py +++ b/tests/unit/models/test_metadata.py @@ -8,6 +8,18 @@ OptionItem, OptionSetInfo, ) +from tests.fixtures.test_data import ( + ACCOUNT_NAME_COLUMN, + BOOLEAN_OPTIONSET, + EMAILADDRESS1_COLUMN, + PICKLIST_COLUMN, + PICKLIST_OPTIONSET, + STATE_COLUMN, + STATE_OPTIONSET, + STATUS_COLUMN, + STATUS_OPTIONSET, + UNIQUEID_COLUMN, +) class TestColumnMetadata: @@ -15,25 +27,7 @@ class TestColumnMetadata: def test_from_api_response_full(self): """Test full API response maps all 13 fields correctly.""" - data = { - "@odata.type": "#Microsoft.Dynamics.CRM.StringAttributeMetadata", - "LogicalName": "emailaddress1", - "SchemaName": "EMailAddress1", - "DisplayName": { - "UserLocalizedLabel": {"Label": "Email", "LanguageCode": 1033}, - }, - "AttributeType": "String", - "AttributeTypeName": {"Value": "StringType"}, - "IsCustomAttribute": False, - "IsPrimaryId": False, - "IsPrimaryName": False, - "RequiredLevel": {"Value": "None"}, - "IsValidForCreate": True, - "IsValidForUpdate": True, - "IsValidForRead": True, - "MetadataId": "def-456", - } - col = ColumnMetadata.from_api_response(data) + col = ColumnMetadata.from_api_response(EMAILADDRESS1_COLUMN) assert col.logical_name == "emailaddress1" assert col.schema_name == "EMailAddress1" assert col.display_name == "Email" @@ -46,7 +40,7 @@ def test_from_api_response_full(self): assert col.is_valid_for_create is True assert col.is_valid_for_update is True assert col.is_valid_for_read is True - assert col.metadata_id == "def-456" + assert col.metadata_id == "024a2ee3-b983-4fd8-8991-f8d548a227e0" def test_from_api_response_minimal(self): """Test minimal dict with only LogicalName and SchemaName uses defaults.""" @@ -82,6 +76,57 @@ def test_display_name_missing_entirely(self): col = ColumnMetadata.from_api_response(data) assert col.display_name is None + def test_from_api_response_primary_name_column(self): + """Test primary name column (account.name) with ApplicationRequired level.""" + col = ColumnMetadata.from_api_response(ACCOUNT_NAME_COLUMN) + assert col.logical_name == "name" + assert col.schema_name == "Name" + assert col.display_name == "Account Name" + assert col.is_primary_name is True + assert col.is_primary_id is False + assert col.required_level == "ApplicationRequired" + + def test_from_api_response_picklist_column(self): + """Test picklist column (account.accountcategorycode) maps correctly.""" + col = ColumnMetadata.from_api_response(PICKLIST_COLUMN) + assert col.logical_name == "accountcategorycode" + assert col.schema_name == "AccountCategoryCode" + assert col.attribute_type == "Picklist" + assert col.attribute_type_name == "PicklistType" + assert col.display_name == "Category" + assert col.required_level == "None" + + def test_from_api_response_status_column(self): + """Test status column (account.statuscode) maps correctly.""" + col = ColumnMetadata.from_api_response(STATUS_COLUMN) + assert col.logical_name == "statuscode" + assert col.schema_name == "StatusCode" + assert col.attribute_type == "Status" + assert col.attribute_type_name == "StatusType" + assert col.display_name == "Status Reason" + + def test_from_api_response_state_column(self): + """Test state column (contact.statecode) maps correctly.""" + col = ColumnMetadata.from_api_response(STATE_COLUMN) + assert col.logical_name == "statecode" + assert col.schema_name == "StateCode" + assert col.attribute_type == "State" + assert col.attribute_type_name == "StateType" + assert col.display_name == "Status" + assert col.required_level == "SystemRequired" + assert col.is_valid_for_create is False + assert col.is_valid_for_update is True + + def test_from_api_response_uniqueidentifier_column(self): + """Test primary ID column (account.accountid) maps correctly.""" + col = ColumnMetadata.from_api_response(UNIQUEID_COLUMN) + assert col.logical_name == "accountid" + assert col.is_primary_id is True + assert col.is_primary_name is False + assert col.attribute_type == "Uniqueidentifier" + assert col.attribute_type_name == "UniqueidentifierType" + assert col.is_valid_for_update is False + def test_required_level_extraction(self): """Test RequiredLevel.Value is extracted correctly.""" data = { @@ -98,13 +143,7 @@ class TestOptionItem: def test_from_api_response(self): """Test option with Value and Label.""" - data = { - "Value": 1, - "Label": { - "UserLocalizedLabel": {"Label": "Preferred Customer", "LanguageCode": 1033}, - }, - } - opt = OptionItem.from_api_response(data) + opt = OptionItem.from_api_response(PICKLIST_OPTIONSET["Options"][0]) assert opt.value == 1 assert opt.label == "Preferred Customer" @@ -115,44 +154,42 @@ def test_from_api_response_no_label(self): assert opt.value == 2 assert opt.label is None + def test_from_api_response_status_option(self): + """Test StatusOptionMetadata with extra State and TransitionData fields.""" + opt = OptionItem.from_api_response(STATUS_OPTIONSET["Options"][0]) + assert opt.value == 1 + assert opt.label == "Active" + + def test_from_api_response_state_option(self): + """Test StateOptionMetadata with DefaultStatus and InvariantName fields.""" + opt = OptionItem.from_api_response(STATE_OPTIONSET["Options"][0]) + assert opt.value == 0 + assert opt.label == "Active" + class TestOptionSetInfo: """Tests for OptionSetInfo.""" def test_from_api_response_picklist(self): """Test picklist-style OptionSet with Options array.""" - data = { - "Name": "account_accountcategorycode", - "DisplayName": {"UserLocalizedLabel": {"Label": "Category", "LanguageCode": 1033}}, - "IsGlobal": False, - "OptionSetType": "Picklist", - "Options": [ - {"Value": 1, "Label": {"UserLocalizedLabel": {"Label": "Preferred Customer"}}}, - {"Value": 2, "Label": {"UserLocalizedLabel": {"Label": "Standard"}}}, - ], - "MetadataId": "meta-guid", - } - opt_set = OptionSetInfo.from_api_response(data) + opt_set = OptionSetInfo.from_api_response(PICKLIST_OPTIONSET) assert opt_set.option_set_type == "Picklist" assert opt_set.name == "account_accountcategorycode" assert opt_set.display_name == "Category" assert opt_set.is_global is False + assert opt_set.metadata_id == "b994cdd8-5ce9-4ab9-bdd3-8888ebdb0407" assert len(opt_set.options) == 2 assert opt_set.options[0].value == 1 assert opt_set.options[0].label == "Preferred Customer" assert opt_set.options[1].value == 2 assert opt_set.options[1].label == "Standard" - assert opt_set.metadata_id == "meta-guid" def test_from_api_response_boolean(self): """Test boolean-style OptionSet with TrueOption and FalseOption.""" - data = { - "OptionSetType": "Boolean", - "TrueOption": {"Value": 1, "Label": {"UserLocalizedLabel": {"Label": "Do Not Allow"}}}, - "FalseOption": {"Value": 0, "Label": {"UserLocalizedLabel": {"Label": "Allow"}}}, - } - opt_set = OptionSetInfo.from_api_response(data) + opt_set = OptionSetInfo.from_api_response(BOOLEAN_OPTIONSET) assert opt_set.option_set_type == "Boolean" + assert opt_set.name == "contact_donotphone" + assert opt_set.display_name == "Do not allow Phone Calls" assert len(opt_set.options) == 2 values = [o.value for o in opt_set.options] assert 0 in values @@ -161,6 +198,35 @@ def test_from_api_response_boolean(self): assert labels[0] == "Allow" assert labels[1] == "Do Not Allow" + def test_from_api_response_status_optionset(self): + """Test Status-type OptionSet (account.statuscode) with StatusOptionMetadata.""" + opt_set = OptionSetInfo.from_api_response(STATUS_OPTIONSET) + assert opt_set.option_set_type == "Status" + assert opt_set.name == "account_statuscode" + assert opt_set.display_name == "Status Reason" + assert opt_set.is_global is False + assert opt_set.metadata_id == "75ad977d-6f28-4c5c-ae44-7816d366ba21" + assert len(opt_set.options) == 2 + assert opt_set.options[0].value == 1 + assert opt_set.options[0].label == "Active" + assert opt_set.options[1].value == 2 + assert opt_set.options[1].label == "Inactive" + + def test_from_api_response_state_optionset(self): + """Test State-type OptionSet (contact.statecode) with StateOptionMetadata.""" + opt_set = OptionSetInfo.from_api_response(STATE_OPTIONSET) + assert opt_set.option_set_type == "State" + assert opt_set.name == "contact_statecode" + assert opt_set.display_name == "Status" + assert opt_set.metadata_id == "88fa5ad0-2a4b-4281-ac9c-b4e71fb77920" + assert len(opt_set.options) == 2 + values = [o.value for o in opt_set.options] + assert 0 in values + assert 1 in values + labels = {o.value: o.label for o in opt_set.options} + assert labels[0] == "Active" + assert labels[1] == "Inactive" + def test_from_api_response_empty_options(self): """Test OptionSet with empty Options array.""" data = {"Options": [], "OptionSetType": "Picklist"} diff --git a/tests/unit/test_tables_operations.py b/tests/unit/test_tables_operations.py index 8070a438..3c45d296 100644 --- a/tests/unit/test_tables_operations.py +++ b/tests/unit/test_tables_operations.py @@ -10,6 +10,18 @@ from PowerPlatform.Dataverse.models.metadata import ColumnMetadata, OptionSetInfo from PowerPlatform.Dataverse.models.relationship import RelationshipInfo from PowerPlatform.Dataverse.operations.tables import TableOperations +from tests.fixtures.test_data import ( + ACCOUNT_CHATS_RELATIONSHIP, + ACCOUNT_NAME_COLUMN, + ACCOUNT_TABLE_ENTRY, + ACCOUNT_TABLE_FULL, + BOOLEAN_OPTIONSET, + CONTACT_TABLE_ENTRY, + EMAILADDRESS1_COLUMN, + PICKLIST_OPTIONSET, + STATE_OPTIONSET, + STATUS_OPTIONSET, +) class TestTableOperations(unittest.TestCase): @@ -112,10 +124,8 @@ def test_get_with_include_columns(self): "SchemaName": "Account", "LogicalName": "account", "EntitySetName": "accounts", - "MetadataId": "meta-guid", - "Attributes": [ - {"LogicalName": "name", "SchemaName": "Name", "AttributeType": "String"}, - ], + "MetadataId": "70816501-edb9-4740-a16c-6a5efbc05d84", + "Attributes": [ACCOUNT_NAME_COLUMN], } self.client._odata._get_table_metadata.return_value = raw @@ -132,9 +142,16 @@ def test_get_with_include_columns(self): self.assertIn("columns", result) self.assertNotIn("columns_created", result) self.assertEqual(len(result["columns"]), 1) - self.assertIsInstance(result["columns"][0], ColumnMetadata) - self.assertEqual(result["columns"][0].logical_name, "name") - self.assertEqual(result["columns"][0].attribute_type, "String") + col = result["columns"][0] + self.assertIsInstance(col, ColumnMetadata) + self.assertEqual(col.logical_name, "name") + self.assertEqual(col.schema_name, "Name") + self.assertEqual(col.attribute_type, "String") + self.assertEqual(col.attribute_type_name, "StringType") + self.assertTrue(col.is_primary_name) + self.assertFalse(col.is_primary_id) + self.assertEqual(col.display_name, "Account Name") + self.assertEqual(col.required_level, "ApplicationRequired") def test_get_with_include_relationships(self): """get(include_relationships=True) should return relationship arrays.""" @@ -142,8 +159,8 @@ def test_get_with_include_relationships(self): "SchemaName": "Account", "LogicalName": "account", "EntitySetName": "accounts", - "MetadataId": "meta-guid", - "OneToManyRelationships": [{"SchemaName": "account_tasks", "ReferencingEntity": "task"}], + "MetadataId": "70816501-edb9-4740-a16c-6a5efbc05d84", + "OneToManyRelationships": [ACCOUNT_CHATS_RELATIONSHIP], "ManyToOneRelationships": [], "ManyToManyRelationships": [], } @@ -161,21 +178,17 @@ def test_get_with_include_relationships(self): ) self.assertIn("one_to_many_relationships", result) self.assertEqual(len(result["one_to_many_relationships"]), 1) - self.assertEqual(result["one_to_many_relationships"][0]["SchemaName"], "account_tasks") + rel0 = result["one_to_many_relationships"][0] + self.assertEqual(rel0["SchemaName"], "account_chats") + self.assertEqual(rel0["ReferencedEntity"], "account") + self.assertEqual(rel0["ReferencingEntity"], "chat") + self.assertEqual(rel0["RelationshipType"], "OneToManyRelationship") self.assertIn("many_to_one_relationships", result) self.assertIn("many_to_many_relationships", result) def test_get_with_select(self): """get(select=[...]) should pass select and include extra properties in result.""" - raw = { - "SchemaName": "Account", - "LogicalName": "account", - "EntitySetName": "accounts", - "MetadataId": "meta-guid", - "DisplayName": {"UserLocalizedLabel": {"Label": "Account"}}, - "Description": {"UserLocalizedLabel": {"Label": "Business account"}}, - } - self.client._odata._get_table_metadata.return_value = raw + self.client._odata._get_table_metadata.return_value = ACCOUNT_TABLE_FULL result = self.client.tables.get("account", select=["DisplayName", "Description"]) @@ -200,10 +213,7 @@ def test_get_extended_returns_none(self): def test_get_columns(self): """get_columns() should return list of ColumnMetadata.""" - raw_list = [ - {"LogicalName": "name", "SchemaName": "Name", "AttributeType": "String"}, - {"LogicalName": "emailaddress1", "SchemaName": "EMailAddress1", "AttributeType": "String"}, - ] + raw_list = [ACCOUNT_NAME_COLUMN, EMAILADDRESS1_COLUMN] self.client._odata._get_table_columns.return_value = raw_list result = self.client.tables.get_columns("account") @@ -217,7 +227,12 @@ def test_get_columns(self): self.assertIsInstance(result[0], ColumnMetadata) self.assertIsInstance(result[1], ColumnMetadata) self.assertEqual(result[0].logical_name, "name") + self.assertEqual(result[0].display_name, "Account Name") + self.assertEqual(result[0].required_level, "ApplicationRequired") + self.assertTrue(result[0].is_primary_name) self.assertEqual(result[1].logical_name, "emailaddress1") + self.assertEqual(result[1].display_name, "Email") + self.assertEqual(result[1].required_level, "None") def test_get_columns_with_filter(self): """get_columns(filter=...) should pass filter to _get_table_columns.""" @@ -234,8 +249,7 @@ def test_get_columns_with_filter(self): def test_get_column_found(self): """get_column() should return ColumnMetadata when column exists.""" - raw = {"LogicalName": "emailaddress1", "SchemaName": "EMailAddress1", "AttributeType": "String"} - self.client._odata._get_table_column.return_value = raw + self.client._odata._get_table_column.return_value = EMAILADDRESS1_COLUMN result = self.client.tables.get_column("account", "emailaddress1") @@ -246,6 +260,14 @@ def test_get_column_found(self): ) self.assertIsInstance(result, ColumnMetadata) self.assertEqual(result.logical_name, "emailaddress1") + self.assertEqual(result.schema_name, "EMailAddress1") + self.assertEqual(result.display_name, "Email") + self.assertEqual(result.attribute_type, "String") + self.assertEqual(result.attribute_type_name, "StringType") + self.assertEqual(result.required_level, "None") + self.assertFalse(result.is_primary_name) + self.assertFalse(result.is_primary_id) + self.assertEqual(result.metadata_id, "024a2ee3-b983-4fd8-8991-f8d548a227e0") def test_get_column_not_found(self): """get_column() should return None when column not found.""" @@ -257,23 +279,74 @@ def test_get_column_not_found(self): def test_get_column_options_picklist(self): """get_column_options() should return OptionSetInfo for picklist column.""" - raw_optionset = { - "Name": "account_accountcategorycode", - "OptionSetType": "Picklist", - "Options": [ - {"Value": 1, "Label": {"UserLocalizedLabel": {"Label": "Preferred Customer"}}}, - {"Value": 2, "Label": {"UserLocalizedLabel": {"Label": "Standard"}}}, - ], - } - self.client._odata._get_column_optionset.return_value = raw_optionset + self.client._odata._get_column_optionset.return_value = PICKLIST_OPTIONSET result = self.client.tables.get_column_options("account", "accountcategorycode") self.client._odata._get_column_optionset.assert_called_once_with("account", "accountcategorycode") self.assertIsInstance(result, OptionSetInfo) + self.assertEqual(result.name, "account_accountcategorycode") + self.assertEqual(result.display_name, "Category") + self.assertEqual(result.option_set_type, "Picklist") + self.assertFalse(result.is_global) + self.assertEqual(result.metadata_id, "b994cdd8-5ce9-4ab9-bdd3-8888ebdb0407") self.assertEqual(len(result.options), 2) self.assertEqual(result.options[0].value, 1) self.assertEqual(result.options[0].label, "Preferred Customer") + self.assertEqual(result.options[1].value, 2) + self.assertEqual(result.options[1].label, "Standard") + + def test_get_column_options_status(self): + """get_column_options() should return OptionSetInfo for Status column.""" + self.client._odata._get_column_optionset.return_value = STATUS_OPTIONSET + + result = self.client.tables.get_column_options("account", "statuscode") + + self.client._odata._get_column_optionset.assert_called_once_with("account", "statuscode") + self.assertIsInstance(result, OptionSetInfo) + self.assertEqual(result.name, "account_statuscode") + self.assertEqual(result.display_name, "Status Reason") + self.assertEqual(result.option_set_type, "Status") + self.assertEqual(len(result.options), 2) + self.assertEqual(result.options[0].value, 1) + self.assertEqual(result.options[0].label, "Active") + self.assertEqual(result.options[1].value, 2) + self.assertEqual(result.options[1].label, "Inactive") + + def test_get_column_options_state(self): + """get_column_options() should return OptionSetInfo for State column.""" + self.client._odata._get_column_optionset.return_value = STATE_OPTIONSET + + result = self.client.tables.get_column_options("contact", "statecode") + + self.client._odata._get_column_optionset.assert_called_once_with("contact", "statecode") + self.assertIsInstance(result, OptionSetInfo) + self.assertEqual(result.name, "contact_statecode") + self.assertEqual(result.display_name, "Status") + self.assertEqual(result.option_set_type, "State") + self.assertEqual(len(result.options), 2) + values = [o.value for o in result.options] + self.assertIn(0, values) + self.assertIn(1, values) + labels = {o.value: o.label for o in result.options} + self.assertEqual(labels[0], "Active") + self.assertEqual(labels[1], "Inactive") + + def test_get_column_options_boolean(self): + """get_column_options() should return OptionSetInfo for Boolean column.""" + self.client._odata._get_column_optionset.return_value = BOOLEAN_OPTIONSET + + result = self.client.tables.get_column_options("contact", "donotphone") + + self.client._odata._get_column_optionset.assert_called_once_with("contact", "donotphone") + self.assertIsInstance(result, OptionSetInfo) + self.assertEqual(result.name, "contact_donotphone") + self.assertEqual(result.display_name, "Do not allow Phone Calls") + self.assertEqual(result.option_set_type, "Boolean") + self.assertEqual(len(result.options), 2) + values = {o.value: o.label for o in result.options} + self.assertEqual(values[0], "Allow") + self.assertEqual(values[1], "Do Not Allow") def test_get_column_options_not_picklist(self): """get_column_options() should return None for non-choice column.""" @@ -286,8 +359,27 @@ def test_get_column_options_not_picklist(self): def test_list_relationships_all(self): """list_relationships() with no type should return all relationship types.""" expected = [ - {"SchemaName": "account_tasks", "_relationship_type": "OneToMany"}, - {"SchemaName": "account_primarycontact", "_relationship_type": "ManyToOne"}, + {**ACCOUNT_CHATS_RELATIONSHIP, "_relationship_type": "OneToMany"}, + { + "MetadataId": "2074fc1d-84a2-48ac-a47c-fcf1d249a052", + "SchemaName": "lk_accountbase_modifiedonbehalfby", + "ReferencedAttribute": "systemuserid", + "ReferencedEntity": "systemuser", + "ReferencingAttribute": "modifiedonbehalfby", + "ReferencingEntity": "account", + "RelationshipType": "OneToManyRelationship", + "IsCustomRelationship": False, + "IsManaged": True, + "CascadeConfiguration": { + "Assign": "NoCascade", + "Delete": "NoCascade", + "Merge": "NoCascade", + "Reparent": "NoCascade", + "Share": "NoCascade", + "Unshare": "NoCascade", + }, + "_relationship_type": "ManyToOne", + }, ] self.client._odata._list_table_relationships.return_value = expected @@ -302,7 +394,7 @@ def test_list_relationships_all(self): def test_list_relationships_filtered(self): """list_relationships(relationship_type=...) should pass type filter.""" - expected = [{"SchemaName": "account_tasks", "_relationship_type": "OneToMany"}] + expected = [{**ACCOUNT_CHATS_RELATIONSHIP, "_relationship_type": "OneToMany"}] self.client._odata._list_table_relationships.return_value = expected result = self.client.tables.list_relationships("account", relationship_type="one_to_many") @@ -351,8 +443,20 @@ def test_list_relationships_select_bare_string_raises(self): def test_list(self): """list() should call _list_tables and return the list of metadata dicts.""" expected_tables = [ - {"LogicalName": "account", "SchemaName": "Account"}, - {"LogicalName": "contact", "SchemaName": "Contact"}, + { + **ACCOUNT_TABLE_ENTRY, + "DisplayName": { + "LocalizedLabels": [{"Label": "Account", "LanguageCode": 1033, "IsManaged": True}], + "UserLocalizedLabel": {"Label": "Account", "LanguageCode": 1033, "IsManaged": True}, + }, + }, + { + **CONTACT_TABLE_ENTRY, + "DisplayName": { + "LocalizedLabels": [{"Label": "Contact", "LanguageCode": 1033, "IsManaged": True}], + "UserLocalizedLabel": {"Label": "Contact", "LanguageCode": 1033, "IsManaged": True}, + }, + }, ] self.client._odata._list_tables.return_value = expected_tables @@ -364,9 +468,7 @@ def test_list(self): def test_list_with_filter(self): """list(filter=...) should pass the filter expression to _list_tables.""" - expected_tables = [ - {"LogicalName": "account", "SchemaName": "Account"}, - ] + expected_tables = [ACCOUNT_TABLE_ENTRY] self.client._odata._list_tables.return_value = expected_tables result = self.client.tables.list(filter="SchemaName eq 'Account'") @@ -377,9 +479,7 @@ def test_list_with_filter(self): def test_list_with_filter_none_explicit(self): """list(filter=None) should behave identically to list() with no args.""" - expected_tables = [ - {"LogicalName": "account", "SchemaName": "Account"}, - ] + expected_tables = [ACCOUNT_TABLE_ENTRY] self.client._odata._list_tables.return_value = expected_tables result = self.client.tables.list(filter=None) @@ -389,9 +489,7 @@ def test_list_with_filter_none_explicit(self): def test_list_with_select(self): """list(select=...) should pass the select list to _list_tables.""" - expected_tables = [ - {"LogicalName": "account", "SchemaName": "Account"}, - ] + expected_tables = [ACCOUNT_TABLE_ENTRY] self.client._odata._list_tables.return_value = expected_tables result = self.client.tables.list(select=["LogicalName", "SchemaName", "EntitySetName"]) @@ -404,9 +502,7 @@ def test_list_with_select(self): def test_list_with_select_none_explicit(self): """list(select=None) should behave identically to list() with no args.""" - expected_tables = [ - {"LogicalName": "account", "SchemaName": "Account"}, - ] + expected_tables = [ACCOUNT_TABLE_ENTRY] self.client._odata._list_tables.return_value = expected_tables result = self.client.tables.list(select=None) @@ -417,7 +513,11 @@ def test_list_with_select_none_explicit(self): def test_list_with_filter_and_select(self): """list(filter=..., select=...) should pass both params to _list_tables.""" expected_tables = [ - {"LogicalName": "account", "SchemaName": "Account"}, + { + "MetadataId": "70816501-edb9-4740-a16c-6a5efbc05d84", + "LogicalName": "account", + "SchemaName": "Account", + }, ] self.client._odata._list_tables.return_value = expected_tables From af887332f96c88cf3670bb3686b0b95c8c1509ab Mon Sep 17 00:00:00 2001 From: maksii <1761348+maksii@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:39:18 +0200 Subject: [PATCH 5/6] Enhance docstring for get_option_set_values method in TableOperations - Updated the docstring to clarify the types of columns supported, including Picklist, MultiSelect, Boolean, Status, and State. - Improved descriptions of the return values for better understanding of the method's functionality. --- src/PowerPlatform/Dataverse/operations/tables.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/PowerPlatform/Dataverse/operations/tables.py b/src/PowerPlatform/Dataverse/operations/tables.py index ec2093f3..55c860cf 100644 --- a/src/PowerPlatform/Dataverse/operations/tables.py +++ b/src/PowerPlatform/Dataverse/operations/tables.py @@ -360,12 +360,14 @@ def get_column_options( table: str, column: str, ) -> Optional[OptionSetInfo]: - """Get option set values for a Picklist, MultiSelect, or Boolean column. + """Get option set values for a Picklist, MultiSelect, Boolean, Status, + or State column. This method retrieves the available choices for a column that uses an option set. For Picklist and MultiSelect columns, the options are the defined choice values. For Boolean columns, the result contains the - True and False option labels. + True and False option labels. For Status and State columns, the options + are the defined status/state values. :param table: Schema name of the table (e.g. ``"account"``). :type table: :class:`str` @@ -374,7 +376,8 @@ def get_column_options( :type column: :class:`str` :return: Option set information with available choices, or ``None`` if - the column is not a choice/boolean type. + the column is not a Picklist, MultiSelect, Boolean, Status, or + State type. :rtype: :class:`~PowerPlatform.Dataverse.models.metadata.OptionSetInfo` or None From 479f0b194a00bbd528d8094ebb20a882338583e5 Mon Sep 17 00:00:00 2001 From: maksii <1761348+maksii@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:55:25 +0200 Subject: [PATCH 6/6] Refactor $select parameter handling in _ODataClient and normalize empty select list in TableOperations --- src/PowerPlatform/Dataverse/data/_odata.py | 6 ++++-- src/PowerPlatform/Dataverse/operations/tables.py | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index ca1ba5ac..2de55337 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -1519,10 +1519,12 @@ def _get_table_metadata( params: Dict[str, str] = {} if select is not None and isinstance(select, str): raise TypeError("select must be a list of property names, not a bare string") + base_fields = {"MetadataId", "LogicalName", "SchemaName", "EntitySetName"} if select: - base_fields = {"MetadataId", "LogicalName", "SchemaName", "EntitySetName"} merged = list(base_fields | set(select)) - params["$select"] = ",".join(merged) + else: + merged = list(base_fields) + params["$select"] = ",".join(merged) expand_parts: List[str] = [] if include_attributes: expand_parts.append("Attributes") diff --git a/src/PowerPlatform/Dataverse/operations/tables.py b/src/PowerPlatform/Dataverse/operations/tables.py index 55c860cf..bd5ce3ad 100644 --- a/src/PowerPlatform/Dataverse/operations/tables.py +++ b/src/PowerPlatform/Dataverse/operations/tables.py @@ -208,6 +208,11 @@ def get( # Extended with relationships info = client.tables.get("account", include_relationships=True) """ + # Normalize empty list to None so callers passing select=[] get the + # lightweight path instead of an expensive full-entity-definition fetch. + if select is not None and len(select) == 0: + select = None + # When no extra parameters are passed, use the original lightweight lookup. # This ensures backward compatibility -- existing callers get identical behavior. if not include_columns and not include_relationships and select is None: