Skip to content

Commit 5177267

Browse files
@W-19854606: Strict Mode & Raise validation errors for missing schema and permissions (#3937)
1 parent 43adabf commit 5177267

8 files changed

Lines changed: 194 additions & 36 deletions

File tree

cumulusci/tasks/bulkdata/generate_from_yaml.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ def _init_options(self, kwargs):
9595
self.working_directory = self.options.get("working_directory")
9696
loading_rules = process_list_arg(self.options.get("loading_rules")) or []
9797
self.loading_rules = [Path(path) for path in loading_rules if path]
98+
# These flags may be provided internally by callers (e.g., Snowfakery task)
99+
self.strict_mode = bool(self.options.get("strict_mode", False))
100+
self.validate_only = bool(self.options.get("validate_only", False))
98101

99102
def _generate_data(self, db_url, mapping_file_path, num_records, current_batch_num):
100103
"""Generate all of the data"""
@@ -171,6 +174,7 @@ def generate_data(self, dburl, num_records, current_batch_num):
171174
"project_config": self.project_config,
172175
**self.plugin_options,
173176
},
177+
strict_mode=self.strict_mode or self.validate_only,
174178
)
175179

176180
if (

cumulusci/tasks/bulkdata/mapping_parser.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -444,9 +444,9 @@ def replace_if_necessary(dct, name, replacement):
444444
except KeyError:
445445
message = f"Field {self.sf_object}.{f} does not exist or is not visible to the current user."
446446
if validation_result:
447-
validation_result.add_warning(message)
447+
validation_result.add_error(message)
448448
else:
449-
logger.warning(message)
449+
logger.error(message)
450450
else:
451451
del field_dict[f]
452452
field_dict[new_name] = entry
@@ -463,9 +463,9 @@ def replace_if_necessary(dct, name, replacement):
463463
if f not in describe:
464464
message = f"Field {self.sf_object}.{f} does not exist or is not visible to the current user."
465465
if validation_result:
466-
validation_result.add_warning(message)
466+
validation_result.add_error(message)
467467
else:
468-
logger.warning(message)
468+
logger.error(message)
469469
error_in_f = True
470470
elif not self._check_field_permission(
471471
describe,
@@ -480,9 +480,9 @@ def replace_if_necessary(dct, name, replacement):
480480
+ f"{relevant_permissions} for this operation."
481481
)
482482
if validation_result:
483-
validation_result.add_warning(message)
483+
validation_result.add_error(message)
484484
else:
485-
logger.warning(message)
485+
logger.error(message)
486486
error_in_f = True
487487

488488
if error_in_f:
@@ -514,9 +514,9 @@ def _validate_sobject(
514514
except KeyError:
515515
message = f"sObject {self.sf_object} does not exist or is not visible to the current user."
516516
if validation_result:
517-
validation_result.add_warning(message)
517+
validation_result.add_error(message)
518518
else:
519-
logger.warning(message)
519+
logger.error(message)
520520
return False
521521

522522
# Validate our access to this sObject.
@@ -525,9 +525,9 @@ def _validate_sobject(
525525
):
526526
message = f"sObject {self.sf_object} does not have the correct permissions for {data_operation_type}."
527527
if validation_result:
528-
validation_result.add_warning(message)
528+
validation_result.add_error(message)
529529
else:
530-
logger.warning(message)
530+
logger.error(message)
531531
return False
532532

533533
return True

cumulusci/tasks/bulkdata/snowfakery.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,12 @@ class Snowfakery(BaseSalesforceApiTask):
141141
"Defaults to False."
142142
},
143143
"validate_only": {
144-
"description": "Boolean: if True, only validate the generated mapping against the org schema without loading data. "
144+
"description": "Boolean: if True, validates snowfakery recipe and generated mapping against the org schema without loading data. "
145145
"Defaults to False."
146146
},
147+
"strict_mode": {
148+
"description": "Boolean: If True, validates the Snowfakery recipe and generated mapping against the org schema (strict mode) and then proceeds with the run",
149+
},
147150
}
148151

149152
def _validate_options(self):
@@ -165,6 +168,7 @@ def _validate_options(self):
165168
self.options.get("drop_missing_schema", False)
166169
)
167170
self.validate_only = process_bool_arg(self.options.get("validate_only", False))
171+
self.strict_mode = process_bool_arg(self.options.get("strict_mode", False))
168172

169173
loading_rules = process_list_arg(self.options.get("loading_rules")) or []
170174
self.loading_rules = [Path(path) for path in loading_rules if path]
@@ -614,6 +618,7 @@ def _run_generate_and_load_subtask(
614618
"ignore_row_errors": self.ignore_row_errors,
615619
"drop_missing_schema": self.drop_missing_schema,
616620
"validate_only": validate_only,
621+
"strict_mode": self.strict_mode,
617622
}
618623
subtask_config = TaskConfig({"options": options})
619624
subtask = GenerateAndLoadDataFromYaml(

cumulusci/tasks/bulkdata/tests/test_generate_from_snowfakery_task.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,102 @@ def test_exception_handled_cleanly(self, generate_data):
206206
assert "Foo" in str(e.value)
207207
assert len(generate_data.mock_calls) == 1
208208

209+
@mock.patch("cumulusci.tasks.bulkdata.generate_from_yaml.generate_data")
210+
def test_validate_only_forces_strict_mode(self, generate_data):
211+
with temp_sqlite_database_url() as database_url:
212+
task = _make_task(
213+
GenerateDataFromYaml,
214+
{
215+
"options": {
216+
"generator_yaml": simple_yaml,
217+
"database_url": database_url,
218+
"validate_only": True,
219+
}
220+
},
221+
)
222+
task()
223+
224+
assert len(generate_data.mock_calls) == 1
225+
_, kwargs = generate_data.call_args
226+
assert kwargs["strict_mode"] is True
227+
228+
@mock.patch("cumulusci.tasks.bulkdata.generate_from_yaml.generate_data")
229+
def test_strict_mode_only(self, generate_data):
230+
with temp_sqlite_database_url() as database_url:
231+
task = _make_task(
232+
GenerateDataFromYaml,
233+
{
234+
"options": {
235+
"generator_yaml": simple_yaml,
236+
"database_url": database_url,
237+
"strict_mode": True,
238+
}
239+
},
240+
)
241+
task()
242+
243+
assert len(generate_data.mock_calls) == 1
244+
_, kwargs = generate_data.call_args
245+
assert kwargs["strict_mode"] is True
246+
247+
@mock.patch("cumulusci.tasks.bulkdata.generate_from_yaml.generate_data")
248+
def test_defaults_no_strict_no_validate(self, generate_data):
249+
with temp_sqlite_database_url() as database_url:
250+
task = _make_task(
251+
GenerateDataFromYaml,
252+
{
253+
"options": {
254+
"generator_yaml": simple_yaml,
255+
"database_url": database_url,
256+
}
257+
},
258+
)
259+
task()
260+
261+
assert len(generate_data.mock_calls) == 1
262+
_, kwargs = generate_data.call_args
263+
assert kwargs["strict_mode"] is False
264+
265+
def test_validate_only_runs_and_creates_mapping(self):
266+
with temporary_file_path("mapping.yml") as mapping_path:
267+
with temp_sqlite_database_url() as database_url:
268+
task = _make_task(
269+
GenerateDataFromYaml,
270+
{
271+
"options": {
272+
"generator_yaml": simple_yaml,
273+
"database_url": database_url,
274+
"generate_mapping_file": mapping_path,
275+
"validate_only": True,
276+
}
277+
},
278+
)
279+
task()
280+
281+
assert mapping_path.exists()
282+
mapping = yaml.safe_load(open(mapping_path))
283+
assert mapping # ensure something was written
284+
285+
def test_strict_mode_runs_and_creates_mapping(self):
286+
with temporary_file_path("mapping.yml") as mapping_path:
287+
with temp_sqlite_database_url() as database_url:
288+
task = _make_task(
289+
GenerateDataFromYaml,
290+
{
291+
"options": {
292+
"generator_yaml": simple_yaml,
293+
"database_url": database_url,
294+
"generate_mapping_file": mapping_path,
295+
"strict_mode": True,
296+
}
297+
},
298+
)
299+
task()
300+
301+
assert mapping_path.exists()
302+
mapping = yaml.safe_load(open(mapping_path))
303+
assert mapping # ensure something was written
304+
209305
@mock.patch(
210306
"cumulusci.tasks.bulkdata.generate_and_load_data_from_yaml.GenerateAndLoadDataFromYaml._dataload"
211307
)

cumulusci/tasks/bulkdata/tests/test_mapping_parser.py

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1868,8 +1868,9 @@ def test_validate_only_collects_missing_field_errors(self):
18681868

18691869
assert result is not None
18701870
assert isinstance(result, ValidationResult)
1871-
# Should have warnings about missing field
1872-
assert any("Nonsense__c" in warning for warning in result.warnings)
1871+
assert result.has_errors()
1872+
# Should have errors about missing field
1873+
assert any("Nonsense__c" in error for error in result.errors)
18731874

18741875
@responses.activate
18751876
def test_validate_only_collects_missing_required_field_errors(self):
@@ -1930,8 +1931,9 @@ def test_validate_only_early_return_on_sobject_error(self):
19301931

19311932
assert result is not None
19321933
assert isinstance(result, ValidationResult)
1933-
# Should have warning about missing object
1934-
assert any("InvalidObject__c" in warning for warning in result.warnings)
1934+
assert result.has_errors()
1935+
# Should have error about missing object
1936+
assert any("InvalidObject__c" in error for error in result.errors)
19351937

19361938
@responses.activate
19371939
def test_validate_only_collects_lookup_errors(self):
@@ -2068,7 +2070,7 @@ def test_check_required_without_validation_result_logs(self, caplog):
20682070
assert "Name" in caplog.text
20692071

20702072
def test_validate_sobject_with_validation_result(self):
2071-
"""Test _validate_sobject adds warnings to ValidationResult"""
2073+
"""Test _validate_sobject adds errors to ValidationResult"""
20722074
from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
20732075

20742076
ms = MappingStep(
@@ -2087,13 +2089,11 @@ def test_validate_sobject_with_validation_result(self):
20872089
)
20882090

20892091
assert not result
2090-
assert len(validation_result.warnings) > 0
2091-
assert any(
2092-
"InvalidObject__c" in warning for warning in validation_result.warnings
2093-
)
2092+
assert validation_result.has_errors()
2093+
assert any("InvalidObject__c" in error for error in validation_result.errors)
20942094

20952095
def test_validate_field_dict_with_validation_result(self):
2096-
"""Test _validate_field_dict adds warnings to ValidationResult"""
2096+
"""Test _validate_field_dict adds errors to ValidationResult"""
20972097
from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
20982098

20992099
ms = MappingStep(
@@ -2114,10 +2114,8 @@ def test_validate_field_dict_with_validation_result(self):
21142114
)
21152115

21162116
assert not result
2117-
assert len(validation_result.warnings) > 0
2118-
assert any(
2119-
"NonexistentField__c" in warning for warning in validation_result.warnings
2120-
)
2117+
assert validation_result.has_errors()
2118+
assert any("NonexistentField__c" in error for error in validation_result.errors)
21212119

21222120
def test_infer_and_validate_lookups_with_validation_result(self):
21232121
"""Test _infer_and_validate_lookups adds errors to ValidationResult"""
@@ -2235,10 +2233,11 @@ def test_validate_field_dict_permission_error_with_validation_result(self):
22352233
)
22362234

22372235
assert not result
2238-
# Should have warning about incorrect permissions
2236+
assert validation_result.has_errors()
2237+
# Should have error about incorrect permissions
22392238
assert any(
2240-
"does not have the correct permissions" in warning
2241-
for warning in validation_result.warnings
2239+
"does not have the correct permissions" in error
2240+
for error in validation_result.errors
22422241
)
22432242

22442243
def test_validate_sobject_permission_error_with_validation_result(self):
@@ -2261,10 +2260,11 @@ def test_validate_sobject_permission_error_with_validation_result(self):
22612260
)
22622261

22632262
assert not result
2264-
# Should have warning about incorrect permissions
2263+
assert validation_result.has_errors()
2264+
# Should have error about incorrect permissions
22652265
assert any(
2266-
"does not have the correct permissions" in warning
2267-
for warning in validation_result.warnings
2266+
"does not have the correct permissions" in error
2267+
for error in validation_result.errors
22682268
)
22692269

22702270
def test_infer_and_validate_lookups_invalid_reference_with_validation_result(self):

cumulusci/tasks/bulkdata/tests/test_snowfakery.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,59 @@ def _run_snowfakery_and_inspect_mapping(**options):
237237
return _run_snowfakery_and_inspect_mapping
238238

239239

240+
@mock.patch("cumulusci.tasks.bulkdata.snowfakery.GenerateAndLoadDataFromYaml")
241+
def test_snowfakery_validate_only_passes_flags(mock_subtask_cls, snowfakery):
242+
mock_subtask = mock.Mock()
243+
mock_subtask.__call__ = mock.Mock(return_value=None)
244+
mock_subtask.return_values = {"validation_result": "ok"}
245+
mock_subtask_cls.return_value = mock_subtask
246+
247+
task = snowfakery(
248+
recipe=str(simple_salesforce_yaml),
249+
validate_only=True,
250+
)
251+
252+
with TemporaryDirectory() as tmpdir:
253+
task._run_generate_and_load_subtask(
254+
Path(tmpdir),
255+
DummyOrgConfig({}, "test"),
256+
options={},
257+
validate_only=True,
258+
)
259+
260+
# task_config passed into subtask should carry validate_only=True and strict_mode flag
261+
call_kwargs = mock_subtask_cls.call_args.kwargs
262+
task_config = call_kwargs["task_config"]
263+
assert task_config.options["validate_only"] is True
264+
assert task_config.options["strict_mode"] is False
265+
266+
267+
@mock.patch("cumulusci.tasks.bulkdata.snowfakery.GenerateAndLoadDataFromYaml")
268+
def test_snowfakery_strict_mode_passes_flags(mock_subtask_cls, snowfakery):
269+
mock_subtask = mock.Mock()
270+
mock_subtask.__call__ = mock.Mock(return_value=None)
271+
mock_subtask.return_values = {"validation_result": "ok"}
272+
mock_subtask_cls.return_value = mock_subtask
273+
274+
task = snowfakery(
275+
recipe=str(simple_salesforce_yaml),
276+
strict_mode=True,
277+
)
278+
279+
with TemporaryDirectory() as tmpdir:
280+
task._run_generate_and_load_subtask(
281+
Path(tmpdir),
282+
DummyOrgConfig({}, "test"),
283+
options={},
284+
validate_only=False,
285+
)
286+
287+
call_kwargs = mock_subtask_cls.call_args.kwargs
288+
task_config = call_kwargs["task_config"]
289+
assert task_config.options["validate_only"] is False
290+
assert task_config.options["strict_mode"] is True
291+
292+
240293
def get_mapping_from_snowfakery_task_results(results: SnowfakeryTaskResults):
241294
"""Find the shared mapping file and return it."""
242295
template_dir = SnowfakeryWorkingDirectory(results.working_dir / "template_1/")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ dependencies = [
5353
"sarge",
5454
"selenium<4",
5555
"simple-salesforce==1.11.4",
56-
"snowfakery>=4.1.0",
56+
"snowfakery>=4.2.0",
5757
"xmltodict",
5858
"docutils<=0.21.2",
5959
]

0 commit comments

Comments
 (0)