Skip to content

Commit 5ba503d

Browse files
committed
Merge remote-tracking branch 'origin/main' into feature/relationship-info-model
2 parents 81903a0 + 78eb5dd commit 5ba503d

File tree

6 files changed

+289
-6
lines changed

6 files changed

+289
-6
lines changed

examples/basic/functional_testing.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ def setup_authentication() -> DataverseClient:
6565
print("Testing connection...")
6666
tables = client.tables.list()
6767
print(f"[OK] Connection successful! Found {len(tables)} tables.")
68+
69+
# Test filtered + selected list
70+
user_owned = client.tables.list(
71+
filter="OwnershipType eq Microsoft.Dynamics.CRM.OwnershipTypes'UserOwned'",
72+
select=["LogicalName", "SchemaName", "DisplayName"],
73+
)
74+
print(f"[OK] Found {len(user_owned)} user-owned tables (filter + select).")
6875
return client
6976

7077
except Exception as e:

examples/basic/installation_example.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,13 @@ def show_usage_examples():
265265
# List all tables
266266
tables = client.tables.list()
267267
print(f"Found {len(tables)} tables")
268+
269+
# List with filter and select
270+
custom_tables = client.tables.list(
271+
filter="IsCustomEntity eq true",
272+
select=["LogicalName", "SchemaName", "DisplayName"],
273+
)
274+
print(f"Found {len(custom_tables)} custom tables")
268275
```
269276
""")
270277

@@ -304,9 +311,14 @@ def interactive_test():
304311

305312
print(" Testing connection...")
306313
tables = client.tables.list()
307-
308314
print(f" [OK] Connection successful!")
309315
print(f" Found {len(tables)} tables in environment")
316+
317+
custom_tables = client.tables.list(
318+
filter="IsCustomEntity eq true",
319+
select=["LogicalName", "SchemaName"],
320+
)
321+
print(f" Found {len(custom_tables)} custom tables (filter + select)")
310322
print(f" Connected to: {org_url}")
311323

312324
print("\n Your SDK is ready for use!")

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1423,16 +1423,44 @@ def _get_table_info(self, table_schema_name: str) -> Optional[Dict[str, Any]]:
14231423
"columns_created": [],
14241424
}
14251425

1426-
def _list_tables(self) -> List[Dict[str, Any]]:
1426+
def _list_tables(
1427+
self,
1428+
filter: Optional[str] = None,
1429+
select: Optional[List[str]] = None,
1430+
) -> List[Dict[str, Any]]:
14271431
"""List all non-private tables (``IsPrivate eq false``).
14281432
1433+
:param filter: Optional additional OData ``$filter`` expression that is
1434+
combined with the default ``IsPrivate eq false`` clause using
1435+
``and``. For example, ``"SchemaName eq 'Account'"`` becomes
1436+
``"IsPrivate eq false and (SchemaName eq 'Account')"``.
1437+
When ``None`` (the default), only the ``IsPrivate eq false`` filter
1438+
is applied.
1439+
:type filter: ``str`` or ``None``
1440+
:param select: Optional list of property names to project via
1441+
``$select``. Values are passed as-is (PascalCase) because
1442+
``EntityDefinitions`` uses PascalCase property names.
1443+
When ``None`` (the default) or an empty list, no ``$select`` is
1444+
applied and all properties are returned. Passing a bare string
1445+
raises ``TypeError``.
1446+
:type select: ``list[str]`` or ``None``
1447+
14291448
:return: Metadata entries for non-private tables (may be empty).
14301449
:rtype: ``list[dict[str, Any]]``
14311450
14321451
:raises HttpError: If the metadata request fails.
14331452
"""
14341453
url = f"{self.api}/EntityDefinitions"
1435-
params = {"$filter": "IsPrivate eq false"}
1454+
base_filter = "IsPrivate eq false"
1455+
if filter:
1456+
combined_filter = f"{base_filter} and ({filter})"
1457+
else:
1458+
combined_filter = base_filter
1459+
params: Dict[str, str] = {"$filter": combined_filter}
1460+
if select is not None and isinstance(select, str):
1461+
raise TypeError("select must be a list of property names, not a bare string")
1462+
if select:
1463+
params["$select"] = ",".join(select)
14361464
r = self._request("get", url, params=params)
14371465
return r.json().get("value", [])
14381466

src/PowerPlatform/Dataverse/operations/tables.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,20 +179,56 @@ def get(self, table: str) -> Optional[Dict[str, Any]]:
179179

180180
# ------------------------------------------------------------------- list
181181

182-
def list(self) -> List[Dict[str, Any]]:
182+
def list(
183+
self,
184+
*,
185+
filter: Optional[str] = None,
186+
select: Optional[List[str]] = None,
187+
) -> List[Dict[str, Any]]:
183188
"""List all non-private tables in the Dataverse environment.
184189
190+
By default returns every table where ``IsPrivate eq false``. Supply
191+
an optional OData ``$filter`` expression to further narrow the results.
192+
The expression is combined with the default ``IsPrivate eq false``
193+
clause using ``and``.
194+
195+
:param filter: Optional OData ``$filter`` expression to further narrow
196+
the list of returned tables (e.g.
197+
``"SchemaName eq 'Account'"``). Column names in filter
198+
expressions must use the exact property names from the
199+
``EntityDefinitions`` metadata (typically PascalCase).
200+
:type filter: :class:`str` or None
201+
:param select: Optional list of property names to include in the
202+
response (projected via the OData ``$select`` query option).
203+
Property names must use the exact PascalCase names from the
204+
``EntityDefinitions`` metadata (e.g.
205+
``["LogicalName", "SchemaName", "DisplayName"]``).
206+
When ``None`` (the default) or an empty list, all properties are
207+
returned.
208+
:type select: :class:`list` of :class:`str` or None
209+
185210
:return: List of EntityDefinition metadata dictionaries.
186211
:rtype: :class:`list` of :class:`dict`
187212
188213
Example::
189214
215+
# List all non-private tables
190216
tables = client.tables.list()
191217
for table in tables:
192218
print(table["LogicalName"])
219+
220+
# List only tables whose schema name starts with "new_"
221+
custom_tables = client.tables.list(
222+
filter="startswith(SchemaName, 'new_')"
223+
)
224+
225+
# List tables with only specific properties
226+
tables = client.tables.list(
227+
select=["LogicalName", "SchemaName", "EntitySetName"]
228+
)
193229
"""
194230
with self._client._scoped_odata() as od:
195-
return od._list_tables()
231+
return od._list_tables(filter=filter, select=select)
196232

197233
# ------------------------------------------------------------- add_columns
198234

tests/unit/data/test_odata_internal.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,136 @@ def test_non_string_key_raises_type_error(self):
125125
self.od._build_alternate_key_str({1: "ACC-001"})
126126

127127

128+
class TestListTables(unittest.TestCase):
129+
"""Unit tests for _ODataClient._list_tables filter and select parameters."""
130+
131+
def setUp(self):
132+
self.od = _make_odata_client()
133+
134+
def _setup_response(self, value):
135+
"""Configure _request to return a response with the given value list."""
136+
mock_response = MagicMock()
137+
mock_response.json.return_value = {"value": value}
138+
self.od._request.return_value = mock_response
139+
140+
def test_no_filter_uses_default(self):
141+
"""_list_tables() without filter sends only IsPrivate eq false."""
142+
self._setup_response([])
143+
self.od._list_tables()
144+
145+
self.od._request.assert_called_once()
146+
call_kwargs = self.od._request.call_args
147+
params = call_kwargs.kwargs.get("params") or call_kwargs[1].get("params", {})
148+
self.assertEqual(params["$filter"], "IsPrivate eq false")
149+
150+
def test_filter_combined_with_default(self):
151+
"""_list_tables(filter=...) combines user filter with IsPrivate eq false."""
152+
self._setup_response([{"LogicalName": "account"}])
153+
self.od._list_tables(filter="SchemaName eq 'Account'")
154+
155+
self.od._request.assert_called_once()
156+
call_kwargs = self.od._request.call_args
157+
params = call_kwargs.kwargs.get("params") or call_kwargs[1].get("params", {})
158+
self.assertEqual(
159+
params["$filter"],
160+
"IsPrivate eq false and (SchemaName eq 'Account')",
161+
)
162+
163+
def test_filter_none_same_as_no_filter(self):
164+
"""_list_tables(filter=None) is equivalent to _list_tables()."""
165+
self._setup_response([])
166+
self.od._list_tables(filter=None)
167+
168+
self.od._request.assert_called_once()
169+
call_kwargs = self.od._request.call_args
170+
params = call_kwargs.kwargs.get("params") or call_kwargs[1].get("params", {})
171+
self.assertEqual(params["$filter"], "IsPrivate eq false")
172+
173+
def test_returns_value_list(self):
174+
"""_list_tables returns the 'value' array from the response."""
175+
expected = [
176+
{"LogicalName": "account"},
177+
{"LogicalName": "contact"},
178+
]
179+
self._setup_response(expected)
180+
result = self.od._list_tables()
181+
self.assertEqual(result, expected)
182+
183+
def test_select_adds_query_param(self):
184+
"""_list_tables(select=...) adds $select as comma-joined string."""
185+
self._setup_response([])
186+
self.od._list_tables(select=["LogicalName", "SchemaName", "DisplayName"])
187+
188+
self.od._request.assert_called_once()
189+
call_kwargs = self.od._request.call_args
190+
params = call_kwargs.kwargs.get("params") or call_kwargs[1].get("params", {})
191+
self.assertEqual(params["$select"], "LogicalName,SchemaName,DisplayName")
192+
193+
def test_select_none_omits_query_param(self):
194+
"""_list_tables(select=None) does not add $select to params."""
195+
self._setup_response([])
196+
self.od._list_tables(select=None)
197+
198+
self.od._request.assert_called_once()
199+
call_kwargs = self.od._request.call_args
200+
params = call_kwargs.kwargs.get("params") or call_kwargs[1].get("params", {})
201+
self.assertNotIn("$select", params)
202+
203+
def test_select_empty_list_omits_query_param(self):
204+
"""_list_tables(select=[]) does not add $select (empty list is falsy)."""
205+
self._setup_response([])
206+
self.od._list_tables(select=[])
207+
208+
self.od._request.assert_called_once()
209+
call_kwargs = self.od._request.call_args
210+
params = call_kwargs.kwargs.get("params") or call_kwargs[1].get("params", {})
211+
self.assertNotIn("$select", params)
212+
213+
def test_select_preserves_case(self):
214+
"""_list_tables does not lowercase select values (PascalCase preserved)."""
215+
self._setup_response([])
216+
self.od._list_tables(select=["EntitySetName", "LogicalName"])
217+
218+
self.od._request.assert_called_once()
219+
call_kwargs = self.od._request.call_args
220+
params = call_kwargs.kwargs.get("params") or call_kwargs[1].get("params", {})
221+
self.assertEqual(params["$select"], "EntitySetName,LogicalName")
222+
223+
def test_select_with_filter(self):
224+
"""_list_tables with both select and filter sends both params."""
225+
self._setup_response([{"LogicalName": "account"}])
226+
self.od._list_tables(
227+
filter="SchemaName eq 'Account'",
228+
select=["LogicalName", "SchemaName"],
229+
)
230+
231+
self.od._request.assert_called_once()
232+
call_kwargs = self.od._request.call_args
233+
params = call_kwargs.kwargs.get("params") or call_kwargs[1].get("params", {})
234+
self.assertEqual(
235+
params["$filter"],
236+
"IsPrivate eq false and (SchemaName eq 'Account')",
237+
)
238+
self.assertEqual(params["$select"], "LogicalName,SchemaName")
239+
240+
def test_select_single_property(self):
241+
"""_list_tables(select=[...]) with a single property works correctly."""
242+
self._setup_response([])
243+
self.od._list_tables(select=["LogicalName"])
244+
245+
self.od._request.assert_called_once()
246+
call_kwargs = self.od._request.call_args
247+
params = call_kwargs.kwargs.get("params") or call_kwargs[1].get("params", {})
248+
self.assertEqual(params["$select"], "LogicalName")
249+
250+
def test_select_bare_string_raises_type_error(self):
251+
"""_list_tables(select='LogicalName') raises TypeError for bare str."""
252+
self._setup_response([])
253+
with self.assertRaises(TypeError) as ctx:
254+
self.od._list_tables(select="LogicalName")
255+
self.assertIn("list of property names", str(ctx.exception))
256+
257+
128258
class TestUpsert(unittest.TestCase):
129259
"""Unit tests for _ODataClient._upsert."""
130260

tests/unit/test_tables_operations.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,80 @@ def test_list(self):
101101

102102
result = self.client.tables.list()
103103

104-
self.client._odata._list_tables.assert_called_once()
104+
self.client._odata._list_tables.assert_called_once_with(filter=None, select=None)
105105
self.assertIsInstance(result, list)
106106
self.assertEqual(result, expected_tables)
107107

108+
def test_list_with_filter(self):
109+
"""list(filter=...) should pass the filter expression to _list_tables."""
110+
expected_tables = [
111+
{"LogicalName": "account", "SchemaName": "Account"},
112+
]
113+
self.client._odata._list_tables.return_value = expected_tables
114+
115+
result = self.client.tables.list(filter="SchemaName eq 'Account'")
116+
117+
self.client._odata._list_tables.assert_called_once_with(filter="SchemaName eq 'Account'", select=None)
118+
self.assertIsInstance(result, list)
119+
self.assertEqual(result, expected_tables)
120+
121+
def test_list_with_filter_none_explicit(self):
122+
"""list(filter=None) should behave identically to list() with no args."""
123+
expected_tables = [
124+
{"LogicalName": "account", "SchemaName": "Account"},
125+
]
126+
self.client._odata._list_tables.return_value = expected_tables
127+
128+
result = self.client.tables.list(filter=None)
129+
130+
self.client._odata._list_tables.assert_called_once_with(filter=None, select=None)
131+
self.assertEqual(result, expected_tables)
132+
133+
def test_list_with_select(self):
134+
"""list(select=...) should pass the select list to _list_tables."""
135+
expected_tables = [
136+
{"LogicalName": "account", "SchemaName": "Account"},
137+
]
138+
self.client._odata._list_tables.return_value = expected_tables
139+
140+
result = self.client.tables.list(select=["LogicalName", "SchemaName", "EntitySetName"])
141+
142+
self.client._odata._list_tables.assert_called_once_with(
143+
filter=None,
144+
select=["LogicalName", "SchemaName", "EntitySetName"],
145+
)
146+
self.assertEqual(result, expected_tables)
147+
148+
def test_list_with_select_none_explicit(self):
149+
"""list(select=None) should behave identically to list() with no args."""
150+
expected_tables = [
151+
{"LogicalName": "account", "SchemaName": "Account"},
152+
]
153+
self.client._odata._list_tables.return_value = expected_tables
154+
155+
result = self.client.tables.list(select=None)
156+
157+
self.client._odata._list_tables.assert_called_once_with(filter=None, select=None)
158+
self.assertEqual(result, expected_tables)
159+
160+
def test_list_with_filter_and_select(self):
161+
"""list(filter=..., select=...) should pass both params to _list_tables."""
162+
expected_tables = [
163+
{"LogicalName": "account", "SchemaName": "Account"},
164+
]
165+
self.client._odata._list_tables.return_value = expected_tables
166+
167+
result = self.client.tables.list(
168+
filter="SchemaName eq 'Account'",
169+
select=["LogicalName", "SchemaName"],
170+
)
171+
172+
self.client._odata._list_tables.assert_called_once_with(
173+
filter="SchemaName eq 'Account'",
174+
select=["LogicalName", "SchemaName"],
175+
)
176+
self.assertEqual(result, expected_tables)
177+
108178
# ------------------------------------------------------------ add_columns
109179

110180
def test_add_columns(self):

0 commit comments

Comments
 (0)