Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Direct TDS via ODBC is not used; SQL reads are executed via the Custom API over

- For Web API (OData), tokens target your Dataverse org URL scope: https://yourorg.crm.dynamics.com/.default. The SDK requests this scope from the provided TokenCredential.
- For complete functionalities, please use one of the PREPROD BAP environments, otherwise McpExecuteSqlQuery might not work.
- For CreateInstantEntities call, it's a prerequisite to import solution https://microsoft-my.sharepoint.com/:u:/p/cdietric/EXuJB0ZywshPuVAc54b2HxUBpnlv9jjQl47QCrx-VhSErA?e=4cdYm0

### Configuration (DataverseConfig)

Expand Down Expand Up @@ -69,6 +70,7 @@ The quickstart demonstrates:
- Bulk create (CreateMultiple) to insert many records in one call
- Retrieve multiple with paging (contrasting `$top` vs `page_size`)
- Executing a read-only SQL query
- Use CreateInstantEntities API to quickly create entities

## Examples

Expand Down Expand Up @@ -218,6 +220,26 @@ info = client.create_table(
},
)

# Alternatively create a custom table with use_instant option
# Only text type column is supported and lookups and display_name are required inputs
# info = client.create_table(
# "new_SampleItemInstant",
# {
# "code": "text",
# "count": "text",
# },
# use_instant=True,
# display_name="Sample Item",
# lookups=[
# {
# "AttributeName": "new_Account",
# "AttributeDisplayName": "Account (Demo Lookup)",
# "ReferencedEntityName": "account",
# "RelationshipName": "new_newSampleItem_account",
# }
# ],
# )

entity_set = info["entity_set_name"] # e.g., "new_sampleitems"
logical = info["entity_logical_name"] # e.g., "new_sampleitem"

Expand Down
116 changes: 104 additions & 12 deletions examples/quickstart.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
# Ask once whether to pause between steps during this run
pause_choice = input("Pause between test steps? (y/N): ").strip() or "n"
pause_between_steps = (str(pause_choice).lower() in ("y", "yes", "true", "1"))
instant_create_choice = input("Run instant create demo? (y/N): ").strip() or "n"
run_instant_create = (str(instant_create_choice).lower() in ("y", "yes", "true", "1"))
# Create a credential we can reuse (for DataverseClient)
credential = InteractiveBrowserCredential()
client = DataverseClient(base_url=base_url, credential=credential)
Expand All @@ -42,6 +44,20 @@ def pause(next_step: str) -> None:
# If stdin is not available, just proceed
pass

# Helper: delete a table if it exists
def delete_table_if_exists(table_name: str) -> None:
try:
log_call(f"client.get_table_info('{table_name}')")
info = client.get_table_info(table_name)
if info:
log_call(f"client.delete_table('{table_name}')")
client.delete_table(table_name)
print({"table_deleted": True})
else:
print({"table_deleted": False, "reason": "not found"})
except Exception as e:
print({f"Delete table failed": str(e)})

# Small generic backoff helper used only in this quickstart
# Include common transient statuses like 429/5xx to improve resilience.
def backoff_retry(op, *, delays=(0, 2, 5, 10, 20), retry_http_statuses=(400, 403, 404, 409, 412, 429, 500, 502, 503, 504), retry_if=None):
Expand All @@ -68,6 +84,11 @@ def backoff_retry(op, *, delays=(0, 2, 5, 10, 20), retry_http_statuses=(400, 403
table_info = None
created_this_run = False

# Timing metrics for comparison (seconds)
instant_create_seconds: float | None = None
standard_create_seconds: float | None = None
warm_up_seconds: float | None = None

# Check for existing table using list_tables
log_call("client.list_tables()")
tables = client.list_tables()
Expand All @@ -87,6 +108,7 @@ def backoff_retry(op, *, delays=(0, 2, 5, 10, 20), retry_http_statuses=(400, 403
# Create it since it doesn't exist
try:
log_call("client.create_table('new_SampleItem', schema={code,count,amount,when,active})")
_t0_standard = time.perf_counter()
table_info = client.create_table(
"new_SampleItem",
{
Expand All @@ -97,6 +119,7 @@ def backoff_retry(op, *, delays=(0, 2, 5, 10, 20), retry_http_statuses=(400, 403
"active": "bool",
},
)
standard_create_seconds = time.perf_counter() - _t0_standard
created_this_run = True if table_info and table_info.get("columns_created") else False
print({
"table": table_info.get("entity_schema") if table_info else None,
Expand Down Expand Up @@ -124,6 +147,19 @@ def backoff_retry(op, *, delays=(0, 2, 5, 10, 20), retry_http_statuses=(400, 403
entity_set = table_info.get("entity_set_name")
logical = table_info.get("entity_logical_name") or entity_set.rstrip("s")

if run_instant_create:
# call early to warm up for instant create
log_call("client.warm_up_instant_create()")
try:
_t0_warm = time.perf_counter()
client.warm_up_instant_create()
warm_up_seconds = time.perf_counter() - _t0_warm
print({"warm_up_for_instant_create": True, "warm_up_seconds": warm_up_seconds})
except Exception as warm_ex:
print({"warm_up_for_instant_create_error": str(warm_ex)})
# Abort instant demo if warm-up fails
sys.exit(1)

# Derive attribute logical name prefix from the entity logical name (segment before first underscore)
attr_prefix = logical.split("_", 1)[0] if "_" in logical else logical
code_key = f"{attr_prefix}_code"
Expand Down Expand Up @@ -410,21 +446,77 @@ def _del_one(rid: str) -> tuple[str, bool, str | None]:
except Exception as e:
print(f"Delete failed: {e}")

# 6) (Optional) Instant create path demo
if not run_instant_create:
print("Skipping instant create demo as per user choice.")
else:
pause("Next: instant create demo")

print("Instant create demo")
print("Delete Instant table first if exists")
# Delete instant table first
delete_table_if_exists("new_SampleItemInstant")

# Create Instant
log_call("client.create_table('new_SampleItemInstant', instant_create)")
instant_schema = {
"code": "text",
"count": "text",
}
# Demo dummy lookup definition (must supply at least one for instant path)
instant_lookups = [
{
"AttributeName": "new_Account",
"AttributeDisplayName": "Account (Demo Lookup)",
"ReferencedEntityName": "account",
"RelationshipName": "new_newSampleItem_account",
}
]
try:
_t0_instant = time.perf_counter()
_table_instant = client.create_table(
"new_SampleItemInstant",
instant_schema,
use_instant=True,
display_name="Sample Item",
lookups=instant_lookups,
)
instant_create_seconds = time.perf_counter() - _t0_instant
table_info = _table_instant
logical = table_info.get("entity_logical_name") if isinstance(table_info, dict) else None
print(table_info)
except Exception as instant_ex:
print({"instant_create_error": str(instant_ex)})
sys.exit(1)

# Timing comparison summary for table creation
_standard_create_ran = standard_create_seconds is not None
_instant_create_ran = instant_create_seconds is not None
print({
"table_creation_timing_compare": {
"warm_up_seconds": warm_up_seconds,
"instant_seconds": instant_create_seconds,
"warm_up+instant_seconds": warm_up_seconds + instant_create_seconds,
"standard_seconds": standard_create_seconds if _standard_create_ran else "standard table pre-existed; omitted",
"delta_standard_minus_instant": (
(standard_create_seconds - instant_create_seconds)
if (_standard_create_ran and _instant_create_ran)
else None
),
}
})


pause("Next: Cleanup table")

# 6) Cleanup: delete the custom table if it exists
# 7) Cleanup: delete the custom table if it exists
print("Cleanup (Metadata):")
if delete_table_at_end:
try:
log_call("client.get_table_info('new_SampleItem')")
info = client.get_table_info("new_SampleItem")
if info:
log_call("client.delete_table('new_SampleItem')")
client.delete_table("new_SampleItem")
print({"table_deleted": True})
else:
print({"table_deleted": False, "reason": "not found"})
except Exception as e:
print(f"Delete table failed: {e}")
delete_table_if_exists("new_SampleItem")
else:
print({"table_deleted": False, "reason": "user opted to keep table"})

# Put instant table delete at the end to avoid metadata cache issues when deletion immediately follows creation
if run_instant_create:
print("Cleanup instant (Metadata):")
delete_table_if_exists("new_SampleItemInstant")
45 changes: 41 additions & 4 deletions src/dataverse_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,24 +198,52 @@ def get_table_info(self, tablename: str) -> Optional[Dict[str, Any]]:
"""
return self._get_odata().get_table_info(tablename)

def create_table(self, tablename: str, schema: Dict[str, str]) -> Dict[str, Any]:
"""Create a simple custom table.
def create_table(
self,
tablename: str,
schema: Dict[str, str],
use_instant: bool = False,
display_name: Optional[str] = None,
lookups: Optional[List[Dict[str, str]]] = None,
) -> Dict[str, Any]:
"""Create a custom table (standard or instant path).

Standard path (default): uses supported metadata APIs and allows a variety of column types
(``string``, ``int``, ``decimal``, ``float``, ``datetime``, ``bool``).

Instant path (``use_instant=True``): attempts creation via the CreateInstantEntities API.
This path currently only supports ``text`` columns and requires at least one lookup
relationship

Parameters
----------
tablename : str
Friendly name (``"SampleItem"``) or a full schema name (``"new_SampleItem"``).
schema : dict[str, str]
Column definitions mapping logical names (without prefix) to types.
Supported: ``string``, ``int``, ``decimal``, ``float``, ``datetime``, ``bool``.
Supported for standard path: ``string``, ``int``, ``decimal``, ``float``, ``datetime``, ``bool``.
For instant path you must supply only text columns (``text``).
use_instant : bool, default False
If True, use the instant entity creation path. Must call warm_up_instant_create before using it
display_name : str | None
Required when ``use_instant`` is True. Singular display label (collection name pluralized with an ``s``).
lookups : list[dict] | None
Required when ``use_instant`` is True. Each item must include:
``AttributeName``, ``AttributeDisplayName``, ``ReferencedEntityName``, ``RelationshipName``.

Returns
-------
dict
Metadata summary including ``entity_schema``, ``entity_set_name``,
``entity_logical_name``, ``metadata_id``, and ``columns_created``.
"""
return self._get_odata().create_table(tablename, schema)
return self._get_odata().create_table(
tablename,
schema,
use_instant=use_instant,
display_name=display_name,
lookups=lookups,
)

def delete_table(self, tablename: str) -> None:
"""Delete a custom table by name.
Expand All @@ -237,6 +265,15 @@ def list_tables(self) -> list[str]:
"""
return self._get_odata().list_tables()

# Instant create warm-up
def warm_up_instant_create(self) -> None:
"""Perform required warm-up for instant table creation.

Must be called (and succeed) before invoking ``create_table``
with ``use_instant=True``.
"""
self._get_odata().warm_up_instant_create()


__all__ = ["DataverseClient"]

Loading