Skip to content

Commit b39c238

Browse files
author
Max Wang
committed
create_column and delete_column
1 parent 587baf4 commit b39c238

5 files changed

Lines changed: 394 additions & 14 deletions

File tree

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@ A Python package allowing developers to connect to Dataverse environments for DD
88
- Bulk update — Provide a list of IDs with a single patch (broadcast) or a list of per‑record patches to `update(...)`; internally uses the bound `UpdateMultiple` action; returns nothing. Each record must include the primary key attribute when sent to UpdateMultiple.
99
- Retrieve multiple (paging) — Generator-based `get(...)` that yields pages, supports `$top` and Prefer: `odata.maxpagesize` (`page_size`).
1010
- Upload files — Call `upload_file(logical_name, ...)` and an upload method will be auto picked (you can override the mode). See https://learn.microsoft.com/en-us/power-apps/developer/data-platform/file-column-data?tabs=sdk#upload-files
11-
- Metadata helpers — Create/inspect/delete simple custom tables (EntityDefinitions + Attributes).
11+
- Metadata helpers — Create/inspect/delete tables and create/delete columns (EntityDefinitions + Attributes).
1212
- Pandas helpers — Convenience DataFrame oriented wrappers for quick prototyping/notebooks.
1313
- Auth — Azure Identity (`TokenCredential`) injection.
1414

1515
## Features
1616

1717
- Simple `DataverseClient` facade for CRUD, SQL (read-only), and table metadata.
1818
- SQL-over-API: Constrained SQL (single SELECT with limited WHERE/TOP/ORDER BY) via native Web API `?sql=` parameter.
19-
- Table metadata ops: create simple custom tables (supports string/int/decimal/float/datetime/bool/optionset) and delete them.
19+
- Table metadata ops: create/delete simple custom tables (supports string/int/decimal/float/datetime/bool/optionset) and create/delete columns.
2020
- Bulk create via `CreateMultiple` (collection-bound) by passing `list[dict]` to `create(logical_name, payloads)`; returns list of created IDs.
2121
- Bulk update via `UpdateMultiple` (invoked internally) by calling unified `update(logical_name, ids, patch|patches)`; returns nothing.
2222
- Retrieve multiple with server-driven paging: `get(...)` yields lists (pages) following `@odata.nextLink`. Control total via `$top` and per-page via `page_size` (Prefer: `odata.maxpagesize`).
@@ -42,9 +42,11 @@ Auth:
4242
| `delete` | `delete(logical_name, list[id])` | `None` | Delete many (sequential). |
4343
| `query_sql` | `query_sql(sql)` | `list[dict]` | Constrained read-only SELECT via `?sql=`. |
4444
| `create_table` | `create_table(tablename, schema)` | `dict` | Creates custom table + columns. Friendly name (e.g. `SampleItem`) becomes schema `new_SampleItem`; explicit schema name (contains `_`) used as-is. |
45+
| `create_column` | `create_column(tablename, columns)` | `list[str]` | Adds columns using a `{name: type}` mapping (same shape as `create_table` schema). Returns schema names for the created columns. |
4546
| `get_table_info` | `get_table_info(schema_name)` | `dict | None` | Basic table metadata by schema name (e.g. `new_SampleItem`). Friendly names not auto-converted. |
4647
| `list_tables` | `list_tables()` | `list[dict]` | Lists non-private tables. |
4748
| `delete_table` | `delete_table(tablename)` | `None` | Drops custom table. Accepts friendly or schema name; friendly converted to `new_<PascalCase>`. |
49+
| `delete_column` | `delete_column(tablename, columns)` | `list[str]` | Deletes one or more columns; returns schema names (accepts string or list[str]). |
4850
| `PandasODataClient.create_df` | `create_df(logical_name, series)` | `str` | Create one record (returns GUID). |
4951
| `PandasODataClient.update` | `update(logical_name, id, series)` | `None` | Returns None; ignored if Series empty. |
5052
| `PandasODataClient.get_ids` | `get_ids(logical_name, ids, select=None)` | `DataFrame` | One row per ID (errors inline). |
@@ -310,6 +312,10 @@ info = client.create_table(
310312
},
311313
)
312314

315+
# Create or delete columns
316+
client.create_column("SampleItem", {"category": "string"}) # returns ["new_Category"]
317+
client.delete_column("SampleItem", "category") # returns ["new_Category"]
318+
313319
logical = info["entity_logical_name"] # e.g., "new_sampleitem"
314320

315321
# Create a record in the new table

examples/quickstart.py

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
sys.path.append(str(Path(__file__).resolve().parents[1] / "src"))
88

99
from dataverse_sdk import DataverseClient
10+
from dataverse_sdk.errors import MetadataError
1011
from enum import IntEnum
1112
from azure.identity import InteractiveBrowserCredential
1213
import traceback
@@ -64,7 +65,7 @@ def backoff_retry(op, *, delays=(0, 2, 5, 10, 20), retry_http_statuses=(400, 403
6465
break
6566
if last_exc:
6667
raise last_exc
67-
68+
6869
# Enum demonstrating local option set creation with multilingual labels (for French labels to work, enable French language in the environment first)
6970
class Status(IntEnum):
7071
Active = 1
@@ -141,7 +142,94 @@ class Status(IntEnum):
141142
pass
142143
# Fail fast: all operations must use the custom table
143144
sys.exit(1)
145+
entity_schema = table_info.get("entity_schema") or "new_SampleItem"
144146
logical = table_info.get("entity_logical_name")
147+
metadata_id = table_info.get("metadata_id")
148+
if not metadata_id:
149+
refreshed_info = client.get_table_info(entity_schema) or {}
150+
metadata_id = refreshed_info.get("metadata_id")
151+
if metadata_id:
152+
table_info["metadata_id"] = metadata_id
153+
154+
# 6) Column metadata helpers: column create/delete
155+
print("Column metadata helpers (create/delete column):")
156+
scratch_column = f"scratch_{int(time.time())}"
157+
column_payload = {scratch_column: "string"}
158+
try:
159+
log_call(f"client.create_column('{entity_schema}', {repr(column_payload)})")
160+
column_create = client.create_columns(entity_schema, column_payload)
161+
if not isinstance(column_create, list) or not column_create:
162+
raise RuntimeError("create_column did not return schema list")
163+
created_details = column_create
164+
if not all(isinstance(item, str) for item in created_details):
165+
raise RuntimeError("create_column entries were not schema strings")
166+
attribute_schema = created_details[0]
167+
odata_client = client._get_odata()
168+
exists_after_create = None
169+
exists_after_delete = None
170+
attr_type_before = None
171+
attr_type_before_norm = None
172+
if metadata_id and attribute_schema:
173+
_ready_message = "Column metadata not yet available"
174+
def _metadata_after_create():
175+
meta = odata_client._get_attribute_metadata(
176+
metadata_id,
177+
attribute_schema,
178+
extra_select="@odata.type,AttributeType",
179+
)
180+
if not meta or not meta.get("MetadataId"):
181+
raise RuntimeError(_ready_message)
182+
return meta
183+
184+
ready_meta = backoff_retry(
185+
_metadata_after_create,
186+
delays=(0, 1, 2, 4, 8),
187+
retry_http_statuses=(),
188+
retry_if=lambda exc: isinstance(exc, RuntimeError) and str(exc) == _ready_message,
189+
)
190+
exists_after_create = bool(ready_meta)
191+
raw_type = ready_meta.get("@odata.type") or ready_meta.get("AttributeType")
192+
if isinstance(raw_type, str):
193+
attr_type_before = raw_type
194+
lowered = raw_type.lower()
195+
attr_type_before_norm = lowered.rsplit(".", 1)[-1] if "." in lowered else lowered
196+
log_call(f"client.delete_column('{entity_schema}', '{scratch_column}')")
197+
column_delete = client.delete_columns(entity_schema, scratch_column)
198+
if not isinstance(column_delete, list) or not column_delete:
199+
raise RuntimeError("delete_column did not return schema list")
200+
deleted_details = column_delete
201+
if not all(isinstance(item, str) for item in deleted_details):
202+
raise RuntimeError("delete_column entries were not schema strings")
203+
if attribute_schema not in deleted_details:
204+
raise RuntimeError("delete_columns response missing expected schema name")
205+
if metadata_id and attribute_schema:
206+
_delete_message = "Column metadata still present after delete"
207+
def _ensure_removed():
208+
meta = odata_client._get_attribute_metadata(metadata_id, attribute_schema)
209+
if meta:
210+
raise RuntimeError(_delete_message)
211+
return True
212+
213+
removed = backoff_retry(
214+
_ensure_removed,
215+
delays=(0, 1, 2, 4, 8),
216+
retry_http_statuses=(),
217+
retry_if=lambda exc: isinstance(exc, RuntimeError) and str(exc) == _delete_message,
218+
)
219+
exists_after_delete = not removed
220+
print({
221+
"created_column": scratch_column,
222+
"create_summary": created_details,
223+
"delete_summary": deleted_details,
224+
"attribute_type_before_delete": attr_type_before,
225+
"exists_after_create": exists_after_create,
226+
"exists_after_delete": exists_after_delete,
227+
})
228+
except MetadataError as meta_err:
229+
print({"column_metadata_error": str(meta_err)})
230+
except Exception as exc:
231+
print({"column_metadata_unexpected": str(exc)})
232+
sys.exit(0)
145233

146234
# Derive attribute logical name prefix from the entity logical name (segment before first underscore)
147235
attr_prefix = logical.split("_", 1)[0] if "_" in logical else logical
@@ -527,9 +615,90 @@ def _del_one(rid: str) -> tuple[str, bool, str | None]:
527615
except Exception as e:
528616
print(f"Delete failed: {e}")
529617

618+
pause("Next: column metadata helpers")
619+
620+
# 6) Column metadata helpers: column create/delete
621+
print("Column metadata helpers (create/delete column):")
622+
scratch_column = f"scratch_{int(time.time())}"
623+
column_payload = {scratch_column: "string"}
624+
try:
625+
log_call(f"client.create_column('{entity_schema}', {repr(column_payload)})")
626+
column_create = client.create_columns(entity_schema, column_payload)
627+
if not isinstance(column_create, list) or not column_create:
628+
raise RuntimeError("create_column did not return schema list")
629+
created_details = column_create
630+
if not all(isinstance(item, str) for item in created_details):
631+
raise RuntimeError("create_column entries were not schema strings")
632+
attribute_schema = created_details[0]
633+
odata_client = client._get_odata()
634+
exists_after_create = None
635+
exists_after_delete = None
636+
attr_type_before = None
637+
attr_type_before_norm = None
638+
if metadata_id and attribute_schema:
639+
_ready_message = "Column metadata not yet available"
640+
def _metadata_after_create():
641+
meta = odata_client._get_attribute_metadata(
642+
metadata_id,
643+
attribute_schema,
644+
extra_select="@odata.type,AttributeType",
645+
)
646+
if not meta or not meta.get("MetadataId"):
647+
raise RuntimeError(_ready_message)
648+
return meta
649+
650+
ready_meta = backoff_retry(
651+
_metadata_after_create,
652+
delays=(0, 1, 2, 4, 8),
653+
retry_http_statuses=(),
654+
retry_if=lambda exc: isinstance(exc, RuntimeError) and str(exc) == _ready_message,
655+
)
656+
exists_after_create = bool(ready_meta)
657+
raw_type = ready_meta.get("@odata.type") or ready_meta.get("AttributeType")
658+
if isinstance(raw_type, str):
659+
attr_type_before = raw_type
660+
lowered = raw_type.lower()
661+
attr_type_before_norm = lowered.rsplit(".", 1)[-1] if "." in lowered else lowered
662+
log_call(f"client.delete_column('{entity_schema}', '{scratch_column}')")
663+
column_delete = client.delete_columns(entity_schema, scratch_column)
664+
if not isinstance(column_delete, list) or not column_delete:
665+
raise RuntimeError("delete_column did not return schema list")
666+
deleted_details = column_delete
667+
if not all(isinstance(item, str) for item in deleted_details):
668+
raise RuntimeError("delete_column entries were not schema strings")
669+
if attribute_schema not in deleted_details:
670+
raise RuntimeError("delete_column response missing expected schema name")
671+
if metadata_id and attribute_schema:
672+
_delete_message = "Column metadata still present after delete"
673+
def _ensure_removed():
674+
meta = odata_client._get_attribute_metadata(metadata_id, attribute_schema)
675+
if meta:
676+
raise RuntimeError(_delete_message)
677+
return True
678+
679+
removed = backoff_retry(
680+
_ensure_removed,
681+
delays=(0, 1, 2, 4, 8),
682+
retry_http_statuses=(),
683+
retry_if=lambda exc: isinstance(exc, RuntimeError) and str(exc) == _delete_message,
684+
)
685+
exists_after_delete = not removed
686+
print({
687+
"created_column": scratch_column,
688+
"create_summary": created_details,
689+
"delete_summary": deleted_details,
690+
"attribute_type_before_delete": attr_type_before,
691+
"exists_after_create": exists_after_create,
692+
"exists_after_delete": exists_after_delete,
693+
})
694+
except MetadataError as meta_err:
695+
print({"column_metadata_error": str(meta_err)})
696+
except Exception as exc:
697+
print({"column_metadata_unexpected": str(exc)})
698+
530699
pause("Next: Cleanup table")
531700

532-
# 6) Cleanup: delete the custom table if it exists
701+
# 7) Cleanup: delete the custom table if it exists
533702
print("Cleanup (Metadata):")
534703
if delete_table_at_end:
535704
try:

src/dataverse_sdk/client.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,68 @@ def list_tables(self) -> list[str]:
241241
A list of table names.
242242
"""
243243
return self._get_odata()._list_tables()
244+
245+
def create_columns(
246+
self,
247+
tablename: str,
248+
columns: Dict[str, Any],
249+
) -> List[str]:
250+
"""
251+
Create one or more columns on an existing table using a schema-style mapping.
252+
253+
:param tablename: Friendly name ("SampleItem") or full schema name ("new_SampleItem").
254+
:type tablename: str
255+
:param columns: Mapping of logical names (without prefix) to supported types. Primitive types include
256+
``string``, ``int``, ``decimal``, ``float``, ``datetime``, and ``bool``. Enum subclasses (IntEnum preferred)
257+
generate a local option set and can specify localized labels via ``__labels__``.
258+
:type columns: Dict[str, Any]
259+
:returns: Schema names for the columns that were created.
260+
:rtype: list[str]
261+
Example:
262+
Create two columns on the custom table::
263+
264+
created = client.create_columns(
265+
"new_SampleItem",
266+
{
267+
"scratch": "string",
268+
"flags": "bool",
269+
},
270+
)
271+
print(created)
272+
"""
273+
return self._get_odata()._create_columns(
274+
tablename,
275+
columns,
276+
)
277+
278+
def delete_columns(
279+
self,
280+
tablename: str,
281+
columns: Union[str, List[str]],
282+
) -> List[str]:
283+
"""
284+
Delete one or more columns from a table.
285+
286+
:param tablename: Friendly or schema name of the table.
287+
:type tablename: str
288+
:param columns: Column name or list of column names to remove. Friendly names are normalized to schema
289+
names using the same prefix logic as ``create_columns``.
290+
:type columns: str | list[str]
291+
:returns: Schema names for the columns that were removed.
292+
:rtype: list[str]
293+
Example:
294+
Remove two custom columns by schema name:
295+
296+
removed = client.delete_columns(
297+
"new_SampleItem",
298+
["new_Scratch", "new_Flags"],
299+
)
300+
print(removed)
301+
"""
302+
return self._get_odata()._delete_columns(
303+
tablename,
304+
columns,
305+
)
244306

245307
# File upload
246308
def upload_file(

src/dataverse_sdk/error_codes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
METADATA_ENTITYSET_NAME_MISSING = "metadata_entityset_name_missing"
4444
METADATA_TABLE_NOT_FOUND = "metadata_table_not_found"
4545
METADATA_TABLE_ALREADY_EXISTS = "metadata_table_already_exists"
46+
METADATA_COLUMN_NOT_FOUND = "metadata_column_not_found"
4647
METADATA_ATTRIBUTE_RETRY_EXHAUSTED = "metadata_attribute_retry_exhausted"
4748
METADATA_PICKLIST_RETRY_EXHAUSTED = "metadata_picklist_retry_exhausted"
4849

0 commit comments

Comments
 (0)