sfq 0.0.32__py3-none-any.whl → 0.0.33__py3-none-any.whl

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.
sfq/crud.py ADDED
@@ -0,0 +1,446 @@
1
+ """
2
+ CRUD operations module for SFQ library.
3
+
4
+ This module provides CRUD (Create, Read, Update, Delete) operations for Salesforce records
5
+ using various APIs including SOAP, REST, and Composite APIs.
6
+ """
7
+
8
+ import base64
9
+ import json
10
+ from concurrent.futures import ThreadPoolExecutor, as_completed
11
+ from typing import Any, Dict, Iterable, List, Literal, Optional
12
+ from urllib.parse import quote
13
+
14
+ from .query import QueryClient
15
+ from .utils import get_logger
16
+
17
+ logger = get_logger("sfq.crud")
18
+
19
+
20
+ class CRUDClient:
21
+ """
22
+ Client for CRUD operations on Salesforce records.
23
+
24
+ This class handles create, update, and delete operations using appropriate
25
+ Salesforce APIs with batch processing and threading support.
26
+ """
27
+
28
+ def __init__(self, http_client, soap_client, api_version: str = "v64.0"):
29
+ """
30
+ Initialize the CRUD client.
31
+
32
+ :param http_client: HTTPClient instance for making HTTP requests
33
+ :param soap_client: SOAPClient instance for SOAP operations
34
+ :param api_version: Salesforce API version to use
35
+ """
36
+ self.http_client = http_client
37
+ self.soap_client = soap_client
38
+ self.api_version = api_version
39
+
40
+ def create(
41
+ self,
42
+ sobject: str,
43
+ insert_list: List[Dict[str, Any]],
44
+ batch_size: int = 200,
45
+ max_workers: int = None,
46
+ api_type: Literal["enterprise", "tooling"] = "enterprise",
47
+ ) -> Optional[Dict[str, Any]]:
48
+ """
49
+ Execute the Insert API to insert multiple records via SOAP calls.
50
+
51
+ :param sobject: The name of the sObject to insert into.
52
+ :param insert_list: A list of dictionaries, each representing a record to insert.
53
+ :param batch_size: The number of records to insert in each batch (default is 200).
54
+ :param max_workers: The maximum number of threads to spawn for concurrent execution.
55
+ :param api_type: API type to use ('enterprise' or 'tooling').
56
+ :return: JSON response from the insert request or None on failure.
57
+ """
58
+ endpoint = "/services/Soap/"
59
+ if api_type == "enterprise":
60
+ endpoint += f"c/{self.api_version}"
61
+ elif api_type == "tooling":
62
+ endpoint += f"T/{self.api_version}"
63
+ else:
64
+ logger.error(
65
+ "Invalid API type: %s. Must be one of: 'enterprise', 'tooling'.",
66
+ api_type,
67
+ )
68
+ return None
69
+
70
+ # Handle API versioning in the endpoint
71
+ endpoint = endpoint.replace("/v", "/")
72
+
73
+ if isinstance(insert_list, dict):
74
+ insert_list = [insert_list]
75
+
76
+ chunks = [
77
+ insert_list[i : i + batch_size]
78
+ for i in range(0, len(insert_list), batch_size)
79
+ ]
80
+
81
+ def insert_chunk(chunk: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
82
+ """Insert a chunk of records using SOAP API."""
83
+ try:
84
+ # Get the access token for the SOAP header
85
+ access_token = self.http_client.auth_manager.access_token
86
+ if not access_token:
87
+ logger.error("No access token available for SOAP request")
88
+ return None
89
+
90
+ header = self.soap_client.generate_soap_header(access_token)
91
+ body = self.soap_client.generate_soap_body(
92
+ sobject=sobject, method="create", data=chunk
93
+ )
94
+ envelope = self.soap_client.generate_soap_envelope(
95
+ header=header, body=body, api_type=api_type
96
+ )
97
+
98
+ soap_headers = self.http_client.get_common_headers().copy()
99
+ soap_headers["Content-Type"] = "text/xml; charset=UTF-8"
100
+ soap_headers["SOAPAction"] = '""'
101
+
102
+ logger.trace("SOAP request envelope: %s", envelope)
103
+ logger.trace("SOAP request headers: %s", soap_headers)
104
+
105
+ status_code, resp_data = self.http_client.send_request(
106
+ method="POST",
107
+ endpoint=endpoint,
108
+ headers=soap_headers,
109
+ body=envelope,
110
+ )
111
+
112
+ if status_code == 200:
113
+ logger.debug("Insert API request successful.")
114
+ logger.trace("Insert API response: %s", resp_data)
115
+ result = self.soap_client.extract_soap_result_fields(resp_data)
116
+ if result:
117
+ return result
118
+ logger.error("Failed to extract fields from SOAP response.")
119
+ else:
120
+ logger.error("Insert API request failed: %s", status_code)
121
+ logger.debug("Response body: %s", resp_data)
122
+ return None
123
+
124
+ except Exception as e:
125
+ logger.exception("Exception during insert chunk: %s", e)
126
+ return None
127
+
128
+ results = []
129
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
130
+ futures = [executor.submit(insert_chunk, chunk) for chunk in chunks]
131
+ for future in as_completed(futures):
132
+ result = future.result()
133
+ if result:
134
+ results.append(result)
135
+
136
+ combined_response = [
137
+ item
138
+ for result in results
139
+ for item in (result if isinstance(result, list) else [result])
140
+ if isinstance(result, (dict, list))
141
+ ]
142
+
143
+ return combined_response or None
144
+
145
+ def cupdate(
146
+ self, update_dict: Dict[str, Any], batch_size: int = 25, max_workers: int = None
147
+ ) -> Optional[Dict[str, Any]]:
148
+ """
149
+ Execute the Composite Update API to update multiple records.
150
+
151
+ :param update_dict: A dictionary of keys of records to be updated, and a dictionary
152
+ of field-value pairs to be updated, with a special key '_' overriding
153
+ the sObject type which is otherwise inferred from the key.
154
+ :param batch_size: The number of records to update in each batch (default is 25).
155
+ :param max_workers: The maximum number of threads to spawn for concurrent execution.
156
+ :return: JSON response from the update request or None on failure.
157
+ """
158
+ allOrNone = False
159
+ endpoint = f"/services/data/{self.api_version}/composite"
160
+
161
+ compositeRequest_payload = []
162
+ sobject_prefixes = {}
163
+
164
+ for key, record in update_dict.items():
165
+ record_copy = record.copy()
166
+ sobject = record_copy.pop("_", None)
167
+ if not sobject and not sobject_prefixes:
168
+ # Get sObject prefixes from query client if available
169
+ # For now, we'll require the sobject to be specified or use key prefix
170
+ sobject_prefixes = self._get_sobject_prefixes()
171
+
172
+ if not sobject:
173
+ sobject = str(sobject_prefixes.get(str(key[:3]), None))
174
+
175
+ compositeRequest_payload.append(
176
+ {
177
+ "method": "PATCH",
178
+ "url": f"/services/data/{self.api_version}/sobjects/{sobject}/{key}",
179
+ "referenceId": key,
180
+ "body": record_copy,
181
+ }
182
+ )
183
+
184
+ chunks = [
185
+ compositeRequest_payload[i : i + batch_size]
186
+ for i in range(0, len(compositeRequest_payload), batch_size)
187
+ ]
188
+
189
+ def update_chunk(chunk: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
190
+ """Update a chunk of records using Composite API."""
191
+ try:
192
+ payload = {"allOrNone": bool(allOrNone), "compositeRequest": chunk}
193
+
194
+ status_code, resp_data = self.http_client.send_request(
195
+ method="POST",
196
+ endpoint=endpoint,
197
+ headers=self.http_client.get_common_headers(),
198
+ body=json.dumps(payload),
199
+ )
200
+
201
+ if status_code == 200:
202
+ logger.debug("Composite update API response without errors.")
203
+ return json.loads(resp_data)
204
+ else:
205
+ logger.error("Composite update API request failed: %s", status_code)
206
+ logger.debug("Response body: %s", resp_data)
207
+ return None
208
+
209
+ except Exception as e:
210
+ logger.exception("Exception during update chunk: %s", e)
211
+ return None
212
+
213
+ results = []
214
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
215
+ futures = [executor.submit(update_chunk, chunk) for chunk in chunks]
216
+ for future in as_completed(futures):
217
+ result = future.result()
218
+ if result:
219
+ results.append(result)
220
+
221
+ combined_response = [
222
+ item
223
+ for result in results
224
+ for item in (result if isinstance(result, list) else [result])
225
+ if isinstance(result, (dict, list))
226
+ ]
227
+
228
+ return combined_response or None
229
+
230
+ def cdelete(
231
+ self, ids: Iterable[str], batch_size: int = 200, max_workers: int = None
232
+ ) -> Optional[Dict[str, Any]]:
233
+ """
234
+ Execute the Collections Delete API to delete multiple records using multithreading.
235
+
236
+ :param ids: A list of record IDs to delete.
237
+ :param batch_size: The number of records to delete in each batch (default is 200).
238
+ :param max_workers: The maximum number of threads to spawn for concurrent execution.
239
+ :return: Combined JSON response from all batches or None on complete failure.
240
+ """
241
+ ids = list(ids)
242
+ chunks = [ids[i : i + batch_size] for i in range(0, len(ids), batch_size)]
243
+
244
+ def delete_chunk(chunk: List[str]) -> Optional[Dict[str, Any]]:
245
+ """Delete a chunk of records using Collections API."""
246
+ try:
247
+ endpoint = f"/services/data/{self.api_version}/composite/sobjects?ids={','.join(chunk)}&allOrNone=false"
248
+ headers = self.http_client.get_common_headers()
249
+
250
+ status_code, resp_data = self.http_client.send_request(
251
+ method="DELETE",
252
+ endpoint=endpoint,
253
+ headers=headers,
254
+ )
255
+
256
+ if status_code == 200:
257
+ logger.debug("Collections delete API response without errors.")
258
+ return json.loads(resp_data)
259
+ else:
260
+ logger.error(
261
+ "Collections delete API request failed: %s", status_code
262
+ )
263
+ logger.debug("Response body: %s", resp_data)
264
+ return None
265
+
266
+ except Exception as e:
267
+ logger.exception("Exception during delete chunk: %s", e)
268
+ return None
269
+
270
+ results = []
271
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
272
+ futures = [executor.submit(delete_chunk, chunk) for chunk in chunks]
273
+ for future in as_completed(futures):
274
+ result = future.result()
275
+ if result:
276
+ results.append(result)
277
+
278
+ combined_response = [
279
+ item
280
+ for result in results
281
+ for item in (result if isinstance(result, list) else [result])
282
+ if isinstance(result, (dict, list))
283
+ ]
284
+ return combined_response or None
285
+
286
+ def read_static_resource_name(
287
+ self, resource_name: str, namespace: Optional[str] = None
288
+ ) -> Optional[str]:
289
+ """
290
+ Read a static resource for a given name from the Salesforce instance.
291
+
292
+ :param resource_name: Name of the static resource to read.
293
+ :param namespace: Namespace of the static resource to read (default is None).
294
+ :return: Static resource content or None on failure.
295
+ """
296
+ _safe_resource_name = quote(resource_name, safe="")
297
+ query = f"SELECT Id FROM StaticResource WHERE Name = '{_safe_resource_name}'"
298
+ if namespace:
299
+ namespace = quote(namespace, safe="")
300
+ query += f" AND NamespacePrefix = '{namespace}'"
301
+ query += " LIMIT 1"
302
+
303
+ # Make the query directly via HTTP client
304
+ query_endpoint = f"/services/data/{self.api_version}/query?q={quote(query)}"
305
+ status_code, response_data = self.http_client.send_authenticated_request(
306
+ method="GET",
307
+ endpoint=query_endpoint,
308
+ )
309
+
310
+ if status_code == 200:
311
+ _static_resource_id_response = json.loads(response_data)
312
+ else:
313
+ logger.error("Failed to query for static resource: %s", status_code)
314
+ _static_resource_id_response = None
315
+
316
+ if (
317
+ _static_resource_id_response
318
+ and _static_resource_id_response.get("records")
319
+ and len(_static_resource_id_response["records"]) > 0
320
+ ):
321
+ return self.read_static_resource_id(
322
+ _static_resource_id_response["records"][0].get("Id")
323
+ )
324
+
325
+ logger.error(f"Failed to read static resource with name {_safe_resource_name}.")
326
+ return None
327
+
328
+ def read_static_resource_id(self, resource_id: str) -> Optional[str]:
329
+ """
330
+ Read a static resource for a given ID from the Salesforce instance.
331
+
332
+ :param resource_id: ID of the static resource to read.
333
+ :return: Static resource content or None on failure.
334
+ """
335
+ endpoint = f"/services/data/{self.api_version}/sobjects/StaticResource/{resource_id}/Body"
336
+
337
+ # Use a special method for binary content
338
+ status, data = self.http_client.send_authenticated_request("GET", endpoint)
339
+
340
+ if status == 200:
341
+ logger.debug("Static resource fetched successfully.")
342
+ # Try to decode as UTF-8, but handle binary content gracefully
343
+ try:
344
+ return data.decode("utf-8") if isinstance(data, bytes) else data
345
+ except UnicodeDecodeError:
346
+ # For binary content, return base64 encoded string
347
+ import base64
348
+
349
+ return (
350
+ base64.b64encode(data).decode("utf-8")
351
+ if isinstance(data, bytes)
352
+ else data
353
+ )
354
+
355
+ logger.error("Failed to fetch static resource: %s", status)
356
+ return None
357
+
358
+ def update_static_resource_name(
359
+ self, resource_name: str, data: str, namespace: Optional[str] = None
360
+ ) -> Optional[Dict[str, Any]]:
361
+ """
362
+ Update a static resource for a given name in the Salesforce instance.
363
+
364
+ :param resource_name: Name of the static resource to update.
365
+ :param data: Content to update the static resource with.
366
+ :param namespace: Optional namespace to search for the static resource.
367
+ :return: Static resource content or None on failure.
368
+ """
369
+ safe_resource_name = quote(resource_name, safe="")
370
+ query = f"SELECT Id FROM StaticResource WHERE Name = '{safe_resource_name}'"
371
+ if namespace:
372
+ namespace = quote(namespace, safe="")
373
+ query += f" AND NamespacePrefix = '{namespace}'"
374
+ query += " LIMIT 1"
375
+
376
+ # We need to use the HTTP client to make a query request
377
+ query_endpoint = f"/services/data/{self.api_version}/query?q={quote(query)}"
378
+ status_code, response_data = self.http_client.send_authenticated_request(
379
+ method="GET",
380
+ endpoint=query_endpoint,
381
+ )
382
+
383
+ if status_code == 200:
384
+ static_resource_id_response = json.loads(response_data)
385
+ else:
386
+ logger.error("Failed to query for static resource: %s", status_code)
387
+ static_resource_id_response = None
388
+
389
+ if (
390
+ static_resource_id_response
391
+ and static_resource_id_response.get("records")
392
+ and len(static_resource_id_response["records"]) > 0
393
+ ):
394
+ return self.update_static_resource_id(
395
+ static_resource_id_response["records"][0].get("Id"), data
396
+ )
397
+
398
+ logger.error(
399
+ f"Failed to update static resource with name {safe_resource_name}."
400
+ )
401
+ return None
402
+
403
+ def update_static_resource_id(
404
+ self, resource_id: str, data: str
405
+ ) -> Optional[Dict[str, Any]]:
406
+ """
407
+ Replace the content of a static resource in the Salesforce instance by ID.
408
+
409
+ :param resource_id: ID of the static resource to update.
410
+ :param data: Content to update the static resource with.
411
+ :return: Parsed JSON response or None on failure.
412
+ """
413
+ payload = {"Body": base64.b64encode(data.encode("utf-8")).decode("utf-8")}
414
+
415
+ endpoint = (
416
+ f"/services/data/{self.api_version}/sobjects/StaticResource/{resource_id}"
417
+ )
418
+ status_code, response_data = self.http_client.send_authenticated_request(
419
+ method="PATCH",
420
+ endpoint=endpoint,
421
+ body=json.dumps(payload),
422
+ )
423
+
424
+ if status_code in [200, 204]: # 204 No Content is also success for PATCH
425
+ logger.debug("Patch Static Resource request successful.")
426
+ if response_data and response_data.strip():
427
+ return json.loads(response_data)
428
+ else:
429
+ return {"success": True} # Return success indicator for 204 responses
430
+
431
+ logger.error(
432
+ "Patch Static Resource API request failed: %s",
433
+ status_code,
434
+ )
435
+ logger.error("Response body: %s", response_data)
436
+
437
+ return None
438
+
439
+ def _get_sobject_prefixes(self) -> Dict[str, str]:
440
+ """
441
+ Get sObject prefixes mapping. This is a placeholder that would normally
442
+ call the query client to get prefixes.
443
+
444
+ :return: Dictionary mapping key prefixes to sObject names
445
+ """
446
+ return QueryClient.get_sobject_prefixes(self)
sfq/exceptions.py ADDED
@@ -0,0 +1,54 @@
1
+ """
2
+ Custom exception classes for the SFQ library.
3
+
4
+ This module defines the exception hierarchy used throughout the SFQ library
5
+ to provide consistent error handling and meaningful error messages.
6
+ """
7
+
8
+
9
+ class SFQException(Exception):
10
+ """Base exception for SFQ library."""
11
+
12
+ pass
13
+
14
+
15
+ class AuthenticationError(SFQException):
16
+ """Raised when authentication fails."""
17
+
18
+ pass
19
+
20
+
21
+ class APIError(SFQException):
22
+ """Raised when API requests fail."""
23
+
24
+ pass
25
+
26
+
27
+ class QueryError(APIError):
28
+ """Raised when query operations fail."""
29
+
30
+ pass
31
+
32
+
33
+ class CRUDError(APIError):
34
+ """Raised when CRUD operations fail."""
35
+
36
+ pass
37
+
38
+
39
+ class SOAPError(APIError):
40
+ """Raised when SOAP operations fail."""
41
+
42
+ pass
43
+
44
+
45
+ class HTTPError(SFQException):
46
+ """Raised when HTTP communication fails."""
47
+
48
+ pass
49
+
50
+
51
+ class ConfigurationError(SFQException):
52
+ """Raised when configuration is invalid."""
53
+
54
+ pass