Skip to content

Commit d60b4cf

Browse files
tpellissierclaude
andcommitted
Add Record and TableInfo typed return models
- Record: wraps OData responses for records.get() and query.sql() with dict-like backward compat (result["key"] still works) - TableInfo: wraps table metadata for tables.create() and tables.get() with legacy key mapping (result["table_schema_name"] still works) - ColumnInfo: typed model for column metadata (from_api_response factory) - Updated all operation return types and existing tests - 188 tests passing (32 new model tests) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4a828ac commit d60b4cf

File tree

12 files changed

+702
-58
lines changed

12 files changed

+702
-58
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""Record data model for Dataverse entities."""
5+
6+
from __future__ import annotations
7+
8+
from dataclasses import dataclass, field
9+
from typing import Any, Dict, Iterator, Optional
10+
11+
__all__ = ["Record"]
12+
13+
_ODATA_PREFIX = "@odata."
14+
15+
16+
@dataclass
17+
class Record:
18+
"""Strongly-typed Dataverse record with dict-like backward compatibility.
19+
20+
Wraps raw OData response data into a structured object while preserving
21+
``result["key"]`` access patterns for existing code.
22+
23+
:param id: Record GUID. Empty string if not extracted (e.g. paginated
24+
results, SQL queries).
25+
:type id: :class:`str`
26+
:param table: Table schema name used in the request.
27+
:type table: :class:`str`
28+
:param data: Record field data as key-value pairs.
29+
:type data: :class:`dict`
30+
:param etag: ETag for optimistic concurrency, extracted from
31+
``@odata.etag`` in the API response.
32+
:type etag: :class:`str` or None
33+
34+
Example::
35+
36+
record = client.records.get("account", account_id, select=["name"])
37+
print(record.id) # structured access
38+
print(record["name"]) # dict-like access (backward compat)
39+
"""
40+
41+
id: str = ""
42+
table: str = ""
43+
data: Dict[str, Any] = field(default_factory=dict)
44+
etag: Optional[str] = None
45+
46+
# --------------------------------------------------------- dict-like access
47+
48+
def __getitem__(self, key: str) -> Any:
49+
return self.data[key]
50+
51+
def __setitem__(self, key: str, value: Any) -> None:
52+
self.data[key] = value
53+
54+
def __delitem__(self, key: str) -> None:
55+
del self.data[key]
56+
57+
def __contains__(self, key: object) -> bool:
58+
return key in self.data
59+
60+
def __iter__(self) -> Iterator[str]:
61+
return iter(self.data)
62+
63+
def __len__(self) -> int:
64+
return len(self.data)
65+
66+
def get(self, key: str, default: Any = None) -> Any:
67+
"""Return value for *key*, or *default* if not present."""
68+
return self.data.get(key, default)
69+
70+
def keys(self):
71+
"""Return data keys."""
72+
return self.data.keys()
73+
74+
def values(self):
75+
"""Return data values."""
76+
return self.data.values()
77+
78+
def items(self):
79+
"""Return data items."""
80+
return self.data.items()
81+
82+
# -------------------------------------------------------------- factories
83+
84+
@classmethod
85+
def from_api_response(
86+
cls,
87+
table: str,
88+
response_data: Dict[str, Any],
89+
*,
90+
record_id: str = "",
91+
) -> Record:
92+
"""Create a :class:`Record` from a raw OData API response.
93+
94+
Strips ``@odata.*`` annotation keys from the data and extracts the
95+
``@odata.etag`` value if present.
96+
97+
:param table: Table schema name.
98+
:type table: :class:`str`
99+
:param response_data: Raw JSON dict from the OData response.
100+
:type response_data: :class:`dict`
101+
:param record_id: Known record GUID. Pass explicitly when available
102+
(e.g. single-record get). Defaults to empty string.
103+
:type record_id: :class:`str`
104+
:rtype: :class:`Record`
105+
"""
106+
etag = response_data.get("@odata.etag")
107+
data = {k: v for k, v in response_data.items() if not k.startswith(_ODATA_PREFIX)}
108+
return cls(id=record_id, table=table, data=data, etag=etag)
109+
110+
# -------------------------------------------------------------- conversion
111+
112+
def to_dict(self) -> Dict[str, Any]:
113+
"""Return a plain dict copy of the record data (excludes metadata)."""
114+
return dict(self.data)
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""Table and column metadata models for Dataverse."""
5+
6+
from __future__ import annotations
7+
8+
from dataclasses import dataclass, field
9+
from typing import Any, ClassVar, Dict, Iterator, List, Optional
10+
11+
__all__ = ["TableInfo", "ColumnInfo"]
12+
13+
14+
@dataclass
15+
class ColumnInfo:
16+
"""Column metadata from a Dataverse table definition.
17+
18+
:param schema_name: Column schema name (e.g. ``"new_Price"``).
19+
:type schema_name: :class:`str`
20+
:param logical_name: Column logical name (lowercase).
21+
:type logical_name: :class:`str`
22+
:param type: Column type string (e.g. ``"String"``, ``"Integer"``).
23+
:type type: :class:`str`
24+
:param is_primary: Whether this is the primary name column.
25+
:type is_primary: :class:`bool`
26+
:param is_required: Whether the column is required.
27+
:type is_required: :class:`bool`
28+
:param max_length: Maximum length for string columns.
29+
:type max_length: :class:`int` or None
30+
:param display_name: Human-readable display name.
31+
:type display_name: :class:`str` or None
32+
:param description: Column description.
33+
:type description: :class:`str` or None
34+
"""
35+
36+
schema_name: str = ""
37+
logical_name: str = ""
38+
type: str = ""
39+
is_primary: bool = False
40+
is_required: bool = False
41+
max_length: Optional[int] = None
42+
display_name: Optional[str] = None
43+
description: Optional[str] = None
44+
45+
@classmethod
46+
def from_api_response(cls, response_data: Dict[str, Any]) -> ColumnInfo:
47+
"""Create from a raw Dataverse ``AttributeMetadata`` API response.
48+
49+
:param response_data: Raw attribute metadata dict (PascalCase keys).
50+
:type response_data: :class:`dict`
51+
:rtype: :class:`ColumnInfo`
52+
"""
53+
# Extract display name from nested structure
54+
display_name_obj = response_data.get("DisplayName", {})
55+
user_label = display_name_obj.get("UserLocalizedLabel") or {}
56+
display_name = user_label.get("Label")
57+
58+
# Extract description from nested structure
59+
desc_obj = response_data.get("Description", {})
60+
desc_label = desc_obj.get("UserLocalizedLabel") or {}
61+
description = desc_label.get("Label")
62+
63+
# Extract required level
64+
req_level = response_data.get("RequiredLevel", {})
65+
is_required = req_level.get("Value", "None") != "None" if isinstance(req_level, dict) else False
66+
67+
return cls(
68+
schema_name=response_data.get("SchemaName", ""),
69+
logical_name=response_data.get("LogicalName", ""),
70+
type=response_data.get("AttributeTypeName", {}).get("Value", response_data.get("AttributeType", "")),
71+
is_primary=response_data.get("IsPrimaryName", False),
72+
is_required=is_required,
73+
max_length=response_data.get("MaxLength"),
74+
display_name=display_name,
75+
description=description,
76+
)
77+
78+
79+
@dataclass
80+
class TableInfo:
81+
"""Table metadata with dict-like backward compatibility.
82+
83+
Supports both new attribute access (``info.schema_name``) and legacy
84+
dict-key access (``info["table_schema_name"]``) for backward
85+
compatibility with code written against the raw dict API.
86+
87+
:param schema_name: Table schema name (e.g. ``"Account"``).
88+
:type schema_name: :class:`str`
89+
:param logical_name: Table logical name (lowercase).
90+
:type logical_name: :class:`str`
91+
:param entity_set_name: OData entity set name.
92+
:type entity_set_name: :class:`str`
93+
:param metadata_id: Metadata GUID.
94+
:type metadata_id: :class:`str`
95+
:param display_name: Human-readable display name.
96+
:type display_name: :class:`str` or None
97+
:param description: Table description.
98+
:type description: :class:`str` or None
99+
:param columns: Column metadata (when retrieved).
100+
:type columns: :class:`list` of :class:`ColumnInfo` or None
101+
:param columns_created: Column schema names created with the table.
102+
:type columns_created: :class:`list` of :class:`str` or None
103+
104+
Example::
105+
106+
info = client.tables.create("new_Product", {"new_Price": "decimal"})
107+
print(info.schema_name) # new attribute access
108+
print(info["table_schema_name"]) # legacy dict-key access
109+
"""
110+
111+
schema_name: str = ""
112+
logical_name: str = ""
113+
entity_set_name: str = ""
114+
metadata_id: str = ""
115+
display_name: Optional[str] = None
116+
description: Optional[str] = None
117+
columns: Optional[List[ColumnInfo]] = field(default=None, repr=False)
118+
columns_created: Optional[List[str]] = field(default=None, repr=False)
119+
120+
# Maps legacy dict keys (used by existing code) to attribute names.
121+
_LEGACY_KEY_MAP: ClassVar[Dict[str, str]] = {
122+
"table_schema_name": "schema_name",
123+
"table_logical_name": "logical_name",
124+
"entity_set_name": "entity_set_name",
125+
"metadata_id": "metadata_id",
126+
"columns_created": "columns_created",
127+
}
128+
129+
# --------------------------------------------------------- dict-like access
130+
131+
def _resolve_key(self, key: str) -> str:
132+
"""Resolve a legacy or direct key to an attribute name."""
133+
return self._LEGACY_KEY_MAP.get(key, key)
134+
135+
def __getitem__(self, key: str) -> Any:
136+
attr = self._resolve_key(key)
137+
if hasattr(self, attr):
138+
return getattr(self, attr)
139+
raise KeyError(key)
140+
141+
def __contains__(self, key: object) -> bool:
142+
if not isinstance(key, str):
143+
return False
144+
attr = self._resolve_key(key)
145+
return hasattr(self, attr)
146+
147+
def __iter__(self) -> Iterator[str]:
148+
return iter(self._LEGACY_KEY_MAP)
149+
150+
def __len__(self) -> int:
151+
return len(self._LEGACY_KEY_MAP)
152+
153+
def get(self, key: str, default: Any = None) -> Any:
154+
"""Return value for *key*, or *default* if not present."""
155+
try:
156+
return self[key]
157+
except KeyError:
158+
return default
159+
160+
def keys(self):
161+
"""Return legacy dict keys."""
162+
return self._LEGACY_KEY_MAP.keys()
163+
164+
def values(self):
165+
"""Return values corresponding to legacy dict keys."""
166+
return [getattr(self, attr) for attr in self._LEGACY_KEY_MAP.values()]
167+
168+
def items(self):
169+
"""Return (legacy_key, value) pairs."""
170+
return [(k, getattr(self, attr)) for k, attr in self._LEGACY_KEY_MAP.items()]
171+
172+
# -------------------------------------------------------------- factories
173+
174+
@classmethod
175+
def from_dict(cls, data: Dict[str, Any]) -> TableInfo:
176+
"""Create from an SDK internal dict (snake_case keys).
177+
178+
This handles the dict format returned by ``_create_table`` and
179+
``_get_table_info`` in the OData layer.
180+
181+
:param data: Dictionary with SDK snake_case keys.
182+
:type data: :class:`dict`
183+
:rtype: :class:`TableInfo`
184+
"""
185+
return cls(
186+
schema_name=data.get("table_schema_name", ""),
187+
logical_name=data.get("table_logical_name", ""),
188+
entity_set_name=data.get("entity_set_name", ""),
189+
metadata_id=data.get("metadata_id", ""),
190+
columns_created=data.get("columns_created"),
191+
)
192+
193+
@classmethod
194+
def from_api_response(cls, response_data: Dict[str, Any]) -> TableInfo:
195+
"""Create from a raw Dataverse ``EntityDefinition`` API response.
196+
197+
:param response_data: Raw entity metadata dict (PascalCase keys).
198+
:type response_data: :class:`dict`
199+
:rtype: :class:`TableInfo`
200+
"""
201+
# Extract display name from nested structure
202+
display_name_obj = response_data.get("DisplayName", {})
203+
user_label = display_name_obj.get("UserLocalizedLabel") or {}
204+
display_name = user_label.get("Label")
205+
206+
# Extract description from nested structure
207+
desc_obj = response_data.get("Description", {})
208+
desc_label = desc_obj.get("UserLocalizedLabel") or {}
209+
description = desc_label.get("Label")
210+
211+
return cls(
212+
schema_name=response_data.get("SchemaName", ""),
213+
logical_name=response_data.get("LogicalName", ""),
214+
entity_set_name=response_data.get("EntitySetName", ""),
215+
metadata_id=response_data.get("MetadataId", ""),
216+
display_name=display_name,
217+
description=description,
218+
)
219+
220+
# -------------------------------------------------------------- conversion
221+
222+
def to_dict(self) -> Dict[str, Any]:
223+
"""Return a dict with legacy keys for backward compatibility."""
224+
return {k: getattr(self, attr) for k, attr in self._LEGACY_KEY_MAP.items()}

src/PowerPlatform/Dataverse/operations/query.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
from typing import Any, Dict, List, TYPE_CHECKING
99

10+
from ..models.record import Record
11+
1012
if TYPE_CHECKING:
1113
from ..client import DataverseClient
1214

@@ -37,7 +39,7 @@ def __init__(self, client: DataverseClient) -> None:
3739

3840
# -------------------------------------------------------------------- sql
3941

40-
def sql(self, sql: str) -> List[Dict[str, Any]]:
42+
def sql(self, sql: str) -> List[Record]:
4143
"""Execute a read-only SQL query using the Dataverse Web API.
4244
4345
The SQL query must follow the supported subset: a single SELECT
@@ -47,9 +49,10 @@ def sql(self, sql: str) -> List[Dict[str, Any]]:
4749
:param sql: Supported SQL SELECT statement.
4850
:type sql: :class:`str`
4951
50-
:return: List of result row dictionaries. Returns an empty list when no
51-
rows match.
52-
:rtype: :class:`list` of :class:`dict`
52+
:return: List of :class:`~PowerPlatform.Dataverse.models.record.Record`
53+
objects. Returns an empty list when no rows match.
54+
:rtype: :class:`list` of
55+
:class:`~PowerPlatform.Dataverse.models.record.Record`
5356
5457
:raises ~PowerPlatform.Dataverse.core.errors.ValidationError:
5558
If ``sql`` is not a string or is empty.
@@ -72,4 +75,5 @@ def sql(self, sql: str) -> List[Dict[str, Any]]:
7275
)
7376
"""
7477
with self._client._scoped_odata() as od:
75-
return od._query_sql(sql)
78+
rows = od._query_sql(sql)
79+
return [Record.from_api_response("", row) for row in rows]

0 commit comments

Comments
 (0)