Skip to content

Commit 0c0f04f

Browse files
author
Max Wang
committed
properly convert nan and datetime fields
1 parent 3f93355 commit 0c0f04f

6 files changed

Lines changed: 85 additions & 13 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ This skill provides guidance for developers working on the PowerPlatform Dataver
1313

1414
### API Design
1515

16-
1. **All public methods in client.py** - Public API methods must be in client.py
16+
1. **client.py** - client.py only contains public API methods and all public methods must be in client.py
1717
2. **Every public method needs README example** - Public API methods must have examples in README.md
1818
3. **Reuse existing APIs** - Always check if an existing method can be used before making direct Web API calls
1919
4. **Update documentation** when adding features - Keep README and SKILL files (both copies) in sync

examples/advanced/dataframe_operations.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,20 @@ def main():
4040
test_filter = f"contains(name,'{tag}')"
4141
print(f"[INFO] Using tag '{tag}' to identify test records")
4242

43+
select_cols = ["name", "telephone1", "websiteurl", "lastonholdtime"]
44+
4345
# ── 1. Create records from a DataFrame ────────────────────────
4446
print("\n" + "-" * 60)
4547
print("1. Create records from a DataFrame")
4648
print("-" * 60)
4749

4850
new_accounts = pd.DataFrame([
49-
{"name": f"Contoso_{tag}", "telephone1": "555-0100", "websiteurl": "https://contoso.com"},
50-
{"name": f"Fabrikam_{tag}", "telephone1": "555-0200", "websiteurl": "https://fabrikam.com"},
51-
{"name": f"Northwind_{tag}", "telephone1": "555-0300", "websiteurl": "https://northwind.com"},
51+
{"name": f"Contoso_{tag}", "telephone1": "555-0100", "websiteurl": "https://contoso.com",
52+
"lastonholdtime": pd.Timestamp("2024-06-15 10:30:00")},
53+
{"name": f"Fabrikam_{tag}", "telephone1": "555-0200", "websiteurl": None,
54+
"lastonholdtime": None},
55+
{"name": f"Northwind_{tag}", "telephone1": None, "websiteurl": "https://northwind.com",
56+
"lastonholdtime": pd.Timestamp("2024-12-01 08:00:00")},
5257
])
5358
print(f" Input DataFrame:\n{new_accounts.to_string(index=False)}\n")
5459

@@ -63,7 +68,7 @@ def main():
6368
print("-" * 60)
6469

6570
page_count = 0
66-
for df_page in client.get_dataframe(table, select=["name", "telephone1"], filter=test_filter, page_size=2):
71+
for df_page in client.get_dataframe(table, select=select_cols, filter=test_filter, page_size=2):
6772
page_count += 1
6873
print(f" Page {page_count} ({len(df_page)} records):\n{df_page.to_string(index=False)}")
6974

@@ -73,7 +78,7 @@ def main():
7378
print("-" * 60)
7479

7580
all_records = pd.concat(
76-
client.get_dataframe(table, select=["name", "telephone1"], filter=test_filter, page_size=2),
81+
client.get_dataframe(table, select=select_cols, filter=test_filter, page_size=2),
7782
ignore_index=True,
7883
)
7984
print(f"[OK] Got {len(all_records)} total records in one DataFrame")
@@ -87,7 +92,7 @@ def main():
8792

8893
first_id = new_accounts["accountid"].iloc[0]
8994
print(f" Fetching record {first_id}...")
90-
single = client.get_dataframe(table, record_id=first_id, select=["name", "telephone1"])
95+
single = client.get_dataframe(table, record_id=first_id, select=select_cols)
9196
print(f"[OK] Single record DataFrame:\n{single.to_string(index=False)}")
9297

9398
# ── 5. Update records from a DataFrame ────────────────────────
@@ -101,7 +106,7 @@ def main():
101106
print("[OK] Updated 3 records")
102107

103108
# Verify the updates with a bulk get
104-
verified = next(client.get_dataframe(table, select=["name", "telephone1"], filter=test_filter))
109+
verified = next(client.get_dataframe(table, select=select_cols, filter=test_filter))
105110
print(f" Verified:\n{verified.to_string(index=False)}")
106111

107112
# ── 6. Broadcast update (same value to all records) ───────────
@@ -116,7 +121,7 @@ def main():
116121
print("[OK] Broadcast update complete")
117122

118123
# Verify all records have the same websiteurl
119-
verified = next(client.get_dataframe(table, select=["name", "websiteurl"], filter=test_filter))
124+
verified = next(client.get_dataframe(table, select=select_cols, filter=test_filter))
120125
print(f" Verified:\n{verified.to_string(index=False)}")
121126

122127
# ── 7. Delete records by passing a Series of GUIDs ────────────
@@ -129,7 +134,7 @@ def main():
129134
print(f"[OK] Deleted {len(new_accounts)} records")
130135

131136
# Verify deletions - filter for our tagged records should return 0
132-
remaining = list(client.get_dataframe(table, select=["name"], filter=test_filter))
137+
remaining = list(client.get_dataframe(table, select=select_cols, filter=test_filter))
133138
count = sum(len(page) for page in remaining)
134139
print(f" Verified: {count} test records remaining (expected 0)")
135140

src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-dev/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ This skill provides guidance for developers working on the PowerPlatform Dataver
1313

1414
### API Design
1515

16-
1. **All public methods in client.py** - Public API methods must be in client.py
16+
1. **client.py** - client.py only contains public API methods and all public methods must be in client.py
1717
2. **Every public method needs README example** - Public API methods must have examples in README.md
1818
3. **Reuse existing APIs** - Always check if an existing method can be used before making direct Web API calls
1919
4. **Update documentation** when adding features - Keep README and SKILL files (both copies) in sync

src/PowerPlatform/Dataverse/client.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .core._auth import _AuthManager
1414
from .core.config import DataverseConfig
1515
from .data._odata import _ODataClient
16+
from .utils._pandas import dataframe_to_records
1617

1718

1819
class DataverseClient:
@@ -472,7 +473,7 @@ def create_dataframe(
472473
if not isinstance(records, pd.DataFrame):
473474
raise TypeError("records must be a pandas DataFrame")
474475

475-
record_list = records.to_dict(orient="records")
476+
record_list = dataframe_to_records(records)
476477
ids = self.create(table_schema_name, record_list)
477478
return pd.Series(ids, index=records.index)
478479

@@ -522,7 +523,7 @@ def update_dataframe(
522523

523524
ids = records[id_column].tolist()
524525
change_columns = [column for column in records.columns if column != id_column]
525-
changes = records[change_columns].to_dict(orient="records")
526+
changes = dataframe_to_records(records[change_columns])
526527

527528
if len(ids) == 1:
528529
self.update(table_schema_name, ids[0], changes[0])
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""Internal pandas helpers"""
5+
6+
from __future__ import annotations
7+
8+
from typing import Any, Dict, List
9+
10+
import pandas as pd
11+
12+
13+
def dataframe_to_records(df: pd.DataFrame) -> List[Dict[str, Any]]:
14+
"""Convert a DataFrame to a list of dicts, dropping NaN values and converting Timestamps to ISO strings."""
15+
records = []
16+
for row in df.to_dict(orient="records"):
17+
clean = {}
18+
for k, v in row.items():
19+
if pd.notna(v):
20+
clean[k] = v.isoformat() if isinstance(v, pd.Timestamp) else v
21+
records.append(clean)
22+
return records

tests/unit/test_client_dataframe.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,36 @@ def test_create_empty_dataframe(self):
187187
self.assertIsInstance(ids, pd.Series)
188188
self.assertEqual(len(ids), 0)
189189

190+
def test_create_drops_nan_values(self):
191+
"""NaN values are stripped from the payload (field omitted, not sent as NaN)."""
192+
df = pd.DataFrame([
193+
{"name": "Contoso", "telephone1": "555-0100"},
194+
{"name": "Fabrikam", "telephone1": None},
195+
])
196+
self.client._odata._create_multiple.return_value = ["guid-1", "guid-2"]
197+
self.client._odata._entity_set_from_schema_name.return_value = "accounts"
198+
199+
self.client.create_dataframe("account", df)
200+
201+
call_args = self.client._odata._create_multiple.call_args
202+
records_arg = call_args[0][2]
203+
self.assertEqual(records_arg[0], {"name": "Contoso", "telephone1": "555-0100"})
204+
self.assertEqual(records_arg[1], {"name": "Fabrikam"})
205+
self.assertNotIn("telephone1", records_arg[1])
206+
207+
def test_create_converts_timestamps_to_iso(self):
208+
"""Timestamp values are converted to ISO 8601 strings."""
209+
ts = pd.Timestamp("2024-01-15 10:30:00")
210+
df = pd.DataFrame([{"name": "Contoso", "createdon": ts}])
211+
self.client._odata._create_multiple.return_value = ["guid-1"]
212+
self.client._odata._entity_set_from_schema_name.return_value = "accounts"
213+
214+
self.client.create_dataframe("account", df)
215+
216+
call_args = self.client._odata._create_multiple.call_args
217+
records_arg = call_args[0][2]
218+
self.assertEqual(records_arg[0]["createdon"], "2024-01-15T10:30:00")
219+
190220

191221
class TestDataFrameUpdate(unittest.TestCase):
192222
"""Tests for update_dataframe."""
@@ -246,6 +276,20 @@ def test_update_multiple_change_columns(self):
246276
self.assertIn("telephone1", changes)
247277
self.assertNotIn("accountid", changes)
248278

279+
def test_update_drops_nan_from_changes(self):
280+
"""NaN values in change columns are stripped from the payload."""
281+
df = pd.DataFrame([
282+
{"accountid": "guid-1", "name": "New Name", "telephone1": None},
283+
{"accountid": "guid-2", "name": None, "telephone1": "555-0200"},
284+
])
285+
286+
self.client.update_dataframe("account", df, id_column="accountid")
287+
288+
call_args = self.client._odata._update_by_ids.call_args[0]
289+
changes = call_args[2]
290+
self.assertEqual(changes[0], {"name": "New Name"})
291+
self.assertEqual(changes[1], {"telephone1": "555-0200"})
292+
249293

250294
class TestDataFrameDelete(unittest.TestCase):
251295
"""Tests for delete_dataframe."""

0 commit comments

Comments
 (0)