surfdataverse 2.1.0__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.1.0
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.1.0"
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,270 +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
- elif field_type == "DateTime":
697
- # This is a date and time field
698
- logger.info(f"Setting {property_name} as datetime property")
699
- setattr(self.__class__, property_name, self.data_property(column_logical_name))
700
- else:
701
- # This is a regular data field
702
- logger.info(f"Setting {property_name} as data property (fallback)")
703
- setattr(self.__class__, property_name, self.data_property(column_logical_name))
704
-
705
- # === Init values ===
706
- def get_relationships(self):
707
- """Fetches all possible lookups (relationships) for this entity."""
708
- 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))
709
741
 
710
- _relationships = {}
711
- for rel in result.get("value", []):
712
- if rel.get("ReferencingEntity") == self.logical_name:
713
- # Store lookup column name -> related entity table
714
- if "ReferencingAttribute" in rel and "ReferencedEntity" in rel:
715
- _relationships[rel["ReferencingAttribute"]] = rel["ReferencedEntity"]
716
- 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
717
747
 
718
748
  # === FREE TEXT COLUMNS ===
719
749
  def set_data(self, column, value):
@@ -723,17 +753,17 @@ class DataverseTable:
723
753
  def get_data(self, column):
724
754
  value = self.data.get(column, "")
725
755
 
726
- # 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
727
757
  if not value and self.guid:
728
758
  try:
729
- entity_set_name = self.dataverse.get_table_entity_set_name(
730
- logical_name=self.logical_name
759
+ entity_set_name = self.get_table_entity_set_name(
760
+ logical_name=self.table_logical_name
731
761
  )
732
- record = self.dataverse.get_record(entity_set_name, self.guid)
762
+ record = self.get_record(entity_set_name, self.guid)
733
763
 
734
764
  # Update our data with the fresh record
735
765
  for field, fresh_value in record.items():
736
- if field.startswith(self.table_prefix) or field.endswith("id"):
766
+ if field.startswith(self.table_logical_name_prefix) or field.endswith("id"):
737
767
  self.data[field] = fresh_value
738
768
 
739
769
  # Return the requested value
@@ -757,23 +787,37 @@ class DataverseTable:
757
787
  return False
758
788
 
759
789
  try:
760
- entity_set_name = self.dataverse.get_table_entity_set_name(
761
- logical_name=self.logical_name
762
- )
763
- 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)
764
792
 
765
793
  # Update our data with the fresh record
766
794
  for field, fresh_value in record.items():
767
- if field.startswith(self.table_prefix) or field.endswith("id"):
795
+ if field.startswith(self.table_logical_name_prefix) or field.endswith("id"):
768
796
  self.data[field] = fresh_value
769
797
 
770
- 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}")
771
799
  return True
772
800
 
773
801
  except Exception as e:
774
- 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
+ )
775
805
  return False
776
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
+
777
821
  @staticmethod
778
822
  def data_property(column):
779
823
  def getter(self):
@@ -787,15 +831,15 @@ class DataverseTable:
787
831
  # === LOOKUP
788
832
  def set_lookup(self, lookup_column, value: str):
789
833
  """Adds a lookup reference using @odata.bind"""
790
- lookup_column_schema_name = self.col_metadata[lookup_column]["SchemaName"]
834
+ lookup_column_schema_name = self.get_table_metadata()[lookup_column]["SchemaName"]
791
835
 
792
- target_entity = self.relationships.get(lookup_column) # Get table of lookup column
793
- target_entity_set_name = self.dataverse.get_table_entity_set_name(
794
- logical_name=target_entity
795
- )
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)
796
840
 
797
841
  if not is_valid_guid(value):
798
- value = self.dataverse.name_to_guid(target_entity, value)
842
+ value = self.name_to_guid(target_entity, value)
799
843
 
800
844
  self.data[f"{lookup_column_schema_name}@odata.bind"] = f"/{target_entity_set_name}({value})"
801
845
 
@@ -819,7 +863,7 @@ class DataverseTable:
819
863
  # === CHOICE
820
864
  def set_choice(self, column, value):
821
865
  """Adds a choice field by looking up its numeric value"""
822
- choice_value = self.choices[column].get(value)
866
+ choice_value = self.filtered_choices[column].get(value) # Get numeric value for choice
823
867
  if choice_value is not None:
824
868
  self.data[column] = choice_value
825
869
  else:
@@ -829,7 +873,10 @@ class DataverseTable:
829
873
 
830
874
  def get_choice(self, column):
831
875
  """Retrieves the readable choice name from its stored numeric value (Int → String)"""
832
- 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)
833
880
 
834
881
  @staticmethod
835
882
  def choice_property(lookup_column):
@@ -844,11 +891,11 @@ class DataverseTable:
844
891
  if not self.guid:
845
892
  raise EntityError("Record must be created first (GUID required)")
846
893
 
847
- entity_set_name = self.dataverse.get_table_entity_set_name(logical_name=self.logical_name)
848
- 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})"
849
896
 
850
897
  # Step 1: PATCH metadata
851
- meta_headers = self.dataverse.session.headers.copy()
898
+ meta_headers = self.client.session.headers.copy()
852
899
  meta_headers["Content-Type"] = "application/json"
853
900
 
854
901
  metadata_payload = {
@@ -915,18 +962,18 @@ class DataverseTable:
915
962
  if not self.guid:
916
963
  return None
917
964
 
918
- entity_set_name = self.dataverse.get_table_entity_set_name(logical_name=self.logical_name)
919
- 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"
920
967
 
921
968
  def get_file(self, column):
922
969
  """Downloads and returns file content from file columns"""
923
970
  if not self.guid:
924
971
  return None
925
972
 
926
- entity_set_name = self.dataverse.get_table_entity_set_name(logical_name=self.logical_name)
927
- 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"
928
975
 
929
- response = self.dataverse.session.get(file_url)
976
+ response = self.client.session.get(file_url)
930
977
 
931
978
  if response.status_code == 200:
932
979
  # For JSON files, try to parse and return as dict
@@ -963,12 +1010,12 @@ class DataverseTable:
963
1010
  validated_data[field] = value
964
1011
  continue
965
1012
 
966
- if field not in self.col_metadata:
1013
+ if field not in self.get_table_metadata():
967
1014
  # Field not in metadata, pass through as-is
968
1015
  validated_data[field] = value
969
1016
  continue
970
1017
 
971
- field_type = self.col_metadata[field].get("AttributeType")
1018
+ field_type = self.get_table_metadata()[field].get("AttributeType")
972
1019
 
973
1020
  if field_type == "Decimal" and isinstance(value, (int, float, str)):
974
1021
  # Convert to proper decimal format for Dataverse
@@ -999,24 +1046,40 @@ class DataverseTable:
999
1046
  if self.guid:
1000
1047
  return self._update_record()
1001
1048
 
1002
- # === CHECK IF ROW EXISTS (for certain table types there is a casual name identifier) ===
1003
- if self.logical_name in [f"{self.table_prefix}article", f"{self.table_prefix}recipe"]:
1004
- # Check if name exists
1005
- guid = self.dataverse.get_guid_by_casual_name(self.logical_name, self.data)
1006
- if guid:
1007
- self.guid = guid
1008
- logger.warning(
1009
- 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
1010
1060
  )
1011
- 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}")
1012
1075
 
1013
1076
  # This is a CREATE operation
1014
1077
  return self._create_record()
1015
1078
 
1016
- def _create_record(self):
1079
+ def _create_record(self) -> uuid.UUID | None:
1017
1080
  """Creates a new record in Dataverse"""
1018
- entity_set_name = self.dataverse.get_table_entity_set_name(logical_name=self.logical_name)
1019
- 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}"
1020
1083
 
1021
1084
  # Validate and convert data types before sending
1022
1085
  validated_data = self._validate_and_convert_data(self.data)
@@ -1030,12 +1093,12 @@ class DataverseTable:
1030
1093
  "POST",
1031
1094
  request_uri,
1032
1095
  data=record_json,
1033
- headers={**self.dataverse.session.headers, "Content-Type": "application/json"},
1096
+ headers={**self.client.session.headers, "Content-Type": "application/json"},
1034
1097
  ).prepare()
1035
- response = self.dataverse.session.send(req)
1098
+ response = self.client.session.send(req)
1036
1099
 
1037
1100
  if response.status_code == 204:
1038
- logger.info(f"✅ Record created in {self.logical_name} successfully!")
1101
+ logger.info(f"✅ Record created in {self.table_logical_name} successfully!")
1039
1102
 
1040
1103
  # Extract GUID from OData-EntityId header
1041
1104
  created_record_url = response.headers.get("OData-EntityId")
@@ -1044,19 +1107,21 @@ class DataverseTable:
1044
1107
  logger.info(f"🆔 Assigned GUID: {self.guid}")
1045
1108
 
1046
1109
  # Update guid mapping
1047
- self.dataverse.name_to_guid(self.logical_name)
1110
+ self.name_to_guid(self.table_logical_name)
1048
1111
  return self.guid
1112
+ return None
1049
1113
  else:
1050
1114
  # Enhanced error message with field information
1051
- error_details = []
1052
- error_details.append(f"Table: {entity_set_name}")
1053
- 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
+ ]
1054
1119
 
1055
1120
  # Analyze data types for decimal conversion issues
1056
1121
  decimal_fields = []
1057
1122
  for field, value in record.items():
1058
- if isinstance(value, (int, float)) and field in self.col_metadata:
1059
- 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")
1060
1125
  if field_type == "Decimal":
1061
1126
  decimal_fields.append(
1062
1127
  f" {field}: {value} (type: {type(value).__name__}, expected: Decimal)"
@@ -1075,10 +1140,8 @@ class DataverseTable:
1075
1140
 
1076
1141
  def _update_record(self):
1077
1142
  """Updates an existing record in Dataverse"""
1078
- entity_set_name = self.dataverse.get_table_entity_set_name(logical_name=self.logical_name)
1079
- request_uri = (
1080
- f"{self.dataverse.environment_uri}api/data/v9.2/{entity_set_name}({self.guid})"
1081
- )
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})"
1082
1145
 
1083
1146
  # Validate and convert data types before sending
1084
1147
  validated_data = self._validate_and_convert_data(self.data)
@@ -1094,7 +1157,7 @@ class DataverseTable:
1094
1157
  update_data[field] = value
1095
1158
 
1096
1159
  if not update_data:
1097
- 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}")
1098
1161
  return self.guid
1099
1162
 
1100
1163
  # Send update to Dataverse (convert Decimals to floats)
@@ -1103,12 +1166,12 @@ class DataverseTable:
1103
1166
  "PATCH",
1104
1167
  request_uri,
1105
1168
  data=update_json,
1106
- headers={**self.dataverse.session.headers, "Content-Type": "application/json"},
1169
+ headers={**self.client.session.headers, "Content-Type": "application/json"},
1107
1170
  ).prepare()
1108
- response = self.dataverse.session.send(req)
1171
+ response = self.client.session.send(req)
1109
1172
 
1110
1173
  if response.status_code == 204:
1111
- logger.info(f"✅ Record updated in {self.logical_name} successfully!")
1174
+ logger.info(f"✅ Record updated in {self.table_logical_name} successfully!")
1112
1175
  return self.guid
1113
1176
  else:
1114
1177
  error_msg = f"Error updating record in {entity_set_name}: {response.text}"