surfdataverse 2.0.1__tar.gz → 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: surfdataverse
3
- Version: 2.0.1
3
+ Version: 3.0.0
4
4
  Summary: A Python package for ionysis Microsoft Dataverse integration
5
5
  Keywords: dataverse,microsoft,crm,api
6
6
  Author: ionysis
@@ -14,9 +14,11 @@ Classifier: Programming Language :: Python :: 3.8
14
14
  Classifier: Programming Language :: Python :: 3.9
15
15
  Classifier: Programming Language :: Python :: 3.10
16
16
  Classifier: Programming Language :: Python :: 3.11
17
+ Requires-Dist: dependency-injector>=4.42.0
17
18
  Requires-Dist: msal>=1.33.0
18
19
  Requires-Dist: numpy>=2.3.3
19
20
  Requires-Dist: pandas>=2.3.2
21
+ Requires-Dist: polars>=1.34.0
20
22
  Requires-Dist: requests>=2.32.5
21
23
  Requires-Python: >=3.11
22
24
  Project-URL: Bug Tracker, https://github.com/FriedemannHeinz/SurfDataverse/issues
@@ -222,11 +224,12 @@ For custom scenarios, you can create properties manually:
222
224
  ```python
223
225
  from surfdataverse import DataverseTable
224
226
 
227
+
225
228
  # Extend the base class
226
229
  class CustomTable(DataverseTable):
227
230
  def __init__(self, logical_name, prefix="prefix_"):
228
- super().__init__(logical_name, table_prefix=prefix)
229
-
231
+ super().__init__(logical_name, prefix=prefix)
232
+
230
233
  # Add custom business logic
231
234
  def validate_data(self):
232
235
  if not self.name:
@@ -195,11 +195,12 @@ For custom scenarios, you can create properties manually:
195
195
  ```python
196
196
  from surfdataverse import DataverseTable
197
197
 
198
+
198
199
  # Extend the base class
199
200
  class CustomTable(DataverseTable):
200
201
  def __init__(self, logical_name, prefix="prefix_"):
201
- super().__init__(logical_name, table_prefix=prefix)
202
-
202
+ super().__init__(logical_name, prefix=prefix)
203
+
203
204
  # Add custom business logic
204
205
  def validate_data(self):
205
206
  if not self.name:
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "surfdataverse"
7
- version = "2.0.1"
7
+ version = "3.0.0"
8
8
  description = "A Python package for ionysis Microsoft Dataverse integration"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -24,9 +24,11 @@ classifiers = [
24
24
  keywords = ["dataverse", "microsoft", "crm", "api"]
25
25
  requires-python = ">=3.11"
26
26
  dependencies = [
27
+ "dependency-injector>=4.42.0",
27
28
  "msal>=1.33.0",
28
29
  "numpy>=2.3.3",
29
30
  "pandas>=2.3.2",
31
+ "polars>=1.34.0",
30
32
  "requests>=2.32.5",
31
33
  ]
32
34
 
@@ -10,7 +10,8 @@ Main Components:
10
10
  - Entity classes: Article, Recipe, Ingredient, etc.
11
11
  """
12
12
 
13
- from .core import DataverseClient, DataverseTable, is_valid_guid
13
+ from .container import connect_entity, connect_table, container, get_client, reset_container
14
+ from .core import DataverseClient, DataverseEntity, DataverseTable, is_valid_guid
14
15
  from .exceptions import (
15
16
  AuthenticationError,
16
17
  ConfigurationError,
@@ -26,7 +27,13 @@ __author__ = "Friedemann Heinz"
26
27
  __all__ = [
27
28
  "DataverseClient",
28
29
  "DataverseTable",
30
+ "DataverseEntity",
29
31
  "is_valid_guid",
32
+ "get_client",
33
+ "connect_table",
34
+ "connect_entity",
35
+ "reset_container",
36
+ "container",
30
37
  "SurfDataverseError",
31
38
  "AuthenticationError",
32
39
  "ConnectionError",
@@ -0,0 +1,49 @@
1
+ """
2
+ Dependency injection container for SurfDataverse.
3
+ """
4
+
5
+ from dependency_injector import containers, providers
6
+
7
+ from .core import DataverseClient, DataverseEntity, DataverseTable
8
+
9
+
10
+ class Container(containers.DeclarativeContainer):
11
+ """Main dependency injection container for SurfDataverse"""
12
+
13
+ # Configuration
14
+ config = providers.Configuration()
15
+
16
+ # Core client as singleton
17
+ client = providers.Singleton(DataverseClient, config_path=config.config_path)
18
+
19
+ # Table factory - creates DataverseTable instances with injected client
20
+ table_factory = providers.Factory(DataverseTable, client=client)
21
+
22
+ # Entity factory - creates DataverseEntity instances with injected client
23
+ entity_factory = providers.Factory(DataverseEntity, client=client)
24
+
25
+
26
+ # Global container instance
27
+ container = Container()
28
+
29
+
30
+ def get_client(config_path=None) -> DataverseClient:
31
+ """Get the singleton DataverseClient instance"""
32
+ if config_path:
33
+ container.config.config_path.from_value(config_path)
34
+ return container.client()
35
+
36
+
37
+ def connect_table(logical_name: str = "", prefix: str = "") -> DataverseTable:
38
+ """Connect to a Dataverse table"""
39
+ return container.table_factory(logical_name, prefix)
40
+
41
+
42
+ def connect_entity(table_logical_name: str, table_prefix: str = "") -> DataverseEntity:
43
+ """Connect to a new Dataverse entity instance"""
44
+ return container.entity_factory(table_logical_name, table_prefix)
45
+
46
+
47
+ def reset_container():
48
+ """Reset the container - useful for testing"""
49
+ container.reset_singletons()
@@ -4,13 +4,13 @@ import logging
4
4
  import sys
5
5
  import uuid
6
6
  from decimal import Decimal
7
- from enum import Enum
8
7
  from pathlib import Path
9
8
  from typing import Optional
10
9
 
11
10
  import msal
12
11
  import pandas as pd
13
12
  import requests
13
+ from pandas.io.sql import table_exists
14
14
 
15
15
  from .exceptions import (
16
16
  AuthenticationError,
@@ -45,22 +45,20 @@ def user_config_dir(app_name: str, company: str) -> Path:
45
45
  return app_config_dir
46
46
 
47
47
 
48
- class SingletonMeta(type):
49
- """Metaclass for creating a Singleton."""
48
+ # Singleton pattern now handled by dependency injection container
50
49
 
51
- _instances: dict = {}
52
-
53
- def __call__(cls, *args, **kwargs):
54
- if cls not in cls._instances:
55
- cls._instances[cls] = super().__call__(*args, **kwargs)
56
- return cls._instances[cls]
57
50
 
51
+ def is_valid_guid(value: str) -> bool:
52
+ """Returns True if the value is a valid GUID, otherwise False."""
53
+ try:
54
+ uuid_obj = uuid.UUID(value) # Ensure it's a valid UUID
55
+ return str(uuid_obj) == value.lower() # Ensure correct format
56
+ except ValueError:
57
+ return False
58
58
 
59
- class DataverseClient(metaclass=SingletonMeta):
60
- def __init__(self, config_path=None, *args):
61
- if hasattr(self, "_initialized"):
62
- return # Prevent reinitialization
63
59
 
60
+ class DataverseClient:
61
+ def __init__(self, config_path=None):
64
62
  self.connected = False
65
63
  self.session = requests.Session()
66
64
  self.environment_uri: str = ""
@@ -69,18 +67,6 @@ class DataverseClient(metaclass=SingletonMeta):
69
67
  self.account = None # Store the logged-in account
70
68
  self.config_path = config_path
71
69
 
72
- if "xml" in args:
73
- self.filetype = "xml"
74
- else:
75
- self.filetype = "json"
76
-
77
- # Stored values for this session
78
- self._table_metadata = {}
79
- self._global_choices = {}
80
- self._guid_mapping = {}
81
- self._table_definitions = {}
82
- self._relationships = {}
83
-
84
70
  # === CONNECTION, AUTHENTICATION, TEST
85
71
  def get_authenticated_session(self, config_json: Optional[str | Path] = None):
86
72
  if config_json is None and self.config_path is None:
@@ -131,7 +117,7 @@ class DataverseClient(metaclass=SingletonMeta):
131
117
  "OData-Version": "4.0",
132
118
  "If-None-Match": "null",
133
119
  "Prefer": 'odata.include-annotations="*"',
134
- "Accept": f"application/{self.filetype}",
120
+ "Accept": "application/json",
135
121
  }
136
122
  )
137
123
  with open(cache_file, "w") as f:
@@ -192,14 +178,37 @@ class DataverseClient(metaclass=SingletonMeta):
192
178
  except requests.RequestException as e:
193
179
  raise ConnectionError(f"Network error during connection test: {str(e)}")
194
180
 
181
+
182
+ class DataverseBase:
183
+ """Base class for Dataverse operations with shared functionality"""
184
+
185
+ def __init__(self, table_logical_name: str = "", prefix="", client=None):
186
+ self.client = client or DataverseClient()
187
+
188
+ self.table_logical_name = table_logical_name
189
+ self.table_logical_name_prefix = prefix
190
+ self.name = (
191
+ table_logical_name.replace(self.table_logical_name_prefix, "").title().replace("_", " ")
192
+ )
193
+
194
+ # Stored values for this session
195
+ self._guid_mapping = {}
196
+ self._all_relationships = {} # Populated by first call
197
+ self._global_choices = {} # Populated by first call
198
+
199
+ self._table_metadata = {}
200
+ self._table_definitions = {}
201
+ self._table_relationships = {}
202
+ self._filtered_choices = {}
203
+
195
204
  # === RELATIONSHIPS
196
205
  @property
197
- def relationships(self):
198
- if not self._relationships:
199
- request_uri = f"{self.environment_uri}api/data/v9.2/RelationshipDefinitions"
200
- response = self.session.get(request_uri)
206
+ def all_relationships(self):
207
+ if not self._all_relationships:
208
+ request_uri = f"{self.client.environment_uri}api/data/v9.2/RelationshipDefinitions"
209
+ response = self.client.session.get(request_uri)
201
210
  if response.status_code == 200:
202
- self._relationships = response.json()
211
+ self._all_relationships = response.json()
203
212
  else:
204
213
  logger.error(f"Error fetching relationships: {response.text}")
205
214
  raise DataverseAPIError(
@@ -207,12 +216,12 @@ class DataverseClient(metaclass=SingletonMeta):
207
216
  response.status_code,
208
217
  response.text,
209
218
  )
210
- return self._relationships
219
+ return self._all_relationships
211
220
 
212
221
  # === FLOWS
213
222
  def get_flows(self):
214
- request_uri = f"{self.environment_uri}api/data/v9.2/workflows"
215
- response = self.session.get(request_uri)
223
+ request_uri = f"{self.client.environment_uri}api/data/v9.2/workflows"
224
+ response = self.client.session.get(request_uri)
216
225
  if response.status_code == 401:
217
226
  raise AuthenticationError("Unauthorized. Check your credentials and permissions.")
218
227
  elif response.status_code != 200:
@@ -226,8 +235,8 @@ class DataverseClient(metaclass=SingletonMeta):
226
235
  def global_choices(self):
227
236
  """Fetch all global choices (option sets) from Dataverse"""
228
237
  if not self._global_choices:
229
- request_uri = f"{self.environment_uri}api/data/v9.2/GlobalOptionSetDefinitions"
230
- response = self.session.get(request_uri)
238
+ request_uri = f"{self.client.environment_uri}api/data/v9.2/GlobalOptionSetDefinitions"
239
+ response = self.client.session.get(request_uri)
231
240
 
232
241
  if response.status_code == 200:
233
242
  choices_data = response.json().get("value", [])
@@ -253,11 +262,22 @@ class DataverseClient(metaclass=SingletonMeta):
253
262
 
254
263
  return self._global_choices
255
264
 
265
+ @property
266
+ def filtered_choices(self):
267
+ """Get choices filtered by table prefix"""
268
+ if not self._filtered_choices:
269
+ self._filtered_choices = {
270
+ name: options
271
+ for name, options in self.global_choices.items()
272
+ if self.table_logical_name_prefix in name
273
+ }
274
+ return self._filtered_choices
275
+
256
276
  # === TABLES
257
277
  def get_table_definitions(self):
258
278
  if not self._table_definitions:
259
- request_uri = f"{self.environment_uri}api/data/v9.2/EntityDefinitions"
260
- response = self.session.get(request_uri)
279
+ request_uri = f"{self.client.environment_uri}api/data/v9.2/EntityDefinitions"
280
+ response = self.client.session.get(request_uri)
261
281
  if response.status_code == 200:
262
282
  self._table_definitions = response.json()
263
283
  else:
@@ -269,10 +289,24 @@ class DataverseClient(metaclass=SingletonMeta):
269
289
  )
270
290
  return self._table_definitions
271
291
 
292
+ def get_table_relationships(self) -> dict:
293
+ """Fetches all possible lookups (relationships) for this entity."""
294
+ result = self.all_relationships
295
+
296
+ if not self._table_relationships:
297
+ _relationships = {}
298
+ for rel in result.get("value", []):
299
+ if rel.get("ReferencingEntity") == self.table_logical_name:
300
+ # Store lookup column name -> related entity table
301
+ if "ReferencingAttribute" in rel and "ReferencedEntity" in rel:
302
+ _relationships[rel["ReferencingAttribute"]] = rel["ReferencedEntity"]
303
+ self._table_relationships = _relationships
304
+ return self._table_relationships
305
+
272
306
  def get_table_metadata_raw(self, table_logical_name):
273
307
  """Fetch column metadata for a specific Dataverse table"""
274
- request_uri = f"{self.environment_uri}api/data/v9.2/EntityDefinitions(LogicalName='{table_logical_name}')?$expand=Attributes"
275
- response = self.session.get(request_uri)
308
+ request_uri = f"{self.client.environment_uri}api/data/v9.2/EntityDefinitions(LogicalName='{table_logical_name}')?$expand=Attributes"
309
+ response = self.client.session.get(request_uri)
276
310
 
277
311
  if response.status_code == 200:
278
312
  return response.json() # Full metadata response
@@ -281,8 +315,10 @@ class DataverseClient(metaclass=SingletonMeta):
281
315
  logger.error(error_msg)
282
316
  raise DataverseAPIError(error_msg, response.status_code, response.text)
283
317
 
284
- def get_table_metadata(self, table_logical_name):
318
+ def get_table_metadata(self, table_logical_name=None):
285
319
  """Finds fields that users can set in create/update operations"""
320
+ if table_logical_name is None:
321
+ table_logical_name = self.table_logical_name
286
322
  if table_logical_name not in self._table_metadata:
287
323
  raw_metadata = self.get_table_metadata_raw(table_logical_name)
288
324
  if not raw_metadata:
@@ -310,13 +346,120 @@ class DataverseClient(metaclass=SingletonMeta):
310
346
 
311
347
  return self._table_metadata[table_logical_name]
312
348
 
313
- def get_table_data(
314
- self, logical_name=None, entity_set_name=None, polars=False
315
- ) -> pd.DataFrame | None:
316
- """Fetches actual data from a given table (by LogicalName)
349
+ # === RECORDS
350
+ def get_record(self, entity_set_name, record_id):
351
+ """Fetches a newly created record by ID to retrieve auto-generated fields."""
352
+ request_uri = f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}({record_id})"
353
+ response = self.client.session.get(request_uri)
354
+
355
+ if response.status_code == 200:
356
+ return response.json()
357
+ else:
358
+ error_msg = f"Error fetching record: {response.text}"
359
+ logger.error(error_msg)
360
+ raise DataverseAPIError(error_msg, response.status_code, response.text)
361
+
362
+ # === HELPER METHODS
363
+ def name_to_guid(self, table_name, name=None, name_column=None) -> uuid.UUID | None:
364
+ """
365
+ Fetches a mapping of unique names to GUIDs in a Dataverse table
366
+
367
+ Args:
368
+ table_name: logical_name of table
369
+ name: name to map to guid
370
+ name_column: The column name to use for lookup (if not provided, will be inferred)
371
+
372
+ Returns:
373
+
374
+ """
375
+ # If name_column not provided, try to infer it
376
+ if not name_column:
377
+ # Extract prefix from table name
378
+ if "_" in table_name:
379
+ prefix = table_name.split("_")[0] + "_"
380
+ table_suffix = table_name.replace(prefix, "")
381
+
382
+ if table_suffix in ["article", "batch"]:
383
+ name_column = table_name + "nr"
384
+ elif table_suffix == "r2rsession":
385
+ name_column = f"{prefix}sessionid"
386
+ else:
387
+ name_column = f"{prefix}name" # Default fallback
388
+ else:
389
+ # For tables without prefix, assume standard naming
390
+ name_column = table_name + "name"
391
+
392
+ guid_column = table_name + "id"
393
+
394
+ def update_mapping():
395
+ entity_set_name = self.get_table_entity_set_name(logical_name=table_name)
396
+ request_uri = f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}?$select={name_column},{guid_column}"
397
+
398
+ response = self.client.session.get(request_uri)
399
+
400
+ if response.status_code == 200:
401
+ records = response.json().get("value", [])
402
+ mapping = {
403
+ record[name_column]: record[guid_column]
404
+ for record in records
405
+ if name_column in record and guid_column in record
406
+ }
407
+ self._guid_mapping[table_name] = mapping
408
+ else:
409
+ error_msg = (
410
+ f"Error fetching data from {table_name} for guid mapping: {response.text}"
411
+ )
412
+ logger.error(error_msg)
413
+ raise DataverseAPIError(error_msg, response.status_code, response.text)
414
+
415
+ if table_name not in self._guid_mapping:
416
+ update_mapping()
417
+
418
+ if name:
419
+ guid = self._guid_mapping[table_name].get(name, None)
420
+ if not guid:
421
+ raise EntityError(f"{name} not found in {table_name}")
422
+ else:
423
+ return guid
424
+ else:
425
+ return None
426
+
427
+ def get_table_of_guid(self, guid):
428
+ return next(
429
+ (
430
+ key
431
+ for key, value in self._guid_mapping.items()
432
+ if guid in [guid for guid in value.values()]
433
+ ),
434
+ None,
435
+ )
436
+
437
+ def get_table_entity_set_name(self, schema_name=None, logical_name=None):
438
+ tables = self.get_table_definitions()
439
+
440
+ if schema_name:
441
+ key = "SchemaName"
442
+ name = schema_name
443
+ elif logical_name:
444
+ key = "LogicalName"
445
+ name = logical_name
446
+ else:
447
+ raise ValueError("Please input schema or logical name")
448
+
449
+ for entity in tables["value"]:
450
+ if name == entity[key]:
451
+ return entity["EntitySetName"]
452
+ raise EntityError(f"{key} not found for {name}")
453
+
454
+
455
+ class DataverseTable(DataverseBase):
456
+ """Table-level operations for Dataverse - inherits shared functionality from DataverseBase"""
457
+
458
+ # === TABLES
459
+ def get_table_data(self, table_logical_name = None, polars=False):
460
+ """Fetches actual data from this table
317
461
  Args:
318
- logical_name: The logical name of the table
319
- entity_set_name: The entity set name (alternative to logical_name)
462
+ table_logical_name: logical name of the table (optional)
320
463
  polars: Return Polars DataFrame instead of pandas
321
464
  """
322
465
 
@@ -332,16 +475,10 @@ class DataverseClient(metaclass=SingletonMeta):
332
475
  df = pl.DataFrame(data_list)
333
476
  return df
334
477
 
335
- if entity_set_name:
336
- pass
337
- elif logical_name and not entity_set_name:
338
- entity_set_name = self.get_table_entity_set_name(logical_name=logical_name)
339
- else:
340
- logger.info("Please provide logical or entity set name")
341
- return None
478
+ entity_set_name = self.get_table_entity_set_name(logical_name=self.table_logical_name or table_logical_name)
342
479
 
343
- request_uri = f"{self.environment_uri}api/data/v9.2/{entity_set_name}"
344
- response = self.session.get(request_uri)
480
+ request_uri = f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}"
481
+ response = self.client.session.get(request_uri)
345
482
 
346
483
  if response.status_code == 200:
347
484
  return entity_to_polars(response.json()) if polars else entity_to_df(response.json())
@@ -350,7 +487,78 @@ class DataverseClient(metaclass=SingletonMeta):
350
487
  logger.error(error_msg)
351
488
  raise DataverseAPIError(error_msg, response.status_code, response.text)
352
489
 
353
- def download_tables_as_df(self, schema_filter=None):
490
+ def get_column_data(self, columns, entity_set_name=None, polars=False):
491
+ """Fetches specific column data from this table
492
+ Args:
493
+ columns: Column name (string) or list of column names to retrieve
494
+ entity_set_name: The entity set name (optional, will be auto-determined)
495
+ polars: Return Polars DataFrame/Series instead of pandas
496
+ Returns:
497
+ pandas.Series (single column) or pandas.DataFrame (multiple columns)
498
+ """
499
+
500
+ def entity_to_df(entity_data, cols):
501
+ data_list = entity_data["value"]
502
+ df = pd.DataFrame(data_list)
503
+ # Return only requested columns that exist in the data
504
+ available_cols = [col for col in cols if col in df.columns]
505
+ if not available_cols:
506
+ logger.warning(f"None of the requested columns {cols} found in data")
507
+ return pd.DataFrame() if len(cols) > 1 else pd.Series(dtype=object)
508
+ result = df[available_cols]
509
+ return (
510
+ result.iloc[:, 0]
511
+ if len(available_cols) == 1 and isinstance(columns, str)
512
+ else result
513
+ )
514
+
515
+ def entity_to_polars(entity_data, cols):
516
+ import polars as pl
517
+
518
+ data_list = entity_data["value"]
519
+ df = pl.DataFrame(data_list)
520
+ # Return only requested columns that exist in the data
521
+ available_cols = [col for col in cols if col in df.columns]
522
+ if not available_cols:
523
+ logger.warning(f"None of the requested columns {cols} found in data")
524
+ return pl.DataFrame() if len(cols) > 1 else pl.Series(dtype=pl.Null)
525
+ result = df.select(available_cols)
526
+ return (
527
+ result.to_series()
528
+ if len(available_cols) == 1 and isinstance(columns, str)
529
+ else result
530
+ )
531
+
532
+ # Handle single column vs list of columns
533
+ if isinstance(columns, str):
534
+ column_list = [columns]
535
+ elif isinstance(columns, list):
536
+ column_list = columns
537
+ else:
538
+ raise ValueError("columns must be a string or list of strings")
539
+
540
+ if not entity_set_name:
541
+ entity_set_name = self.get_table_entity_set_name(logical_name=self.table_logical_name)
542
+
543
+ # Build OData $select query for specific columns
544
+ select_clause = ",".join(column_list)
545
+ request_uri = (
546
+ f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}?$select={select_clause}"
547
+ )
548
+ response = self.client.session.get(request_uri)
549
+
550
+ if response.status_code == 200:
551
+ return (
552
+ entity_to_polars(response.json(), column_list)
553
+ if polars
554
+ else entity_to_df(response.json(), column_list)
555
+ )
556
+ else:
557
+ error_msg = f"Failed to retrieve column data for {entity_set_name}: {response.text}"
558
+ logger.error(error_msg)
559
+ raise DataverseAPIError(error_msg, response.status_code, response.text)
560
+
561
+ def download_tables_as_df(self, schema_filter=None) -> tuple[dict, dict, dict]:
354
562
  """Download all tables as DataFrames, optionally filtered by schema name
355
563
 
356
564
  Args:
@@ -368,19 +576,18 @@ class DataverseClient(metaclass=SingletonMeta):
368
576
  continue
369
577
 
370
578
  if table["DisplayName"]["UserLocalizedLabel"]:
371
- logical_name = table["LogicalName"]
372
- table_name = logical_name
579
+ table_logical_name = table["LogicalName"]
373
580
 
374
581
  # Get selected definitions
375
- _table_definitions[table_name] = table
582
+ _table_definitions[table_logical_name] = table
376
583
 
377
584
  # Get data
378
- _table_data[table_name] = self.get_table_data(logical_name=logical_name)
379
- if _table_data[table_name] is None:
380
- return
585
+ _table_data[table_logical_name] = self.get_table_data(table_logical_name=table_logical_name)
586
+ if _table_data[table_logical_name] is None:
587
+ return {}, {}, {}
381
588
 
382
589
  # Get metadata
383
- _table_metadata[table_name] = self.get_table_metadata(logical_name) or {}
590
+ _table_metadata[table_logical_name] = self.get_table_metadata(table_logical_name) or {}
384
591
 
385
592
  return _table_definitions, _table_data, _table_metadata
386
593
 
@@ -450,266 +657,93 @@ class DataverseClient(metaclass=SingletonMeta):
450
657
 
451
658
  return df_transformed
452
659
 
453
- # === RECORDS
454
- def get_record(self, entity_set_name, record_id):
455
- """Fetches a newly created record by ID to retrieve auto-generated fields."""
456
- request_uri = f"{self.environment_uri}api/data/v9.2/{entity_set_name}({record_id})"
457
- response = self.session.get(request_uri)
458
-
459
- if response.status_code == 200:
460
- return response.json() # Return the full record with auto-filled values
461
- else:
462
- error_msg = f"Error fetching record: {response.text}"
463
- logger.error(error_msg)
464
- raise DataverseAPIError(error_msg, response.status_code, response.text)
465
-
466
- # === HELPER METHODS
467
- def get_guid_by_casual_name(self, table_name, data, name_column=None) -> uuid.UUID | None:
468
- """
469
- Map display names to GUIDs if row exists
470
-
471
- Args:
472
- table_name: logical_name of table
473
- data: data which holds the name to map to guid
474
- name_column: The column name to use for lookup (if not provided, will be inferred)
475
-
476
- Returns:
477
-
478
- """
479
- # If name_column not provided, try to infer it
480
- if not name_column:
481
- # Extract prefix from table name
482
- if "_" in table_name:
483
- prefix = table_name.split("_")[0] + "_"
484
- table_suffix = table_name.replace(prefix, "")
485
-
486
- if table_suffix == "recipe":
487
- name_column = f"{prefix}namecasual"
488
- elif table_suffix == "article":
489
- name_column = f"{prefix}name"
490
- elif table_suffix == "r2rsession":
491
- name_column = f"{prefix}sessionid"
492
- else:
493
- name_column = f"{prefix}name" # Default fallback
494
- else:
495
- raise EntityError(
496
- f"Cannot infer name column for {table_name}. Please provide name_column parameter."
497
- )
498
-
499
- guid_column = table_name + "id"
500
-
501
- name = data[name_column]
502
-
503
- entity_set_name = self.get_table_entity_set_name(logical_name=table_name)
504
- request_uri = f"{self.environment_uri}api/data/v9.2/{entity_set_name}?$select={name_column},{guid_column}"
505
-
506
- response = self.session.get(request_uri)
507
-
508
- if response.status_code == 200:
509
- records = response.json().get("value", [])
510
- mapping = {
511
- record[name_column]: record[guid_column]
512
- for record in records
513
- if name_column in record and guid_column in record
514
- }
515
- return mapping.get(name, None)
516
- else:
517
- error_msg = (
518
- f"Error fetching data from {table_name} for row existence check: {response.text}"
519
- )
520
- logger.error(error_msg)
521
- raise DataverseAPIError(error_msg, response.status_code, response.text)
522
-
523
- def name_to_guid(self, table_name, name=None, name_column=None) -> uuid.UUID | None:
524
- """
525
- Fetches a mapping of unique names to GUIDs in a Dataverse table
526
-
527
- Args:
528
- table_name: logical_name of table
529
- name: name to map to guid
530
- name_column: The column name to use for lookup (if not provided, will be inferred)
531
-
532
- Returns:
533
-
534
- """
535
- # If name_column not provided, try to infer it
536
- if not name_column:
537
- # Extract prefix from table name
538
- if "_" in table_name:
539
- prefix = table_name.split("_")[0] + "_"
540
- table_suffix = table_name.replace(prefix, "")
541
-
542
- if table_suffix in ["article", "batch"]:
543
- name_column = table_name + "nr"
544
- elif table_suffix == "r2rsession":
545
- name_column = f"{prefix}sessionid"
546
- else:
547
- name_column = f"{prefix}name" # Default fallback
548
- else:
549
- # For tables without prefix, assume standard naming
550
- name_column = table_name + "name"
551
-
552
- guid_column = table_name + "id"
553
-
554
- def update_mapping():
555
- entity_set_name = self.get_table_entity_set_name(logical_name=table_name)
556
- request_uri = f"{self.environment_uri}api/data/v9.2/{entity_set_name}?$select={name_column},{guid_column}"
557
-
558
- response = self.session.get(request_uri)
559
-
560
- if response.status_code == 200:
561
- records = response.json().get("value", [])
562
- mapping = {
563
- record[name_column]: record[guid_column]
564
- for record in records
565
- if name_column in record and guid_column in record
566
- }
567
- self._guid_mapping[table_name] = mapping
568
- else:
569
- error_msg = (
570
- f"Error fetching data from {table_name} for guid mapping: {response.text}"
571
- )
572
- logger.error(error_msg)
573
- raise DataverseAPIError(error_msg, response.status_code, response.text)
574
-
575
- if table_name not in self._guid_mapping:
576
- update_mapping()
577
-
578
- if name:
579
- guid = self._guid_mapping[table_name].get(name, None)
580
- if not guid:
581
- raise EntityError(f"{name} not found in {table_name}")
582
- else:
583
- return guid
584
- else:
585
- return None
586
660
 
587
- def get_table_of_guid(self, guid):
588
- return next(
589
- (
590
- key
591
- for key, value in self._guid_mapping.items()
592
- if guid in [guid for guid in value.values()]
593
- ),
594
- None,
595
- )
596
-
597
- def get_table_entity_set_name(self, schema_name=None, logical_name=None):
598
- tables = self.get_table_definitions()
599
-
600
- if schema_name:
601
- key = "SchemaName"
602
- name = schema_name
603
- elif logical_name:
604
- key = "LogicalName"
605
- name = logical_name
606
- else:
607
- raise ValueError("Please input schema or logical name")
608
-
609
- for entity in tables["value"]:
610
- if name == entity[key]:
611
- return entity["EntitySetName"]
612
- raise EntityError(f"{key} not found for {name}")
613
-
614
-
615
- def is_valid_guid(value: str) -> bool:
616
- """Returns True if the value is a valid GUID, otherwise False."""
617
- try:
618
- uuid_obj = uuid.UUID(value) # Ensure it's a valid UUID
619
- return str(uuid_obj) == value.lower() # Ensure correct format
620
- except ValueError:
621
- return False
622
-
623
-
624
- class DataverseTable:
661
+ class DataverseEntity(DataverseBase):
625
662
  """Generic class for Dataverse entities"""
626
663
 
627
- def __init__(self, table_logical_name, table_prefix="prefix_"):
664
+ def __init__(self, table_logical_name, table_prefix="", client=None, **kwargs):
628
665
  """
629
666
  Args:
630
667
  table_logical_name: The pluralized name of the Dataverse table
631
668
  table_prefix: The prefix used for custom table/column names (default: "prefix_")
669
+ client: Optional DataverseClient instance (will create singleton if not provided)
670
+ **kwargs: Additional options including suppress_property_generation for testing
632
671
  """
633
- self.dataverse = DataverseClient()
634
- if isinstance(table_logical_name, Enum):
635
- table_logical_name = str(table_logical_name)
636
- self.logical_name = table_logical_name
637
- self.table_prefix = table_prefix
638
- self.table_name = table_logical_name.replace(self.table_prefix, "").title()
639
- self.col_metadata = self.dataverse.get_table_metadata(self.logical_name)
640
- # Filter global choices to only include those relevant to our prefix
641
- all_choices = self.dataverse.global_choices
642
- prefix_filter = self.table_prefix.rstrip("_")
643
- self.choices = {
644
- name: options for name, options in all_choices.items() if prefix_filter in name
645
- }
646
- self.relationships = self.get_relationships()
672
+ super().__init__(table_logical_name, table_prefix, client)
673
+ self.client = client or DataverseClient()
647
674
 
648
675
  self.guid = None
649
676
  self.data = {} # Dictionary to store record fields
677
+ self._properties_generated = False
650
678
 
651
- # Auto-generate properties based on metadata
652
- self._generate_properties()
679
+ # Generate properties unless suppressed (useful for testing)
680
+ if not kwargs.get("suppress_property_generation", False):
681
+ self._generate_properties()
653
682
 
654
683
  def _generate_properties(self):
655
684
  """Automatically generates properties for all valid columns in the table"""
656
- logger.info(f"Generating properties for {self.logical_name}")
657
- for column_logical_name, metadata in self.col_metadata.items():
658
- # Skip system fields and non-user fields
659
- if not column_logical_name.startswith(self.table_prefix):
660
- continue
661
-
662
- # File and Virtual columns often have IsValidForCreate/Update = False, so allow them through
663
- field_type = metadata.get("AttributeType")
664
- if (
665
- field_type not in ["File", "Virtual"]
666
- and not metadata.get("IsValidForCreate")
667
- and not metadata.get("IsValidForUpdate")
668
- ):
669
- continue
685
+ if self._properties_generated:
686
+ return
670
687
 
671
- # Generate property name by removing prefix
672
- if column_logical_name.startswith(self.table_prefix):
673
- property_name = column_logical_name.replace(
674
- self.table_prefix, "", 1
675
- ) # Remove prefix
676
- else:
677
- property_name = column_logical_name
678
-
679
- # Determine property type based on field metadata
680
- field_type = metadata.get("AttributeType")
681
-
682
- if field_type == "File" or (
683
- field_type == "Virtual" and "json" in column_logical_name.lower()
684
- ):
685
- # This is a file field or virtual file field - check this FIRST
686
- logger.info(f"Setting {property_name} as file property")
687
- setattr(self.__class__, property_name, self.file_property(column_logical_name))
688
- elif column_logical_name in self.relationships:
689
- # This is a lookup field
690
- logger.info(f"Setting {property_name} as lookup property")
691
- setattr(self.__class__, property_name, self.lookup_property(column_logical_name))
692
- elif column_logical_name in self.choices:
693
- # This is a choice field
694
- logger.info(f"Setting {property_name} as choice property")
695
- setattr(self.__class__, property_name, self.choice_property(column_logical_name))
696
- else:
697
- # This is a regular data field
698
- logger.info(f"Setting {property_name} as data property (fallback)")
699
- setattr(self.__class__, property_name, self.data_property(column_logical_name))
700
-
701
- # === Init values ===
702
- def get_relationships(self):
703
- """Fetches all possible lookups (relationships) for this entity."""
704
- result = self.dataverse.relationships
688
+ logger.info(f"Generating properties for {self.table_logical_name}")
689
+ try:
690
+ for column_logical_name, metadata in self.get_table_metadata().items():
691
+ # Skip system fields and non-user fields
692
+ if not column_logical_name.startswith(self.table_logical_name_prefix):
693
+ continue
694
+
695
+ # File and Virtual columns often have IsValidForCreate/Update = False, so allow them through
696
+ field_type = metadata.get("AttributeType")
697
+ if (
698
+ field_type not in ["File", "Virtual"]
699
+ and not metadata.get("IsValidForCreate")
700
+ and not metadata.get("IsValidForUpdate")
701
+ ):
702
+ continue
703
+
704
+ # Generate property name by removing prefix
705
+ if column_logical_name.startswith(self.table_logical_name_prefix):
706
+ property_name = column_logical_name.replace(
707
+ self.table_logical_name_prefix, "", 1
708
+ ) # Remove prefix
709
+ else:
710
+ property_name = column_logical_name
711
+
712
+ # Determine property type based on field metadata
713
+ field_type = metadata.get("AttributeType")
714
+
715
+ if field_type == "File" or (
716
+ field_type == "Virtual" and "json" in column_logical_name.lower()
717
+ ):
718
+ # This is a file field or virtual file field - check this FIRST
719
+ logger.info(f"Setting {property_name} as file property")
720
+ setattr(self.__class__, property_name, self.file_property(column_logical_name))
721
+ elif column_logical_name in self.get_table_relationships():
722
+ # This is a lookup field
723
+ logger.info(f"Setting {property_name} as lookup property")
724
+ setattr(
725
+ self.__class__, property_name, self.lookup_property(column_logical_name)
726
+ )
727
+ elif column_logical_name in self.filtered_choices:
728
+ # This is a choice field
729
+ logger.info(f"Setting {property_name} as choice property")
730
+ setattr(
731
+ self.__class__, property_name, self.choice_property(column_logical_name)
732
+ )
733
+ elif field_type == "DateTime":
734
+ # This is a date and time field
735
+ logger.info(f"Setting {property_name} as datetime property")
736
+ setattr(self.__class__, property_name, self.data_property(column_logical_name))
737
+ else:
738
+ # This is a regular data field
739
+ logger.info(f"Setting {property_name} as data property (fallback)")
740
+ setattr(self.__class__, property_name, self.data_property(column_logical_name))
705
741
 
706
- _relationships = {}
707
- for rel in result.get("value", []):
708
- if rel.get("ReferencingEntity") == self.logical_name:
709
- # Store lookup column name -> related entity table
710
- if "ReferencingAttribute" in rel and "ReferencedEntity" in rel:
711
- _relationships[rel["ReferencingAttribute"]] = rel["ReferencedEntity"]
712
- return _relationships
742
+ self._properties_generated = True
743
+ logger.info(f"Property generation completed for {self.table_logical_name}")
744
+ except Exception as e:
745
+ logger.warning(f"Property generation failed for {self.table_logical_name}: {e}")
746
+ # Don't raise the exception - entity should still be usable without dynamic properties
713
747
 
714
748
  # === FREE TEXT COLUMNS ===
715
749
  def set_data(self, column, value):
@@ -719,17 +753,17 @@ class DataverseTable:
719
753
  def get_data(self, column):
720
754
  value = self.data.get(column, "")
721
755
 
722
- # If value is empty but we have a GUID, try to fetch from Dataverse
756
+ # If value is empty, but we have a GUID, try to fetch from Dataverse
723
757
  if not value and self.guid:
724
758
  try:
725
- entity_set_name = self.dataverse.get_table_entity_set_name(
726
- logical_name=self.logical_name
759
+ entity_set_name = self.get_table_entity_set_name(
760
+ logical_name=self.table_logical_name
727
761
  )
728
- record = self.dataverse.get_record(entity_set_name, self.guid)
762
+ record = self.get_record(entity_set_name, self.guid)
729
763
 
730
764
  # Update our data with the fresh record
731
765
  for field, fresh_value in record.items():
732
- if field.startswith(self.table_prefix) or field.endswith("id"):
766
+ if field.startswith(self.table_logical_name_prefix) or field.endswith("id"):
733
767
  self.data[field] = fresh_value
734
768
 
735
769
  # Return the requested value
@@ -753,23 +787,37 @@ class DataverseTable:
753
787
  return False
754
788
 
755
789
  try:
756
- entity_set_name = self.dataverse.get_table_entity_set_name(
757
- logical_name=self.logical_name
758
- )
759
- record = self.dataverse.get_record(entity_set_name, self.guid)
790
+ entity_set_name = self.get_table_entity_set_name(logical_name=self.table_logical_name)
791
+ record = self.get_record(entity_set_name, self.guid)
760
792
 
761
793
  # Update our data with the fresh record
762
794
  for field, fresh_value in record.items():
763
- if field.startswith(self.table_prefix) or field.endswith("id"):
795
+ if field.startswith(self.table_logical_name_prefix) or field.endswith("id"):
764
796
  self.data[field] = fresh_value
765
797
 
766
- logger.info(f"Refreshed data for {self.logical_name} GUID: {self.guid}")
798
+ logger.info(f"Refreshed data for {self.table_logical_name} GUID: {self.guid}")
767
799
  return True
768
800
 
769
801
  except Exception as e:
770
- logger.error(f"Failed to refresh data for {self.logical_name} GUID {self.guid}: {e}")
802
+ logger.error(
803
+ f"Failed to refresh data for {self.table_logical_name} GUID {self.guid}: {e}"
804
+ )
771
805
  return False
772
806
 
807
+ def fetch_data(self, entity_id=None):
808
+ """
809
+ Fetch data for an existing entity from Dataverse by its ID.
810
+
811
+ Args:
812
+ entity_id: The GUID of the entity to fetch
813
+
814
+ Returns:
815
+ bool: True if fetch was successful, False otherwise
816
+ """
817
+ if entity_id is not None:
818
+ self.guid = entity_id
819
+ return self.refresh()
820
+
773
821
  @staticmethod
774
822
  def data_property(column):
775
823
  def getter(self):
@@ -783,15 +831,15 @@ class DataverseTable:
783
831
  # === LOOKUP
784
832
  def set_lookup(self, lookup_column, value: str):
785
833
  """Adds a lookup reference using @odata.bind"""
786
- lookup_column_schema_name = self.col_metadata[lookup_column]["SchemaName"]
834
+ lookup_column_schema_name = self.get_table_metadata()[lookup_column]["SchemaName"]
787
835
 
788
- target_entity = self.relationships.get(lookup_column) # Get table of lookup column
789
- target_entity_set_name = self.dataverse.get_table_entity_set_name(
790
- logical_name=target_entity
791
- )
836
+ target_entity = self.get_table_relationships().get(
837
+ lookup_column
838
+ ) # Get table of lookup column
839
+ target_entity_set_name = self.get_table_entity_set_name(logical_name=target_entity)
792
840
 
793
841
  if not is_valid_guid(value):
794
- value = self.dataverse.name_to_guid(target_entity, value)
842
+ value = self.name_to_guid(target_entity, value)
795
843
 
796
844
  self.data[f"{lookup_column_schema_name}@odata.bind"] = f"/{target_entity_set_name}({value})"
797
845
 
@@ -815,7 +863,7 @@ class DataverseTable:
815
863
  # === CHOICE
816
864
  def set_choice(self, column, value):
817
865
  """Adds a choice field by looking up its numeric value"""
818
- choice_value = self.choices[column].get(value)
866
+ choice_value = self.filtered_choices[column].get(value) # Get numeric value for choice
819
867
  if choice_value is not None:
820
868
  self.data[column] = choice_value
821
869
  else:
@@ -825,7 +873,10 @@ class DataverseTable:
825
873
 
826
874
  def get_choice(self, column):
827
875
  """Retrieves the readable choice name from its stored numeric value (Int → String)"""
828
- return self.choices.get(column, {}).get(self.data.get(column), None)
876
+ choice_dict = self.filtered_choices.get(column, {})
877
+ choices_invert = {v: k for k, v in choice_dict.items()} # Invert dict for reverse lookup
878
+ numeric_value = self.data.get(column, None)
879
+ return choices_invert.get(numeric_value, None)
829
880
 
830
881
  @staticmethod
831
882
  def choice_property(lookup_column):
@@ -840,11 +891,11 @@ class DataverseTable:
840
891
  if not self.guid:
841
892
  raise EntityError("Record must be created first (GUID required)")
842
893
 
843
- entity_set_name = self.dataverse.get_table_entity_set_name(logical_name=self.logical_name)
844
- base_url = f"{self.dataverse.environment_uri}api/data/v9.2/{entity_set_name}({self.guid})"
894
+ entity_set_name = self.get_table_entity_set_name(logical_name=self.table_logical_name)
895
+ base_url = f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}({self.guid})"
845
896
 
846
897
  # Step 1: PATCH metadata
847
- meta_headers = self.dataverse.session.headers.copy()
898
+ meta_headers = self.client.session.headers.copy()
848
899
  meta_headers["Content-Type"] = "application/json"
849
900
 
850
901
  metadata_payload = {
@@ -911,18 +962,18 @@ class DataverseTable:
911
962
  if not self.guid:
912
963
  return None
913
964
 
914
- entity_set_name = self.dataverse.get_table_entity_set_name(logical_name=self.logical_name)
915
- return f"{self.dataverse.environment_uri}api/data/v9.2/{entity_set_name}({self.guid})/{column}/$value"
965
+ entity_set_name = self.get_table_entity_set_name(logical_name=self.table_logical_name)
966
+ return f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}({self.guid})/{column}/$value"
916
967
 
917
968
  def get_file(self, column):
918
969
  """Downloads and returns file content from file columns"""
919
970
  if not self.guid:
920
971
  return None
921
972
 
922
- entity_set_name = self.dataverse.get_table_entity_set_name(logical_name=self.logical_name)
923
- file_url = f"{self.dataverse.environment_uri}api/data/v9.2/{entity_set_name}({self.guid})/{column}/$value"
973
+ entity_set_name = self.get_table_entity_set_name(logical_name=self.table_logical_name)
974
+ file_url = f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}({self.guid})/{column}/$value"
924
975
 
925
- response = self.dataverse.session.get(file_url)
976
+ response = self.client.session.get(file_url)
926
977
 
927
978
  if response.status_code == 200:
928
979
  # For JSON files, try to parse and return as dict
@@ -959,12 +1010,12 @@ class DataverseTable:
959
1010
  validated_data[field] = value
960
1011
  continue
961
1012
 
962
- if field not in self.col_metadata:
1013
+ if field not in self.get_table_metadata():
963
1014
  # Field not in metadata, pass through as-is
964
1015
  validated_data[field] = value
965
1016
  continue
966
1017
 
967
- field_type = self.col_metadata[field].get("AttributeType")
1018
+ field_type = self.get_table_metadata()[field].get("AttributeType")
968
1019
 
969
1020
  if field_type == "Decimal" and isinstance(value, (int, float, str)):
970
1021
  # Convert to proper decimal format for Dataverse
@@ -995,24 +1046,40 @@ class DataverseTable:
995
1046
  if self.guid:
996
1047
  return self._update_record()
997
1048
 
998
- # === CHECK IF ROW EXISTS (for certain table types there is a casual name identifier) ===
999
- if self.logical_name in [f"{self.table_prefix}article", f"{self.table_prefix}recipe"]:
1000
- # Check if name exists
1001
- guid = self.dataverse.get_guid_by_casual_name(self.logical_name, self.data)
1002
- if guid:
1003
- self.guid = guid
1004
- logger.warning(
1005
- f"✅ This {self.logical_name} already exists. GUID {guid} fetched and stored, no data written."
1049
+ # === OPTIONAL DUPLICATE CHECK (warn if potential name-based duplicate exists) ===
1050
+ name_columns = [col for col in self.data.keys() if "name" in col.lower()]
1051
+ if name_columns:
1052
+ try:
1053
+ # Simple check for existing record with same name (warning only)
1054
+ name_column = name_columns[0]
1055
+ name_value = self.data[name_column]
1056
+ guid_column = self.table_logical_name + "id"
1057
+
1058
+ entity_set_name = self.get_table_entity_set_name(
1059
+ logical_name=self.table_logical_name
1006
1060
  )
1007
- return guid
1061
+ request_uri = f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}?$select={name_column},{guid_column}&$filter={name_column} eq '{name_value}'"
1062
+
1063
+ response = self.client.session.get(request_uri)
1064
+ if response.status_code == 200:
1065
+ records = response.json().get("value", [])
1066
+ if records:
1067
+ existing_guid = records[0].get(guid_column)
1068
+ logger.warning(
1069
+ f"⚠️ Record with name '{name_value}' may already exist in {self.table_logical_name}. "
1070
+ f"Existing GUID: {existing_guid}. Creating new record anyway."
1071
+ )
1072
+ except Exception as e:
1073
+ # If duplicate check fails, just continue with creation
1074
+ logger.info(f"Duplicate check skipped due to error: {e}")
1008
1075
 
1009
1076
  # This is a CREATE operation
1010
1077
  return self._create_record()
1011
1078
 
1012
- def _create_record(self):
1079
+ def _create_record(self) -> uuid.UUID | None:
1013
1080
  """Creates a new record in Dataverse"""
1014
- entity_set_name = self.dataverse.get_table_entity_set_name(logical_name=self.logical_name)
1015
- request_uri = f"{self.dataverse.environment_uri}api/data/v9.2/{entity_set_name}"
1081
+ entity_set_name = self.get_table_entity_set_name(logical_name=self.table_logical_name)
1082
+ request_uri = f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}"
1016
1083
 
1017
1084
  # Validate and convert data types before sending
1018
1085
  validated_data = self._validate_and_convert_data(self.data)
@@ -1026,12 +1093,12 @@ class DataverseTable:
1026
1093
  "POST",
1027
1094
  request_uri,
1028
1095
  data=record_json,
1029
- headers={**self.dataverse.session.headers, "Content-Type": "application/json"},
1096
+ headers={**self.client.session.headers, "Content-Type": "application/json"},
1030
1097
  ).prepare()
1031
- response = self.dataverse.session.send(req)
1098
+ response = self.client.session.send(req)
1032
1099
 
1033
1100
  if response.status_code == 204:
1034
- logger.info(f"✅ Record created in {self.logical_name} successfully!")
1101
+ logger.info(f"✅ Record created in {self.table_logical_name} successfully!")
1035
1102
 
1036
1103
  # Extract GUID from OData-EntityId header
1037
1104
  created_record_url = response.headers.get("OData-EntityId")
@@ -1040,19 +1107,21 @@ class DataverseTable:
1040
1107
  logger.info(f"🆔 Assigned GUID: {self.guid}")
1041
1108
 
1042
1109
  # Update guid mapping
1043
- self.dataverse.name_to_guid(self.logical_name)
1110
+ self.name_to_guid(self.table_logical_name)
1044
1111
  return self.guid
1112
+ return None
1045
1113
  else:
1046
1114
  # Enhanced error message with field information
1047
- error_details = []
1048
- error_details.append(f"Table: {entity_set_name}")
1049
- error_details.append(f"Record data sent: {json.dumps(record, indent=2)}")
1115
+ error_details = [
1116
+ f"Table: {entity_set_name}",
1117
+ f"Record data sent: {json.dumps(record, indent=2)}",
1118
+ ]
1050
1119
 
1051
1120
  # Analyze data types for decimal conversion issues
1052
1121
  decimal_fields = []
1053
1122
  for field, value in record.items():
1054
- if isinstance(value, (int, float)) and field in self.col_metadata:
1055
- field_type = self.col_metadata[field].get("AttributeType")
1123
+ if isinstance(value, (int, float)) and field in self.get_table_metadata():
1124
+ field_type = self.get_table_metadata()[field].get("AttributeType")
1056
1125
  if field_type == "Decimal":
1057
1126
  decimal_fields.append(
1058
1127
  f" {field}: {value} (type: {type(value).__name__}, expected: Decimal)"
@@ -1071,10 +1140,8 @@ class DataverseTable:
1071
1140
 
1072
1141
  def _update_record(self):
1073
1142
  """Updates an existing record in Dataverse"""
1074
- entity_set_name = self.dataverse.get_table_entity_set_name(logical_name=self.logical_name)
1075
- request_uri = (
1076
- f"{self.dataverse.environment_uri}api/data/v9.2/{entity_set_name}({self.guid})"
1077
- )
1143
+ entity_set_name = self.get_table_entity_set_name(logical_name=self.table_logical_name)
1144
+ request_uri = f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}({self.guid})"
1078
1145
 
1079
1146
  # Validate and convert data types before sending
1080
1147
  validated_data = self._validate_and_convert_data(self.data)
@@ -1090,7 +1157,7 @@ class DataverseTable:
1090
1157
  update_data[field] = value
1091
1158
 
1092
1159
  if not update_data:
1093
- logger.info(f"No data to update for {self.logical_name} GUID: {self.guid}")
1160
+ logger.info(f"No data to update for {self.table_logical_name} GUID: {self.guid}")
1094
1161
  return self.guid
1095
1162
 
1096
1163
  # Send update to Dataverse (convert Decimals to floats)
@@ -1099,12 +1166,12 @@ class DataverseTable:
1099
1166
  "PATCH",
1100
1167
  request_uri,
1101
1168
  data=update_json,
1102
- headers={**self.dataverse.session.headers, "Content-Type": "application/json"},
1169
+ headers={**self.client.session.headers, "Content-Type": "application/json"},
1103
1170
  ).prepare()
1104
- response = self.dataverse.session.send(req)
1171
+ response = self.client.session.send(req)
1105
1172
 
1106
1173
  if response.status_code == 204:
1107
- logger.info(f"✅ Record updated in {self.logical_name} successfully!")
1174
+ logger.info(f"✅ Record updated in {self.table_logical_name} successfully!")
1108
1175
  return self.guid
1109
1176
  else:
1110
1177
  error_msg = f"Error updating record in {entity_set_name}: {response.text}"