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/query.py ADDED
@@ -0,0 +1,398 @@
1
+ """
2
+ Query client module for the SFQ library.
3
+
4
+ This module handles SOQL query operations, query result pagination,
5
+ composite query operations with threading, and sObject prefix management.
6
+ """
7
+
8
+ import json
9
+ from collections import OrderedDict
10
+ from concurrent.futures import ThreadPoolExecutor, as_completed
11
+ from typing import Any, Dict, List, Literal, Optional
12
+ from urllib.parse import quote
13
+
14
+ from .utils import get_logger
15
+
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ class QueryClient:
20
+ """
21
+ Manages SOQL query operations for Salesforce API communication.
22
+
23
+ This class encapsulates all query-related functionality including
24
+ standard SOQL queries, tooling API queries, composite batch queries,
25
+ query result pagination, and sObject prefix management.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ http_client: "HTTPClient", # Forward reference to avoid circular import # noqa: F821
31
+ api_version: str = "v64.0",
32
+ ) -> None:
33
+ """
34
+ Initialize the QueryClient with HTTP client and API version.
35
+
36
+ :param http_client: HTTPClient instance for making requests
37
+ :param api_version: Salesforce API version to use
38
+ """
39
+ self.http_client = http_client
40
+ self.api_version = api_version
41
+
42
+ def query(self, query: str, tooling: bool = False) -> Optional[Dict[str, Any]]:
43
+ """
44
+ Execute a SOQL query using the REST or Tooling API.
45
+
46
+ :param query: The SOQL query string
47
+ :param tooling: If True, use the Tooling API endpoint
48
+ :return: Parsed JSON response with paginated results or None on failure
49
+ """
50
+ endpoint = f"/services/data/{self.api_version}/"
51
+ endpoint += "tooling/query" if tooling else "query"
52
+ query_string = f"?q={quote(query)}"
53
+ endpoint += query_string
54
+
55
+ try:
56
+ status_code, data = self.http_client.send_authenticated_request(
57
+ method="GET",
58
+ endpoint=endpoint,
59
+ )
60
+
61
+ if status_code == 200:
62
+ result = json.loads(data)
63
+ # Paginate the results to get all records
64
+ paginated = self._paginate_query_result(result)
65
+
66
+ logger.debug(
67
+ "Query successful, returned %s records: %r",
68
+ paginated.get("totalSize"),
69
+ query,
70
+ )
71
+ logger.trace("Query full response: %s", paginated)
72
+ return paginated
73
+ else:
74
+ logger.debug("Query failed: %r", query)
75
+ logger.error(
76
+ "Query failed with HTTP status %s",
77
+ status_code,
78
+ )
79
+ logger.debug("Query response: %s", data)
80
+
81
+ except Exception as err:
82
+ logger.exception("Exception during query: %s", err)
83
+
84
+ return None
85
+
86
+ def tooling_query(self, query: str) -> Optional[Dict[str, Any]]:
87
+ """
88
+ Execute a SOQL query using the Tooling API.
89
+
90
+ This is a convenience method that calls query() with tooling=True.
91
+
92
+ :param query: The SOQL query string
93
+ :return: Parsed JSON response with paginated results or None on failure
94
+ """
95
+ return self.query(query, tooling=True)
96
+
97
+ def _paginate_query_result(self, initial_result: dict) -> dict:
98
+ """
99
+ Helper to paginate Salesforce query results.
100
+
101
+ This method automatically follows nextRecordsUrl to retrieve all
102
+ records from a query result, combining them into a single response.
103
+
104
+ :param initial_result: The initial query result from Salesforce
105
+ :return: Dictionary with all records combined and pagination info updated
106
+ """
107
+ records = list(initial_result.get("records", []))
108
+ done = initial_result.get("done", True)
109
+ next_url = initial_result.get("nextRecordsUrl")
110
+ total_size = initial_result.get("totalSize", len(records))
111
+
112
+ # Get headers for subsequent requests
113
+ headers = self.http_client.get_common_headers(include_auth=True)
114
+
115
+ while not done and next_url:
116
+ status_code, data = self.http_client.send_request(
117
+ method="GET",
118
+ endpoint=next_url,
119
+ headers=headers,
120
+ )
121
+
122
+ if status_code == 200:
123
+ next_result = json.loads(data)
124
+ records.extend(next_result.get("records", []))
125
+ done = next_result.get("done", True)
126
+ next_url = next_result.get("nextRecordsUrl")
127
+ total_size = next_result.get("totalSize", total_size)
128
+ else:
129
+ logger.error("Failed to fetch next records: %s", data)
130
+ break
131
+
132
+ # Create the paginated result
133
+ paginated = dict(initial_result)
134
+ paginated["records"] = records
135
+ paginated["done"] = done
136
+ paginated["totalSize"] = total_size
137
+
138
+ # Remove nextRecordsUrl since we've fetched all records
139
+ if "nextRecordsUrl" in paginated:
140
+ del paginated["nextRecordsUrl"]
141
+
142
+ return paginated
143
+
144
+ def cquery(
145
+ self,
146
+ query_dict: Dict[str, str],
147
+ batch_size: int = 25,
148
+ max_workers: Optional[int] = None,
149
+ ) -> Optional[Dict[str, Any]]:
150
+ """
151
+ Execute multiple SOQL queries using the Composite Batch API with threading.
152
+
153
+ This method reduces network overhead by batching multiple queries into
154
+ composite API calls and using threading for concurrent execution.
155
+ Each query (subrequest) is counted as a unique API request against
156
+ Salesforce governance limits.
157
+
158
+ :param query_dict: Dictionary of SOQL queries with keys as logical names (referenceId) and values as SOQL queries
159
+ :param batch_size: Number of queries to include in each batch (default is 25, max is 25)
160
+ :param max_workers: Maximum number of threads for concurrent execution (default is None)
161
+ :return: Dictionary mapping the original keys to their corresponding batch response or None on failure
162
+ """
163
+ if not query_dict:
164
+ logger.warning("No queries to execute.")
165
+ return None
166
+
167
+ def _execute_batch(
168
+ batch_keys: List[str], batch_queries: List[str]
169
+ ) -> Dict[str, Any]:
170
+ """
171
+ Execute a single batch of queries using the Composite Batch API.
172
+
173
+ :param batch_keys: List of query keys for this batch
174
+ :param batch_queries: List of query strings for this batch
175
+ :return: Dictionary mapping keys to their results
176
+ """
177
+ endpoint = f"/services/data/{self.api_version}/composite/batch"
178
+
179
+ payload = {
180
+ "haltOnError": False,
181
+ "batchRequests": [
182
+ {
183
+ "method": "GET",
184
+ "url": f"/services/data/{self.api_version}/query?q={quote(query)}",
185
+ }
186
+ for query in batch_queries
187
+ ],
188
+ }
189
+
190
+ status_code, data = self.http_client.send_authenticated_request(
191
+ method="POST",
192
+ endpoint=endpoint,
193
+ body=json.dumps(payload),
194
+ )
195
+
196
+ batch_results = {}
197
+ if status_code == 200:
198
+ logger.debug("Composite query successful.")
199
+ logger.trace("Composite query full response: %s", data)
200
+
201
+ results = json.loads(data).get("results", [])
202
+ for i, result in enumerate(results):
203
+ key = batch_keys[i]
204
+ if result.get("statusCode") == 200 and "result" in result:
205
+ # Paginate individual query results
206
+ paginated = self._paginate_query_result(result["result"])
207
+ batch_results[key] = paginated
208
+ else:
209
+ logger.error("Query failed for key %s: %s", key, result)
210
+ batch_results[key] = result
211
+ else:
212
+ logger.error(
213
+ "Composite query failed with HTTP status %s (%s)",
214
+ status_code,
215
+ data,
216
+ )
217
+ # Return error for all queries in this batch
218
+ for key in batch_keys:
219
+ batch_results[key] = data
220
+ logger.trace("Composite query response: %s", data)
221
+
222
+ return batch_results
223
+
224
+ # Prepare batches
225
+ keys = list(query_dict.keys())
226
+ results_dict = OrderedDict()
227
+
228
+ # Execute batches with threading
229
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
230
+ futures = []
231
+
232
+ # Create batches of queries
233
+ for i in range(0, len(keys), batch_size):
234
+ batch_keys = keys[i : i + batch_size]
235
+ batch_queries = [query_dict[key] for key in batch_keys]
236
+ futures.append(
237
+ executor.submit(_execute_batch, batch_keys, batch_queries)
238
+ )
239
+
240
+ # Collect results as they complete
241
+ for future in as_completed(futures):
242
+ results_dict.update(future.result())
243
+
244
+ logger.trace("Composite query results: %s", results_dict)
245
+ return results_dict
246
+
247
+ def get_sobject_prefixes(
248
+ self, key_type: Literal["id", "name"] = "id"
249
+ ) -> Optional[Dict[str, str]]:
250
+ """
251
+ Fetch all key prefixes from the Salesforce instance and map them to sObject names or vice versa.
252
+
253
+ This method retrieves the sObject metadata to build a mapping between
254
+ 3-character key prefixes (like "001" for Account) and sObject API names.
255
+
256
+ :param key_type: The type of key to return. Either 'id' (prefix->name) or 'name' (name->prefix)
257
+ :return: Dictionary mapping key prefixes to sObject names (or vice versa) or None on failure
258
+ """
259
+ valid_key_types = {"id", "name"}
260
+ if key_type not in valid_key_types:
261
+ logger.error(
262
+ "Invalid key type: %s, must be one of: %s",
263
+ key_type,
264
+ ", ".join(valid_key_types),
265
+ )
266
+ return None
267
+
268
+ endpoint = f"/services/data/{self.api_version}/sobjects/"
269
+
270
+ try:
271
+ logger.trace("Request endpoint: %s", endpoint)
272
+
273
+ status_code, data = self.http_client.send_authenticated_request(
274
+ method="GET",
275
+ endpoint=endpoint,
276
+ )
277
+
278
+ if status_code == 200:
279
+ logger.debug("Key prefixes API request successful.")
280
+ logger.trace("Response body: %s", data)
281
+
282
+ prefixes = {}
283
+ sobjects_data = json.loads(data)
284
+
285
+ for sobject in sobjects_data.get("sobjects", []):
286
+ key_prefix = sobject.get("keyPrefix")
287
+ name = sobject.get("name")
288
+
289
+ # Skip sObjects without key prefix or name
290
+ if not key_prefix or not name:
291
+ continue
292
+
293
+ if key_type == "id":
294
+ prefixes[key_prefix] = name
295
+ elif key_type == "name":
296
+ prefixes[name] = key_prefix
297
+
298
+ logger.debug("Key prefixes: %s", prefixes)
299
+ return prefixes
300
+
301
+ logger.error(
302
+ "Key prefixes API request failed: %s",
303
+ status_code,
304
+ )
305
+ logger.debug("Response body: %s", data)
306
+
307
+ except Exception as err:
308
+ logger.exception("Exception during key prefixes API request: %s", err)
309
+
310
+ return None
311
+
312
+ def get_sobject_name_from_id(self, record_id: str) -> Optional[str]:
313
+ """
314
+ Get the sObject name from a record ID using the key prefix.
315
+
316
+ This is a convenience method that extracts the 3-character prefix
317
+ from a record ID and looks up the corresponding sObject name.
318
+
319
+ :param record_id: The Salesforce record ID (15 or 18 characters)
320
+ :return: sObject API name or None if not found
321
+ """
322
+ if not record_id or len(record_id) < 3:
323
+ logger.error("Invalid record ID: %s", record_id)
324
+ return None
325
+
326
+ # Extract the 3-character prefix
327
+ prefix = record_id[:3]
328
+
329
+ # Get the prefix mapping
330
+ prefixes = self.get_sobject_prefixes(key_type="id")
331
+ if not prefixes:
332
+ return None
333
+
334
+ return prefixes.get(prefix)
335
+
336
+ def get_key_prefix_for_sobject(self, sobject_name: str) -> Optional[str]:
337
+ """
338
+ Get the 3-character key prefix for a given sObject name.
339
+
340
+ :param sobject_name: The sObject API name (e.g., "Account", "Contact")
341
+ :return: 3-character key prefix or None if not found
342
+ """
343
+ if not sobject_name:
344
+ logger.error("sObject name cannot be empty")
345
+ return None
346
+
347
+ # Get the name-to-prefix mapping
348
+ prefixes = self.get_sobject_prefixes(key_type="name")
349
+ if not prefixes:
350
+ return None
351
+
352
+ return prefixes.get(sobject_name)
353
+
354
+ def validate_query_syntax(self, query: str) -> bool:
355
+ """
356
+ Perform basic validation of SOQL query syntax.
357
+
358
+ This method performs simple checks to catch common syntax errors
359
+ before sending the query to Salesforce.
360
+
361
+ :param query: The SOQL query string to validate
362
+ :return: True if basic validation passes, False otherwise
363
+ """
364
+ if not query or not query.strip():
365
+ logger.error("Query cannot be empty")
366
+ return False
367
+
368
+ query_upper = query.upper().strip()
369
+
370
+ # Check for required SELECT keyword
371
+ if not query_upper.startswith("SELECT"):
372
+ logger.error("Query must start with SELECT")
373
+ return False
374
+
375
+ # Check for required FROM keyword
376
+ if " FROM " not in query_upper:
377
+ logger.error("Query must contain FROM clause")
378
+ return False
379
+
380
+ # Check for balanced parentheses
381
+ if query.count("(") != query.count(")"):
382
+ logger.error("Unbalanced parentheses in query")
383
+ return False
384
+
385
+ # Check for balanced quotes
386
+ single_quotes = query.count("'")
387
+ if single_quotes % 2 != 0:
388
+ logger.error("Unbalanced single quotes in query")
389
+ return False
390
+
391
+ return True
392
+
393
+ def __repr__(self) -> str:
394
+ """String representation of QueryClient for debugging."""
395
+ return (
396
+ f"QueryClient(api_version='{self.api_version}', "
397
+ f"http_client={type(self.http_client).__name__})"
398
+ )
sfq/soap.py ADDED
@@ -0,0 +1,181 @@
1
+ """
2
+ SOAP client module for Salesforce API operations.
3
+
4
+ This module provides SOAP envelope generation, XML parsing, and result field extraction
5
+ functionality for interacting with Salesforce's SOAP APIs (Enterprise and Tooling).
6
+ """
7
+
8
+ import xml.etree.ElementTree as ET
9
+ from typing import Any, Dict, List, Optional, Union
10
+
11
+ from .utils import get_logger
12
+
13
+ logger = get_logger("sfq.soap")
14
+
15
+
16
+ class SOAPClient:
17
+ """
18
+ SOAP client for Salesforce API operations.
19
+
20
+ Handles SOAP envelope generation, XML parsing, and result field extraction
21
+ for both Enterprise and Tooling APIs.
22
+ """
23
+
24
+ def __init__(self, http_client, api_version: str = "v64.0"):
25
+ """
26
+ Initialize the SOAP client.
27
+
28
+ :param http_client: HTTPClient instance for making requests
29
+ :param api_version: Salesforce API version (e.g., "v64.0")
30
+ """
31
+ self.http_client = http_client
32
+ self.api_version = api_version
33
+
34
+ def generate_soap_envelope(self, header: str, body: str, api_type: str) -> str:
35
+ """
36
+ Generate a full SOAP envelope with all required namespaces for Salesforce API.
37
+
38
+ :param header: SOAP header content
39
+ :param body: SOAP body content
40
+ :param api_type: API type - either "enterprise" or "tooling"
41
+ :return: Complete SOAP envelope as XML string
42
+ :raises ValueError: If api_type is not "enterprise" or "tooling"
43
+ """
44
+ if api_type == "enterprise":
45
+ return (
46
+ '<?xml version="1.0" encoding="UTF-8"?>'
47
+ "<soapenv:Envelope "
48
+ 'xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" '
49
+ 'xmlns:xsd="http://www.w3.org/2001/XMLSchema" '
50
+ 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
51
+ 'xmlns="urn:enterprise.soap.sforce.com" '
52
+ 'xmlns:sf="urn:sobject.enterprise.soap.sforce.com">'
53
+ f"{header}{body}"
54
+ "</soapenv:Envelope>"
55
+ )
56
+ elif api_type == "tooling":
57
+ return (
58
+ '<?xml version="1.0" encoding="UTF-8"?>'
59
+ "<soapenv:Envelope "
60
+ 'xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" '
61
+ 'xmlns:xsd="http://www.w3.org/2001/XMLSchema" '
62
+ 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
63
+ 'xmlns="urn:tooling.soap.sforce.com" '
64
+ 'xmlns:mns="urn:metadata.tooling.soap.sforce.com" '
65
+ 'xmlns:sf="urn:sobject.tooling.soap.sforce.com">'
66
+ f"{header}{body}"
67
+ "</soapenv:Envelope>"
68
+ )
69
+ else:
70
+ raise ValueError(
71
+ f"Unsupported API type: {api_type}. Must be 'enterprise' or 'tooling'."
72
+ )
73
+
74
+ def generate_soap_header(self, session_id: str) -> str:
75
+ """
76
+ Generate the SOAP header for Salesforce API requests.
77
+
78
+ :param session_id: OAuth access token to use as session ID
79
+ :return: SOAP header XML string
80
+ """
81
+ return f"<soapenv:Header><SessionHeader><sessionId>{session_id}</sessionId></SessionHeader></soapenv:Header>"
82
+
83
+ def generate_soap_body(
84
+ self,
85
+ sobject: str,
86
+ method: str,
87
+ data: Union[Dict[str, Any], List[Dict[str, Any]]],
88
+ ) -> str:
89
+ """
90
+ Generate a SOAP request body for one or more records.
91
+
92
+ :param sobject: Salesforce object type (e.g., "Account", "Contact")
93
+ :param method: SOAP method name (e.g., "create", "update")
94
+ :param data: Single record dict or list of record dicts
95
+ :return: SOAP body XML string
96
+ """
97
+ # Accept both a single dict and a list of dicts
98
+ if isinstance(data, dict):
99
+ records = [data]
100
+ else:
101
+ records = data
102
+
103
+ sobjects = "".join(
104
+ f'<sObjects xsi:type="{sobject}">'
105
+ + "".join(f"<{k}>{v}</{k}>" for k, v in record.items())
106
+ + "</sObjects>"
107
+ for record in records
108
+ )
109
+ return f"<soapenv:Body><{method}>{sobjects}</{method}></soapenv:Body>"
110
+
111
+ def extract_soap_result_fields(
112
+ self, xml_string: str
113
+ ) -> Optional[Union[Dict[str, Any], List[Dict[str, Any]]]]:
114
+ """
115
+ Parse SOAP XML response and extract all child fields from <result> elements as dict(s).
116
+
117
+ :param xml_string: SOAP response XML string
118
+ :return: Single dict for one result, list of dicts for multiple results, or None on parse error
119
+ """
120
+
121
+ def strip_namespace(tag):
122
+ """Remove XML namespace from tag name."""
123
+ return tag.split("}", 1)[-1] if "}" in tag else tag
124
+
125
+ try:
126
+ root = ET.fromstring(xml_string)
127
+ results = []
128
+
129
+ # Find all elements that end with "result" (handles namespaced tags)
130
+ for result in root.iter():
131
+ if result.tag.endswith("result"):
132
+ result_dict = {}
133
+ for child in result:
134
+ result_dict[strip_namespace(child.tag)] = child.text
135
+ results.append(result_dict)
136
+
137
+ if not results:
138
+ return None
139
+ if len(results) == 1:
140
+ return results[0]
141
+ return results
142
+
143
+ except ET.ParseError as e:
144
+ logger.error("Failed to parse SOAP XML: %s", e)
145
+ return None
146
+
147
+ def xml_to_dict(self, xml_string: str) -> Optional[Dict[str, Any]]:
148
+ """
149
+ Convert an XML string to a JSON-like dictionary.
150
+
151
+ :param xml_string: The XML string to convert
152
+ :return: A dictionary representation of the XML or None on failure
153
+ """
154
+ try:
155
+ root = ET.fromstring(xml_string)
156
+ return self._xml_element_to_dict(root)
157
+ except ET.ParseError as e:
158
+ logger.error("Failed to parse XML: %s", e)
159
+ return None
160
+
161
+ def _xml_element_to_dict(self, element: ET.Element) -> Union[str, Dict[str, Any]]:
162
+ """
163
+ Recursively convert an XML Element to a dictionary.
164
+
165
+ :param element: The XML Element to convert
166
+ :return: A dictionary representation of the XML Element or text content
167
+ """
168
+ if len(element) == 0:
169
+ return element.text or ""
170
+
171
+ result = {}
172
+ for child in element:
173
+ child_dict = self._xml_element_to_dict(child)
174
+ if child.tag not in result:
175
+ result[child.tag] = child_dict
176
+ else:
177
+ # Handle multiple children with same tag name
178
+ if not isinstance(result[child.tag], list):
179
+ result[child.tag] = [result[child.tag]]
180
+ result[child.tag].append(child_dict)
181
+ return result