cumulusci-plus 5.0.21__py3-none-any.whl → 5.0.35__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.
- cumulusci/__about__.py +1 -1
- cumulusci/cli/logger.py +2 -2
- cumulusci/cli/service.py +20 -0
- cumulusci/cli/task.py +17 -0
- cumulusci/cli/tests/test_error.py +3 -1
- cumulusci/cli/tests/test_flow.py +279 -2
- cumulusci/cli/tests/test_service.py +15 -12
- cumulusci/cli/tests/test_task.py +88 -2
- cumulusci/cli/tests/utils.py +1 -4
- cumulusci/core/config/base_task_flow_config.py +26 -1
- cumulusci/core/config/project_config.py +2 -20
- cumulusci/core/config/tests/test_config_expensive.py +9 -3
- cumulusci/core/config/universal_config.py +3 -4
- cumulusci/core/dependencies/base.py +1 -1
- cumulusci/core/dependencies/dependencies.py +1 -1
- cumulusci/core/dependencies/github.py +1 -2
- cumulusci/core/dependencies/resolvers.py +1 -1
- cumulusci/core/dependencies/tests/test_dependencies.py +1 -1
- cumulusci/core/dependencies/tests/test_resolvers.py +1 -1
- cumulusci/core/flowrunner.py +90 -6
- cumulusci/core/github.py +1 -1
- cumulusci/core/sfdx.py +3 -1
- cumulusci/core/source_transforms/tests/test_transforms.py +1 -1
- cumulusci/core/source_transforms/transforms.py +1 -1
- cumulusci/core/tasks.py +13 -2
- cumulusci/core/tests/test_flowrunner.py +100 -0
- cumulusci/core/tests/test_tasks.py +65 -0
- cumulusci/core/utils.py +3 -1
- cumulusci/core/versions.py +1 -1
- cumulusci/cumulusci.yml +55 -0
- cumulusci/oauth/client.py +1 -1
- cumulusci/plugins/plugin_base.py +5 -3
- cumulusci/robotframework/pageobjects/ObjectManagerPageObject.py +1 -1
- cumulusci/salesforce_api/rest_deploy.py +1 -1
- cumulusci/schema/cumulusci.jsonschema.json +64 -0
- cumulusci/tasks/apex/anon.py +1 -1
- cumulusci/tasks/apex/testrunner.py +416 -142
- cumulusci/tasks/apex/tests/test_apex_tasks.py +917 -1
- cumulusci/tasks/bulkdata/extract.py +0 -1
- cumulusci/tasks/bulkdata/extract_dataset_utils/extract_yml.py +1 -1
- cumulusci/tasks/bulkdata/extract_dataset_utils/synthesize_extract_declarations.py +1 -1
- cumulusci/tasks/bulkdata/extract_dataset_utils/tests/test_extract_yml.py +1 -1
- cumulusci/tasks/bulkdata/generate_and_load_data.py +136 -12
- cumulusci/tasks/bulkdata/mapping_parser.py +139 -44
- cumulusci/tasks/bulkdata/select_utils.py +1 -1
- cumulusci/tasks/bulkdata/snowfakery.py +100 -25
- cumulusci/tasks/bulkdata/tests/test_generate_and_load.py +159 -0
- cumulusci/tasks/bulkdata/tests/test_load.py +0 -2
- cumulusci/tasks/bulkdata/tests/test_mapping_parser.py +763 -1
- cumulusci/tasks/bulkdata/tests/test_select_utils.py +26 -0
- cumulusci/tasks/bulkdata/tests/test_snowfakery.py +133 -0
- cumulusci/tasks/create_package_version.py +190 -16
- cumulusci/tasks/datadictionary.py +1 -1
- cumulusci/tasks/metadata_etl/base.py +7 -3
- cumulusci/tasks/metadata_etl/layouts.py +1 -1
- cumulusci/tasks/metadata_etl/permissions.py +1 -1
- cumulusci/tasks/metadata_etl/remote_site_settings.py +2 -2
- cumulusci/tasks/push/README.md +15 -17
- cumulusci/tasks/release_notes/README.md +13 -13
- cumulusci/tasks/release_notes/generator.py +13 -8
- cumulusci/tasks/robotframework/tests/test_robotframework.py +6 -1
- cumulusci/tasks/salesforce/Deploy.py +53 -2
- cumulusci/tasks/salesforce/SfPackageCommands.py +363 -0
- cumulusci/tasks/salesforce/__init__.py +1 -0
- cumulusci/tasks/salesforce/assign_ps_psg.py +448 -0
- cumulusci/tasks/salesforce/composite.py +1 -1
- cumulusci/tasks/salesforce/custom_settings_wait.py +1 -1
- cumulusci/tasks/salesforce/enable_prediction.py +5 -1
- cumulusci/tasks/salesforce/getPackageVersion.py +89 -0
- cumulusci/tasks/salesforce/sourcetracking.py +1 -1
- cumulusci/tasks/salesforce/tests/test_Deploy.py +316 -1
- cumulusci/tasks/salesforce/tests/test_SfPackageCommands.py +554 -0
- cumulusci/tasks/salesforce/tests/test_assign_ps_psg.py +1055 -0
- cumulusci/tasks/salesforce/tests/test_getPackageVersion.py +651 -0
- cumulusci/tasks/salesforce/tests/test_update_dependencies.py +1 -1
- cumulusci/tasks/salesforce/tests/test_update_external_credential.py +912 -0
- cumulusci/tasks/salesforce/tests/test_update_named_credential.py +1042 -0
- cumulusci/tasks/salesforce/update_dependencies.py +2 -2
- cumulusci/tasks/salesforce/update_external_credential.py +562 -0
- cumulusci/tasks/salesforce/update_named_credential.py +441 -0
- cumulusci/tasks/salesforce/update_profile.py +17 -13
- cumulusci/tasks/salesforce/users/permsets.py +62 -5
- cumulusci/tasks/salesforce/users/tests/test_permsets.py +237 -11
- cumulusci/tasks/sfdmu/__init__.py +0 -0
- cumulusci/tasks/sfdmu/sfdmu.py +363 -0
- cumulusci/tasks/sfdmu/tests/__init__.py +1 -0
- cumulusci/tasks/sfdmu/tests/test_runner.py +212 -0
- cumulusci/tasks/sfdmu/tests/test_sfdmu.py +1012 -0
- cumulusci/tasks/tests/test_create_package_version.py +716 -1
- cumulusci/tasks/tests/test_util.py +42 -0
- cumulusci/tasks/util.py +37 -1
- cumulusci/tasks/utility/copyContents.py +402 -0
- cumulusci/tasks/utility/credentialManager.py +256 -0
- cumulusci/tasks/utility/directoryRecreator.py +30 -0
- cumulusci/tasks/utility/env_management.py +1 -1
- cumulusci/tasks/utility/secretsToEnv.py +135 -0
- cumulusci/tasks/utility/tests/test_copyContents.py +1719 -0
- cumulusci/tasks/utility/tests/test_credentialManager.py +564 -0
- cumulusci/tasks/utility/tests/test_directoryRecreator.py +439 -0
- cumulusci/tasks/utility/tests/test_secretsToEnv.py +1091 -0
- cumulusci/tests/test_integration_infrastructure.py +3 -1
- cumulusci/tests/test_utils.py +70 -6
- cumulusci/utils/__init__.py +54 -9
- cumulusci/utils/classutils.py +5 -2
- cumulusci/utils/http/tests/cassettes/ManualEditTestCompositeParallelSalesforce.test_http_headers.yaml +31 -30
- cumulusci/utils/options.py +23 -1
- cumulusci/utils/parallel/task_worker_queues/parallel_worker.py +1 -1
- cumulusci/utils/yaml/cumulusci_yml.py +7 -3
- cumulusci/utils/yaml/model_parser.py +2 -2
- cumulusci/utils/yaml/tests/test_cumulusci_yml.py +1 -1
- cumulusci/utils/yaml/tests/test_model_parser.py +3 -3
- cumulusci/vcs/base.py +23 -15
- cumulusci/vcs/bootstrap.py +5 -4
- cumulusci/vcs/utils/list_modified_files.py +189 -0
- cumulusci/vcs/utils/tests/test_list_modified_files.py +588 -0
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/METADATA +12 -10
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/RECORD +121 -96
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/WHEEL +0 -0
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/entry_points.txt +0 -0
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/licenses/AUTHORS.rst +0 -0
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/licenses/LICENSE +0 -0
|
@@ -213,7 +213,6 @@ class ExtractData(SqlAlchemyMixin, BaseSalesforceApiTask):
|
|
|
213
213
|
IsPersonAccount_index = columns.index(mapping.fields["IsPersonAccount"])
|
|
214
214
|
|
|
215
215
|
def strip_name_field(record):
|
|
216
|
-
nonlocal Name_index, IsPersonAccount_index
|
|
217
216
|
if record[IsPersonAccount_index].lower() == "true":
|
|
218
217
|
record[Name_index] = ""
|
|
219
218
|
return record
|
|
@@ -7,8 +7,13 @@ from sqlalchemy import MetaData, create_engine
|
|
|
7
7
|
|
|
8
8
|
from cumulusci.core.config import TaskConfig
|
|
9
9
|
from cumulusci.core.exceptions import TaskOptionsError
|
|
10
|
-
from cumulusci.core.utils import import_global
|
|
10
|
+
from cumulusci.core.utils import import_global, process_bool_arg
|
|
11
11
|
from cumulusci.tasks.bulkdata import LoadData
|
|
12
|
+
from cumulusci.tasks.bulkdata.mapping_parser import (
|
|
13
|
+
parse_from_yaml,
|
|
14
|
+
validate_and_inject_mapping,
|
|
15
|
+
)
|
|
16
|
+
from cumulusci.tasks.bulkdata.step import DataOperationType
|
|
12
17
|
from cumulusci.tasks.bulkdata.utils import generate_batches
|
|
13
18
|
from cumulusci.tasks.salesforce import BaseSalesforceApiTask
|
|
14
19
|
|
|
@@ -79,6 +84,10 @@ class GenerateAndLoadData(BaseSalesforceApiTask):
|
|
|
79
84
|
"working_directory": {
|
|
80
85
|
"description": "Store temporary files in working_directory for easier debugging."
|
|
81
86
|
},
|
|
87
|
+
"validate_only": {
|
|
88
|
+
"description": "Boolean: if True, only validate the generated mapping against the org schema without loading data. "
|
|
89
|
+
"Defaults to False."
|
|
90
|
+
},
|
|
82
91
|
**LoadData.task_options,
|
|
83
92
|
}
|
|
84
93
|
task_options["mapping"]["required"] = False
|
|
@@ -114,6 +123,7 @@ class GenerateAndLoadData(BaseSalesforceApiTask):
|
|
|
114
123
|
|
|
115
124
|
self.working_directory = self.options.get("working_directory", None)
|
|
116
125
|
self.database_url = self.options.get("database_url")
|
|
126
|
+
self.validate_only = process_bool_arg(self.options.get("validate_only", False))
|
|
117
127
|
|
|
118
128
|
if self.database_url:
|
|
119
129
|
engine, metadata = self._setup_engine(self.database_url)
|
|
@@ -132,6 +142,16 @@ class GenerateAndLoadData(BaseSalesforceApiTask):
|
|
|
132
142
|
if working_directory:
|
|
133
143
|
tempdir = Path(working_directory)
|
|
134
144
|
tempdir.mkdir(exist_ok=True)
|
|
145
|
+
|
|
146
|
+
# Route to validation flow if validate_only is True
|
|
147
|
+
if self.validate_only:
|
|
148
|
+
return self._run_validation(
|
|
149
|
+
database_url=self.database_url,
|
|
150
|
+
tempdir=self.working_directory or tempdir,
|
|
151
|
+
mapping_file=self.mapping_file,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Normal data generation and loading flow
|
|
135
155
|
if self.batch_size:
|
|
136
156
|
batches = generate_batches(self.num_records, self.batch_size)
|
|
137
157
|
else:
|
|
@@ -186,6 +206,47 @@ class GenerateAndLoadData(BaseSalesforceApiTask):
|
|
|
186
206
|
total_batches: int,
|
|
187
207
|
) -> dict:
|
|
188
208
|
"""Generate a batch in database_url or a tempfile if it isn't specified."""
|
|
209
|
+
# Setup and generate data
|
|
210
|
+
subtask_options = self._setup_and_generate_data(
|
|
211
|
+
database_url=database_url,
|
|
212
|
+
tempdir=tempdir,
|
|
213
|
+
mapping_file=mapping_file,
|
|
214
|
+
num_records=batch_size,
|
|
215
|
+
batch_index=index,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Load the data
|
|
219
|
+
return self._dataload(subtask_options)
|
|
220
|
+
|
|
221
|
+
def _setup_engine(self, database_url):
|
|
222
|
+
"""Set up the database engine"""
|
|
223
|
+
engine = create_engine(database_url)
|
|
224
|
+
|
|
225
|
+
metadata = MetaData(engine)
|
|
226
|
+
metadata.reflect()
|
|
227
|
+
return engine, metadata
|
|
228
|
+
|
|
229
|
+
def _setup_and_generate_data(
|
|
230
|
+
self,
|
|
231
|
+
*,
|
|
232
|
+
database_url: Optional[str],
|
|
233
|
+
tempdir: Union[Path, str, None],
|
|
234
|
+
mapping_file: Union[Path, str, None],
|
|
235
|
+
num_records: Optional[int],
|
|
236
|
+
batch_index: int,
|
|
237
|
+
) -> dict:
|
|
238
|
+
"""Setup database and generate data, returning subtask options with mapping.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
database_url: Database URL or None to create temp SQLite
|
|
242
|
+
tempdir: Temporary directory for generated files
|
|
243
|
+
mapping_file: Path to mapping file or None to generate
|
|
244
|
+
num_records: Number of records to generate
|
|
245
|
+
batch_index: Current batch number
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
dict: subtask_options with mapping file path set
|
|
249
|
+
"""
|
|
189
250
|
if not database_url:
|
|
190
251
|
sqlite_path = Path(tempdir) / "generated_data.db"
|
|
191
252
|
database_url = f"sqlite:///{sqlite_path}"
|
|
@@ -197,28 +258,91 @@ class GenerateAndLoadData(BaseSalesforceApiTask):
|
|
|
197
258
|
"mapping": mapping_file,
|
|
198
259
|
"reset_oids": False,
|
|
199
260
|
"database_url": database_url,
|
|
200
|
-
"num_records":
|
|
201
|
-
"current_batch_number":
|
|
261
|
+
"num_records": num_records,
|
|
262
|
+
"current_batch_number": batch_index,
|
|
202
263
|
"working_directory": tempdir,
|
|
203
264
|
}
|
|
204
265
|
|
|
205
|
-
#
|
|
266
|
+
# Generate mapping file if needed
|
|
206
267
|
if not subtask_options.get("mapping"):
|
|
207
268
|
temp_mapping = Path(tempdir) / "temp_mapping.yml"
|
|
208
269
|
mapping_file = self.options.get("generate_mapping_file", temp_mapping)
|
|
209
270
|
subtask_options["generate_mapping_file"] = mapping_file
|
|
271
|
+
|
|
272
|
+
# Run data generation
|
|
210
273
|
self._datagen(subtask_options)
|
|
274
|
+
|
|
211
275
|
if not subtask_options.get("mapping"):
|
|
212
|
-
subtask_options["mapping"] =
|
|
213
|
-
return self._dataload(subtask_options)
|
|
276
|
+
subtask_options["mapping"] = subtask_options["generate_mapping_file"]
|
|
214
277
|
|
|
215
|
-
|
|
216
|
-
"""Set up the database engine"""
|
|
217
|
-
engine = create_engine(database_url)
|
|
278
|
+
return subtask_options
|
|
218
279
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
280
|
+
def _run_validation(
|
|
281
|
+
self,
|
|
282
|
+
*,
|
|
283
|
+
database_url: Optional[str],
|
|
284
|
+
tempdir: Union[Path, str, None],
|
|
285
|
+
mapping_file: Union[Path, str, None],
|
|
286
|
+
):
|
|
287
|
+
"""Run validation flow: generate data once and validate mapping.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
database_url: Database URL or None to create temp SQLite
|
|
291
|
+
tempdir: Temporary directory for generated files
|
|
292
|
+
mapping_file: Path to mapping file or None to generate
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
dict: return_values with validation_result
|
|
296
|
+
"""
|
|
297
|
+
# Setup and generate minimal data to create mapping
|
|
298
|
+
subtask_options = self._setup_and_generate_data(
|
|
299
|
+
database_url=database_url,
|
|
300
|
+
tempdir=tempdir,
|
|
301
|
+
mapping_file=mapping_file,
|
|
302
|
+
num_records=1, # Generate minimal data just to create mapping
|
|
303
|
+
batch_index=0,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Validate the mapping
|
|
307
|
+
validation_result = self._validate_mapping(subtask_options)
|
|
308
|
+
|
|
309
|
+
self.return_values = {"validation_result": validation_result}
|
|
310
|
+
return self.return_values
|
|
311
|
+
|
|
312
|
+
def _validate_mapping(self, subtask_options):
|
|
313
|
+
"""Validate the mapping against the org schema without loading data."""
|
|
314
|
+
mapping_file = subtask_options.get("mapping")
|
|
315
|
+
if not mapping_file:
|
|
316
|
+
raise TaskOptionsError("Mapping file path required for validation")
|
|
317
|
+
|
|
318
|
+
self.logger.info(f"Validating mapping file: {mapping_file}")
|
|
319
|
+
mapping = parse_from_yaml(mapping_file)
|
|
320
|
+
|
|
321
|
+
validation_result = validate_and_inject_mapping(
|
|
322
|
+
mapping=mapping,
|
|
323
|
+
sf=self.sf,
|
|
324
|
+
namespace=self.project_config.project__package__namespace,
|
|
325
|
+
data_operation=DataOperationType.INSERT,
|
|
326
|
+
inject_namespaces=self.options.get("inject_namespaces", False),
|
|
327
|
+
drop_missing=self.options.get("drop_missing_schema", False),
|
|
328
|
+
validate_only=True,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
# Log summary message
|
|
332
|
+
self.logger.info("")
|
|
333
|
+
if validation_result and validation_result.has_errors():
|
|
334
|
+
self.logger.error("== Validation Failed ==")
|
|
335
|
+
self.logger.error(f" Errors: {len(validation_result.errors)}")
|
|
336
|
+
if validation_result.warnings:
|
|
337
|
+
self.logger.warning(f" Warnings: {len(validation_result.warnings)}")
|
|
338
|
+
elif validation_result and validation_result.warnings:
|
|
339
|
+
self.logger.warning("== Validation Successful (With Warnings) ==")
|
|
340
|
+
self.logger.warning(f" Warnings: {len(validation_result.warnings)}")
|
|
341
|
+
else:
|
|
342
|
+
self.logger.info("== Validation Successful ==")
|
|
343
|
+
self.logger.info("")
|
|
344
|
+
|
|
345
|
+
return validation_result
|
|
222
346
|
|
|
223
347
|
def _cleanup_object_tables(self, engine, metadata):
|
|
224
348
|
"""Delete all tables that do not relate to id->OID mapping"""
|
|
@@ -7,7 +7,7 @@ from logging import getLogger
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import IO, Any, Callable, Dict, List, Mapping, Optional, Tuple, Union
|
|
9
9
|
|
|
10
|
-
from pydantic import Field, ValidationError, root_validator, validator
|
|
10
|
+
from pydantic.v1 import Field, ValidationError, root_validator, validator
|
|
11
11
|
from simple_salesforce import Salesforce
|
|
12
12
|
from typing_extensions import Literal
|
|
13
13
|
|
|
@@ -23,6 +23,28 @@ from cumulusci.utils.yaml.model_parser import CCIDictModel
|
|
|
23
23
|
logger = getLogger(__name__)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
class ValidationResult:
|
|
27
|
+
"""Collects validation errors and warnings during mapping validation."""
|
|
28
|
+
|
|
29
|
+
def __init__(self):
|
|
30
|
+
self.errors = []
|
|
31
|
+
self.warnings = []
|
|
32
|
+
|
|
33
|
+
def add_error(self, message: str):
|
|
34
|
+
"""Add an error message."""
|
|
35
|
+
self.errors.append(message)
|
|
36
|
+
logger.error(message)
|
|
37
|
+
|
|
38
|
+
def add_warning(self, message: str):
|
|
39
|
+
"""Add a warning message."""
|
|
40
|
+
self.warnings.append(message)
|
|
41
|
+
logger.warning(message)
|
|
42
|
+
|
|
43
|
+
def has_errors(self) -> bool:
|
|
44
|
+
"""Check if there are any errors."""
|
|
45
|
+
return len(self.errors) > 0
|
|
46
|
+
|
|
47
|
+
|
|
26
48
|
class MappingLookup(CCIDictModel):
|
|
27
49
|
"Lookup relationship between two tables."
|
|
28
50
|
table: Union[str, List[str]] # Support for polymorphic lookups
|
|
@@ -382,6 +404,7 @@ class MappingStep(CCIDictModel):
|
|
|
382
404
|
strip: Optional[Callable[[str], str]],
|
|
383
405
|
drop_missing: bool,
|
|
384
406
|
data_operation_type: DataOperationType,
|
|
407
|
+
validation_result: Optional["ValidationResult"] = None,
|
|
385
408
|
) -> bool:
|
|
386
409
|
ret = True
|
|
387
410
|
|
|
@@ -405,9 +428,11 @@ class MappingStep(CCIDictModel):
|
|
|
405
428
|
|
|
406
429
|
if inject and self._is_injectable(f) and inject(f) not in orig_fields:
|
|
407
430
|
if f in describe and inject(f) in describe:
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
431
|
+
message = f"Both {self.sf_object}.{f} and {self.sf_object}.{inject(f)} are present in the target org. Using {f}."
|
|
432
|
+
if validation_result:
|
|
433
|
+
validation_result.add_warning(message)
|
|
434
|
+
else:
|
|
435
|
+
logger.warning(message)
|
|
411
436
|
|
|
412
437
|
f = replace_if_necessary(field_dict, f, inject(f))
|
|
413
438
|
if strip:
|
|
@@ -417,9 +442,11 @@ class MappingStep(CCIDictModel):
|
|
|
417
442
|
try:
|
|
418
443
|
new_name = describe.canonical_key(f)
|
|
419
444
|
except KeyError:
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
445
|
+
message = f"Field {self.sf_object}.{f} does not exist or is not visible to the current user."
|
|
446
|
+
if validation_result:
|
|
447
|
+
validation_result.add_warning(message)
|
|
448
|
+
else:
|
|
449
|
+
logger.warning(message)
|
|
423
450
|
else:
|
|
424
451
|
del field_dict[f]
|
|
425
452
|
field_dict[new_name] = entry
|
|
@@ -434,9 +461,11 @@ class MappingStep(CCIDictModel):
|
|
|
434
461
|
error_in_f = False
|
|
435
462
|
|
|
436
463
|
if f not in describe:
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
464
|
+
message = f"Field {self.sf_object}.{f} does not exist or is not visible to the current user."
|
|
465
|
+
if validation_result:
|
|
466
|
+
validation_result.add_warning(message)
|
|
467
|
+
else:
|
|
468
|
+
logger.warning(message)
|
|
440
469
|
error_in_f = True
|
|
441
470
|
elif not self._check_field_permission(
|
|
442
471
|
describe,
|
|
@@ -446,10 +475,14 @@ class MappingStep(CCIDictModel):
|
|
|
446
475
|
relevant_permissions = self._get_required_permission_types(
|
|
447
476
|
relevant_operation
|
|
448
477
|
)
|
|
449
|
-
|
|
478
|
+
message = (
|
|
450
479
|
f"Field {self.sf_object}.{f} does not have the correct permissions "
|
|
451
480
|
+ f"{relevant_permissions} for this operation."
|
|
452
481
|
)
|
|
482
|
+
if validation_result:
|
|
483
|
+
validation_result.add_warning(message)
|
|
484
|
+
else:
|
|
485
|
+
logger.warning(message)
|
|
453
486
|
error_in_f = True
|
|
454
487
|
|
|
455
488
|
if error_in_f:
|
|
@@ -466,6 +499,7 @@ class MappingStep(CCIDictModel):
|
|
|
466
499
|
inject: Optional[Callable[[str], str]],
|
|
467
500
|
strip: Optional[Callable[[str], str]],
|
|
468
501
|
data_operation_type: DataOperationType,
|
|
502
|
+
validation_result: Optional["ValidationResult"] = None,
|
|
469
503
|
) -> bool:
|
|
470
504
|
# Determine whether we need to inject or strip our sObject.
|
|
471
505
|
|
|
@@ -478,23 +512,29 @@ class MappingStep(CCIDictModel):
|
|
|
478
512
|
try:
|
|
479
513
|
self.sf_object = global_describe.canonical_key(self.sf_object)
|
|
480
514
|
except KeyError:
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
515
|
+
message = f"sObject {self.sf_object} does not exist or is not visible to the current user."
|
|
516
|
+
if validation_result:
|
|
517
|
+
validation_result.add_warning(message)
|
|
518
|
+
else:
|
|
519
|
+
logger.warning(message)
|
|
484
520
|
return False
|
|
485
521
|
|
|
486
522
|
# Validate our access to this sObject.
|
|
487
523
|
if not self._check_object_permission(
|
|
488
524
|
global_describe, self.sf_object, data_operation_type
|
|
489
525
|
):
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
526
|
+
message = f"sObject {self.sf_object} does not have the correct permissions for {data_operation_type}."
|
|
527
|
+
if validation_result:
|
|
528
|
+
validation_result.add_warning(message)
|
|
529
|
+
else:
|
|
530
|
+
logger.warning(message)
|
|
493
531
|
return False
|
|
494
532
|
|
|
495
533
|
return True
|
|
496
534
|
|
|
497
|
-
def check_required(
|
|
535
|
+
def check_required(
|
|
536
|
+
self, fields_describe, validation_result: Optional["ValidationResult"] = None
|
|
537
|
+
):
|
|
498
538
|
required_fields = set()
|
|
499
539
|
for field in fields_describe:
|
|
500
540
|
defaulted = (
|
|
@@ -508,9 +548,11 @@ class MappingStep(CCIDictModel):
|
|
|
508
548
|
set(self.fields.keys()) | set(self.lookups)
|
|
509
549
|
)
|
|
510
550
|
if len(missing_fields) > 0:
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
551
|
+
message = f"One or more required fields are missing for loading on {self.sf_object} :{missing_fields}"
|
|
552
|
+
if validation_result:
|
|
553
|
+
validation_result.add_error(message)
|
|
554
|
+
else:
|
|
555
|
+
logger.error(message)
|
|
514
556
|
return False
|
|
515
557
|
else:
|
|
516
558
|
return True
|
|
@@ -523,6 +565,7 @@ class MappingStep(CCIDictModel):
|
|
|
523
565
|
inject_namespaces: bool = False,
|
|
524
566
|
drop_missing: bool = False,
|
|
525
567
|
is_load: bool = False,
|
|
568
|
+
validation_result: Optional["ValidationResult"] = None,
|
|
526
569
|
):
|
|
527
570
|
"""Process the schema elements in this step.
|
|
528
571
|
|
|
@@ -554,7 +597,9 @@ class MappingStep(CCIDictModel):
|
|
|
554
597
|
global_describe = CaseInsensitiveDict(
|
|
555
598
|
{entry["name"]: entry for entry in sf.describe()["sobjects"]}
|
|
556
599
|
)
|
|
557
|
-
if not self._validate_sobject(
|
|
600
|
+
if not self._validate_sobject(
|
|
601
|
+
global_describe, inject, strip, operation, validation_result
|
|
602
|
+
):
|
|
558
603
|
# Don't attempt to validate field permissions if the object doesn't exist.
|
|
559
604
|
return False
|
|
560
605
|
|
|
@@ -562,16 +607,31 @@ class MappingStep(CCIDictModel):
|
|
|
562
607
|
# By this point, we know the attribute is valid.
|
|
563
608
|
describe = self.describe_data(sf)
|
|
564
609
|
fields_correct = self._validate_field_dict(
|
|
565
|
-
describe,
|
|
610
|
+
describe,
|
|
611
|
+
self.fields,
|
|
612
|
+
inject,
|
|
613
|
+
strip,
|
|
614
|
+
drop_missing,
|
|
615
|
+
operation,
|
|
616
|
+
validation_result,
|
|
566
617
|
)
|
|
567
618
|
|
|
568
619
|
lookups_correct = self._validate_field_dict(
|
|
569
|
-
describe,
|
|
620
|
+
describe,
|
|
621
|
+
self.lookups,
|
|
622
|
+
inject,
|
|
623
|
+
strip,
|
|
624
|
+
drop_missing,
|
|
625
|
+
operation,
|
|
626
|
+
validation_result,
|
|
570
627
|
)
|
|
571
628
|
|
|
572
629
|
if is_load:
|
|
573
|
-
#
|
|
574
|
-
self.check_required(describe)
|
|
630
|
+
# Check for unspecified required fields
|
|
631
|
+
required_fields_present = self.check_required(describe, validation_result)
|
|
632
|
+
# Only block if drop_missing is False, otherwise just warn
|
|
633
|
+
if not required_fields_present and not drop_missing:
|
|
634
|
+
return False
|
|
575
635
|
|
|
576
636
|
if not (fields_correct and lookups_correct):
|
|
577
637
|
return False
|
|
@@ -587,6 +647,7 @@ class MappingStep(CCIDictModel):
|
|
|
587
647
|
strip,
|
|
588
648
|
drop_missing=False,
|
|
589
649
|
data_operation_type=operation,
|
|
650
|
+
validation_result=validation_result,
|
|
590
651
|
):
|
|
591
652
|
return False
|
|
592
653
|
self.update_key = tuple(update_keys.keys())
|
|
@@ -638,7 +699,11 @@ def parse_from_yaml(source: Union[str, Path, IO]) -> Dict:
|
|
|
638
699
|
return MappingSteps.parse_from_yaml(source)
|
|
639
700
|
|
|
640
701
|
|
|
641
|
-
def _infer_and_validate_lookups(
|
|
702
|
+
def _infer_and_validate_lookups(
|
|
703
|
+
mapping: Dict,
|
|
704
|
+
sf: Salesforce,
|
|
705
|
+
validation_result: Optional["ValidationResult"] = None,
|
|
706
|
+
):
|
|
642
707
|
"""Validate that all the lookup tables mentioned are valid references
|
|
643
708
|
to the lookup. Also verify that the mapping for the tables are mentioned
|
|
644
709
|
before they are mentioned in the lookups"""
|
|
@@ -673,14 +738,18 @@ def _infer_and_validate_lookups(mapping: Dict, sf: Salesforce):
|
|
|
673
738
|
if sf_object in reference_to_objects:
|
|
674
739
|
target_objects.append(sf_object)
|
|
675
740
|
else:
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
741
|
+
message = f"The lookup {sf_object} is not a valid lookup for {lookup_name} in sf_object: {m.sf_object}"
|
|
742
|
+
if validation_result:
|
|
743
|
+
validation_result.add_error(message)
|
|
744
|
+
else:
|
|
745
|
+
logger.error(message)
|
|
679
746
|
fail = True
|
|
680
747
|
except KeyError:
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
748
|
+
message = f"The table {table} does not exist in the mapping file"
|
|
749
|
+
if validation_result:
|
|
750
|
+
validation_result.add_error(message)
|
|
751
|
+
else:
|
|
752
|
+
logger.error(message)
|
|
684
753
|
fail = True
|
|
685
754
|
|
|
686
755
|
if fail:
|
|
@@ -701,14 +770,18 @@ def _infer_and_validate_lookups(mapping: Dict, sf: Salesforce):
|
|
|
701
770
|
list(sf_objects.values()).index(t) for t in target_objects
|
|
702
771
|
]
|
|
703
772
|
if not all([target_index < idx for target_index in target_indices]):
|
|
704
|
-
|
|
773
|
+
message = (
|
|
705
774
|
f"All included target objects ({','.join(target_objects)}) for the field {m.sf_object}.{lookup_name} "
|
|
706
775
|
f"must precede {m.sf_object} in the mapping."
|
|
707
776
|
)
|
|
777
|
+
if validation_result:
|
|
778
|
+
validation_result.add_error(message)
|
|
779
|
+
else:
|
|
780
|
+
logger.error(message)
|
|
708
781
|
fail = True
|
|
709
782
|
continue
|
|
710
783
|
|
|
711
|
-
if fail:
|
|
784
|
+
if fail and validation_result is None:
|
|
712
785
|
raise BulkDataException(
|
|
713
786
|
"One or more relationship errors blocked the operation."
|
|
714
787
|
)
|
|
@@ -723,23 +796,36 @@ def validate_and_inject_mapping(
|
|
|
723
796
|
inject_namespaces: bool,
|
|
724
797
|
drop_missing: bool,
|
|
725
798
|
org_has_person_accounts_enabled: bool = False,
|
|
726
|
-
|
|
799
|
+
validate_only: bool = False,
|
|
800
|
+
) -> Optional[ValidationResult]:
|
|
727
801
|
# Check if operation is load or extract
|
|
728
802
|
is_load = True if data_operation == DataOperationType.INSERT else False
|
|
729
803
|
|
|
804
|
+
# Create ValidationResult if validate_only is True
|
|
805
|
+
validation_result = ValidationResult() if validate_only else None
|
|
806
|
+
|
|
730
807
|
should_continue = [
|
|
731
808
|
m.validate_and_inject_namespace(
|
|
732
|
-
sf,
|
|
809
|
+
sf,
|
|
810
|
+
namespace,
|
|
811
|
+
data_operation,
|
|
812
|
+
inject_namespaces,
|
|
813
|
+
drop_missing,
|
|
814
|
+
is_load,
|
|
815
|
+
validation_result,
|
|
733
816
|
)
|
|
734
817
|
for m in mapping.values()
|
|
735
818
|
]
|
|
736
819
|
|
|
737
820
|
if not drop_missing and not all(should_continue):
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
821
|
+
if validate_only and validation_result:
|
|
822
|
+
return validation_result
|
|
823
|
+
else:
|
|
824
|
+
raise BulkDataException(
|
|
825
|
+
"One or more schema or permissions errors blocked the operation.\n"
|
|
826
|
+
"If you would like to attempt the load regardless, you can specify "
|
|
827
|
+
"'--drop_missing_schema True' on the command option and ensure all required fields are included in the mapping file."
|
|
828
|
+
)
|
|
743
829
|
|
|
744
830
|
if drop_missing:
|
|
745
831
|
# Drop any steps with sObjects that are not present.
|
|
@@ -767,15 +853,22 @@ def validate_and_inject_mapping(
|
|
|
767
853
|
# Make sure this didn't cause the operation to be invalid
|
|
768
854
|
# by dropping a required field.
|
|
769
855
|
if not describe[field]["nillable"]:
|
|
770
|
-
|
|
856
|
+
message = (
|
|
771
857
|
f"{m.sf_object}.{field} is a required field, but the target object "
|
|
772
858
|
f"{describe[field]['referenceTo']} was removed from the operation "
|
|
773
859
|
"due to missing permissions."
|
|
774
860
|
)
|
|
861
|
+
if validate_only and validation_result:
|
|
862
|
+
validation_result.add_error(message)
|
|
863
|
+
return validation_result
|
|
864
|
+
else:
|
|
865
|
+
raise BulkDataException(message)
|
|
775
866
|
|
|
776
867
|
# Infer/validate lookups
|
|
777
868
|
if is_load:
|
|
778
|
-
_infer_and_validate_lookups(mapping, sf)
|
|
869
|
+
_infer_and_validate_lookups(mapping, sf, validation_result)
|
|
870
|
+
if validate_only and validation_result:
|
|
871
|
+
return validation_result
|
|
779
872
|
|
|
780
873
|
# If the org has person accounts enable, add a field mapping to track "IsPersonAccount".
|
|
781
874
|
# IsPersonAccount field values are used to properly load person account records.
|
|
@@ -784,6 +877,8 @@ def validate_and_inject_mapping(
|
|
|
784
877
|
if step["sf_object"] in ("Account", "Contact"):
|
|
785
878
|
step["fields"]["IsPersonAccount"] = "IsPersonAccount"
|
|
786
879
|
|
|
880
|
+
return validation_result
|
|
881
|
+
|
|
787
882
|
|
|
788
883
|
def _inject_or_strip_name(name, transform, global_describe):
|
|
789
884
|
if not transform:
|
|
@@ -4,7 +4,7 @@ import re
|
|
|
4
4
|
import typing as T
|
|
5
5
|
from enum import Enum
|
|
6
6
|
|
|
7
|
-
from pydantic import Field, root_validator, validator
|
|
7
|
+
from pydantic.v1 import Field, root_validator, validator
|
|
8
8
|
|
|
9
9
|
from cumulusci.core.enums import StrEnum
|
|
10
10
|
from cumulusci.tasks.bulkdata.utils import CaseInsensitiveDict
|