diff --git a/.claude/skills/dataverse-sdk-use/SKILL.md b/.claude/skills/dataverse-sdk-use/SKILL.md index a0063863..c2010a70 100644 --- a/.claude/skills/dataverse-sdk-use/SKILL.md +++ b/.claude/skills/dataverse-sdk-use/SKILL.md @@ -232,7 +232,7 @@ client.tables.delete("new_Product") #### Create One-to-Many Relationship ```python -from PowerPlatform.Dataverse.models.metadata import ( +from PowerPlatform.Dataverse.models.relationship import ( LookupAttributeMetadata, OneToManyRelationshipMetadata, Label, @@ -264,7 +264,7 @@ print(f"Created lookup field: {result['lookup_schema_name']}") #### Create Many-to-Many Relationship ```python -from PowerPlatform.Dataverse.models.metadata import ManyToManyRelationshipMetadata +from PowerPlatform.Dataverse.models.relationship import ManyToManyRelationshipMetadata relationship = ManyToManyRelationshipMetadata( schema_name="new_employee_project", diff --git a/CHANGELOG.md b/CHANGELOG.md index bd039234..1660f4f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,57 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.0b5] - 2026-02-27 + +### Fixed +- UpsertMultiple: exclude alternate key fields from request body (#127). The create path of UpsertMultiple failed with `400 Bad Request` when alternate key column values appeared in both the body and `@odata.id`. + +## [0.1.0b4] - 2026-02-25 + +### Added +- Operation namespaces: `client.records`, `client.query`, `client.tables`, `client.files` (#102) +- Relationship management: `create_one_to_many_relationship`, `create_many_to_many_relationship`, `get_relationship`, `delete_relationship`, `create_lookup_field` with typed `RelationshipInfo` return model (#105, #114) +- `client.records.upsert()` for upsert operations with alternate key support (#106) +- `client.files.upload()` for file upload operations (#111) +- `client.tables.list(filter=, select=)` parameters for filtering and projecting table metadata (#112) +- Cascade behavior constants (`CASCADE_BEHAVIOR_CASCADE`, `CASCADE_BEHAVIOR_REMOVE_LINK`, etc.) and input models (`CascadeConfiguration`, `LookupAttributeMetadata`, `Label`, `LocalizedLabel`) + +### Deprecated +- All flat methods on `DataverseClient` (`create`, `update`, `delete`, `get`, `query_sql`, `upload_file`, etc.) now emit `DeprecationWarning` and delegate to the corresponding namespaced operations + +## [0.1.0b3] - 2025-12-19 + +### Added +- Client-side correlation ID and client request ID for request tracing (#70) +- Unit tests for `DataverseClient` (#71) + +### Changed +- Standardized package versioning (#84) +- Updated package link (#69) + +### Fixed +- Retry logic for examples (#72) +- Removed double space formatting issue (#82) +- Updated CI trigger to include main branch (#81) + +## [0.1.0b2] - 2025-11-17 + +### Added +- Enforce Black formatting across the codebase (#61, #62) +- Python 3.14 support added to `pyproject.toml` (#55) + +### Changed +- Removed `pandas` dependency (#57) +- Refactored SDK architecture and quality improvements (#55) +- Prefixed table names with schema name for consistency (#51) +- Updated docstrings across core modules (#54, #63) + +### Fixed +- Fixed `get` for single-select option set columns (#52) +- Fixed example filename references and documentation URLs (#60) +- Fixed API documentation link in examples (#64) +- Fixed CI pipeline to use modern `pyproject.toml` dev dependencies (#56, #59) + ## [0.1.0b1] - 2025-11-14 ### Added @@ -19,6 +70,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Comprehensive error handling with specific exception types (`DataverseError`, `AuthenticationError`, etc.) (#22, #24) - HTTP retry logic with exponential backoff for resilient operations (#72) +[0.1.0b5]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b4...v0.1.0b5 +[0.1.0b4]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b3...v0.1.0b4 [0.1.0b3]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b2...v0.1.0b3 [0.1.0b2]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b1...v0.1.0b2 [0.1.0b1]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/releases/tag/v0.1.0b1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75612b84..d09ba322 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -109,4 +109,16 @@ Brief summary of the release ### Fixed - Bug fix 1 (#125) - Bug fix 2 (#126) +``` + +**Post-Release Version Bump:** + +After tagging and publishing a release, immediately bump the version on `main` to the next +development target. This ensures builds from source are clearly distinguished from the +published release: + +```bash +# After publishing v0.1.0b4, bump to v0.1.0b5 on main +# Update version in pyproject.toml +# Commit directly to main: "Bump version to 0.1.0b5 for next development cycle" ``` \ No newline at end of file diff --git a/README.md b/README.md index ff4e0bb4..a83dcf9b 100644 --- a/README.md +++ b/README.md @@ -317,13 +317,12 @@ client.tables.delete("new_Product") Create relationships between tables using the relationship API. For a complete working example, see [examples/advanced/relationships.py](https://github.com/microsoft/PowerPlatform-DataverseClient-Python/blob/main/examples/advanced/relationships.py). ```python -from PowerPlatform.Dataverse.models.metadata import ( +from PowerPlatform.Dataverse.models.relationship import ( LookupAttributeMetadata, OneToManyRelationshipMetadata, ManyToManyRelationshipMetadata, - Label, - LocalizedLabel, ) +from PowerPlatform.Dataverse.models.labels import Label, LocalizedLabel # Create a one-to-many relationship: Department (1) -> Employee (N) # This adds a "Department" lookup field to the Employee table diff --git a/examples/advanced/relationships.py b/examples/advanced/relationships.py index ecbb21e1..dba9a75a 100644 --- a/examples/advanced/relationships.py +++ b/examples/advanced/relationships.py @@ -20,14 +20,13 @@ import time from azure.identity import InteractiveBrowserCredential from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.models.metadata import ( +from PowerPlatform.Dataverse.models.relationship import ( LookupAttributeMetadata, OneToManyRelationshipMetadata, ManyToManyRelationshipMetadata, - Label, - LocalizedLabel, CascadeConfiguration, ) +from PowerPlatform.Dataverse.models.labels import Label, LocalizedLabel from PowerPlatform.Dataverse.common.constants import ( CASCADE_BEHAVIOR_NO_CASCADE, CASCADE_BEHAVIOR_REMOVE_LINK, diff --git a/pyproject.toml b/pyproject.toml index 8a3cc6e4..8e26a78e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "PowerPlatform-Dataverse-Client" -version = "0.1.0b3" +version = "0.1.0b6" description = "Python SDK for Microsoft Dataverse" readme = {file = "README.md", content-type = "text/markdown"} authors = [{name = "Microsoft Corporation"}] diff --git a/src/PowerPlatform/Dataverse/__init__.py b/src/PowerPlatform/Dataverse/__init__.py index 92f52538..95b5171c 100644 --- a/src/PowerPlatform/Dataverse/__init__.py +++ b/src/PowerPlatform/Dataverse/__init__.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -from .__version__ import __version__ +from importlib.metadata import version + +__version__ = version("PowerPlatform-Dataverse-Client") __all__ = ["__version__"] diff --git a/src/PowerPlatform/Dataverse/__version__.py b/src/PowerPlatform/Dataverse/__version__.py deleted file mode 100644 index a245b42d..00000000 --- a/src/PowerPlatform/Dataverse/__version__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -"""Version information for PowerPlatform-Dataverse-Client package.""" - -__version__ = "0.1.0b3" diff --git a/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md b/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md index a0063863..c2010a70 100644 --- a/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md +++ b/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md @@ -232,7 +232,7 @@ client.tables.delete("new_Product") #### Create One-to-Many Relationship ```python -from PowerPlatform.Dataverse.models.metadata import ( +from PowerPlatform.Dataverse.models.relationship import ( LookupAttributeMetadata, OneToManyRelationshipMetadata, Label, @@ -264,7 +264,7 @@ print(f"Created lookup field: {result['lookup_schema_name']}") #### Create Many-to-Many Relationship ```python -from PowerPlatform.Dataverse.models.metadata import ManyToManyRelationshipMetadata +from PowerPlatform.Dataverse.models.relationship import ManyToManyRelationshipMetadata relationship = ManyToManyRelationshipMetadata( schema_name="new_employee_project", diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index 986a85b1..eb341f22 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -35,7 +35,7 @@ VALIDATION_UNSUPPORTED_CACHE_KIND, ) -from ..__version__ import __version__ as _SDK_VERSION +from .. import __version__ as _SDK_VERSION _USER_AGENT = f"DataverseSvcPythonClient:{_SDK_VERSION}" _GUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") @@ -460,12 +460,11 @@ def _upsert_multiple( } if conflicting: raise ValueError(f"record payload conflicts with alternate_key on fields: {sorted(conflicting)!r}") - combined: Dict[str, Any] = {**alt_key_lower, **record_processed} - if "@odata.type" not in combined: - combined["@odata.type"] = f"Microsoft.Dynamics.CRM.{logical_name}" + if "@odata.type" not in record_processed: + record_processed["@odata.type"] = f"Microsoft.Dynamics.CRM.{logical_name}" key_str = self._build_alternate_key_str(alt_key) - combined["@odata.id"] = f"{entity_set}({key_str})" - targets.append(combined) + record_processed["@odata.id"] = f"{entity_set}({key_str})" + targets.append(record_processed) payload = {"Targets": targets} url = f"{self.api}/{entity_set}/Microsoft.Dynamics.CRM.UpsertMultiple" self._request("post", url, json=payload, expected=(200, 201, 204)) diff --git a/src/PowerPlatform/Dataverse/data/_relationships.py b/src/PowerPlatform/Dataverse/data/_relationships.py index 57e31d57..c2099a53 100644 --- a/src/PowerPlatform/Dataverse/data/_relationships.py +++ b/src/PowerPlatform/Dataverse/data/_relationships.py @@ -35,9 +35,9 @@ def _create_one_to_many_relationship( Posts to /RelationshipDefinitions with OneToManyRelationshipMetadata. :param lookup: Lookup attribute metadata (LookupAttributeMetadata instance). - :type lookup: ~PowerPlatform.Dataverse.models.metadata.LookupAttributeMetadata + :type lookup: ~PowerPlatform.Dataverse.models.relationship.LookupAttributeMetadata :param relationship: Relationship metadata (OneToManyRelationshipMetadata instance). - :type relationship: ~PowerPlatform.Dataverse.models.metadata.OneToManyRelationshipMetadata + :type relationship: ~PowerPlatform.Dataverse.models.relationship.OneToManyRelationshipMetadata :param solution: Optional solution unique name to add the relationship to. :type solution: ``str`` | ``None`` @@ -80,7 +80,7 @@ def _create_many_to_many_relationship( Posts to /RelationshipDefinitions with ManyToManyRelationshipMetadata. :param relationship: Relationship metadata (ManyToManyRelationshipMetadata instance). - :type relationship: ~PowerPlatform.Dataverse.models.metadata.ManyToManyRelationshipMetadata + :type relationship: ~PowerPlatform.Dataverse.models.relationship.ManyToManyRelationshipMetadata :param solution: Optional solution unique name to add the relationship to. :type solution: ``str`` | ``None`` diff --git a/src/PowerPlatform/Dataverse/models/metadata.py b/src/PowerPlatform/Dataverse/models/metadata.py deleted file mode 100644 index 7696c6fc..00000000 --- a/src/PowerPlatform/Dataverse/models/metadata.py +++ /dev/null @@ -1,396 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -""" -Metadata entity types for Microsoft Dataverse. - -These classes represent the metadata entity types used in the Dataverse Web API -for defining and managing table definitions, attributes, and relationships. - -See: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/reference/metadataentitytypes -""" - -from __future__ import annotations - -from typing import Any, Dict, List, Optional -from dataclasses import dataclass, field - -from ..common.constants import ( - ODATA_TYPE_LOCALIZED_LABEL, - ODATA_TYPE_LABEL, - ODATA_TYPE_LOOKUP_ATTRIBUTE, - ODATA_TYPE_ONE_TO_MANY_RELATIONSHIP, - ODATA_TYPE_MANY_TO_MANY_RELATIONSHIP, - CASCADE_BEHAVIOR_CASCADE, - CASCADE_BEHAVIOR_NO_CASCADE, - CASCADE_BEHAVIOR_REMOVE_LINK, - CASCADE_BEHAVIOR_RESTRICT, -) - - -@dataclass -class LocalizedLabel: - """ - Represents a localized label with a language code. - - :param label: The text of the label. - :type label: str - :param language_code: The language code (LCID), e.g., 1033 for English. - :type language_code: int - :param additional_properties: Optional dict of additional properties to include - in the Web API payload. These are merged last and can override default values. - :type additional_properties: Optional[Dict[str, Any]] - """ - - label: str - language_code: int - additional_properties: Optional[Dict[str, Any]] = None - - def to_dict(self) -> Dict[str, Any]: - """ - Convert to Web API JSON format. - - Example:: - - >>> label = LocalizedLabel(label="Account", language_code=1033) - >>> label.to_dict() - { - '@odata.type': 'Microsoft.Dynamics.CRM.LocalizedLabel', - 'Label': 'Account', - 'LanguageCode': 1033 - } - """ - result = { - "@odata.type": ODATA_TYPE_LOCALIZED_LABEL, - "Label": self.label, - "LanguageCode": self.language_code, - } - if self.additional_properties: - result.update(self.additional_properties) - return result - - -@dataclass -class Label: - """ - Represents a label that can have multiple localized versions. - - :param localized_labels: List of LocalizedLabel instances. - :type localized_labels: List[LocalizedLabel] - :param user_localized_label: Optional user-specific localized label. - :type user_localized_label: Optional[LocalizedLabel] - :param additional_properties: Optional dict of additional properties to include - in the Web API payload. These are merged last and can override default values. - :type additional_properties: Optional[Dict[str, Any]] - """ - - localized_labels: List[LocalizedLabel] - user_localized_label: Optional[LocalizedLabel] = None - additional_properties: Optional[Dict[str, Any]] = None - - def to_dict(self) -> Dict[str, Any]: - """ - Convert to Web API JSON format. - - Example:: - - >>> label = Label(localized_labels=[LocalizedLabel("Account", 1033)]) - >>> label.to_dict() - { - '@odata.type': 'Microsoft.Dynamics.CRM.Label', - 'LocalizedLabels': [ - {'@odata.type': '...', 'Label': 'Account', 'LanguageCode': 1033} - ], - 'UserLocalizedLabel': {'@odata.type': '...', 'Label': 'Account', ...} - } - """ - result = { - "@odata.type": ODATA_TYPE_LABEL, - "LocalizedLabels": [ll.to_dict() for ll in self.localized_labels], - } - # Use explicit user_localized_label, or default to first localized label - if self.user_localized_label: - result["UserLocalizedLabel"] = self.user_localized_label.to_dict() - elif self.localized_labels: - result["UserLocalizedLabel"] = self.localized_labels[0].to_dict() - if self.additional_properties: - result.update(self.additional_properties) - return result - - -@dataclass -class CascadeConfiguration: - """ - Defines cascade behavior for relationship operations. - - :param assign: Cascade behavior for assign operations. - :type assign: str - :param delete: Cascade behavior for delete operations. - :type delete: str - :param merge: Cascade behavior for merge operations. - :type merge: str - :param reparent: Cascade behavior for reparent operations. - :type reparent: str - :param share: Cascade behavior for share operations. - :type share: str - :param unshare: Cascade behavior for unshare operations. - :type unshare: str - :param additional_properties: Optional dict of additional properties to include - in the Web API payload (e.g., "Archive", "RollupView"). These are merged - last and can override default values. - :type additional_properties: Optional[Dict[str, Any]] - - Valid values for each parameter: - - "Cascade": Perform the operation on all related records - - "NoCascade": Do not perform the operation on related records - - "RemoveLink": Remove the relationship link but keep the records - - "Restrict": Prevent the operation if related records exist - """ - - assign: str = CASCADE_BEHAVIOR_NO_CASCADE - delete: str = CASCADE_BEHAVIOR_REMOVE_LINK - merge: str = CASCADE_BEHAVIOR_NO_CASCADE - reparent: str = CASCADE_BEHAVIOR_NO_CASCADE - share: str = CASCADE_BEHAVIOR_NO_CASCADE - unshare: str = CASCADE_BEHAVIOR_NO_CASCADE - additional_properties: Optional[Dict[str, Any]] = None - - def to_dict(self) -> Dict[str, Any]: - """ - Convert to Web API JSON format. - - Example:: - - >>> config = CascadeConfiguration(delete="Cascade", assign="NoCascade") - >>> config.to_dict() - { - 'Assign': 'NoCascade', - 'Delete': 'Cascade', - 'Merge': 'NoCascade', - 'Reparent': 'NoCascade', - 'Share': 'NoCascade', - 'Unshare': 'NoCascade' - } - """ - result = { - "Assign": self.assign, - "Delete": self.delete, - "Merge": self.merge, - "Reparent": self.reparent, - "Share": self.share, - "Unshare": self.unshare, - } - if self.additional_properties: - result.update(self.additional_properties) - return result - - -@dataclass -class LookupAttributeMetadata: - """ - Metadata for a lookup attribute. - - :param schema_name: Schema name for the attribute (e.g., "new_AccountId"). - :type schema_name: str - :param display_name: Display name for the attribute. - :type display_name: Label - :param description: Optional description of the attribute. - :type description: Optional[Label] - :param required_level: Requirement level for the attribute. - :type required_level: str - :param additional_properties: Optional dict of additional properties to include - in the Web API payload. Useful for setting properties like "Targets" (to - specify which entity types the lookup can reference), "LogicalName", - "IsSecured", "IsValidForAdvancedFind", etc. These are merged last and - can override default values. - :type additional_properties: Optional[Dict[str, Any]] - - Valid required_level values: - - "None": The attribute is optional - - "Recommended": The attribute is recommended - - "ApplicationRequired": The attribute is required - """ - - schema_name: str - display_name: Label - description: Optional[Label] = None - required_level: str = "None" - additional_properties: Optional[Dict[str, Any]] = None - - def to_dict(self) -> Dict[str, Any]: - """ - Convert to Web API JSON format. - - Example:: - - >>> lookup = LookupAttributeMetadata( - ... schema_name="new_AccountId", - ... display_name=Label([LocalizedLabel("Account", 1033)]) - ... ) - >>> lookup.to_dict() - { - '@odata.type': 'Microsoft.Dynamics.CRM.LookupAttributeMetadata', - 'SchemaName': 'new_AccountId', - 'AttributeType': 'Lookup', - 'AttributeTypeName': {'Value': 'LookupType'}, - 'DisplayName': {...}, - 'RequiredLevel': {'Value': 'None', 'CanBeChanged': True, ...} - } - """ - result = { - "@odata.type": ODATA_TYPE_LOOKUP_ATTRIBUTE, - "SchemaName": self.schema_name, - "AttributeType": "Lookup", - "AttributeTypeName": {"Value": "LookupType"}, - "DisplayName": self.display_name.to_dict(), - "RequiredLevel": { - "Value": self.required_level, - "CanBeChanged": True, - "ManagedPropertyLogicalName": "canmodifyrequirementlevelsettings", - }, - } - if self.description: - result["Description"] = self.description.to_dict() - if self.additional_properties: - result.update(self.additional_properties) - return result - - -@dataclass -class OneToManyRelationshipMetadata: - """ - Metadata for a one-to-many entity relationship. - - :param schema_name: Schema name for the relationship (e.g., "new_Account_Orders"). - :type schema_name: str - :param referenced_entity: Logical name of the referenced (parent) entity. - :type referenced_entity: str - :param referencing_entity: Logical name of the referencing (child) entity. - :type referencing_entity: str - :param referenced_attribute: Attribute on the referenced entity (typically the primary key). - :type referenced_attribute: str - :param cascade_configuration: Cascade behavior configuration. - :type cascade_configuration: CascadeConfiguration - :param referencing_attribute: Optional name for the referencing attribute (usually auto-generated). - :type referencing_attribute: Optional[str] - :param additional_properties: Optional dict of additional properties to include - in the Web API payload. Useful for setting inherited properties like - "IsValidForAdvancedFind", "IsCustomizable", "SecurityTypes", etc. - These are merged last and can override default values. - :type additional_properties: Optional[Dict[str, Any]] - """ - - schema_name: str - referenced_entity: str - referencing_entity: str - referenced_attribute: str - cascade_configuration: CascadeConfiguration = field(default_factory=CascadeConfiguration) - referencing_attribute: Optional[str] = None - additional_properties: Optional[Dict[str, Any]] = None - - def to_dict(self) -> Dict[str, Any]: - """ - Convert to Web API JSON format. - - Example:: - - >>> rel = OneToManyRelationshipMetadata( - ... schema_name="new_account_orders", - ... referenced_entity="account", - ... referencing_entity="new_order", - ... referenced_attribute="accountid" - ... ) - >>> rel.to_dict() - { - '@odata.type': 'Microsoft.Dynamics.CRM.OneToManyRelationshipMetadata', - 'SchemaName': 'new_account_orders', - 'ReferencedEntity': 'account', - 'ReferencingEntity': 'new_order', - 'ReferencedAttribute': 'accountid', - 'CascadeConfiguration': {...} - } - """ - result = { - "@odata.type": ODATA_TYPE_ONE_TO_MANY_RELATIONSHIP, - "SchemaName": self.schema_name, - "ReferencedEntity": self.referenced_entity, - "ReferencingEntity": self.referencing_entity, - "ReferencedAttribute": self.referenced_attribute, - "CascadeConfiguration": self.cascade_configuration.to_dict(), - } - if self.referencing_attribute: - result["ReferencingAttribute"] = self.referencing_attribute - if self.additional_properties: - result.update(self.additional_properties) - return result - - -@dataclass -class ManyToManyRelationshipMetadata: - """ - Metadata for a many-to-many entity relationship. - - :param schema_name: Schema name for the relationship. - :type schema_name: str - :param entity1_logical_name: Logical name of the first entity. - :type entity1_logical_name: str - :param entity2_logical_name: Logical name of the second entity. - :type entity2_logical_name: str - :param intersect_entity_name: Name for the intersect table (defaults to schema_name if not provided). - :type intersect_entity_name: Optional[str] - :param additional_properties: Optional dict of additional properties to include - in the Web API payload. Useful for setting inherited properties like - "IsValidForAdvancedFind", "IsCustomizable", "SecurityTypes", or direct - properties like "Entity1NavigationPropertyName". These are merged last - and can override default values. - :type additional_properties: Optional[Dict[str, Any]] - """ - - schema_name: str - entity1_logical_name: str - entity2_logical_name: str - intersect_entity_name: Optional[str] = None - additional_properties: Optional[Dict[str, Any]] = None - - def to_dict(self) -> Dict[str, Any]: - """ - Convert to Web API JSON format. - - Example:: - - >>> rel = ManyToManyRelationshipMetadata( - ... schema_name="new_account_contact", - ... entity1_logical_name="account", - ... entity2_logical_name="contact" - ... ) - >>> rel.to_dict() - { - '@odata.type': 'Microsoft.Dynamics.CRM.ManyToManyRelationshipMetadata', - 'SchemaName': 'new_account_contact', - 'Entity1LogicalName': 'account', - 'Entity2LogicalName': 'contact', - 'IntersectEntityName': 'new_account_contact' - } - """ - # IntersectEntityName is required - use provided value or default to schema_name - intersect_name = self.intersect_entity_name or self.schema_name - result = { - "@odata.type": ODATA_TYPE_MANY_TO_MANY_RELATIONSHIP, - "SchemaName": self.schema_name, - "Entity1LogicalName": self.entity1_logical_name, - "Entity2LogicalName": self.entity2_logical_name, - "IntersectEntityName": intersect_name, - } - if self.additional_properties: - result.update(self.additional_properties) - return result - - -__all__ = [ - "LocalizedLabel", - "Label", - "CascadeConfiguration", - "LookupAttributeMetadata", - "OneToManyRelationshipMetadata", - "ManyToManyRelationshipMetadata", -] diff --git a/src/PowerPlatform/Dataverse/models/record.py b/src/PowerPlatform/Dataverse/models/record.py new file mode 100644 index 00000000..f3eddc30 --- /dev/null +++ b/src/PowerPlatform/Dataverse/models/record.py @@ -0,0 +1,114 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Record data model for Dataverse entities.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, Iterator, Optional + +__all__ = ["Record"] + +_ODATA_PREFIX = "@odata." + + +@dataclass +class Record: + """Strongly-typed Dataverse record with dict-like backward compatibility. + + Wraps raw OData response data into a structured object while preserving + ``result["key"]`` access patterns for existing code. + + :param id: Record GUID. Empty string if not extracted (e.g. paginated + results, SQL queries). + :type id: :class:`str` + :param table: Table schema name used in the request. + :type table: :class:`str` + :param data: Record field data as key-value pairs. + :type data: :class:`dict` + :param etag: ETag for optimistic concurrency, extracted from + ``@odata.etag`` in the API response. + :type etag: :class:`str` or None + + Example:: + + record = client.records.get("account", account_id, select=["name"]) + print(record.id) # structured access + print(record["name"]) # dict-like access (backward compat) + """ + + id: str = "" + table: str = "" + data: Dict[str, Any] = field(default_factory=dict) + etag: Optional[str] = None + + # --------------------------------------------------------- dict-like access + + def __getitem__(self, key: str) -> Any: + return self.data[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.data[key] = value + + def __delitem__(self, key: str) -> None: + del self.data[key] + + def __contains__(self, key: object) -> bool: + return key in self.data + + def __iter__(self) -> Iterator[str]: + return iter(self.data) + + def __len__(self) -> int: + return len(self.data) + + def get(self, key: str, default: Any = None) -> Any: + """Return value for *key*, or *default* if not present.""" + return self.data.get(key, default) + + def keys(self): + """Return data keys.""" + return self.data.keys() + + def values(self): + """Return data values.""" + return self.data.values() + + def items(self): + """Return data items.""" + return self.data.items() + + # -------------------------------------------------------------- factories + + @classmethod + def from_api_response( + cls, + table: str, + response_data: Dict[str, Any], + *, + record_id: str = "", + ) -> Record: + """Create a :class:`Record` from a raw OData API response. + + Strips ``@odata.*`` annotation keys from the data and extracts the + ``@odata.etag`` value if present. + + :param table: Table schema name. + :type table: :class:`str` + :param response_data: Raw JSON dict from the OData response. + :type response_data: :class:`dict` + :param record_id: Known record GUID. Pass explicitly when available + (e.g. single-record get). Defaults to empty string. + :type record_id: :class:`str` + :rtype: :class:`Record` + """ + etag = response_data.get("@odata.etag") + data = {k: v for k, v in response_data.items() if not k.startswith(_ODATA_PREFIX)} + return cls(id=record_id, table=table, data=data, etag=etag) + + # -------------------------------------------------------------- conversion + + def to_dict(self) -> Dict[str, Any]: + """Return a plain dict copy of the record data (excludes metadata).""" + return dict(self.data) diff --git a/src/PowerPlatform/Dataverse/models/table_info.py b/src/PowerPlatform/Dataverse/models/table_info.py new file mode 100644 index 00000000..6c503a03 --- /dev/null +++ b/src/PowerPlatform/Dataverse/models/table_info.py @@ -0,0 +1,224 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Table and column metadata models for Dataverse.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, ClassVar, Dict, Iterator, List, Optional + +__all__ = ["TableInfo", "ColumnInfo"] + + +@dataclass +class ColumnInfo: + """Column metadata from a Dataverse table definition. + + :param schema_name: Column schema name (e.g. ``"new_Price"``). + :type schema_name: :class:`str` + :param logical_name: Column logical name (lowercase). + :type logical_name: :class:`str` + :param type: Column type string (e.g. ``"String"``, ``"Integer"``). + :type type: :class:`str` + :param is_primary: Whether this is the primary name column. + :type is_primary: :class:`bool` + :param is_required: Whether the column is required. + :type is_required: :class:`bool` + :param max_length: Maximum length for string columns. + :type max_length: :class:`int` or None + :param display_name: Human-readable display name. + :type display_name: :class:`str` or None + :param description: Column description. + :type description: :class:`str` or None + """ + + schema_name: str = "" + logical_name: str = "" + type: str = "" + is_primary: bool = False + is_required: bool = False + max_length: Optional[int] = None + display_name: Optional[str] = None + description: Optional[str] = None + + @classmethod + def from_api_response(cls, response_data: Dict[str, Any]) -> ColumnInfo: + """Create from a raw Dataverse ``AttributeMetadata`` API response. + + :param response_data: Raw attribute metadata dict (PascalCase keys). + :type response_data: :class:`dict` + :rtype: :class:`ColumnInfo` + """ + # Extract display name from nested structure + display_name_obj = response_data.get("DisplayName", {}) + user_label = display_name_obj.get("UserLocalizedLabel") or {} + display_name = user_label.get("Label") + + # Extract description from nested structure + desc_obj = response_data.get("Description", {}) + desc_label = desc_obj.get("UserLocalizedLabel") or {} + description = desc_label.get("Label") + + # Extract required level + req_level = response_data.get("RequiredLevel", {}) + is_required = req_level.get("Value", "None") != "None" if isinstance(req_level, dict) else False + + return cls( + schema_name=response_data.get("SchemaName", ""), + logical_name=response_data.get("LogicalName", ""), + type=response_data.get("AttributeTypeName", {}).get("Value", response_data.get("AttributeType", "")), + is_primary=response_data.get("IsPrimaryName", False), + is_required=is_required, + max_length=response_data.get("MaxLength"), + display_name=display_name, + description=description, + ) + + +@dataclass +class TableInfo: + """Table metadata with dict-like backward compatibility. + + Supports both new attribute access (``info.schema_name``) and legacy + dict-key access (``info["table_schema_name"]``) for backward + compatibility with code written against the raw dict API. + + :param schema_name: Table schema name (e.g. ``"Account"``). + :type schema_name: :class:`str` + :param logical_name: Table logical name (lowercase). + :type logical_name: :class:`str` + :param entity_set_name: OData entity set name. + :type entity_set_name: :class:`str` + :param metadata_id: Metadata GUID. + :type metadata_id: :class:`str` + :param display_name: Human-readable display name. + :type display_name: :class:`str` or None + :param description: Table description. + :type description: :class:`str` or None + :param columns: Column metadata (when retrieved). + :type columns: :class:`list` of :class:`ColumnInfo` or None + :param columns_created: Column schema names created with the table. + :type columns_created: :class:`list` of :class:`str` or None + + Example:: + + info = client.tables.create("new_Product", {"new_Price": "decimal"}) + print(info.schema_name) # new attribute access + print(info["table_schema_name"]) # legacy dict-key access + """ + + schema_name: str = "" + logical_name: str = "" + entity_set_name: str = "" + metadata_id: str = "" + display_name: Optional[str] = None + description: Optional[str] = None + columns: Optional[List[ColumnInfo]] = field(default=None, repr=False) + columns_created: Optional[List[str]] = field(default=None, repr=False) + + # Maps legacy dict keys (used by existing code) to attribute names. + _LEGACY_KEY_MAP: ClassVar[Dict[str, str]] = { + "table_schema_name": "schema_name", + "table_logical_name": "logical_name", + "entity_set_name": "entity_set_name", + "metadata_id": "metadata_id", + "columns_created": "columns_created", + } + + # --------------------------------------------------------- dict-like access + + def _resolve_key(self, key: str) -> str: + """Resolve a legacy or direct key to an attribute name.""" + return self._LEGACY_KEY_MAP.get(key, key) + + def __getitem__(self, key: str) -> Any: + attr = self._resolve_key(key) + if hasattr(self, attr): + return getattr(self, attr) + raise KeyError(key) + + def __contains__(self, key: object) -> bool: + if not isinstance(key, str): + return False + attr = self._resolve_key(key) + return hasattr(self, attr) + + def __iter__(self) -> Iterator[str]: + return iter(self._LEGACY_KEY_MAP) + + def __len__(self) -> int: + return len(self._LEGACY_KEY_MAP) + + def get(self, key: str, default: Any = None) -> Any: + """Return value for *key*, or *default* if not present.""" + try: + return self[key] + except KeyError: + return default + + def keys(self): + """Return legacy dict keys.""" + return self._LEGACY_KEY_MAP.keys() + + def values(self): + """Return values corresponding to legacy dict keys.""" + return [getattr(self, attr) for attr in self._LEGACY_KEY_MAP.values()] + + def items(self): + """Return (legacy_key, value) pairs.""" + return [(k, getattr(self, attr)) for k, attr in self._LEGACY_KEY_MAP.items()] + + # -------------------------------------------------------------- factories + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> TableInfo: + """Create from an SDK internal dict (snake_case keys). + + This handles the dict format returned by ``_create_table`` and + ``_get_table_info`` in the OData layer. + + :param data: Dictionary with SDK snake_case keys. + :type data: :class:`dict` + :rtype: :class:`TableInfo` + """ + return cls( + schema_name=data.get("table_schema_name", ""), + logical_name=data.get("table_logical_name", ""), + entity_set_name=data.get("entity_set_name", ""), + metadata_id=data.get("metadata_id", ""), + columns_created=data.get("columns_created"), + ) + + @classmethod + def from_api_response(cls, response_data: Dict[str, Any]) -> TableInfo: + """Create from a raw Dataverse ``EntityDefinition`` API response. + + :param response_data: Raw entity metadata dict (PascalCase keys). + :type response_data: :class:`dict` + :rtype: :class:`TableInfo` + """ + # Extract display name from nested structure + display_name_obj = response_data.get("DisplayName", {}) + user_label = display_name_obj.get("UserLocalizedLabel") or {} + display_name = user_label.get("Label") + + # Extract description from nested structure + desc_obj = response_data.get("Description", {}) + desc_label = desc_obj.get("UserLocalizedLabel") or {} + description = desc_label.get("Label") + + return cls( + schema_name=response_data.get("SchemaName", ""), + logical_name=response_data.get("LogicalName", ""), + entity_set_name=response_data.get("EntitySetName", ""), + metadata_id=response_data.get("MetadataId", ""), + display_name=display_name, + description=description, + ) + + # -------------------------------------------------------------- conversion + + def to_dict(self) -> Dict[str, Any]: + """Return a dict with legacy keys for backward compatibility.""" + return {k: getattr(self, attr) for k, attr in self._LEGACY_KEY_MAP.items()} diff --git a/src/PowerPlatform/Dataverse/operations/query.py b/src/PowerPlatform/Dataverse/operations/query.py index 9193fe9f..aa07a9a8 100644 --- a/src/PowerPlatform/Dataverse/operations/query.py +++ b/src/PowerPlatform/Dataverse/operations/query.py @@ -7,6 +7,8 @@ from typing import Any, Dict, List, TYPE_CHECKING +from ..models.record import Record + if TYPE_CHECKING: from ..client import DataverseClient @@ -37,7 +39,7 @@ def __init__(self, client: DataverseClient) -> None: # -------------------------------------------------------------------- sql - def sql(self, sql: str) -> List[Dict[str, Any]]: + def sql(self, sql: str) -> List[Record]: """Execute a read-only SQL query using the Dataverse Web API. The SQL query must follow the supported subset: a single SELECT @@ -47,9 +49,10 @@ def sql(self, sql: str) -> List[Dict[str, Any]]: :param sql: Supported SQL SELECT statement. :type sql: :class:`str` - :return: List of result row dictionaries. Returns an empty list when no - rows match. - :rtype: :class:`list` of :class:`dict` + :return: List of :class:`~PowerPlatform.Dataverse.models.record.Record` + objects. Returns an empty list when no rows match. + :rtype: :class:`list` of + :class:`~PowerPlatform.Dataverse.models.record.Record` :raises ~PowerPlatform.Dataverse.core.errors.ValidationError: If ``sql`` is not a string or is empty. @@ -72,4 +75,5 @@ def sql(self, sql: str) -> List[Dict[str, Any]]: ) """ with self._client._scoped_odata() as od: - return od._query_sql(sql) + rows = od._query_sql(sql) + return [Record.from_api_response("", row) for row in rows] diff --git a/src/PowerPlatform/Dataverse/operations/records.py b/src/PowerPlatform/Dataverse/operations/records.py index a86c751f..03363de3 100644 --- a/src/PowerPlatform/Dataverse/operations/records.py +++ b/src/PowerPlatform/Dataverse/operations/records.py @@ -7,6 +7,7 @@ from typing import Any, Dict, Iterable, List, Optional, Union, overload, TYPE_CHECKING +from ..models.record import Record from ..models.upsert import UpsertItem if TYPE_CHECKING: @@ -231,7 +232,7 @@ def get( record_id: str, *, select: Optional[List[str]] = None, - ) -> Dict[str, Any]: + ) -> Record: """Fetch a single record by its GUID. :param table: Schema name of the table (e.g. ``"account"``). @@ -242,8 +243,8 @@ def get( response. :type select: :class:`list` of :class:`str` or None - :return: Record dictionary with the requested attributes. - :rtype: :class:`dict` + :return: Typed record with dict-like access for backward compatibility. + :rtype: :class:`~PowerPlatform.Dataverse.models.record.Record` :raises TypeError: If ``record_id`` is not a string. @@ -253,7 +254,8 @@ def get( record = client.records.get( "account", account_id, select=["name", "telephone1"] ) - print(record["name"]) + print(record["name"]) # dict-like access + print(record.id) # structured access """ ... @@ -268,10 +270,11 @@ def get( top: Optional[int] = None, expand: Optional[List[str]] = None, page_size: Optional[int] = None, - ) -> Iterable[List[Dict[str, Any]]]: + ) -> Iterable[List[Record]]: """Fetch multiple records from a Dataverse table with pagination. - Returns a generator that yields one page (list of record dicts) at a + Returns a generator that yields one page (list of + :class:`~PowerPlatform.Dataverse.models.record.Record` objects) at a time. Automatically follows ``@odata.nextLink`` for server-side paging. :param table: Schema name of the table (e.g. ``"account"`` or @@ -298,10 +301,10 @@ def get( ``Prefer: odata.maxpagesize``. :type page_size: :class:`int` or None - :return: Generator yielding pages, where each page is a list of record - dictionaries. + :return: Generator yielding pages, where each page is a list of + :class:`~PowerPlatform.Dataverse.models.record.Record` objects. :rtype: :class:`collections.abc.Iterable` of :class:`list` of - :class:`dict` + :class:`~PowerPlatform.Dataverse.models.record.Record` Example: Fetch with filtering and pagination:: @@ -328,7 +331,7 @@ def get( top: Optional[int] = None, expand: Optional[List[str]] = None, page_size: Optional[int] = None, - ) -> Union[Dict[str, Any], Iterable[List[Dict[str, Any]]]]: + ) -> Union[Record, Iterable[List[Record]]]: """Fetch a single record by ID, or fetch multiple records with pagination. This method has two usage patterns: @@ -418,11 +421,12 @@ def get( "expand, page_size) when fetching a single record by ID" ) with self._client._scoped_odata() as od: - return od._get(table, record_id, select=select) + raw = od._get(table, record_id, select=select) + return Record.from_api_response(table, raw, record_id=record_id) - def _paged() -> Iterable[List[Dict[str, Any]]]: + def _paged() -> Iterable[List[Record]]: with self._client._scoped_odata() as od: - yield from od._get_multiple( + for page in od._get_multiple( table, select=select, filter=filter, @@ -430,7 +434,8 @@ def _paged() -> Iterable[List[Dict[str, Any]]]: top=top, expand=expand, page_size=page_size, - ) + ): + yield [Record.from_api_response(table, row) for row in page] return _paged() diff --git a/src/PowerPlatform/Dataverse/operations/tables.py b/src/PowerPlatform/Dataverse/operations/tables.py index ce5d44b3..be913059 100644 --- a/src/PowerPlatform/Dataverse/operations/tables.py +++ b/src/PowerPlatform/Dataverse/operations/tables.py @@ -7,14 +7,15 @@ from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING -from ..models.metadata import ( +from ..models.relationship import ( LookupAttributeMetadata, OneToManyRelationshipMetadata, ManyToManyRelationshipMetadata, - Label, - LocalizedLabel, CascadeConfiguration, + RelationshipInfo, ) +from ..models.labels import Label, LocalizedLabel +from ..models.table_info import TableInfo from ..common.constants import CASCADE_BEHAVIOR_REMOVE_LINK if TYPE_CHECKING: @@ -72,7 +73,7 @@ def create( *, solution: Optional[str] = None, primary_column: Optional[str] = None, - ) -> Dict[str, Any]: + ) -> TableInfo: """Create a custom table with the specified columns. :param table: Schema name of the table with customization prefix @@ -94,10 +95,11 @@ def create( defaults to ``"{prefix}_Name"``. :type primary_column: :class:`str` or None - :return: Dictionary containing table metadata including - ``table_schema_name``, ``entity_set_name``, ``table_logical_name``, - ``metadata_id``, and ``columns_created``. - :rtype: :class:`dict` + :return: Table metadata with ``schema_name``, ``entity_set_name``, + ``logical_name``, ``metadata_id``, and ``columns_created``. + Supports dict-like access with legacy keys for backward + compatibility. + :rtype: :class:`~PowerPlatform.Dataverse.models.table_info.TableInfo` :raises ~PowerPlatform.Dataverse.core.errors.MetadataError: If table creation fails or the table already exists. @@ -124,12 +126,13 @@ class ItemStatus(IntEnum): print(f"Created: {result['table_schema_name']}") """ with self._client._scoped_odata() as od: - return od._create_table( + raw = od._create_table( table, columns, solution, primary_column, ) + return TableInfo.from_dict(raw) # ----------------------------------------------------------------- delete @@ -155,17 +158,18 @@ def delete(self, table: str) -> None: # -------------------------------------------------------------------- get - def get(self, table: str) -> Optional[Dict[str, Any]]: + def get(self, table: str) -> Optional[TableInfo]: """Get basic metadata for a table if it exists. :param table: Schema name of the table (e.g. ``"new_MyTestTable"`` or ``"account"``). :type table: :class:`str` - :return: Dictionary containing ``table_schema_name``, - ``table_logical_name``, ``entity_set_name``, and ``metadata_id``. - Returns None if the table is not found. - :rtype: :class:`dict` or None + :return: Table metadata, or ``None`` if the table is not found. + Supports dict-like access with legacy keys for backward + compatibility. + :rtype: :class:`~PowerPlatform.Dataverse.models.table_info.TableInfo` + or None Example:: @@ -175,7 +179,10 @@ def get(self, table: str) -> Optional[Dict[str, Any]]: print(f"Entity set: {info['entity_set_name']}") """ with self._client._scoped_odata() as od: - return od._get_table_info(table) + raw = od._get_table_info(table) + if raw is None: + return None + return TableInfo.from_dict(raw) # ------------------------------------------------------------------- list @@ -304,22 +311,24 @@ def create_one_to_many_relationship( relationship: OneToManyRelationshipMetadata, *, solution: Optional[str] = None, - ) -> Dict[str, Any]: + ) -> RelationshipInfo: """Create a one-to-many relationship between tables. This operation creates both the relationship and the lookup attribute on the referencing table. :param lookup: Metadata defining the lookup attribute. - :type lookup: ~PowerPlatform.Dataverse.models.metadata.LookupAttributeMetadata + :type lookup: ~PowerPlatform.Dataverse.models.relationship.LookupAttributeMetadata :param relationship: Metadata defining the relationship. - :type relationship: ~PowerPlatform.Dataverse.models.metadata.OneToManyRelationshipMetadata + :type relationship: ~PowerPlatform.Dataverse.models.relationship.OneToManyRelationshipMetadata :param solution: Optional solution unique name to add relationship to. :type solution: :class:`str` or None - :return: Dictionary with ``relationship_id``, ``lookup_schema_name``, - and related metadata. - :rtype: :class:`dict` + :return: Relationship metadata with ``relationship_id``, + ``relationship_schema_name``, ``relationship_type``, + ``lookup_schema_name``, ``referenced_entity``, and + ``referencing_entity``. + :rtype: :class:`~PowerPlatform.Dataverse.models.relationship.RelationshipInfo` :raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the Web API request fails. @@ -327,7 +336,7 @@ def create_one_to_many_relationship( Example: Create a one-to-many relationship: Department (1) -> Employee (N):: - from PowerPlatform.Dataverse.models.metadata import ( + from PowerPlatform.Dataverse.models.relationship import ( LookupAttributeMetadata, OneToManyRelationshipMetadata, Label, @@ -358,14 +367,21 @@ def create_one_to_many_relationship( ) result = client.tables.create_one_to_many_relationship(lookup, relationship) - print(f"Created lookup field: {result['lookup_schema_name']}") + print(f"Created lookup field: {result.lookup_schema_name}") """ with self._client._scoped_odata() as od: - return od._create_one_to_many_relationship( + raw = od._create_one_to_many_relationship( lookup, relationship, solution, ) + return RelationshipInfo.from_one_to_many( + relationship_id=raw["relationship_id"], + relationship_schema_name=raw["relationship_schema_name"], + lookup_schema_name=raw["lookup_schema_name"], + referenced_entity=raw["referenced_entity"], + referencing_entity=raw["referencing_entity"], + ) # ----------------------------------------------------- create_many_to_many @@ -374,20 +390,21 @@ def create_many_to_many_relationship( relationship: ManyToManyRelationshipMetadata, *, solution: Optional[str] = None, - ) -> Dict[str, Any]: + ) -> RelationshipInfo: """Create a many-to-many relationship between tables. This operation creates a many-to-many relationship and an intersect table to manage the relationship. :param relationship: Metadata defining the many-to-many relationship. - :type relationship: ~PowerPlatform.Dataverse.models.metadata.ManyToManyRelationshipMetadata + :type relationship: ~PowerPlatform.Dataverse.models.relationship.ManyToManyRelationshipMetadata :param solution: Optional solution unique name to add relationship to. :type solution: :class:`str` or None - :return: Dictionary with ``relationship_id``, - ``relationship_schema_name``, and entity names. - :rtype: :class:`dict` + :return: Relationship metadata with ``relationship_id``, + ``relationship_schema_name``, ``relationship_type``, + ``entity1_logical_name``, and ``entity2_logical_name``. + :rtype: :class:`~PowerPlatform.Dataverse.models.relationship.RelationshipInfo` :raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the Web API request fails. @@ -395,7 +412,7 @@ def create_many_to_many_relationship( Example: Create a many-to-many relationship: Employee <-> Project:: - from PowerPlatform.Dataverse.models.metadata import ( + from PowerPlatform.Dataverse.models.relationship import ( ManyToManyRelationshipMetadata, ) @@ -406,13 +423,19 @@ def create_many_to_many_relationship( ) result = client.tables.create_many_to_many_relationship(relationship) - print(f"Created: {result['relationship_schema_name']}") + print(f"Created: {result.relationship_schema_name}") """ with self._client._scoped_odata() as od: - return od._create_many_to_many_relationship( + raw = od._create_many_to_many_relationship( relationship, solution, ) + return RelationshipInfo.from_many_to_many( + relationship_id=raw["relationship_id"], + relationship_schema_name=raw["relationship_schema_name"], + entity1_logical_name=raw["entity1_logical_name"], + entity2_logical_name=raw["entity2_logical_name"], + ) # ------------------------------------------------------- delete_relationship @@ -440,14 +463,15 @@ def delete_relationship(self, relationship_id: str) -> None: # -------------------------------------------------------- get_relationship - def get_relationship(self, schema_name: str) -> Optional[Dict[str, Any]]: + def get_relationship(self, schema_name: str) -> Optional[RelationshipInfo]: """Retrieve relationship metadata by schema name. :param schema_name: The schema name of the relationship. :type schema_name: :class:`str` - :return: Relationship metadata dictionary, or None if not found. - :rtype: :class:`dict` or None + :return: Relationship metadata, or ``None`` if not found. + :rtype: :class:`~PowerPlatform.Dataverse.models.relationship.RelationshipInfo` + or None :raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the Web API request fails. @@ -456,10 +480,13 @@ def get_relationship(self, schema_name: str) -> Optional[Dict[str, Any]]: rel = client.tables.get_relationship("new_Department_Employee") if rel: - print(f"Found: {rel['SchemaName']}") + print(f"Found: {rel.relationship_schema_name}") """ with self._client._scoped_odata() as od: - return od._get_relationship(schema_name) + raw = od._get_relationship(schema_name) + if raw is None: + return None + return RelationshipInfo.from_api_response(raw) # ------------------------------------------------------- create_lookup_field @@ -475,7 +502,7 @@ def create_lookup_field( cascade_delete: str = CASCADE_BEHAVIOR_REMOVE_LINK, solution: Optional[str] = None, language_code: int = 1033, - ) -> Dict[str, Any]: + ) -> RelationshipInfo: """Create a simple lookup field relationship. This is a convenience method that wraps :meth:`create_one_to_many_relationship` @@ -507,9 +534,11 @@ def create_lookup_field( (English). :type language_code: :class:`int` - :return: Dictionary with ``relationship_id``, ``lookup_schema_name``, - and related metadata. - :rtype: :class:`dict` + :return: Relationship metadata with ``relationship_id``, + ``relationship_schema_name``, ``relationship_type``, + ``lookup_schema_name``, ``referenced_entity``, and + ``referencing_entity``. + :rtype: :class:`~PowerPlatform.Dataverse.models.relationship.RelationshipInfo` :raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the Web API request fails. diff --git a/tests/unit/data/test_odata_internal.py b/tests/unit/data/test_odata_internal.py index 73281630..9a2bc179 100644 --- a/tests/unit/data/test_odata_internal.py +++ b/tests/unit/data/test_odata_internal.py @@ -57,6 +57,42 @@ def test_equal_lengths_does_not_raise(self): self.assertEqual(len(post_calls), 1) self.assertIn("UpsertMultiple", post_calls[0].args[1]) + def test_payload_excludes_alternate_key_fields(self): + """Alternate key fields must NOT appear in the request body (only in @odata.id).""" + self.od._upsert_multiple( + "accounts", + "account", + [{"accountnumber": "ACC-001"}], + [{"name": "Contoso", "telephone1": "555-0100"}], + ) + post_calls = [c for c in self.od._request.call_args_list if c.args[0] == "post"] + self.assertEqual(len(post_calls), 1) + payload = post_calls[0].kwargs.get("json", {}) + target = payload["Targets"][0] + # accountnumber should only be in @odata.id, NOT as a body field + self.assertNotIn("accountnumber", target) + self.assertIn("name", target) + self.assertIn("telephone1", target) + self.assertIn("@odata.id", target) + self.assertIn("accountnumber", target["@odata.id"]) + + def test_payload_excludes_alternate_key_even_when_in_record(self): + """If user passes matching key field in record, it should still be excluded from body.""" + self.od._upsert_multiple( + "accounts", + "account", + [{"accountnumber": "ACC-001"}], + [{"accountnumber": "ACC-001", "name": "Contoso"}], + ) + post_calls = [c for c in self.od._request.call_args_list if c.args[0] == "post"] + payload = post_calls[0].kwargs.get("json", {}) + target = payload["Targets"][0] + # Even though user passed accountnumber in record with same value, + # it should still appear in the body because it came from record_processed + # (the conflict check allows matching values through) + self.assertIn("@odata.id", target) + self.assertIn("accountnumber", target["@odata.id"]) + def test_record_conflicts_with_alternate_key_raises_value_error(self): """_upsert_multiple raises ValueError when a record field contradicts its alternate key.""" with self.assertRaises(ValueError) as ctx: diff --git a/tests/unit/data/test_relationships.py b/tests/unit/data/test_relationships.py index 581c0a40..c67b3f1e 100644 --- a/tests/unit/data/test_relationships.py +++ b/tests/unit/data/test_relationships.py @@ -7,13 +7,12 @@ from unittest.mock import MagicMock, Mock from PowerPlatform.Dataverse.data._relationships import _RelationshipOperationsMixin -from PowerPlatform.Dataverse.models.metadata import ( +from PowerPlatform.Dataverse.models.relationship import ( LookupAttributeMetadata, OneToManyRelationshipMetadata, ManyToManyRelationshipMetadata, - Label, - LocalizedLabel, ) +from PowerPlatform.Dataverse.models.labels import Label, LocalizedLabel class TestExtractIdFromHeader(unittest.TestCase): diff --git a/tests/unit/models/test_metadata.py b/tests/unit/models/test_metadata.py deleted file mode 100644 index 691b02eb..00000000 --- a/tests/unit/models/test_metadata.py +++ /dev/null @@ -1,307 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -"""Tests for metadata entity types.""" - -from PowerPlatform.Dataverse.models.metadata import ( - LocalizedLabel, - Label, - CascadeConfiguration, - LookupAttributeMetadata, - OneToManyRelationshipMetadata, - ManyToManyRelationshipMetadata, -) - - -class TestLocalizedLabel: - """Tests for LocalizedLabel.""" - - def test_to_dict_basic(self): - """Test basic serialization.""" - label = LocalizedLabel(label="Test", language_code=1033) - result = label.to_dict() - - assert result["@odata.type"] == "Microsoft.Dynamics.CRM.LocalizedLabel" - assert result["Label"] == "Test" - assert result["LanguageCode"] == 1033 - - def test_to_dict_with_additional_properties(self): - """Test that additional_properties are merged.""" - label = LocalizedLabel( - label="Test", - language_code=1033, - additional_properties={"IsManaged": True, "MetadataId": "abc-123"}, - ) - result = label.to_dict() - - assert result["Label"] == "Test" - assert result["IsManaged"] is True - assert result["MetadataId"] == "abc-123" - - def test_additional_properties_can_override(self): - """Test that additional_properties can override default values.""" - label = LocalizedLabel( - label="Original", - language_code=1033, - additional_properties={"Label": "Overridden"}, - ) - result = label.to_dict() - - assert result["Label"] == "Overridden" - - -class TestLabel: - """Tests for Label.""" - - def test_to_dict_basic(self): - """Test basic serialization with auto UserLocalizedLabel.""" - label = Label(localized_labels=[LocalizedLabel(label="Test", language_code=1033)]) - result = label.to_dict() - - assert result["@odata.type"] == "Microsoft.Dynamics.CRM.Label" - assert len(result["LocalizedLabels"]) == 1 - assert result["LocalizedLabels"][0]["Label"] == "Test" - # UserLocalizedLabel should default to first localized label - assert result["UserLocalizedLabel"]["Label"] == "Test" - - def test_to_dict_with_explicit_user_label(self): - """Test that explicit user_localized_label is used.""" - label = Label( - localized_labels=[ - LocalizedLabel(label="English", language_code=1033), - LocalizedLabel(label="French", language_code=1036), - ], - user_localized_label=LocalizedLabel(label="French", language_code=1036), - ) - result = label.to_dict() - - assert result["UserLocalizedLabel"]["Label"] == "French" - assert result["UserLocalizedLabel"]["LanguageCode"] == 1036 - - def test_to_dict_with_additional_properties(self): - """Test that additional_properties are merged.""" - label = Label( - localized_labels=[LocalizedLabel(label="Test", language_code=1033)], - additional_properties={"CustomProperty": "value"}, - ) - result = label.to_dict() - - assert result["CustomProperty"] == "value" - - -class TestCascadeConfiguration: - """Tests for CascadeConfiguration.""" - - def test_to_dict_defaults(self): - """Test default values.""" - cascade = CascadeConfiguration() - result = cascade.to_dict() - - assert result["Assign"] == "NoCascade" - assert result["Delete"] == "RemoveLink" - assert result["Merge"] == "NoCascade" - assert result["Reparent"] == "NoCascade" - assert result["Share"] == "NoCascade" - assert result["Unshare"] == "NoCascade" - - def test_to_dict_custom_values(self): - """Test custom cascade values.""" - cascade = CascadeConfiguration( - assign="Cascade", - delete="Restrict", - ) - result = cascade.to_dict() - - assert result["Assign"] == "Cascade" - assert result["Delete"] == "Restrict" - - def test_to_dict_with_additional_properties(self): - """Test additional properties like Archive and RollupView.""" - cascade = CascadeConfiguration( - additional_properties={ - "Archive": "NoCascade", - "RollupView": "NoCascade", - } - ) - result = cascade.to_dict() - - assert result["Archive"] == "NoCascade" - assert result["RollupView"] == "NoCascade" - - -class TestLookupAttributeMetadata: - """Tests for LookupAttributeMetadata.""" - - def test_to_dict_basic(self): - """Test basic serialization.""" - lookup = LookupAttributeMetadata( - schema_name="new_AccountId", - display_name=Label(localized_labels=[LocalizedLabel(label="Account", language_code=1033)]), - ) - result = lookup.to_dict() - - assert result["@odata.type"] == "Microsoft.Dynamics.CRM.LookupAttributeMetadata" - assert result["SchemaName"] == "new_AccountId" - assert result["AttributeType"] == "Lookup" - assert result["AttributeTypeName"]["Value"] == "LookupType" - assert result["RequiredLevel"]["Value"] == "None" - - def test_to_dict_required(self): - """Test required level.""" - lookup = LookupAttributeMetadata( - schema_name="new_AccountId", - display_name=Label(localized_labels=[LocalizedLabel(label="Account", language_code=1033)]), - required_level="ApplicationRequired", - ) - result = lookup.to_dict() - - assert result["RequiredLevel"]["Value"] == "ApplicationRequired" - - def test_to_dict_with_description(self): - """Test with description.""" - lookup = LookupAttributeMetadata( - schema_name="new_AccountId", - display_name=Label(localized_labels=[LocalizedLabel(label="Account", language_code=1033)]), - description=Label(localized_labels=[LocalizedLabel(label="The related account", language_code=1033)]), - ) - result = lookup.to_dict() - - assert "Description" in result - assert result["Description"]["LocalizedLabels"][0]["Label"] == "The related account" - - def test_to_dict_with_additional_properties(self): - """Test additional properties like Targets and IsSecured.""" - lookup = LookupAttributeMetadata( - schema_name="new_ParentId", - display_name=Label(localized_labels=[LocalizedLabel(label="Parent", language_code=1033)]), - additional_properties={ - "Targets": ["account", "contact"], - "IsSecured": True, - "IsValidForAdvancedFind": True, - }, - ) - result = lookup.to_dict() - - assert result["Targets"] == ["account", "contact"] - assert result["IsSecured"] is True - assert result["IsValidForAdvancedFind"] is True - - -class TestOneToManyRelationshipMetadata: - """Tests for OneToManyRelationshipMetadata.""" - - def test_to_dict_basic(self): - """Test basic serialization.""" - rel = OneToManyRelationshipMetadata( - schema_name="new_account_orders", - referenced_entity="account", - referencing_entity="new_order", - referenced_attribute="accountid", - ) - result = rel.to_dict() - - assert result["@odata.type"] == "Microsoft.Dynamics.CRM.OneToManyRelationshipMetadata" - assert result["SchemaName"] == "new_account_orders" - assert result["ReferencedEntity"] == "account" - assert result["ReferencingEntity"] == "new_order" - assert result["ReferencedAttribute"] == "accountid" - assert "CascadeConfiguration" in result - - def test_to_dict_with_custom_cascade(self): - """Test with custom cascade configuration.""" - rel = OneToManyRelationshipMetadata( - schema_name="new_account_orders", - referenced_entity="account", - referencing_entity="new_order", - referenced_attribute="accountid", - cascade_configuration=CascadeConfiguration( - delete="Cascade", - assign="Cascade", - ), - ) - result = rel.to_dict() - - assert result["CascadeConfiguration"]["Delete"] == "Cascade" - assert result["CascadeConfiguration"]["Assign"] == "Cascade" - - def test_to_dict_with_referencing_attribute(self): - """Test with explicit referencing attribute.""" - rel = OneToManyRelationshipMetadata( - schema_name="new_account_orders", - referenced_entity="account", - referencing_entity="new_order", - referenced_attribute="accountid", - referencing_attribute="new_accountid", - ) - result = rel.to_dict() - - assert result["ReferencingAttribute"] == "new_accountid" - - def test_to_dict_with_additional_properties(self): - """Test additional properties like IsCustomizable.""" - rel = OneToManyRelationshipMetadata( - schema_name="new_account_orders", - referenced_entity="account", - referencing_entity="new_order", - referenced_attribute="accountid", - additional_properties={ - "IsCustomizable": {"Value": True, "CanBeChanged": True}, - "IsValidForAdvancedFind": True, - "SecurityTypes": "None", - }, - ) - result = rel.to_dict() - - assert result["IsCustomizable"]["Value"] is True - assert result["IsValidForAdvancedFind"] is True - assert result["SecurityTypes"] == "None" - - -class TestManyToManyRelationshipMetadata: - """Tests for ManyToManyRelationshipMetadata.""" - - def test_to_dict_basic(self): - """Test basic serialization with auto intersect name.""" - rel = ManyToManyRelationshipMetadata( - schema_name="new_account_contact", - entity1_logical_name="account", - entity2_logical_name="contact", - ) - result = rel.to_dict() - - assert result["@odata.type"] == "Microsoft.Dynamics.CRM.ManyToManyRelationshipMetadata" - assert result["SchemaName"] == "new_account_contact" - assert result["Entity1LogicalName"] == "account" - assert result["Entity2LogicalName"] == "contact" - # IntersectEntityName should default to schema_name - assert result["IntersectEntityName"] == "new_account_contact" - - def test_to_dict_with_explicit_intersect_name(self): - """Test with explicit intersect entity name.""" - rel = ManyToManyRelationshipMetadata( - schema_name="new_account_contact", - entity1_logical_name="account", - entity2_logical_name="contact", - intersect_entity_name="new_account_contact_assoc", - ) - result = rel.to_dict() - - assert result["IntersectEntityName"] == "new_account_contact_assoc" - - def test_to_dict_with_additional_properties(self): - """Test additional properties like navigation property names.""" - rel = ManyToManyRelationshipMetadata( - schema_name="new_account_contact", - entity1_logical_name="account", - entity2_logical_name="contact", - additional_properties={ - "Entity1NavigationPropertyName": "new_contacts", - "Entity2NavigationPropertyName": "new_accounts", - "IsCustomizable": {"Value": True, "CanBeChanged": True}, - }, - ) - result = rel.to_dict() - - assert result["Entity1NavigationPropertyName"] == "new_contacts" - assert result["Entity2NavigationPropertyName"] == "new_accounts" - assert result["IsCustomizable"]["Value"] is True diff --git a/tests/unit/models/test_record.py b/tests/unit/models/test_record.py new file mode 100644 index 00000000..562dab37 --- /dev/null +++ b/tests/unit/models/test_record.py @@ -0,0 +1,100 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import unittest + +from PowerPlatform.Dataverse.models.record import Record + + +class TestRecordDictLike(unittest.TestCase): + """Dict-like access on Record delegates to self.data.""" + + def setUp(self): + self.record = Record( + id="guid-1", + table="account", + data={"name": "Contoso", "telephone1": "555-0100"}, + ) + + def test_getitem(self): + self.assertEqual(self.record["name"], "Contoso") + + def test_getitem_missing_raises(self): + with self.assertRaises(KeyError): + _ = self.record["nonexistent"] + + def test_get_with_default(self): + self.assertEqual(self.record.get("name"), "Contoso") + self.assertEqual(self.record.get("missing", "fallback"), "fallback") + + def test_contains(self): + self.assertIn("name", self.record) + self.assertNotIn("missing", self.record) + + def test_iter(self): + self.assertEqual(set(self.record), {"name", "telephone1"}) + + def test_len(self): + self.assertEqual(len(self.record), 2) + + def test_setitem(self): + self.record["new_key"] = "value" + self.assertEqual(self.record["new_key"], "value") + + def test_delitem(self): + del self.record["telephone1"] + self.assertNotIn("telephone1", self.record) + + def test_keys_values_items(self): + self.assertEqual(set(self.record.keys()), {"name", "telephone1"}) + self.assertIn("Contoso", list(self.record.values())) + self.assertIn(("name", "Contoso"), list(self.record.items())) + + +class TestRecordFromApiResponse(unittest.TestCase): + """Tests for Record.from_api_response factory.""" + + def test_strips_odata_keys(self): + raw = { + "@odata.context": "https://org.crm.dynamics.com/...", + "@odata.etag": 'W/"12345"', + "accountid": "guid-1", + "name": "Contoso", + } + record = Record.from_api_response("account", raw, record_id="guid-1") + self.assertNotIn("@odata.context", record) + self.assertNotIn("@odata.etag", record) + self.assertEqual(record["accountid"], "guid-1") + self.assertEqual(record["name"], "Contoso") + + def test_extracts_etag(self): + raw = {"@odata.etag": 'W/"12345"', "name": "Test"} + record = Record.from_api_response("account", raw) + self.assertEqual(record.etag, 'W/"12345"') + + def test_no_etag(self): + raw = {"name": "Test"} + record = Record.from_api_response("account", raw) + self.assertIsNone(record.etag) + + def test_record_id_set(self): + raw = {"name": "Test"} + record = Record.from_api_response("account", raw, record_id="guid-1") + self.assertEqual(record.id, "guid-1") + self.assertEqual(record.table, "account") + + def test_record_id_default_empty(self): + raw = {"name": "Test"} + record = Record.from_api_response("account", raw) + self.assertEqual(record.id, "") + + def test_to_dict(self): + raw = {"@odata.etag": 'W/"1"', "name": "Test", "revenue": 1000} + record = Record.from_api_response("account", raw) + d = record.to_dict() + self.assertIsInstance(d, dict) + self.assertEqual(d, {"name": "Test", "revenue": 1000}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/models/test_relationship_info.py b/tests/unit/models/test_relationship_info.py new file mode 100644 index 00000000..2859eea2 --- /dev/null +++ b/tests/unit/models/test_relationship_info.py @@ -0,0 +1,148 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import unittest + +from PowerPlatform.Dataverse.models.relationship import RelationshipInfo + + +class TestRelationshipInfoFromOneToMany(unittest.TestCase): + """Tests for RelationshipInfo.from_one_to_many factory.""" + + def test_sets_fields(self): + """from_one_to_many should populate all 1:N fields.""" + info = RelationshipInfo.from_one_to_many( + relationship_id="rel-guid-1", + relationship_schema_name="new_Dept_Emp", + lookup_schema_name="new_DeptId", + referenced_entity="new_department", + referencing_entity="new_employee", + ) + self.assertEqual(info.relationship_id, "rel-guid-1") + self.assertEqual(info.relationship_schema_name, "new_Dept_Emp") + self.assertEqual(info.lookup_schema_name, "new_DeptId") + self.assertEqual(info.referenced_entity, "new_department") + self.assertEqual(info.referencing_entity, "new_employee") + + def test_relationship_type(self): + """from_one_to_many should set relationship_type to 'one_to_many'.""" + info = RelationshipInfo.from_one_to_many( + relationship_id=None, + relationship_schema_name="rel", + lookup_schema_name="lk", + referenced_entity="a", + referencing_entity="b", + ) + self.assertEqual(info.relationship_type, "one_to_many") + + def test_nn_fields_are_none(self): + """N:N-specific fields should be None on a 1:N instance.""" + info = RelationshipInfo.from_one_to_many( + relationship_id=None, + relationship_schema_name="rel", + lookup_schema_name="lk", + referenced_entity="a", + referencing_entity="b", + ) + self.assertIsNone(info.entity1_logical_name) + self.assertIsNone(info.entity2_logical_name) + + +class TestRelationshipInfoFromManyToMany(unittest.TestCase): + """Tests for RelationshipInfo.from_many_to_many factory.""" + + def test_sets_fields(self): + """from_many_to_many should populate all N:N fields.""" + info = RelationshipInfo.from_many_to_many( + relationship_id="rel-guid-2", + relationship_schema_name="new_emp_proj", + entity1_logical_name="new_employee", + entity2_logical_name="new_project", + ) + self.assertEqual(info.relationship_id, "rel-guid-2") + self.assertEqual(info.relationship_schema_name, "new_emp_proj") + self.assertEqual(info.entity1_logical_name, "new_employee") + self.assertEqual(info.entity2_logical_name, "new_project") + + def test_relationship_type(self): + """from_many_to_many should set relationship_type to 'many_to_many'.""" + info = RelationshipInfo.from_many_to_many( + relationship_id=None, + relationship_schema_name="rel", + entity1_logical_name="a", + entity2_logical_name="b", + ) + self.assertEqual(info.relationship_type, "many_to_many") + + def test_otm_fields_are_none(self): + """1:N-specific fields should be None on a N:N instance.""" + info = RelationshipInfo.from_many_to_many( + relationship_id=None, + relationship_schema_name="rel", + entity1_logical_name="a", + entity2_logical_name="b", + ) + self.assertIsNone(info.lookup_schema_name) + self.assertIsNone(info.referenced_entity) + self.assertIsNone(info.referencing_entity) + + +class TestRelationshipInfoFromApiResponse(unittest.TestCase): + """Tests for RelationshipInfo.from_api_response factory.""" + + def test_one_to_many_detection(self): + """Should detect 1:N from @odata.type and map PascalCase fields.""" + raw = { + "@odata.type": "#Microsoft.Dynamics.CRM.OneToManyRelationshipMetadata", + "MetadataId": "rel-guid-1", + "SchemaName": "new_Dept_Emp", + "ReferencedEntity": "new_department", + "ReferencingEntity": "new_employee", + "ReferencingEntityNavigationPropertyName": "new_DeptId", + } + info = RelationshipInfo.from_api_response(raw) + self.assertEqual(info.relationship_type, "one_to_many") + self.assertEqual(info.relationship_id, "rel-guid-1") + self.assertEqual(info.relationship_schema_name, "new_Dept_Emp") + self.assertEqual(info.referenced_entity, "new_department") + self.assertEqual(info.referencing_entity, "new_employee") + self.assertEqual(info.lookup_schema_name, "new_DeptId") + + def test_many_to_many_detection(self): + """Should detect N:N from @odata.type and map PascalCase fields.""" + raw = { + "@odata.type": "#Microsoft.Dynamics.CRM.ManyToManyRelationshipMetadata", + "MetadataId": "rel-guid-2", + "SchemaName": "new_emp_proj", + "Entity1LogicalName": "new_employee", + "Entity2LogicalName": "new_project", + } + info = RelationshipInfo.from_api_response(raw) + self.assertEqual(info.relationship_type, "many_to_many") + self.assertEqual(info.relationship_id, "rel-guid-2") + self.assertEqual(info.relationship_schema_name, "new_emp_proj") + self.assertEqual(info.entity1_logical_name, "new_employee") + self.assertEqual(info.entity2_logical_name, "new_project") + + def test_unknown_type_raises(self): + """Should raise ValueError for unknown @odata.type.""" + raw = {"MetadataId": "guid", "SchemaName": "unknown_rel"} + with self.assertRaises(ValueError): + RelationshipInfo.from_api_response(raw) + + def test_missing_optional_fields(self): + """Should handle missing optional fields without error.""" + raw = { + "@odata.type": "#Microsoft.Dynamics.CRM.OneToManyRelationshipMetadata", + "SchemaName": "minimal", + "ReferencedEntity": "new_department", + "ReferencingEntity": "new_employee", + } + info = RelationshipInfo.from_api_response(raw) + self.assertEqual(info.relationship_type, "one_to_many") + self.assertIsNone(info.relationship_id) + self.assertEqual(info.lookup_schema_name, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/models/test_table_info.py b/tests/unit/models/test_table_info.py new file mode 100644 index 00000000..0e0754e7 --- /dev/null +++ b/tests/unit/models/test_table_info.py @@ -0,0 +1,162 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import unittest + +from PowerPlatform.Dataverse.models.table_info import ColumnInfo, TableInfo + + +class TestTableInfoLegacyAccess(unittest.TestCase): + """TableInfo should support both legacy dict keys and attribute access.""" + + def setUp(self): + self.info = TableInfo( + schema_name="new_Product", + logical_name="new_product", + entity_set_name="new_products", + metadata_id="meta-guid-1", + columns_created=["new_Price", "new_InStock"], + ) + + def test_legacy_key_getitem(self): + self.assertEqual(self.info["table_schema_name"], "new_Product") + self.assertEqual(self.info["table_logical_name"], "new_product") + self.assertEqual(self.info["entity_set_name"], "new_products") + self.assertEqual(self.info["metadata_id"], "meta-guid-1") + self.assertEqual(self.info["columns_created"], ["new_Price", "new_InStock"]) + + def test_attribute_access(self): + self.assertEqual(self.info.schema_name, "new_Product") + self.assertEqual(self.info.logical_name, "new_product") + + def test_new_key_also_works(self): + """Direct attribute names also work as dict keys.""" + self.assertEqual(self.info["schema_name"], "new_Product") + + def test_legacy_key_contains(self): + self.assertIn("table_schema_name", self.info) + self.assertIn("entity_set_name", self.info) + + def test_missing_key_raises(self): + with self.assertRaises(KeyError): + _ = self.info["nonexistent_key_xyz"] + + def test_get_with_default(self): + self.assertEqual(self.info.get("table_schema_name"), "new_Product") + self.assertEqual(self.info.get("nonexistent", "fallback"), "fallback") + + def test_legacy_key_iteration(self): + keys = list(self.info) + self.assertEqual( + keys, + ["table_schema_name", "table_logical_name", "entity_set_name", "metadata_id", "columns_created"], + ) + + def test_len(self): + self.assertEqual(len(self.info), 5) + + def test_keys_values_items(self): + self.assertEqual(list(self.info.keys()), list(self.info._LEGACY_KEY_MAP.keys())) + items = dict(self.info.items()) + self.assertEqual(items["table_schema_name"], "new_Product") + + def test_to_dict(self): + d = self.info.to_dict() + self.assertIsInstance(d, dict) + self.assertEqual(d["table_schema_name"], "new_Product") + self.assertEqual(d["columns_created"], ["new_Price", "new_InStock"]) + + +class TestTableInfoFromDict(unittest.TestCase): + """Tests for TableInfo.from_dict factory (SDK internal dict format).""" + + def test_from_dict(self): + data = { + "table_schema_name": "new_Product", + "table_logical_name": "new_product", + "entity_set_name": "new_products", + "metadata_id": "meta-guid-1", + "columns_created": ["new_Price"], + } + info = TableInfo.from_dict(data) + self.assertEqual(info.schema_name, "new_Product") + self.assertEqual(info.logical_name, "new_product") + self.assertEqual(info.entity_set_name, "new_products") + self.assertEqual(info.metadata_id, "meta-guid-1") + self.assertEqual(info.columns_created, ["new_Price"]) + + def test_from_dict_missing_keys(self): + info = TableInfo.from_dict({}) + self.assertEqual(info.schema_name, "") + self.assertIsNone(info.columns_created) + + +class TestTableInfoFromApiResponse(unittest.TestCase): + """Tests for TableInfo.from_api_response factory (PascalCase keys).""" + + def test_from_api_response(self): + raw = { + "SchemaName": "Account", + "LogicalName": "account", + "EntitySetName": "accounts", + "MetadataId": "meta-guid-2", + "DisplayName": {"UserLocalizedLabel": {"Label": "Account", "LanguageCode": 1033}}, + "Description": {"UserLocalizedLabel": {"Label": "Business account", "LanguageCode": 1033}}, + } + info = TableInfo.from_api_response(raw) + self.assertEqual(info.schema_name, "Account") + self.assertEqual(info.logical_name, "account") + self.assertEqual(info.entity_set_name, "accounts") + self.assertEqual(info.metadata_id, "meta-guid-2") + self.assertEqual(info.display_name, "Account") + self.assertEqual(info.description, "Business account") + + def test_from_api_response_no_labels(self): + raw = {"SchemaName": "contact", "LogicalName": "contact", "EntitySetName": "contacts", "MetadataId": "guid"} + info = TableInfo.from_api_response(raw) + self.assertIsNone(info.display_name) + self.assertIsNone(info.description) + + +class TestColumnInfoFromApiResponse(unittest.TestCase): + """Tests for ColumnInfo.from_api_response factory.""" + + def test_from_api_response(self): + raw = { + "SchemaName": "new_Price", + "LogicalName": "new_price", + "AttributeTypeName": {"Value": "DecimalType"}, + "IsPrimaryName": False, + "RequiredLevel": {"Value": "None"}, + "MaxLength": None, + "DisplayName": {"UserLocalizedLabel": {"Label": "Price"}}, + "Description": {"UserLocalizedLabel": {"Label": "Product price"}}, + } + col = ColumnInfo.from_api_response(raw) + self.assertEqual(col.schema_name, "new_Price") + self.assertEqual(col.logical_name, "new_price") + self.assertEqual(col.type, "DecimalType") + self.assertFalse(col.is_primary) + self.assertFalse(col.is_required) + self.assertEqual(col.display_name, "Price") + self.assertEqual(col.description, "Product price") + + def test_required_level_not_none(self): + raw = { + "SchemaName": "name", + "LogicalName": "name", + "AttributeTypeName": {"Value": "StringType"}, + "RequiredLevel": {"Value": "ApplicationRequired"}, + } + col = ColumnInfo.from_api_response(raw) + self.assertTrue(col.is_required) + + def test_missing_nested_labels(self): + raw = {"SchemaName": "x", "LogicalName": "x"} + col = ColumnInfo.from_api_response(raw) + self.assertIsNone(col.display_name) + self.assertIsNone(col.description) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 8dd5bd26..8eb07ca1 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -103,7 +103,8 @@ def test_get_single(self): result = self.client.get("account", "00000000-0000-0000-0000-000000000000") self.client._odata._get.assert_called_once_with("account", "00000000-0000-0000-0000-000000000000", select=None) - self.assertEqual(result, expected_record) + self.assertEqual(result["accountid"], "00000000-0000-0000-0000-000000000000") + self.assertEqual(result["name"], "Contoso") def test_get_multiple(self): """Test get method for querying multiple records.""" @@ -126,7 +127,10 @@ def test_get_multiple(self): expand=None, page_size=None, ) - self.assertEqual(results, [expected_batch]) + self.assertEqual(len(results), 1) + self.assertEqual(len(results[0]), 2) + self.assertEqual(results[0][0]["name"], "A") + self.assertEqual(results[0][1]["name"], "B") class TestCreateLookupField(unittest.TestCase): diff --git a/tests/unit/test_client_deprecations.py b/tests/unit/test_client_deprecations.py index 157b09bd..7c997079 100644 --- a/tests/unit/test_client_deprecations.py +++ b/tests/unit/test_client_deprecations.py @@ -103,7 +103,8 @@ def test_get_single_warns(self): result = self.client.get("account", record_id="guid-1") self.client._odata._get.assert_called_once_with("account", "guid-1", select=None) - self.assertEqual(result, expected) + self.assertEqual(result["accountid"], "guid-1") + self.assertEqual(result["name"], "Contoso") def test_get_multiple_warns(self): """client.get() without record_id emits a DeprecationWarning and delegates @@ -117,7 +118,9 @@ def test_get_multiple_warns(self): # The result is a generator; consume it. pages = list(result) - self.assertEqual(pages, [page]) + self.assertEqual(len(pages), 1) + self.assertEqual(pages[0][0]["name"], "A") + self.assertEqual(pages[0][1]["name"], "B") self.client._odata._get_multiple.assert_called_once_with( "account", @@ -142,7 +145,9 @@ def test_query_sql_warns(self): result = self.client.query_sql("SELECT name FROM account") self.client._odata._query_sql.assert_called_once_with("SELECT name FROM account") - self.assertEqual(result, expected_rows) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["name"], "Contoso") + self.assertEqual(result[1]["name"], "Fabrikam") # -------------------------------------------------------- get_table_info @@ -162,7 +167,8 @@ def test_get_table_info_warns(self): result = self.client.get_table_info("new_MyTable") self.client._odata._get_table_info.assert_called_once_with("new_MyTable") - self.assertEqual(result, expected_info) + self.assertEqual(result["table_schema_name"], "new_MyTable") + self.assertEqual(result["entity_set_name"], "new_mytables") # --------------------------------------------------------- create_table @@ -196,7 +202,8 @@ def test_create_table_warns(self): "MySolution", "new_ProductName", ) - self.assertEqual(result, expected) + self.assertEqual(result["table_schema_name"], "new_Product") + self.assertEqual(result["columns_created"], ["new_Price"]) # --------------------------------------------------------- delete_table diff --git a/tests/unit/test_query_operations.py b/tests/unit/test_query_operations.py index a4cbd158..b2abf97b 100644 --- a/tests/unit/test_query_operations.py +++ b/tests/unit/test_query_operations.py @@ -7,6 +7,7 @@ from azure.core.credentials import TokenCredential from PowerPlatform.Dataverse.client import DataverseClient +from PowerPlatform.Dataverse.models.record import Record from PowerPlatform.Dataverse.operations.query import QueryOperations @@ -28,18 +29,21 @@ def test_namespace_exists(self): # -------------------------------------------------------------------- sql def test_sql(self): - """sql() should call _query_sql and return the result list.""" - expected_rows = [ + """sql() should return Record objects with dict-like access.""" + raw_rows = [ {"accountid": "1", "name": "Contoso"}, {"accountid": "2", "name": "Fabrikam"}, ] - self.client._odata._query_sql.return_value = expected_rows + self.client._odata._query_sql.return_value = raw_rows result = self.client.query.sql("SELECT accountid, name FROM account") self.client._odata._query_sql.assert_called_once_with("SELECT accountid, name FROM account") self.assertIsInstance(result, list) - self.assertEqual(result, expected_rows) + self.assertEqual(len(result), 2) + self.assertIsInstance(result[0], Record) + self.assertEqual(result[0]["name"], "Contoso") + self.assertEqual(result[1]["name"], "Fabrikam") def test_sql_empty_result(self): """sql() should return an empty list when _query_sql returns no rows.""" diff --git a/tests/unit/test_records_operations.py b/tests/unit/test_records_operations.py index 9f75f05f..8387db4d 100644 --- a/tests/unit/test_records_operations.py +++ b/tests/unit/test_records_operations.py @@ -7,6 +7,7 @@ from azure.core.credentials import TokenCredential from PowerPlatform.Dataverse.client import DataverseClient +from PowerPlatform.Dataverse.models.record import Record from PowerPlatform.Dataverse.models.upsert import UpsertItem from PowerPlatform.Dataverse.operations.records import RecordOperations @@ -139,15 +140,18 @@ def test_delete_empty_list(self): # --------------------------------------------------------------------- get def test_get_single(self): - """get() with a record_id should call _get with correct params and return a dict.""" - expected = {"accountid": "guid-1", "name": "Contoso"} - self.client._odata._get.return_value = expected + """get() with a record_id should return a Record with dict-like access.""" + raw = {"accountid": "guid-1", "name": "Contoso"} + self.client._odata._get.return_value = raw result = self.client.records.get("account", "guid-1", select=["name", "telephone1"]) self.client._odata._get.assert_called_once_with("account", "guid-1", select=["name", "telephone1"]) - self.assertIsInstance(result, dict) - self.assertEqual(result, expected) + self.assertIsInstance(result, Record) + self.assertEqual(result.id, "guid-1") + self.assertEqual(result.table, "account") + self.assertEqual(result["name"], "Contoso") + self.assertEqual(result["accountid"], "guid-1") def test_get_single_with_query_params_raises(self): """get() with record_id and query params should raise ValueError.""" @@ -155,7 +159,7 @@ def test_get_single_with_query_params_raises(self): self.client.records.get("account", "guid-1", filter="statecode eq 0") def test_get_paginated(self): - """get() without record_id should yield pages from _get_multiple.""" + """get() without record_id should yield pages of Record objects.""" page_1 = [{"accountid": "1", "name": "A"}] page_2 = [{"accountid": "2", "name": "B"}] self.client._odata._get_multiple.return_value = iter([page_1, page_2]) @@ -163,8 +167,11 @@ def test_get_paginated(self): pages = list(self.client.records.get("account")) self.assertEqual(len(pages), 2) - self.assertEqual(pages[0], page_1) - self.assertEqual(pages[1], page_2) + self.assertIsInstance(pages[0][0], Record) + self.assertEqual(pages[0][0]["name"], "A") + self.assertEqual(pages[0][0].table, "account") + self.assertIsInstance(pages[1][0], Record) + self.assertEqual(pages[1][0]["name"], "B") def test_get_paginated_with_all_params(self): """get() without record_id should pass all query params to _get_multiple.""" diff --git a/tests/unit/test_tables_operations.py b/tests/unit/test_tables_operations.py index ae692306..36e45745 100644 --- a/tests/unit/test_tables_operations.py +++ b/tests/unit/test_tables_operations.py @@ -7,6 +7,8 @@ from azure.core.credentials import TokenCredential from PowerPlatform.Dataverse.client import DataverseClient +from PowerPlatform.Dataverse.models.relationship import RelationshipInfo +from PowerPlatform.Dataverse.models.table_info import TableInfo from PowerPlatform.Dataverse.operations.tables import TableOperations @@ -28,15 +30,15 @@ def test_namespace_exists(self): # ------------------------------------------------------------------ create def test_create(self): - """create() should call _create_table with correct positional args including renamed kwargs.""" - expected_result = { + """create() should return TableInfo with dict-like backward compat.""" + raw = { "table_schema_name": "new_Product", "entity_set_name": "new_products", "table_logical_name": "new_product", "metadata_id": "meta-guid-1", "columns_created": ["new_Price", "new_InStock"], } - self.client._odata._create_table.return_value = expected_result + self.client._odata._create_table.return_value = raw columns = {"new_Price": "decimal", "new_InStock": "bool"} result = self.client.tables.create( @@ -52,7 +54,10 @@ def test_create(self): "MySolution", "new_ProductName", ) - self.assertEqual(result, expected_result) + self.assertIsInstance(result, TableInfo) + self.assertEqual(result.schema_name, "new_Product") + self.assertEqual(result["table_schema_name"], "new_Product") + self.assertEqual(result["entity_set_name"], "new_products") # ------------------------------------------------------------------ delete @@ -65,19 +70,21 @@ def test_delete(self): # --------------------------------------------------------------------- get def test_get(self): - """get() should call _get_table_info and return the metadata dict.""" - expected_info = { + """get() should return TableInfo with dict-like backward compat.""" + raw = { "table_schema_name": "new_Product", "table_logical_name": "new_product", "entity_set_name": "new_products", "metadata_id": "meta-guid-1", } - self.client._odata._get_table_info.return_value = expected_info + self.client._odata._get_table_info.return_value = raw result = self.client.tables.get("new_Product") self.client._odata._get_table_info.assert_called_once_with("new_Product") - self.assertEqual(result, expected_info) + self.assertIsInstance(result, TableInfo) + self.assertEqual(result.schema_name, "new_Product") + self.assertEqual(result["table_schema_name"], "new_Product") def test_get_returns_none(self): """get() should return None when _get_table_info returns None (table not found).""" @@ -209,40 +216,51 @@ def test_remove_columns_list(self): # ---------------------------------------------------- create_one_to_many def test_create_one_to_many(self): - """create_one_to_many() should call _create_one_to_many_relationship.""" - expected = { + """create_one_to_many() should return RelationshipInfo.""" + raw = { "relationship_id": "rel-guid-1", "relationship_schema_name": "new_Dept_Emp", "lookup_schema_name": "new_DeptId", "referenced_entity": "new_department", "referencing_entity": "new_employee", } - self.client._odata._create_one_to_many_relationship.return_value = expected + self.client._odata._create_one_to_many_relationship.return_value = raw lookup = MagicMock() relationship = MagicMock() result = self.client.tables.create_one_to_many_relationship(lookup, relationship, solution="MySolution") self.client._odata._create_one_to_many_relationship.assert_called_once_with(lookup, relationship, "MySolution") - self.assertEqual(result, expected) + self.assertIsInstance(result, RelationshipInfo) + self.assertEqual(result.relationship_id, "rel-guid-1") + self.assertEqual(result.relationship_schema_name, "new_Dept_Emp") + self.assertEqual(result.lookup_schema_name, "new_DeptId") + self.assertEqual(result.referenced_entity, "new_department") + self.assertEqual(result.referencing_entity, "new_employee") + self.assertEqual(result.relationship_type, "one_to_many") # --------------------------------------------------- create_many_to_many def test_create_many_to_many(self): - """create_many_to_many() should call _create_many_to_many_relationship.""" - expected = { + """create_many_to_many() should return RelationshipInfo.""" + raw = { "relationship_id": "rel-guid-2", "relationship_schema_name": "new_emp_proj", "entity1_logical_name": "new_employee", "entity2_logical_name": "new_project", } - self.client._odata._create_many_to_many_relationship.return_value = expected + self.client._odata._create_many_to_many_relationship.return_value = raw relationship = MagicMock() result = self.client.tables.create_many_to_many_relationship(relationship, solution="MySolution") self.client._odata._create_many_to_many_relationship.assert_called_once_with(relationship, "MySolution") - self.assertEqual(result, expected) + self.assertIsInstance(result, RelationshipInfo) + self.assertEqual(result.relationship_id, "rel-guid-2") + self.assertEqual(result.relationship_schema_name, "new_emp_proj") + self.assertEqual(result.entity1_logical_name, "new_employee") + self.assertEqual(result.entity2_logical_name, "new_project") + self.assertEqual(result.relationship_type, "many_to_many") # ----------------------------------------------------- delete_relationship @@ -255,14 +273,24 @@ def test_delete_relationship(self): # ------------------------------------------------------- get_relationship def test_get_relationship(self): - """get_relationship() should call _get_relationship and return the dict.""" - expected = {"SchemaName": "new_Dept_Emp", "MetadataId": "rel-guid-1"} - self.client._odata._get_relationship.return_value = expected + """get_relationship() should return RelationshipInfo from API response.""" + raw = { + "@odata.type": "#Microsoft.Dynamics.CRM.OneToManyRelationshipMetadata", + "SchemaName": "new_Dept_Emp", + "MetadataId": "rel-guid-1", + "ReferencedEntity": "new_department", + "ReferencingEntity": "new_employee", + "ReferencingEntityNavigationPropertyName": "new_DeptId", + } + self.client._odata._get_relationship.return_value = raw result = self.client.tables.get_relationship("new_Dept_Emp") self.client._odata._get_relationship.assert_called_once_with("new_Dept_Emp") - self.assertEqual(result, expected) + self.assertIsInstance(result, RelationshipInfo) + self.assertEqual(result.relationship_schema_name, "new_Dept_Emp") + self.assertEqual(result.relationship_id, "rel-guid-1") + self.assertEqual(result.relationship_type, "one_to_many") def test_get_relationship_returns_none(self): """get_relationship() should return None when not found."""