Skip to content

Commit 142b3ac

Browse files
feat: SDK-native column constraints (create, update, typed read) — Layer 1 of cutting raw-urllib usage (#206)
# SDK-native column constraints — Layer 1 of reducing raw-`urllib` usage ## The problem this fixes In the Dataverse plugin's telemetry, a large share of traffic is **raw `urllib`** (hand-rolled HTTP) instead of the managed SDK. The single biggest driver is **schema metadata** on the `dv-metadata` skill: agents were forced to hand-build calls to `EntityDefinitions/Attributes` because the SDK **could not express column constraints**. Concretely, three operations had no SDK path: 1. **Create** a column with constraints (`MaxLength`, `MinValue`/`MaxValue`, `Format`, `RequiredLevel`, a custom `DisplayName`). 2. **Update** an existing column's constraints (requires the counter-intuitive `PUT`-not-`PATCH` plus a derived `@odata.type` discriminator). 3. **Read** type-specific fields back (needs a second request with an `@odata.type` URL cast). So for a routine ask like *"add a Rating 1–5 and a 2000-char Comment"*, the agent dropped to raw `urllib`, hit the metadata quirks, and looped. This PR gives all three an SDK path, so agents stay on the managed client (which carries auth, retries, and telemetry attribution) and **never hand-roll the metadata Web API**. > **Scope: this is Layer 1 (SDK / product fixes).** Layer 2 — **fixing the skills** (updating `dv-metadata` to teach the new SDK spec and stop describing the raw Web-API workaround) — is a separate follow-up in the plugin repo and depends on this PR shipping. ## What's in this PR Four changes, all backward-compatible, sync + async parity. ### 1. Create with constraints (closes #194) `tables.create` / `add_columns` now accept a **dict** column spec alongside the existing `str` / `Enum`: ```python client.tables.create("cfb_CustomerFeedback", { "cfb_Name": "string", # unchanged "cfb_Rating": {"type": "int", "min_value": 1, "max_value": 5}, "cfb_Comment": {"type": "memo", "max_length": 2000, "display_name": "Comment"}, }) ``` Override keys: `max_length`, `min_value`, `max_value`, `precision`, `format`, `required`, `display_name`. ### 2. Update an existing column (closes #202) New `tables.update_column` / `update_columns`. The SDK reads the typed attribute, applies the overrides, and issues the `PUT` with the right `@odata.type` internally — the caller never sees a verb or a discriminator: ```python client.tables.update_column("cfb_CustomerFeedback", "cfb_Comment", {"max_length": 4000, "display_name": "Customer Comment"}) ``` ### 3. Typed reads in one request (closes #203) `typed=True` on `list_columns` / `get_column`, backed by `RetrieveMetadataChanges` with property projection — type-specific fields come back in a **single** request, no `@odata.type` URL cast: ```python col = client.tables.get_column("account", "emailaddress1", typed=True) # col["MaxLength"], etc. cols = client.tables.list_columns("account", typed=True) ``` ### 4. Attribution allowlist Add `dv-overview` + `erp-xpp` to `OperationContext._ALLOWED_SKILLS` (parity with the plugin's `scripts/auth.py`, which allows all 10). Previously `get_client('dv-overview'|'erp-xpp')` raised `ValueError` on the SDK side, and the plugin silently fell back to raw `urllib` for those two skills. ### Compatibility `str` / `Enum` column specs and every existing signature are unchanged; the only new parameter is a keyword-only `typed=False` on `list_columns`. No CHANGELOG / version edit (per CONTRIBUTING — maintainers handle those at release). ## Testing **Unit** — full suite green: **2437 passed** (sync + async). New coverage: dict-spec overrides, `update_column` (PUT verb + `@odata.type` + override application + error paths), `RetrieveMetadataChanges` typed reads (request shape, projection, criteria), `get_column(typed=True)`, and allowlist parity. **Live end-to-end** against a Dataverse org (`*.crm10.dynamics.com`) — **12/12 checks pass**: | Check | Result | |---|---| | #194 create table w/ bounded int (1–5) + 2000-char renamed memo | PASS | | #203 typed read → `MinValue=1`, `MaxValue=5`, `MaxLength=2000` (one request) | PASS | | #203 `list_columns(typed=True)` returns typed fields | PASS | | #202 `update_column` memo `2000 → 4000`, re-read confirms | PASS | | core CRUD (create / retrieve / update / list / delete) — no regression | PASS | For contrast, the **unmodified** SDK raises `ValueError: Unsupported column spec type ... <class 'dict'>` on the very same create — the exact gap this PR closes.
1 parent 494ee63 commit 142b3ac

18 files changed

Lines changed: 1162 additions & 22 deletions

‎.azdo/ci-pr.yaml‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,14 @@ extends:
3939
versionSpec: '3.12'
4040
addToPath: true
4141
displayName: 'Use Python 3.12'
42-
42+
43+
# 1ES managed pools have no public PyPI egress; route pip through the
44+
# internal Azure Artifacts feed (PyPI upstream), same as the Official pipeline.
45+
- task: PipAuthenticate@1
46+
inputs:
47+
artifactFeeds: 'OneCRM/DPX-Tools-Upstream'
48+
displayName: 'Authenticate pip to internal feed'
49+
4350
- script: |
4451
python -m pip install --upgrade pip
4552
python -m pip install flake8 black build diff-cover

‎README.md‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ df = results.to_dataframe() # same rows as a pandas DataFrame
111111

112112
### Define and evolve schema
113113

114-
`client.tables` creates and inspects tables, columns, relationships, and alternate keys. Column types are simple strings (`"string"`, `"int"`, `"decimal"`, `"money"`, `"datetime"`, `"bool"`, `"memo"`); pass an `IntEnum` subclass to create a choice column. `tables.get` returns `None` when the table does not exist, which makes schema setup idempotent. Every table gets a primary name column automatically — `<prefix>_Name` unless you pass `primary_column` — so do not list it in `columns`.
114+
`client.tables` creates and inspects tables, columns, relationships, and alternate keys. Column types are simple strings (`"string"`, `"int"`, `"decimal"`, `"money"`, `"datetime"`, `"bool"`, `"memo"`); pass an `IntEnum` subclass to create a choice column, or a dict spec (e.g. `{"type": "memo", "max_length": 2000}`) to set constraints like length, range, format, required level, or display name. `tables.get` returns `None` when the table does not exist, which makes schema setup idempotent. Every table gets a primary name column automatically — `<prefix>_Name` unless you pass `primary_column` — so do not list it in `columns`.
115115

116116
```python
117117
# Create a custom table with typed columns
@@ -136,6 +136,30 @@ client.tables.create_lookup_field(
136136
)
137137
```
138138

139+
Columns can carry constraints, be updated in place, and be read back with their
140+
type-specific metadata in a single request:
141+
142+
```python
143+
# Create columns with constraints -- pass a dict spec instead of a bare type.
144+
# Keys: max_length, min_value, max_value, precision, format, required, display_name.
145+
client.tables.create("new_Feedback", {
146+
"new_Rating": {"type": "int", "min_value": 1, "max_value": 5},
147+
"new_Comment": {"type": "memo", "max_length": 2000, "display_name": "Comment"},
148+
})
149+
150+
# Update an existing column's constraints (same override keys as create).
151+
client.tables.update_column("new_Feedback", "new_Comment", {"max_length": 4000})
152+
client.tables.update_columns("new_Feedback", {
153+
"new_Rating": {"max_value": 10},
154+
"new_Comment": {"display_name": "Customer Comment"},
155+
})
156+
157+
# Read a column (or all columns) with typed fields in one request (typed=True).
158+
col = client.tables.get_column("new_Feedback", "new_Comment", typed=True)
159+
print(col["MaxLength"]) # -> 4000
160+
cols = client.tables.list_columns("new_Feedback", typed=True)
161+
```
162+
139163
Relationship methods take logical names, which are always the schema name in lowercase. For choice columns, many-to-many relationships, and alternate keys, see [Customize tables and columns](https://learn.microsoft.com/power-apps/developer/data-platform/sdk-python/metadata) and [Manage table relationships](https://learn.microsoft.com/power-apps/developer/data-platform/sdk-python/relationships).
140164

141165
### Work in bulk

‎src/PowerPlatform/Dataverse/aio/core/_async_http.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,13 @@ async def _request(self, method: str, url: str, **kwargs: Any) -> _AsyncResponse
115115
raise RuntimeError("No aiohttp.ClientSession set. Set _session before making requests.")
116116

117117
# If no timeout is provided, use the user-specified default timeout if set;
118-
# otherwise, apply per-method defaults (120s for POST/PATCH/DELETE, 10s for others).
118+
# otherwise, apply per-method defaults (120s for POST/PUT/PATCH/DELETE, 10s for others).
119119
if "timeout" not in kwargs:
120120
if self.default_timeout is not None:
121121
t = self.default_timeout
122122
else:
123123
m = (method or "").lower()
124-
t = _TIMEOUT_WRITE_METHODS if m in ("post", "patch", "delete") else _TIMEOUT_READ_METHODS
124+
t = _TIMEOUT_WRITE_METHODS if m in ("post", "put", "patch", "delete") else _TIMEOUT_READ_METHODS
125125
kwargs["timeout"] = aiohttp.ClientTimeout(total=t)
126126

127127
# Log outbound request once (before retry loop).

‎src/PowerPlatform/Dataverse/aio/data/_async_odata.py‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
_USER_AGENT,
4545
_DEFAULT_EXPECTED_STATUSES,
4646
_RequestContext,
47+
_TYPED_COLUMN_PROPERTIES,
4748
)
4849

4950

@@ -920,6 +921,7 @@ async def _list_columns(
920921
*,
921922
select: Optional[List[str]] = None,
922923
filter: Optional[str] = None,
924+
typed: bool = False,
923925
) -> List[Dict[str, Any]]:
924926
"""List all attribute (column) definitions for a table.
925927
@@ -942,6 +944,14 @@ async def _list_columns(
942944
:raises MetadataError: If the table is not found.
943945
:raises HttpError: If the Web API request fails.
944946
"""
947+
if typed:
948+
if filter:
949+
raise ValueError(
950+
"The 'filter' argument is not supported with typed=True: RetrieveMetadataChanges "
951+
"projects typed fields but does not accept an OData $filter. Use typed=False to "
952+
"filter, or drop filter when typed=True."
953+
)
954+
return await self._retrieve_metadata_changes(table_schema_name, properties=select)
945955
ent = await self._get_entity_by_table_schema_name(table_schema_name)
946956
if not ent or not ent.get("MetadataId"):
947957
raise MetadataError(
@@ -958,6 +968,138 @@ async def _list_columns(
958968
r = await self._request("get", url, params=params)
959969
return (r.json()).get("value", [])
960970

971+
async def _retrieve_metadata_changes(
972+
self,
973+
table_schema_name: str,
974+
*,
975+
attribute_logical_name: Optional[str] = None,
976+
properties: Optional[List[str]] = None,
977+
) -> List[Dict[str, Any]]:
978+
"""Return typed column metadata in one request (RetrieveMetadataChanges, #203)."""
979+
props = properties or _TYPED_COLUMN_PROPERTIES
980+
attr_query: Dict[str, Any] = {"Properties": {"PropertyNames": props}}
981+
if attribute_logical_name:
982+
attr_query["Criteria"] = {
983+
"Conditions": [
984+
{
985+
"PropertyName": "LogicalName",
986+
"ConditionOperator": "Equals",
987+
"Value": {"Type": "System.String", "Value": attribute_logical_name.lower()},
988+
}
989+
]
990+
}
991+
query: Dict[str, Any] = {
992+
"Properties": {"PropertyNames": ["LogicalName", "Attributes"]},
993+
"Criteria": {
994+
"Conditions": [
995+
{
996+
"PropertyName": "LogicalName",
997+
"ConditionOperator": "Equals",
998+
"Value": {"Type": "System.String", "Value": table_schema_name.lower()},
999+
}
1000+
]
1001+
},
1002+
"AttributeQuery": attr_query,
1003+
}
1004+
url = f"{self.api}/RetrieveMetadataChanges(Query=@p1)"
1005+
r = await self._request_metadata_with_retry("get", url, params={"@p1": json.dumps(query, ensure_ascii=False)})
1006+
entities = (r.json()).get("EntityMetadata", [])
1007+
if not entities:
1008+
raise MetadataError(
1009+
f"Table '{table_schema_name}' not found.",
1010+
subcode=METADATA_TABLE_NOT_FOUND,
1011+
)
1012+
return entities[0].get("Attributes", []) or []
1013+
1014+
async def _get_column(
1015+
self,
1016+
table_schema_name: str,
1017+
column_name: str,
1018+
*,
1019+
typed: bool = False,
1020+
select: Optional[List[str]] = None,
1021+
) -> Optional[Dict[str, Any]]:
1022+
"""Return one column's metadata (typed via RetrieveMetadataChanges when ``typed``)."""
1023+
if typed:
1024+
attrs = await self._retrieve_metadata_changes(
1025+
table_schema_name, attribute_logical_name=column_name, properties=select
1026+
)
1027+
return attrs[0] if attrs else None
1028+
ent = await self._get_entity_by_table_schema_name(table_schema_name)
1029+
if not ent or not ent.get("MetadataId"):
1030+
raise MetadataError(
1031+
f"Table '{table_schema_name}' not found.",
1032+
subcode=METADATA_TABLE_NOT_FOUND,
1033+
)
1034+
metadata_id = ent["MetadataId"]
1035+
logical = self._escape_odata_quotes(column_name.lower())
1036+
url = f"{self.api}/EntityDefinitions({metadata_id})/Attributes(LogicalName='{logical}')"
1037+
params: Dict[str, str] = {}
1038+
if select:
1039+
params["$select"] = ",".join(select)
1040+
try:
1041+
r = await self._request("get", url, params=params)
1042+
return r.json()
1043+
except HttpError as err:
1044+
if getattr(err, "status_code", None) == 404:
1045+
return None
1046+
raise
1047+
1048+
async def _update_attribute(
1049+
self,
1050+
table_schema_name: str,
1051+
column_name: str,
1052+
overrides: Dict[str, Any],
1053+
) -> str:
1054+
"""Update constraints on an existing column: retrieve the full attr -> PUT it back (#202).
1055+
1056+
Follows the documented column-update contract -- ``PUT`` the *entire*
1057+
current attribute definition so untouched properties are preserved -- and
1058+
sends ``MSCRM.MergeLabels: true`` so localized labels in other languages
1059+
survive.
1060+
"""
1061+
self._validate_column_overrides(column_name, overrides)
1062+
ent = await self._get_entity_by_table_schema_name(table_schema_name)
1063+
if not ent or not ent.get("MetadataId"):
1064+
raise MetadataError(
1065+
f"Table '{table_schema_name}' not found.",
1066+
subcode=METADATA_TABLE_NOT_FOUND,
1067+
)
1068+
metadata_id = ent["MetadataId"]
1069+
logical = self._escape_odata_quotes(column_name.lower())
1070+
attr_url = f"{self.api}/EntityDefinitions({metadata_id})/Attributes(LogicalName='{logical}')"
1071+
try:
1072+
resp = await self._request("get", attr_url, params={"$select": "MetadataId,SchemaName"})
1073+
existing = resp.json()
1074+
except HttpError as err:
1075+
if getattr(err, "status_code", None) == 404:
1076+
raise MetadataError(
1077+
f"Column '{column_name}' not found on table '{table_schema_name}'.",
1078+
subcode=METADATA_COLUMN_NOT_FOUND,
1079+
) from err
1080+
raise
1081+
attr_metadata_id = existing.get("MetadataId")
1082+
odata_type = str(existing.get("@odata.type", "")).lstrip("#")
1083+
if not attr_metadata_id or not odata_type:
1084+
raise MetadataError(
1085+
f"Column '{column_name}' not found on table '{table_schema_name}'.",
1086+
subcode=METADATA_COLUMN_NOT_FOUND,
1087+
)
1088+
full = (await self._request("get", f"{attr_url}/{odata_type}")).json()
1089+
body: Dict[str, Any] = {k: v for k, v in full.items() if not str(k).startswith("@odata.")}
1090+
body["@odata.type"] = odata_type
1091+
body["MetadataId"] = attr_metadata_id
1092+
body.setdefault("SchemaName", existing.get("SchemaName", column_name))
1093+
self._apply_column_overrides(body, overrides)
1094+
req = _RawRequest(
1095+
method="PUT",
1096+
url=f"{self.api}/EntityDefinitions({metadata_id})/Attributes({attr_metadata_id})",
1097+
body=json.dumps(body, ensure_ascii=False),
1098+
headers={"MSCRM.MergeLabels": "true"},
1099+
)
1100+
await self._execute_raw(req)
1101+
return column_name
1102+
9611103
async def _wait_for_attribute_visibility(
9621104
self,
9631105
entity_set: str,

‎src/PowerPlatform/Dataverse/aio/operations/async_tables.py‎

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,13 @@ async def create(
8787
(or ``"money"``), ``"float"`` (or ``"double"``), ``"datetime"``
8888
(or ``"date"``), ``"bool"`` (or ``"boolean"``), ``"file"``, and
8989
``Enum`` subclasses
90-
(for local option sets).
90+
(for local option sets). A value may instead be a dict that pairs
91+
a base ``type`` with constraint overrides -- ``max_length``,
92+
``min_value``, ``max_value``, ``precision``, ``format``,
93+
``required`` (``"None"``/``"ApplicationRequired"``/``"Recommended"``),
94+
and ``display_name`` -- e.g.
95+
``{"type": "int", "min_value": 1, "max_value": 5}`` or
96+
``{"type": "memo", "max_length": 2000, "display_name": "Comment"}``.
9197
:type columns: :class:`dict`
9298
:param solution: Optional solution unique name that should own the new
9399
table. When omitted the table is created in the default solution.
@@ -705,6 +711,7 @@ async def list_columns(
705711
*,
706712
select: Optional[List[str]] = None,
707713
filter: Optional[str] = None,
714+
typed: bool = False,
708715
) -> List[Dict[str, Any]]:
709716
"""List all attribute (column) definitions for a table.
710717
@@ -714,9 +721,15 @@ async def list_columns(
714721
:param select: Optional list of property names to project via
715722
``$select``. Values are passed as-is (PascalCase).
716723
:type select: list[str] or None
717-
:param filter: Optional OData ``$filter`` expression. For example,
718-
``"AttributeType eq 'String'"`` returns only string columns.
724+
:param filter: Optional OData ``$filter`` expression (only when
725+
``typed=False``). For example, ``"AttributeType eq 'String'"``
726+
returns only string columns.
719727
:type filter: :class:`str` or None
728+
:param typed: When ``True``, read via ``RetrieveMetadataChanges`` so
729+
type-specific fields (``MaxLength``, ``MinValue``/``MaxValue``, ...)
730+
come back in one request without the ``@odata.type`` URL cast.
731+
``filter`` is not supported in this mode.
732+
:type typed: :class:`bool`
720733
721734
:return: List of raw attribute metadata dictionaries.
722735
:rtype: list[dict[str, typing.Any]]
@@ -725,6 +738,8 @@ async def list_columns(
725738
If the table is not found.
726739
:raises ~PowerPlatform.Dataverse.core.errors.HttpError:
727740
If the Web API request fails.
741+
:raises ValueError:
742+
If ``filter`` is combined with ``typed=True``.
728743
729744
Example::
730745
@@ -746,7 +761,59 @@ async def list_columns(
746761
)
747762
"""
748763
async with self._client._scoped_odata() as od:
749-
return await od._list_columns(table, select=select, filter=filter)
764+
return await od._list_columns(table, select=select, filter=filter, typed=typed)
765+
766+
async def get_column(
767+
self,
768+
table: str,
769+
column: str,
770+
*,
771+
typed: bool = False,
772+
select: Optional[List[str]] = None,
773+
) -> Optional[Dict[str, Any]]:
774+
"""Return a single column's metadata, or ``None`` if it does not exist.
775+
776+
When ``typed`` is ``True``, reads via ``RetrieveMetadataChanges`` so
777+
type-specific fields come back in one request (no ``@odata.type`` cast).
778+
"""
779+
async with self._client._scoped_odata() as od:
780+
return await od._get_column(table, column, typed=typed, select=select)
781+
782+
async def update_column(
783+
self,
784+
table: str,
785+
column: str,
786+
spec: Dict[str, Any],
787+
) -> str:
788+
"""Update constraints on an existing column.
789+
790+
Accepts the same override keys as the dict column spec in
791+
:meth:`create` (``max_length``, ``min_value``, ``max_value``,
792+
``precision``, ``format``, ``required``, ``display_name``). The SDK
793+
reads the current typed attribute and issues the update internally;
794+
callers never pass an HTTP verb or ``@odata.type``.
795+
"""
796+
async with self._client._scoped_odata() as od:
797+
return await od._update_attribute(table, column, spec)
798+
799+
async def update_columns(
800+
self,
801+
table: str,
802+
columns: Dict[str, Dict[str, Any]],
803+
) -> List[str]:
804+
"""Update constraints on multiple existing columns."""
805+
if not isinstance(columns, dict) or not columns:
806+
raise TypeError("columns must be a non-empty dict of {column: overrides}")
807+
updated: List[str] = []
808+
async with self._client._scoped_odata() as od:
809+
# Validate every spec up front so a bad entry can't leave the table
810+
# partially modified by earlier successful updates.
811+
for col, spec in columns.items():
812+
od._validate_column_overrides(col, spec)
813+
for col, spec in columns.items():
814+
await od._update_attribute(table, col, spec)
815+
updated.append(col)
816+
return updated
750817

751818
# ------------------------------------------------- list_relationships
752819

‎src/PowerPlatform/Dataverse/core/_http.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,14 @@ def _request(self, method: str, url: str, **kwargs: Any) -> requests.Response:
7676
:raises requests.exceptions.RequestException: If all retry attempts fail.
7777
"""
7878
# If no timeout is provided, use the user-specified default timeout if set;
79-
# otherwise, apply per-method defaults (120s for POST/PATCH/DELETE, 10s for others).
79+
# otherwise, apply per-method defaults (120s for POST/PUT/PATCH/DELETE, 10s for others).
8080
if "timeout" not in kwargs:
8181
if self.default_timeout is not None:
8282
kwargs["timeout"] = self.default_timeout
8383
else:
8484
m = (method or "").lower()
8585
kwargs["timeout"] = (
86-
_TIMEOUT_WRITE_METHODS if m in ("post", "patch", "delete") else _TIMEOUT_READ_METHODS
86+
_TIMEOUT_WRITE_METHODS if m in ("post", "put", "patch", "delete") else _TIMEOUT_READ_METHODS
8787
)
8888

8989
# Log outbound request once (before retry loop).

‎src/PowerPlatform/Dataverse/core/config.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,15 @@
3030
_ALLOWED_KEYS = frozenset({"app", "skill", "agent"})
3131
_ALLOWED_SKILLS = frozenset(
3232
{
33+
"dv-overview",
3334
"dv-connect",
3435
"dv-data",
3536
"dv-query",
3637
"dv-metadata",
3738
"dv-solution",
3839
"dv-admin",
3940
"dv-security",
41+
"erp-xpp",
4042
"unknown",
4143
}
4244
)

0 commit comments

Comments
 (0)