Skip to content

Commit e37f2bb

Browse files
committed
Merge remote-tracking branch 'origin/main' into feature/record-tableinfo-models
2 parents b1dd6de + 78eb5dd commit e37f2bb

6 files changed

Lines changed: 289 additions & 6 deletions

File tree

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
@@ -187,20 +187,56 @@ def get(self, table: str) -> Optional[TableInfo]:
187187

188188
# ------------------------------------------------------------------- list
189189

190-
def list(self) -> List[Dict[str, Any]]:
190+
def list(
191+
self,
192+
*,
193+
filter: Optional[str] = None,
194+
select: Optional[List[str]] = None,
195+
) -> List[Dict[str, Any]]:
191196
"""List all non-private tables in the Dataverse environment.
192197
198+
By default returns every table where ``IsPrivate eq false``. Supply
199+
an optional OData ``$filter`` expression to further narrow the results.
200+
The expression is combined with the default ``IsPrivate eq false``
201+
clause using ``and``.
202+
203+
:param filter: Optional OData ``$filter`` expression to further narrow
204+
the list of returned tables (e.g.
205+
``"SchemaName eq 'Account'"``). Column names in filter
206+
expressions must use the exact property names from the
207+
``EntityDefinitions`` metadata (typically PascalCase).
208+
:type filter: :class:`str` or None
209+
:param select: Optional list of property names to include in the
210+
response (projected via the OData ``$select`` query option).
211+
Property names must use the exact PascalCase names from the
212+
``EntityDefinitions`` metadata (e.g.
213+
``["LogicalName", "SchemaName", "DisplayName"]``).
214+
When ``None`` (the default) or an empty list, all properties are
215+
returned.
216+
:type select: :class:`list` of :class:`str` or None
217+
193218
:return: List of EntityDefinition metadata dictionaries.
194219
:rtype: :class:`list` of :class:`dict`
195220
196221
Example::
197222
223+
# List all non-private tables
198224
tables = client.tables.list()
199225
for table in tables:
200226
print(table["LogicalName"])
227+
228+
# List only tables whose schema name starts with "new_"
229+
custom_tables = client.tables.list(
230+
filter="startswith(SchemaName, 'new_')"
231+
)
232+
233+
# List tables with only specific properties
234+
tables = client.tables.list(
235+
select=["LogicalName", "SchemaName", "EntitySetName"]
236+
)
201237
"""
202238
with self._client._scoped_odata() as od:
203-
return od._list_tables()
239+
return od._list_tables(filter=filter, select=select)
204240

205241
# ------------------------------------------------------------- add_columns
206242

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
@@ -107,10 +107,80 @@ def test_list(self):
107107

108108
result = self.client.tables.list()
109109

110-
self.client._odata._list_tables.assert_called_once()
110+
self.client._odata._list_tables.assert_called_once_with(filter=None, select=None)
111111
self.assertIsInstance(result, list)
112112
self.assertEqual(result, expected_tables)
113113

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

116186
def test_add_columns(self):

0 commit comments

Comments
 (0)