Skip to content

Commit 31ec26f

Browse files
Merge pull request #30 from microsoft/users/zhaodongwang/columnHelpers
Users/zhaodongwang/column helpers
2 parents 9529416 + 2359526 commit 31ec26f

5 files changed

Lines changed: 310 additions & 13 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: 89 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,14 @@ 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
145153

146154
# Derive attribute logical name prefix from the entity logical name (segment before first underscore)
147155
attr_prefix = logical.split("_", 1)[0] if "_" in logical else logical
@@ -527,9 +535,88 @@ def _del_one(rid: str) -> tuple[str, bool, str | None]:
527535
except Exception as e:
528536
print(f"Delete failed: {e}")
529537

538+
pause("Next: column metadata helpers")
539+
540+
# 6) Column metadata helpers: column create/delete
541+
print("Column metadata helpers (create/delete column):")
542+
scratch_column = f"scratch_{int(time.time())}"
543+
column_payload = {scratch_column: "string"}
544+
try:
545+
log_call(f"client.create_column('{entity_schema}', {repr(column_payload)})")
546+
column_create = client.create_columns(entity_schema, column_payload)
547+
if not isinstance(column_create, list) or not column_create:
548+
raise RuntimeError("create_column did not return schema list")
549+
created_details = column_create
550+
if not all(isinstance(item, str) for item in created_details):
551+
raise RuntimeError("create_column entries were not schema strings")
552+
attribute_schema = created_details[0]
553+
odata_client = client._get_odata()
554+
exists_after_create = None
555+
exists_after_delete = None
556+
attr_type_before = None
557+
if metadata_id and attribute_schema:
558+
_ready_message = "Column metadata not yet available"
559+
def _metadata_after_create():
560+
meta = odata_client._get_attribute_metadata(
561+
metadata_id,
562+
attribute_schema,
563+
extra_select="@odata.type,AttributeType",
564+
)
565+
if not meta or not meta.get("MetadataId"):
566+
raise RuntimeError(_ready_message)
567+
return meta
568+
569+
ready_meta = backoff_retry(
570+
_metadata_after_create,
571+
delays=(0, 1, 2, 4, 8),
572+
retry_http_statuses=(),
573+
retry_if=lambda exc: isinstance(exc, RuntimeError) and str(exc) == _ready_message,
574+
)
575+
exists_after_create = bool(ready_meta)
576+
raw_type = ready_meta.get("@odata.type") or ready_meta.get("AttributeType")
577+
if isinstance(raw_type, str):
578+
attr_type_before = raw_type
579+
lowered = raw_type.lower()
580+
log_call(f"client.delete_column('{entity_schema}', '{scratch_column}')")
581+
column_delete = client.delete_columns(entity_schema, scratch_column)
582+
if not isinstance(column_delete, list) or not column_delete:
583+
raise RuntimeError("delete_column did not return schema list")
584+
deleted_details = column_delete
585+
if not all(isinstance(item, str) for item in deleted_details):
586+
raise RuntimeError("delete_column entries were not schema strings")
587+
if attribute_schema not in deleted_details:
588+
raise RuntimeError("delete_column response missing expected schema name")
589+
if metadata_id and attribute_schema:
590+
_delete_message = "Column metadata still present after delete"
591+
def _ensure_removed():
592+
meta = odata_client._get_attribute_metadata(metadata_id, attribute_schema)
593+
if meta:
594+
raise RuntimeError(_delete_message)
595+
return True
596+
597+
removed = backoff_retry(
598+
_ensure_removed,
599+
delays=(0, 1, 2, 4, 8),
600+
retry_http_statuses=(),
601+
retry_if=lambda exc: isinstance(exc, RuntimeError) and str(exc) == _delete_message,
602+
)
603+
exists_after_delete = not removed
604+
print({
605+
"created_column": scratch_column,
606+
"create_summary": created_details,
607+
"delete_summary": deleted_details,
608+
"attribute_type_before_delete": attr_type_before,
609+
"exists_after_create": exists_after_create,
610+
"exists_after_delete": exists_after_delete,
611+
})
612+
except MetadataError as meta_err:
613+
print({"column_metadata_error": str(meta_err)})
614+
except Exception as exc:
615+
print({"column_metadata_unexpected": str(exc)})
616+
530617
pause("Next: Cleanup table")
531618

532-
# 6) Cleanup: delete the custom table if it exists
619+
# 7) Cleanup: delete the custom table if it exists
533620
print("Cleanup (Metadata):")
534621
if delete_table_at_end:
535622
try:

src/dataverse_sdk/client.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,68 @@ def list_tables(self) -> list[str]:
463463
print(table)
464464
"""
465465
return self._get_odata()._list_tables()
466+
467+
def create_columns(
468+
self,
469+
tablename: str,
470+
columns: Dict[str, Any],
471+
) -> List[str]:
472+
"""
473+
Create one or more columns on an existing table using a schema-style mapping.
474+
475+
:param tablename: Friendly name ("SampleItem") or full schema name ("new_SampleItem").
476+
:type tablename: str
477+
:param columns: Mapping of logical names (without prefix) to supported types. Primitive types include
478+
``string``, ``int``, ``decimal``, ``float``, ``datetime``, and ``bool``. Enum subclasses (IntEnum preferred)
479+
generate a local option set and can specify localized labels via ``__labels__``.
480+
:type columns: Dict[str, Any]
481+
:returns: Schema names for the columns that were created.
482+
:rtype: list[str]
483+
Example:
484+
Create two columns on the custom table::
485+
486+
created = client.create_columns(
487+
"new_SampleItem",
488+
{
489+
"scratch": "string",
490+
"flags": "bool",
491+
},
492+
)
493+
print(created)
494+
"""
495+
return self._get_odata()._create_columns(
496+
tablename,
497+
columns,
498+
)
499+
500+
def delete_columns(
501+
self,
502+
tablename: str,
503+
columns: Union[str, List[str]],
504+
) -> List[str]:
505+
"""
506+
Delete one or more columns from a table.
507+
508+
:param tablename: Friendly or schema name of the table.
509+
:type tablename: str
510+
:param columns: Column name or list of column names to remove. Friendly names are normalized to schema
511+
names using the same prefix logic as ``create_columns``.
512+
:type columns: str | list[str]
513+
:returns: Schema names for the columns that were removed.
514+
:rtype: list[str]
515+
Example:
516+
Remove two custom columns by schema name:
517+
518+
removed = client.delete_columns(
519+
"new_SampleItem",
520+
["new_Scratch", "new_Flags"],
521+
)
522+
print(removed)
523+
"""
524+
return self._get_odata()._delete_columns(
525+
tablename,
526+
columns,
527+
)
466528

467529
# File upload
468530
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)