Skip to content

Commit c2090f7

Browse files
author
Samson Gebre
committed
Enhance SKILL.md and examples with improved documentation and usage of QueryResult indexing
1 parent 03ae951 commit c2090f7

10 files changed

Lines changed: 60 additions & 160 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ The SDK supports Dataverse's native bulk operations: Pass lists to `create()`, `
3838
- Returned by `records.list()`, `records.retrieve()`, `execute()`, and each page from `list_pages()` / `execute_pages()`
3939
- Iterable: `for record in result` — each item is a `dict`-like `Record`
4040
- `.to_dataframe()` — convert to pandas DataFrame
41-
- `.first()` — return the first record or `None`
41+
- `.first()` — return the first record or `None` (safe: returns `None` on empty result)
42+
- `result[n]` — index access returns a `Record`; `result[n:m]` returns a `QueryResult`
4243
- `len(result)` — number of records in this result/page
4344

4445
### DataFrame Support

examples/advanced/fetchxml.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,10 @@ def _run_examples(client):
268268
print(f"[OK] {len(result)} projects:")
269269
for r in result:
270270
print(f" {r.get('new_code', ''):<10s} Budget={r.get('new_budget')} Active={r.get('new_active')}")
271+
# Index access and first() are equivalent; first() returns None on empty result
272+
if result:
273+
print(f" First by index : {result[0].get('new_code')}")
274+
print(f" First by .first(): {result.first().get('new_code')}")
271275

272276
# ===============================================================
273277
# 4. <condition> operators: eq, like, in, null, not-null, between

examples/basic/installation_example.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -239,9 +239,8 @@ def show_usage_examples():
239239
select=["name", "telephone1"],
240240
top=10)
241241
242-
for page in accounts:
243-
for account in page:
244-
print(f"Account: {account['name']}")
242+
for account in accounts:
243+
print(f"Account: {account['name']}")
245244
246245
# SQL queries (if enabled)
247246
results = client.query.sql("SELECT TOP 5 name FROM account")

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ The SDK supports Dataverse's native bulk operations: Pass lists to `create()`, `
3838
- Returned by `records.list()`, `records.retrieve()`, `execute()`, and each page from `list_pages()` / `execute_pages()`
3939
- Iterable: `for record in result` — each item is a `dict`-like `Record`
4040
- `.to_dataframe()` — convert to pandas DataFrame
41-
- `.first()` — return the first record or `None`
41+
- `.first()` — return the first record or `None` (safe: returns `None` on empty result)
42+
- `result[n]` — index access returns a `Record`; `result[n:m]` returns a `QueryResult`
4243
- `len(result)` — number of records in this result/page
4344

4445
### DataFrame Support

src/PowerPlatform/Dataverse/models/query_builder.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -513,9 +513,9 @@ def execute(self, *, by_page=_BY_PAGE_UNSET) -> Union[QueryResult, Iterator[Quer
513513
"Use build() and pass parameters to client.records.list() instead."
514514
)
515515

516-
if not self._select and not self._filter_parts and self._top is None:
516+
if not self._select and not self._filter_parts and self._top is None and self._page_size is None:
517517
raise ValueError(
518-
"At least one of select(), where(), or top() must be called before "
518+
"At least one of select(), where(), top(), or page_size() must be called before "
519519
"execute() to prevent accidental full-table scans."
520520
)
521521

@@ -577,9 +577,9 @@ def execute_pages(self) -> Iterator[QueryResult]:
577577
"Use build() and pass parameters to client.records.list() instead."
578578
)
579579

580-
if not self._select and not self._filter_parts and self._top is None:
580+
if not self._select and not self._filter_parts and self._top is None and self._page_size is None:
581581
raise ValueError(
582-
"At least one of select(), where(), or top() must be called before "
582+
"At least one of select(), where(), top(), or page_size() must be called before "
583583
"execute_pages() to prevent accidental full-table scans."
584584
)
585585

src/PowerPlatform/Dataverse/models/record.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ def __bool__(self) -> bool:
141141
def __repr__(self) -> str:
142142
return f"QueryResult({len(self.records)} records)"
143143

144+
def __getitem__(self, index):
145+
result = self.records[index]
146+
return QueryResult(result) if isinstance(index, slice) else result
147+
144148
def first(self) -> Optional[Record]:
145149
"""Return the first record, or ``None`` if the result is empty."""
146150
return self.records[0] if self.records else None

src/PowerPlatform/Dataverse/operations/records.py

Lines changed: 7 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import warnings
99
from typing import Any, Dict, Iterable, Iterator, List, Optional, Union, overload, TYPE_CHECKING
1010

11-
from ..models.protocol import DataverseModel
1211
from ..models.record import QueryResult, Record
1312
from ..models.upsert import UpsertItem
1413

@@ -57,16 +56,10 @@ def create(self, table: str, data: Dict[str, Any]) -> str: ...
5756
@overload
5857
def create(self, table: str, data: List[Dict[str, Any]]) -> List[str]: ...
5958

60-
@overload
61-
def create(self, table_or_entity: DataverseModel, data: None = None) -> str: ...
62-
63-
@overload
64-
def create(self, table_or_entity: List[DataverseModel], data: None = None) -> List[str]: ...
65-
6659
def create(
6760
self,
68-
table_or_entity: Union[str, DataverseModel, List[DataverseModel]],
69-
data: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None,
61+
table: str,
62+
data: Union[Dict[str, Any], List[Dict[str, Any]]],
7063
) -> Union[str, List[str]]:
7164
"""Create one or more records in a Dataverse table.
7265
@@ -87,49 +80,19 @@ def create(
8780
:raises TypeError: If ``data`` is not a dict or list[dict].
8881
8982
Example:
90-
Create a single record (dict form)::
83+
Create a single record::
9184
9285
guid = client.records.create("account", {"name": "Contoso"})
9386
print(f"Created: {guid}")
9487
95-
Create multiple records (dict form)::
88+
Create multiple records::
9689
9790
guids = client.records.create("account", [
9891
{"name": "Contoso"},
9992
{"name": "Fabrikam"},
10093
])
10194
print(f"Created {len(guids)} accounts")
102-
103-
Create from a DataverseModel instance::
104-
105-
guid = client.records.create(Account(name="Contoso"))
10695
"""
107-
# DataverseModel dispatch: list of entities
108-
if isinstance(table_or_entity, list) and table_or_entity and isinstance(table_or_entity[0], DataverseModel):
109-
entities = table_or_entity
110-
table = entities[0].__entity_logical_name__
111-
data_list = [e.to_dict() for e in entities]
112-
with self._client._scoped_odata() as od:
113-
entity_set = od._entity_set_from_schema_name(table)
114-
ids = od._create_multiple(entity_set, table, data_list)
115-
if not isinstance(ids, list) or not all(isinstance(x, str) for x in ids):
116-
raise TypeError("_create (multi) did not return list[str]")
117-
return ids
118-
119-
# DataverseModel dispatch: single entity
120-
if isinstance(table_or_entity, DataverseModel):
121-
entity = table_or_entity
122-
table = entity.__entity_logical_name__
123-
record_data = entity.to_dict()
124-
with self._client._scoped_odata() as od:
125-
entity_set = od._entity_set_from_schema_name(table)
126-
rid = od._create(entity_set, table, record_data)
127-
if not isinstance(rid, str):
128-
raise TypeError("_create (single) did not return GUID string")
129-
return rid
130-
131-
# Existing str/dict path
132-
table = table_or_entity # type: ignore[assignment]
13396
with self._client._scoped_odata() as od:
13497
entity_set = od._entity_set_from_schema_name(table)
13598
if isinstance(data, dict):
@@ -148,9 +111,9 @@ def create(
148111

149112
def update(
150113
self,
151-
table_or_entity: Union[str, DataverseModel],
152-
ids: Optional[Union[str, List[str]]] = None,
153-
changes: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None,
114+
table: str,
115+
ids: Union[str, List[str]],
116+
changes: Union[Dict[str, Any], List[Dict[str, Any]]],
154117
) -> None:
155118
"""Update one or more records in a Dataverse table.
156119
@@ -191,28 +154,7 @@ def update(
191154
[{"name": "Name A"}, {"name": "Name B"}],
192155
)
193156
194-
Update from a DataverseModel instance::
195-
196-
client.records.update(Account(name="Contoso Updated"), account_id)
197157
"""
198-
# DataverseModel dispatch: entity provides table + changes
199-
if isinstance(table_or_entity, DataverseModel):
200-
entity = table_or_entity
201-
table = entity.__entity_logical_name__
202-
record_data = entity.to_dict()
203-
if ids is None:
204-
raise TypeError("record_id must be provided when updating from a DataverseModel")
205-
with self._client._scoped_odata() as od:
206-
if isinstance(ids, str):
207-
od._update(table, ids, record_data)
208-
return None
209-
if isinstance(ids, list):
210-
od._update_by_ids(table, ids, record_data)
211-
return None
212-
return None
213-
214-
# Existing str/dict path
215-
table: str = table_or_entity # type: ignore[assignment]
216158
with self._client._scoped_odata() as od:
217159
if isinstance(ids, str):
218160
if not isinstance(changes, dict):

tests/unit/test_phase2_ga.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,37 @@ def test_first_returns_first_record(self):
8484
def test_first_returns_none_when_empty(self):
8585
self.assertIsNone(QueryResult([]).first())
8686

87+
# ----- __getitem__
88+
89+
def test_getitem_int_returns_record(self):
90+
recs = self._records(3)
91+
qr = QueryResult(recs)
92+
self.assertIs(qr[0], recs[0])
93+
self.assertIs(qr[2], recs[2])
94+
95+
def test_getitem_negative_index(self):
96+
recs = self._records(3)
97+
qr = QueryResult(recs)
98+
self.assertIs(qr[-1], recs[-1])
99+
100+
def test_getitem_out_of_range_raises(self):
101+
qr = QueryResult(self._records(2))
102+
with self.assertRaises(IndexError):
103+
_ = qr[99]
104+
105+
def test_getitem_slice_returns_query_result(self):
106+
recs = self._records(5)
107+
qr = QueryResult(recs)
108+
sliced = qr[1:3]
109+
self.assertIsInstance(sliced, QueryResult)
110+
self.assertEqual(list(sliced), recs[1:3])
111+
112+
def test_getitem_slice_empty(self):
113+
qr = QueryResult(self._records(3))
114+
sliced = qr[10:]
115+
self.assertIsInstance(sliced, QueryResult)
116+
self.assertEqual(len(sliced), 0)
117+
87118
# ----- to_dataframe()
88119

89120
def test_to_dataframe_nonempty(self):

tests/unit/test_phase3_ga.py

Lines changed: 4 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
- records.get() deprecation (DeprecationWarning, still functional)
88
- records.retrieve() — single record, None on 404
99
- records.list() — QueryResult, accepts str/FilterExpression filter
10-
- DataverseModel Protocol and isinstance() check
11-
- records.create(DataverseModel) dispatch
12-
- records.update(DataverseModel, record_id) dispatch
10+
- DataverseModel Protocol definition and isinstance() check
1311
- DataverseModel exported from models and package root
1412
- execute() emits zero DeprecationWarning (internal records.get() suppressed)
13+
14+
Note: records.create(DataverseModel) and records.update(DataverseModel) dispatch
15+
are deferred to post-GA and are not covered here.
1516
"""
1617

1718
import unittest
@@ -310,88 +311,6 @@ def test_list_result_to_dataframe(self):
310311
self.assertEqual(len(df), 2)
311312

312313

313-
class TestCreateWithDataverseModel(unittest.TestCase):
314-
"""records.create() accepts DataverseModel."""
315-
316-
def setUp(self):
317-
self.client = _make_client()
318-
self.client._odata._create.return_value = "new-guid-123"
319-
self.client._odata._create_multiple.return_value = ["guid-1", "guid-2"]
320-
321-
def test_create_single_entity(self):
322-
account = _Account(name="Contoso", telephone1="555-0100")
323-
result = self.client.records.create(account)
324-
self.assertEqual(result, "new-guid-123")
325-
326-
def test_create_single_entity_uses_logical_name(self):
327-
account = _Account(name="Contoso")
328-
self.client.records.create(account)
329-
self.client._odata._entity_set_from_schema_name.assert_called_with("account")
330-
331-
def test_create_single_entity_calls_to_dict(self):
332-
account = _Account(name="Contoso", telephone1="555-0100")
333-
self.client.records.create(account)
334-
self.client._odata._create.assert_called_once()
335-
call_args = self.client._odata._create.call_args
336-
self.assertEqual(call_args[0][2]["name"], "Contoso")
337-
338-
def test_create_list_of_entities(self):
339-
entities = [_Account(name="A"), _Account(name="B")]
340-
result = self.client.records.create(entities)
341-
self.assertEqual(result, ["guid-1", "guid-2"])
342-
343-
def test_create_list_uses_first_entity_logical_name(self):
344-
entities = [_Account(name="A"), _Account(name="B")]
345-
self.client.records.create(entities)
346-
self.client._odata._entity_set_from_schema_name.assert_called_with("account")
347-
348-
def test_create_entity_no_deprecation_warning(self):
349-
with warnings.catch_warnings(record=True) as caught:
350-
warnings.simplefilter("always")
351-
self.client.records.create(_Account(name="Contoso"))
352-
dep = [w for w in caught if issubclass(w.category, DeprecationWarning)]
353-
self.assertEqual(dep, [])
354-
355-
def test_create_dict_path_still_works(self):
356-
result = self.client.records.create("account", {"name": "Contoso"})
357-
self.assertEqual(result, "new-guid-123")
358-
359-
def test_create_list_dict_path_still_works(self):
360-
result = self.client.records.create("account", [{"name": "A"}, {"name": "B"}])
361-
self.assertEqual(result, ["guid-1", "guid-2"])
362-
363-
364-
class TestUpdateWithDataverseModel(unittest.TestCase):
365-
"""records.update() accepts DataverseModel as first arg."""
366-
367-
def setUp(self):
368-
self.client = _make_client()
369-
370-
def test_update_single_entity_with_id(self):
371-
account = _Account(name="Updated Name")
372-
self.client.records.update(account, "guid-abc")
373-
self.client._odata._update.assert_called_once_with(
374-
"account", "guid-abc", {"name": "Updated Name", "telephone1": ""}
375-
)
376-
377-
def test_update_entity_no_id_raises(self):
378-
account = _Account(name="Updated")
379-
with self.assertRaises(TypeError):
380-
self.client.records.update(account)
381-
382-
def test_update_entity_no_deprecation_warning(self):
383-
account = _Account(name="Updated")
384-
with warnings.catch_warnings(record=True) as caught:
385-
warnings.simplefilter("always")
386-
self.client.records.update(account, "guid-abc")
387-
dep = [w for w in caught if issubclass(w.category, DeprecationWarning)]
388-
self.assertEqual(dep, [])
389-
390-
def test_update_dict_path_still_works(self):
391-
self.client.records.update("account", "guid-1", {"name": "New Name"})
392-
self.client._odata._update.assert_called_with("account", "guid-1", {"name": "New Name"})
393-
394-
395314
class TestExecuteNoDeprecationFromRecordsGet(unittest.TestCase):
396315
"""execute() suppresses DeprecationWarning from the internal records.get() call."""
397316

tests/unit/test_records_operations.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
# Licensed under the MIT license.
33

44
import unittest
5-
import warnings
65
from unittest.mock import MagicMock
76

87
from azure.core.credentials import TokenCredential

0 commit comments

Comments
 (0)