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..552da5a7 100644 --- a/src/PowerPlatform/Dataverse/common/constants.py +++ b/src/PowerPlatform/Dataverse/common/constants.py @@ -29,3 +29,32 @@ 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_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" +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..2de55337 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -34,6 +34,13 @@ 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, + ODATA_TYPE_STATE_ATTRIBUTE, + ODATA_TYPE_STATUS_ATTRIBUTE, +) from .. import __version__ as _SDK_VERSION @@ -80,6 +87,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 +1479,267 @@ 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") + base_fields = {"MetadataId", "LogicalName", "SchemaName", "EntitySetName"} + if select: + merged = list(base_fields | set(select)) + else: + merged = list(base_fields) + 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, + ODATA_TYPE_STATUS_ATTRIBUTE, + ODATA_TYPE_STATE_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..bd5ce3ad 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,305 @@ 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) + """ + # 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: + 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"), + } + + # 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: + 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 + + # -------------------------------------------------------------- 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: - return od._get_table_info(table) + 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, 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. 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` + :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 Picklist, MultiSelect, Boolean, Status, or + State 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: + 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/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 new file mode 100644 index 00000000..ca504ca0 --- /dev/null +++ b/tests/unit/models/test_metadata.py @@ -0,0 +1,247 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Tests for metadata models.""" + +from PowerPlatform.Dataverse.models.metadata import ( + ColumnMetadata, + 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: + """Tests for ColumnMetadata.""" + + def test_from_api_response_full(self): + """Test full API response maps all 13 fields correctly.""" + col = ColumnMetadata.from_api_response(EMAILADDRESS1_COLUMN) + 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 == "024a2ee3-b983-4fd8-8991-f8d548a227e0" + + 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_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 = { + "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.""" + opt = OptionItem.from_api_response(PICKLIST_OPTIONSET["Options"][0]) + 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 + + 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.""" + 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" + + def test_from_api_response_boolean(self): + """Test boolean-style OptionSet with TrueOption and FalseOption.""" + 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 + 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_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"} + 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..3c45d296 100644 --- a/tests/unit/test_tables_operations.py +++ b/tests/unit/test_tables_operations.py @@ -7,8 +7,21 @@ 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 +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): @@ -89,13 +102,361 @@ 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": "70816501-edb9-4740-a16c-6a5efbc05d84", + "Attributes": [ACCOUNT_NAME_COLUMN], + } + 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.assertNotIn("columns_created", result) + self.assertEqual(len(result["columns"]), 1) + 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.""" + raw = { + "SchemaName": "Account", + "LogicalName": "account", + "EntitySetName": "accounts", + "MetadataId": "70816501-edb9-4740-a16c-6a5efbc05d84", + "OneToManyRelationships": [ACCOUNT_CHATS_RELATIONSHIP], + "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) + 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.""" + self.client._odata._get_table_metadata.return_value = ACCOUNT_TABLE_FULL + + 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 = [ACCOUNT_NAME_COLUMN, EMAILADDRESS1_COLUMN] + 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[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.""" + 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.""" + self.client._odata._get_table_column.return_value = EMAILADDRESS1_COLUMN + + 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") + 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.""" + 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.""" + 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.""" + 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 = [ + {**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 + + 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 = [{**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") + + 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): """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 @@ -107,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'") @@ -120,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) @@ -132,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"]) @@ -147,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) @@ -160,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