Skip to content

Commit 58b9955

Browse files
authored
DEVOPS-814 chore: sync CumulusCI from upstream (2026.02.12) (#21)
Merge pull request #21 from ClaritiSoftware/chore/DEVOPS-814
2 parents a539ba6 + 64f24be commit 58b9955

13 files changed

Lines changed: 417 additions & 83 deletions

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/load.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
AddPersonAccountsToQuery,
2929
AddRecordTypesToQuery,
3030
DynamicLookupQueryExtender,
31+
register_sqlite_functions,
3132
)
3233
from cumulusci.tasks.bulkdata.step import (
3334
DEFAULT_BULK_BATCH_SIZE,
@@ -766,6 +767,9 @@ def _init_db(self):
766767
with self._database_url() as database_url:
767768
parent_engine = create_engine(database_url)
768769
with parent_engine.connect() as connection:
770+
# Register custom SQLite functions for smart lookup resolution
771+
register_sqlite_functions(connection)
772+
769773
# initialize the DB session
770774
self.session = Session(connection)
771775

cumulusci/tasks/bulkdata/mapping_parser.py

Lines changed: 11 additions & 11 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
@@ -545,7 +545,7 @@ def check_required(
545545
if fields_describe[field]["createable"] and not defaulted:
546546
required_fields.add(field)
547547
missing_fields = required_fields.difference(
548-
set(self.fields.keys()) | set(self.lookups)
548+
set(self.fields.keys()) | set(self.lookups) | set(self.static.keys())
549549
)
550550
if len(missing_fields) > 0:
551551
message = f"One or more required fields are missing for loading on {self.sf_object} :{missing_fields}"

cumulusci/tasks/bulkdata/query_transformers.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import re
12
import typing as T
23
from functools import cached_property
34

4-
from sqlalchemy import String, and_, func, text
5+
from sqlalchemy import String, and_, case, func, text
56
from sqlalchemy.orm import Query, aliased
67
from sqlalchemy.sql import literal_column
78

@@ -10,6 +11,29 @@
1011
Criterion = T.Any
1112
ID_TABLE_NAME = "cumulusci_id_table"
1213

14+
# Salesforce ID pattern: 15 or 18 alphanumeric characters
15+
# This matches the OID_REGEX pattern used in robotframework/Salesforce.py
16+
SF_ID_PATTERN = re.compile(r"^[a-zA-Z0-9]{15}$|^[a-zA-Z0-9]{18}$")
17+
18+
19+
def is_salesforce_id(value: T.Optional[str]) -> bool:
20+
"""Check if a value looks like a valid Salesforce ID."""
21+
if value is None:
22+
return False
23+
return bool(SF_ID_PATTERN.match(str(value)))
24+
25+
26+
def _is_salesforce_id_sqlite(value: T.Optional[str]) -> int:
27+
"""SQLite UDF wrapper for is_salesforce_id."""
28+
return 1 if is_salesforce_id(value) else 0
29+
30+
31+
def register_sqlite_functions(connection) -> None:
32+
"""Register custom SQLite functions on a database connection."""
33+
# Get the underlying DBAPI connection
34+
dbapi_connection = connection.connection.dbapi_connection
35+
dbapi_connection.create_function("is_salesforce_id", 1, _is_salesforce_id_sqlite)
36+
1337

1438
class LoadQueryExtender:
1539
"""Class that transforms a load.py query with columns, filters, joins"""
@@ -61,9 +85,26 @@ def __init__(self, mapping, metadata, model, _old_format) -> None:
6185

6286
@cached_property
6387
def columns_to_add(self):
88+
"""Build column expressions for lookup fields with smart ID resolution."""
89+
columns = []
6490
for lookup in self.lookups:
6591
lookup.aliased_table = aliased(self.metadata.tables[ID_TABLE_NAME])
66-
return [lookup.aliased_table.columns.sf_id for lookup in self.lookups]
92+
key_field = lookup.get_lookup_key_field(self.model)
93+
value_column = getattr(self.model, key_field)
94+
95+
# The resolved SF ID from the ID table join (may be NULL)
96+
sf_id_from_table = lookup.aliased_table.columns.sf_id
97+
98+
smart_lookup = case(
99+
# If we found a match in the ID table, use that
100+
(sf_id_from_table.isnot(None), sf_id_from_table),
101+
# If the original value is already a SF ID, use it directly
102+
(func.is_salesforce_id(value_column) == 1, value_column),
103+
# Otherwise return NULL (lookup not found)
104+
else_=None,
105+
)
106+
columns.append(smart_lookup)
107+
return columns
67108

68109
@cached_property
69110
def outerjoins_to_add(self):

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
)

0 commit comments

Comments
 (0)