Skip to content

Commit 43a6b7a

Browse files
abelmilash-msftAbel Milashclaude
committed
Add memo/multiline column type support (#155)
## Summary Adds support for `"memo"` (or `"multiline"`) column type, enabling creation of multiline text columns on Dataverse tables. Users can specify `"memo"` as a column type in `client.tables.create()` and `client.tables.add_columns()`. ## Changes **`src/PowerPlatform/Dataverse/data/_odata.py`** - Add `"memo"` / `"multiline"` handling in `_attribute_payload()`, generating `MemoAttributeMetadata` with `MaxLength: 4000`, `Format: Text`, `ImeMode: Auto` **`src/PowerPlatform/Dataverse/operations/tables.py`** - Document `"memo"` / `"multiline"` in `create()` docstring **`examples/advanced/walkthrough.py`** - Add `"new_Notes": "memo"` column to walkthrough table - Include memo field in record creation with multiline content - Read back and display memo field in Section 4 - Update memo with new multiline content in Section 5 **`tests/unit/data/test_odata_internal.py`** - `test_memo_type()` — validates MemoAttributeMetadata payload - `test_multiline_alias()` — validates `"multiline"` produces identical result **`tests/unit/test_tables_operations.py`** - `test_add_columns_memo()` — validates memo type through `add_columns()` **`README.md`** / **SKILL docs** - List `"memo"` in supported column types ## Testing - 620 unit tests passing - E2E memo walkthrough verified against live Dataverse (10 assertions): multiline create/read/update, empty string, None, special characters, long text (4000 chars), memo not mistaken for picklist label, triple-quoted strings, clearing memo to None --------- Co-authored-by: Abel Milash <abelmilash@microsoft.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7b34258 commit 43a6b7a

File tree

8 files changed

+79
-8
lines changed

8 files changed

+79
-8
lines changed

.claude/skills/dataverse-sdk-use/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ table_info = client.tables.create(
250250
#### Supported Column Types
251251
Types on the same line map to the same exact format under the hood
252252
- `"string"` or `"text"` - Single line of text
253+
- `"memo"` or `"multiline"` - Multiple lines of text (4000 character default)
253254
- `"int"` or `"integer"` - Whole number
254255
- `"decimal"` or `"money"` - Decimal number
255256
- `"float"` or `"double"` - Floating point number

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,7 @@ for page in client.records.get(
409409
# Create a custom table, including the customization prefix value in the schema names for the table and columns.
410410
table_info = client.tables.create("new_Product", {
411411
"new_Code": "string",
412+
"new_Description": "memo",
412413
"new_Price": "decimal",
413414
"new_Active": "bool"
414415
})
@@ -679,7 +680,7 @@ For optimal performance in production environments:
679680
### Limitations
680681

681682
- SQL queries are **read-only** and support a limited subset of SQL syntax
682-
- Create Table supports a limited number of column types (string, int, decimal, bool, datetime, picklist)
683+
- Create Table supports the following column types: string, memo, int, decimal, float, bool, datetime, file, and picklist (Enum subclass)
683684
- File uploads are limited by Dataverse file size restrictions (default 128MB per file)
684685

685686
## Contributing

examples/advanced/walkthrough.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ def _run_walkthrough(client):
120120
"new_Quantity": "int",
121121
"new_Amount": "decimal",
122122
"new_Completed": "bool",
123+
"new_Notes": "memo",
123124
"new_Priority": Priority,
124125
}
125126
table_info = backoff(lambda: client.tables.create(table_name, columns))
@@ -140,6 +141,7 @@ def _run_walkthrough(client):
140141
"new_Quantity": 5,
141142
"new_Amount": 1250.50,
142143
"new_Completed": False,
144+
"new_Notes": "This is a multiline memo field.\nIt supports longer text content.",
143145
"new_Priority": Priority.MEDIUM,
144146
}
145147
id1 = backoff(lambda: client.records.create(table_name, single_record))
@@ -192,6 +194,7 @@ def _run_walkthrough(client):
192194
"new_quantity": record.get("new_quantity"),
193195
"new_amount": record.get("new_amount"),
194196
"new_completed": record.get("new_completed"),
197+
"new_notes": record.get("new_notes"),
195198
"new_priority": record.get("new_priority"),
196199
"new_priority@FormattedValue": record.get("new_priority@OData.Community.Display.V1.FormattedValue"),
197200
},
@@ -218,9 +221,19 @@ def _run_walkthrough(client):
218221

219222
# Single update
220223
log_call(f"client.records.update('{table_name}', '{id1}', {{...}})")
221-
backoff(lambda: client.records.update(table_name, id1, {"new_Quantity": 100}))
224+
backoff(
225+
lambda: client.records.update(
226+
table_name,
227+
id1,
228+
{
229+
"new_Quantity": 100,
230+
"new_Notes": "Updated memo field.\nNow with revised content across multiple lines.",
231+
},
232+
)
233+
)
222234
updated = backoff(lambda: client.records.get(table_name, id1))
223235
print(f"[OK] Updated single record new_Quantity: {updated.get('new_quantity')}")
236+
print(f" new_Notes: {repr(updated.get('new_notes'))}")
224237

225238
# Multiple update (broadcast same change)
226239
log_call(f"client.records.update('{table_name}', [{len(ids)} IDs], {{...}})")
@@ -493,14 +506,14 @@ def _run_walkthrough(client):
493506
print("12. Column Management")
494507
print("=" * 80)
495508

496-
log_call(f"client.tables.add_columns('{table_name}', {{'new_Notes': 'string'}})")
497-
created_cols = backoff(lambda: client.tables.add_columns(table_name, {"new_Notes": "string"}))
509+
log_call(f"client.tables.add_columns('{table_name}', {{'new_Tags': 'string'}})")
510+
created_cols = backoff(lambda: client.tables.add_columns(table_name, {"new_Tags": "string"}))
498511
print(f"[OK] Added column: {created_cols[0]}")
499512

500513
# Delete the column we just added
501-
log_call(f"client.tables.remove_columns('{table_name}', ['new_Notes'])")
502-
backoff(lambda: client.tables.remove_columns(table_name, ["new_Notes"]))
503-
print(f"[OK] Deleted column: new_Notes")
514+
log_call(f"client.tables.remove_columns('{table_name}', ['new_Tags'])")
515+
backoff(lambda: client.tables.remove_columns(table_name, ["new_Tags"]))
516+
print(f"[OK] Deleted column: new_Tags")
504517

505518
# ============================================================================
506519
# 13. DELETE OPERATIONS

src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ table_info = client.tables.create(
250250
#### Supported Column Types
251251
Types on the same line map to the same exact format under the hood
252252
- `"string"` or `"text"` - Single line of text
253+
- `"memo"` or `"multiline"` - Multiple lines of text (4000 character default)
253254
- `"int"` or `"integer"` - Whole number
254255
- `"decimal"` or `"money"` - Decimal number
255256
- `"float"` or `"double"` - Floating point number

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1296,6 +1296,16 @@ def _attribute_payload(
12961296
"FormatName": {"Value": "Text"},
12971297
"IsPrimaryName": bool(is_primary_name),
12981298
}
1299+
if dtype_l in ("memo", "multiline"):
1300+
return {
1301+
"@odata.type": "Microsoft.Dynamics.CRM.MemoAttributeMetadata",
1302+
"SchemaName": column_schema_name,
1303+
"DisplayName": self._label(label),
1304+
"RequiredLevel": {"Value": "None"},
1305+
"MaxLength": 4000,
1306+
"FormatName": {"Value": "Text"},
1307+
"ImeMode": "Auto",
1308+
}
12991309
if dtype_l in ("int", "integer"):
13001310
return {
13011311
"@odata.type": "Microsoft.Dynamics.CRM.IntegerAttributeMetadata",

src/PowerPlatform/Dataverse/operations/tables.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ def create(
8282
:type table: :class:`str`
8383
:param columns: Mapping of column schema names (with customization
8484
prefix) to their types. Supported types include ``"string"``
85-
(or ``"text"``), ``"int"`` (or ``"integer"``), ``"decimal"``
85+
(or ``"text"``), ``"memo"`` (or ``"multiline"``),
86+
``"int"`` (or ``"integer"``), ``"decimal"``
8687
(or ``"money"``), ``"float"`` (or ``"double"``), ``"datetime"``
8788
(or ``"date"``), ``"bool"`` (or ``"boolean"``), ``"file"``, and
8889
``Enum`` subclasses

tests/unit/data/test_odata_internal.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,40 @@ def test_returns_none(self):
519519
self.assertIsNone(result)
520520

521521

522+
class TestAttributePayload(unittest.TestCase):
523+
"""Unit tests for _ODataClient._attribute_payload."""
524+
525+
def setUp(self):
526+
self.od = _make_odata_client()
527+
528+
def test_memo_type(self):
529+
"""'memo' should produce MemoAttributeMetadata with MaxLength 4000."""
530+
result = self.od._attribute_payload("new_Notes", "memo")
531+
self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.MemoAttributeMetadata")
532+
self.assertEqual(result["SchemaName"], "new_Notes")
533+
self.assertEqual(result["MaxLength"], 4000)
534+
self.assertEqual(result["FormatName"], {"Value": "Text"})
535+
self.assertNotIn("IsPrimaryName", result)
536+
537+
def test_multiline_alias(self):
538+
"""'multiline' should produce identical payload to 'memo'."""
539+
memo_result = self.od._attribute_payload("new_Description", "memo")
540+
multiline_result = self.od._attribute_payload("new_Description", "multiline")
541+
self.assertEqual(multiline_result, memo_result)
542+
543+
def test_string_type(self):
544+
"""'string' should produce StringAttributeMetadata with MaxLength 200."""
545+
result = self.od._attribute_payload("new_Title", "string")
546+
self.assertEqual(result["@odata.type"], "Microsoft.Dynamics.CRM.StringAttributeMetadata")
547+
self.assertEqual(result["MaxLength"], 200)
548+
self.assertEqual(result["FormatName"], {"Value": "Text"})
549+
550+
def test_unsupported_type_returns_none(self):
551+
"""An unknown type string should return None."""
552+
result = self.od._attribute_payload("new_Col", "unknown_type")
553+
self.assertIsNone(result)
554+
555+
522556
class TestPicklistLabelResolution(unittest.TestCase):
523557
"""Tests for picklist label-to-integer resolution.
524558

tests/unit/test_tables_operations.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,16 @@ def test_add_columns(self):
193193
self.client._odata._create_columns.assert_called_once_with("new_Product", columns)
194194
self.assertEqual(result, ["new_Notes", "new_Active"])
195195

196+
def test_add_columns_memo(self):
197+
"""add_columns() with memo type should pass through correctly."""
198+
self.client._odata._create_columns.return_value = ["new_Description"]
199+
200+
columns = {"new_Description": "memo"}
201+
result = self.client.tables.add_columns("new_Product", columns)
202+
203+
self.client._odata._create_columns.assert_called_once_with("new_Product", columns)
204+
self.assertEqual(result, ["new_Description"])
205+
196206
# --------------------------------------------------------- remove_columns
197207

198208
def test_remove_columns_single(self):

0 commit comments

Comments
 (0)