Skip to content

Commit 05dd478

Browse files
author
Max Wang
committed
auto create file column if it doesnt exist
1 parent 09ff1fe commit 05dd478

7 files changed

Lines changed: 64 additions & 107 deletions

File tree

.claude/skills/dataverse-sdk/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ client.delete_table("new_Product")
199199
client.upload_file(
200200
table_schema_name="account",
201201
record_id=account_id,
202-
file_name_attribute="new_document",
202+
file_name_attribute="new_document", # If the file column doesn't exist, it will be created automatically
203203
path="/path/to/document.pdf"
204204
)
205205
```

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ client.delete_table("new_Product")
269269
client.upload_file(
270270
table_schema_name="account",
271271
record_id=account_id,
272-
file_name_attribute="new_document",
272+
file_name_attribute="new_document", # If the file column doesn't exist, it will be created automatically
273273
path="/path/to/document.pdf"
274274
)
275275
```

examples/advanced/file_upload.py

Lines changed: 5 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,12 @@
4848
run_small = mode_int in (1, 3)
4949
run_chunk = mode_int in (2, 3)
5050

51-
delete_table_choice = input("Delete the table at end? (y/N): ").strip() or "n"
52-
cleanup_table = delete_table_choice.lower() in ("y", "yes", "true", "1")
53-
5451
delete_record_choice = input("Delete the created record at end? (Y/n): ").strip() or "y"
5552
cleanup_record = delete_record_choice.lower() in ("y", "yes", "true", "1")
5653

54+
delete_table_choice = input("Delete the table at end? (y/N): ").strip() or "n"
55+
cleanup_table = delete_table_choice.lower() in ("y", "yes", "true", "1")
56+
5757
credential = InteractiveBrowserCredential()
5858
client = DataverseClient(base_url=base_url, credential=credential)
5959

@@ -183,7 +183,7 @@ def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)):
183183

184184

185185
# --------------------------- Table ensure ---------------------------
186-
TABLE_SCHEMA_NAME = "new_FileSample"
186+
TABLE_SCHEMA_NAME = "new_FileSample111"
187187

188188

189189
def ensure_table():
@@ -192,7 +192,7 @@ def ensure_table():
192192
if existing:
193193
print({"table": TABLE_SCHEMA_NAME, "existed": True})
194194
return existing
195-
log("client.create_table('new_FileSample', schema={'new_Title': 'string'})")
195+
log(f"client.create_table('{TABLE_SCHEMA_NAME}', schema={{'new_Title': 'string'}})")
196196
info = backoff(lambda: client.create_table(TABLE_SCHEMA_NAME, {"new_Title": "string"}))
197197
print({"table": TABLE_SCHEMA_NAME, "existed": False, "metadata_id": info.get("metadata_id")})
198198
return info
@@ -214,99 +214,6 @@ def ensure_table():
214214
chunk_file_attr_schema = f"{attr_prefix}_ChunkDocument" # attribute for streaming chunk upload demo
215215
chunk_file_attr_logical = f"{attr_prefix}_chunkdocument" # expected logical name
216216

217-
218-
def ensure_file_attribute_generic(schema_name: str, label: str, key_prefix: str):
219-
meta_id = table_info.get("metadata_id")
220-
if not meta_id:
221-
print({f"{key_prefix}_attribute": "skipped", "reason": "missing metadata_id"})
222-
return False
223-
odata = client._get_odata()
224-
# Probe existing
225-
try:
226-
url = (
227-
f"{odata.api}/EntityDefinitions({meta_id})/Attributes?$select=SchemaName&$filter="
228-
f"SchemaName eq '{schema_name}'"
229-
)
230-
r = backoff(lambda: odata._request("get", url), delays=ATTRIBUTE_VISIBILITY_DELAYS)
231-
val = []
232-
try:
233-
val = r.json().get("value", [])
234-
except Exception: # noqa: BLE001
235-
pass
236-
if any(a.get("SchemaName") == schema_name for a in val if isinstance(a, dict)):
237-
return True
238-
except Exception as ex: # noqa: BLE001
239-
print({f"{key_prefix}_file_attr_probe_error": str(ex)})
240-
241-
payload = {
242-
"@odata.type": "Microsoft.Dynamics.CRM.FileAttributeMetadata",
243-
"SchemaName": schema_name,
244-
"DisplayName": {
245-
"@odata.type": "Microsoft.Dynamics.CRM.Label",
246-
"LocalizedLabels": [
247-
{
248-
"@odata.type": "Microsoft.Dynamics.CRM.LocalizedLabel",
249-
"Label": label,
250-
"LanguageCode": int(client._config.language_code),
251-
}
252-
],
253-
},
254-
"RequiredLevel": {"Value": "None"},
255-
}
256-
try:
257-
url = f"{odata.api}/EntityDefinitions({meta_id})/Attributes"
258-
backoff(lambda: odata._request("post", url, json=payload), delays=ATTRIBUTE_VISIBILITY_DELAYS)
259-
print({f"{key_prefix}_file_attribute_created": True})
260-
time.sleep(2)
261-
return True
262-
except Exception as ex: # noqa: BLE001
263-
resp = getattr(ex, "response", None)
264-
body_l = None
265-
try:
266-
body_l = resp.text.lower() if getattr(resp, "text", None) else None
267-
except Exception: # noqa: BLE001
268-
pass
269-
if body_l and ("duplicate" in body_l or "exists" in body_l):
270-
print({f"{key_prefix}_file_attribute_created": False, "reason": "already exists (race)"})
271-
return True
272-
print({f"{key_prefix}_file_attribute_created": False, "error": str(ex)})
273-
return False
274-
275-
276-
def wait_for_attribute_visibility(logical_name: str, label: str):
277-
if not logical_name or not entity_set:
278-
return False
279-
odata = client._get_odata()
280-
probe_url = f"{odata.api}/{entity_set}?$top=1&$select={logical_name}"
281-
waited = 0
282-
last_error = None
283-
for delay in ATTRIBUTE_VISIBILITY_DELAYS:
284-
if delay:
285-
time.sleep(delay)
286-
waited += delay
287-
try:
288-
resp = odata._request("get", probe_url)
289-
try:
290-
resp.json()
291-
except Exception: # noqa: BLE001
292-
pass
293-
if waited:
294-
print({f"{label}_attribute_visible_wait_seconds": waited})
295-
return True
296-
except Exception as ex: # noqa: BLE001
297-
last_error = ex
298-
continue
299-
raise RuntimeError(f"Timed out waiting for attribute '{logical_name}' to materialize") from last_error
300-
301-
302-
# Conditionally ensure each attribute only if its mode is selected
303-
if run_small:
304-
ensure_file_attribute_generic(small_file_attr_schema, "Small Document", "small")
305-
wait_for_attribute_visibility(small_file_attr_logical, "small")
306-
if run_chunk:
307-
ensure_file_attribute_generic(chunk_file_attr_schema, "Chunk Document", "chunk")
308-
wait_for_attribute_visibility(chunk_file_attr_logical, "chunk")
309-
310217
# --------------------------- Record create ---------------------------
311218
record_id = None
312219
try:

src/PowerPlatform/Dataverse/claude_skill/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ client.delete_table("new_Product")
199199
client.upload_file(
200200
table_schema_name="account",
201201
record_id=account_id,
202-
file_name_attribute="new_document",
202+
file_name_attribute="new_document", # If the file column doesn't exist, it will be created automatically
203203
path="/path/to/document.pdf"
204204
)
205205
```

src/PowerPlatform/Dataverse/client.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -661,9 +661,8 @@ def upload_file(
661661
)
662662
"""
663663
with self._scoped_odata() as od:
664-
entity_set = od._entity_set_from_schema_name(table_schema_name)
665664
od._upload_file(
666-
entity_set,
665+
table_schema_name,
667666
record_id,
668667
file_name_attribute,
669668
path,

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -896,6 +896,31 @@ def _get_attribute_metadata(
896896
return item
897897
return None
898898

899+
def _wait_for_attribute_visibility(
900+
self,
901+
entity_set: str,
902+
attribute_logical_name: str,
903+
delays: tuple = (0, 3, 10, 20),
904+
) -> bool:
905+
"""Wait for a newly created attribute to become visible in the data API.
906+
907+
After creating an attribute via the metadata API, there can be a delay before
908+
it becomes queryable in the data API. This method polls the entity set with
909+
the attribute in the $select clause until it succeeds or all delays are exhausted.
910+
"""
911+
import time
912+
913+
probe_url = f"{self.api}/{entity_set}?$top=1&$select={attribute_logical_name}"
914+
for delay in delays:
915+
if delay:
916+
time.sleep(delay)
917+
try:
918+
self._request("get", probe_url)
919+
return True
920+
except Exception: # noqa: BLE001
921+
continue
922+
return False
923+
899924
# ---------------------- Enum / Option Set helpers ------------------
900925
def _build_localizedlabels_payload(self, translations: Dict[int, str]) -> Dict[str, Any]:
901926
"""Build a Dataverse Label object from {<language_code>: <text>} entries.
@@ -1239,6 +1264,13 @@ def _attribute_payload(
12391264
"IsGlobal": False,
12401265
},
12411266
}
1267+
if dtype_l == "file":
1268+
return {
1269+
"@odata.type": "Microsoft.Dynamics.CRM.FileAttributeMetadata",
1270+
"SchemaName": column_schema_name,
1271+
"DisplayName": self._label(label),
1272+
"RequiredLevel": {"Value": "None"},
1273+
}
12421274
return None
12431275

12441276
def _get_table_info(self, table_schema_name: str) -> Optional[Dict[str, Any]]:

src/PowerPlatform/Dataverse/data/_upload.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class _ODataFileUpload:
1313

1414
def _upload_file(
1515
self,
16-
entity_set: str,
16+
table_schema_name: str,
1717
record_id: str,
1818
file_name_attribute: str,
1919
path: str,
@@ -25,12 +25,12 @@ def _upload_file(
2525
2626
Parameters
2727
----------
28-
entity_set : :class:`str`
29-
Target entity set (plural logical name), e.g. "accounts".
28+
table_schema_name : :class:`str`
29+
Table schema name (singular logical name), e.g. "account".
3030
record_id : :class:`str`
3131
GUID of the target record.
3232
file_name_attribute : :class:`str`
33-
Logical name of the file column attribute
33+
Logical name of the file column attribute. If the column doesn't exist, it will be created.
3434
path : :class:`str`
3535
Local filesystem path to the file.
3636
mode : :class:`str` | None
@@ -42,6 +42,25 @@ def _upload_file(
4242
"""
4343
import os
4444

45+
# Resolve entity set from table schema name
46+
entity_set = self._entity_set_from_schema_name(table_schema_name)
47+
48+
# Check if the file column exists, create it if it doesn't
49+
entity_metadata = self._get_entity_by_table_schema_name(table_schema_name)
50+
if entity_metadata:
51+
metadata_id = entity_metadata.get("MetadataId")
52+
if metadata_id:
53+
attr_metadata = self._get_attribute_metadata(metadata_id, file_name_attribute)
54+
if not attr_metadata:
55+
# Attribute doesn't exist, create it
56+
self._create_columns(table_schema_name, {file_name_attribute: "file"})
57+
# Wait for the attribute to become visible in the data API
58+
if not self._wait_for_attribute_visibility(entity_set, file_name_attribute):
59+
raise RuntimeError(
60+
f"Attribute '{file_name_attribute}' was created but did not become visible "
61+
f"in the data API after 33 seconds (max retry timeout). The upload cannot proceed. Retry upload later might help."
62+
)
63+
4564
mode = (mode or "auto").lower()
4665

4766
if mode == "auto":

0 commit comments

Comments
 (0)