Skip to content

Commit ad4a9b3

Browse files
authored
Merge pull request #1 from luisggc/add-option-to-continue-on-script-error
feat: add optional continue on error
2 parents ad2a2ea + 6810d23 commit ad4a9b3

17 files changed

+254
-27
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file.
33

44
*The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).*
55

6+
## [4.1.0] - 2025-03-18
7+
### Added
8+
- Optional flags to continue deploying remaining scripts after an error, recording full error messages in the change history table and listing failed scripts at completion.
9+
### Fixed
10+
- Automatically add missing `ERROR_MESSAGE` column to the change history table to capture full script errors
11+
612
## [4.0.1] - 2025-02-17
713
### Changed
814
- Added back the ability to pass the Snowflake password in the `SNOWFLAKE_PASSWORD` environment variable.

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,8 +476,14 @@ usage: schemachange deploy [-h] [--config-folder CONFIG_FOLDER] [--config-file-n
476476
| -ac, --autocommit | Enable autocommit feature for DML commands. The default is 'False'. |
477477
| -v, --verbose | Display verbose debugging details during execution. The default is 'False'. |
478478
| --dry-run | Run schemachange in dry run mode. The default is 'False'. |
479+
| --continue-all-on-error | Continue executing remaining scripts even if one fails. The default is 'False'. Use the script-type specific flags for finer control. |
480+
| --continue-versioned-on-error | Continue executing remaining versioned scripts after an error. The default is 'False'. |
481+
| --continue-repeatable-on-error | Continue executing remaining repeatable scripts after an error. The default is 'False'. |
482+
| --continue-always-on-error | Continue executing remaining always scripts after an error. The default is 'False'. |
479483
| --query-tag | A string to include in the QUERY_TAG that is attached to every SQL statement executed. |
480484

485+
When any continue-on-error flag is used, schemachange records full error messages for failed scripts in the change history table and reports the list of failed scripts before exiting.
486+
481487
### render
482488

483489
This subcommand is used to render a single script to the console. It is intended to support the development and

schemachange/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
# region Global Variables
1515
# metadata
16-
SCHEMACHANGE_VERSION = "4.0.1"
16+
SCHEMACHANGE_VERSION = "4.1.0"
1717
SNOWFLAKE_APPLICATION_NAME = "schemachange"
1818
module_logger = structlog.getLogger(__name__)
1919

schemachange/config/DeployConfig.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ class DeployConfig(BaseConfig):
3838
create_change_history_table: bool = False
3939
autocommit: bool = False
4040
dry_run: bool = False
41+
continue_versioned_on_error: bool = False
42+
continue_repeatable_on_error: bool = False
43+
continue_always_on_error: bool = False
4144
query_tag: str | None = None
4245

4346
@classmethod

schemachange/config/get_merged_config.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ def get_merged_config(
9595
**{k: v for k, v in yaml_kwargs.items() if v is not None},
9696
**{k: v for k, v in cli_kwargs.items() if v is not None},
9797
}
98+
if "continue_all_on_error" in kwargs:
99+
if kwargs["continue_all_on_error"]:
100+
kwargs["continue_versioned_on_error"] = True
101+
kwargs["continue_repeatable_on_error"] = True
102+
kwargs["continue_always_on_error"] = True
103+
del kwargs["continue_all_on_error"]
98104
if connections_file_path is not None:
99105
kwargs["connections_file_path"] = connections_file_path
100106
if connection_name is not None:
@@ -107,4 +113,4 @@ def get_merged_config(
107113
elif cli_kwargs["subcommand"] == "render":
108114
return RenderConfig.factory(**kwargs)
109115
else:
110-
raise Exception(f"unhandled subcommand: {cli_kwargs['subcommand'] }")
116+
raise Exception(f"unhandled subcommand: {cli_kwargs['subcommand']}")

schemachange/config/parse_cli_args.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,38 @@ def parse_cli_args(args) -> dict:
221221
help="The string to add to the Snowflake QUERY_TAG session value for each query executed",
222222
required=False,
223223
)
224+
parser_deploy.add_argument(
225+
"--continue-versioned-on-error",
226+
action="store_const",
227+
const=True,
228+
default=None,
229+
help="Continue running remaining versioned scripts after an error (the default is False)",
230+
required=False,
231+
)
232+
parser_deploy.add_argument(
233+
"--continue-repeatable-on-error",
234+
action="store_const",
235+
const=True,
236+
default=None,
237+
help="Continue running remaining repeatable scripts after an error (the default is False)",
238+
required=False,
239+
)
240+
parser_deploy.add_argument(
241+
"--continue-always-on-error",
242+
action="store_const",
243+
const=True,
244+
default=None,
245+
help="Continue running remaining always scripts after an error (the default is False)",
246+
required=False,
247+
)
248+
parser_deploy.add_argument(
249+
"--continue-all-on-error",
250+
action="store_const",
251+
const=True,
252+
default=None,
253+
help="Continue running remaining scripts after an error (the default is False)",
254+
required=False,
255+
)
224256
parser_render = subcommands.add_parser(
225257
"render",
226258
description="Renders a script to the console, used to check and verify jinja output from scripts.",

schemachange/deploy.py

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ def deploy(config: DeployConfig, session: SnowflakeSession):
7575

7676
scripts_skipped = 0
7777
scripts_applied = 0
78+
scripts_failed = 0
79+
failed_scripts: list[str] = []
7880

7981
# Loop through each script in order and apply any required changes
8082
for script_name in all_script_names_sorted:
@@ -139,17 +141,40 @@ def deploy(config: DeployConfig, session: SnowflakeSession):
139141
scripts_skipped += 1
140142
continue
141143

142-
session.apply_change_script(
143-
script=script,
144-
script_content=content,
145-
dry_run=config.dry_run,
146-
logger=script_log,
144+
should_continue = (
145+
(script.type == "V" and config.continue_versioned_on_error)
146+
or (script.type == "R" and config.continue_repeatable_on_error)
147+
or (script.type == "A" and config.continue_always_on_error)
148+
)
149+
try:
150+
session.apply_change_script(
151+
script=script,
152+
script_content=content,
153+
dry_run=config.dry_run,
154+
logger=script_log,
155+
)
156+
scripts_applied += 1
157+
except Exception as e:
158+
scripts_failed += 1
159+
failed_scripts.append(script.name)
160+
script_log.error("Failed to apply change script", error=str(e))
161+
if not should_continue:
162+
raise
163+
164+
if scripts_failed > 0:
165+
logger.error(
166+
"Completed with errors",
167+
scripts_applied=scripts_applied,
168+
scripts_skipped=scripts_skipped,
169+
scripts_failed=scripts_failed,
170+
failed_scripts=failed_scripts,
171+
)
172+
raise Exception(
173+
f"{scripts_failed} change script(s) failed: {', '.join(failed_scripts)}"
174+
)
175+
else:
176+
logger.info(
177+
"Completed successfully",
178+
scripts_applied=scripts_applied,
179+
scripts_skipped=scripts_skipped,
147180
)
148-
149-
scripts_applied += 1
150-
151-
logger.info(
152-
"Completed successfully",
153-
scripts_applied=scripts_applied,
154-
scripts_skipped=scripts_skipped,
155-
)

schemachange/session/SnowflakeSession.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ def create_change_history_table(self, dry_run: bool) -> None:
168168
CHECKSUM VARCHAR,
169169
EXECUTION_TIME NUMBER,
170170
STATUS VARCHAR,
171+
ERROR_MESSAGE VARCHAR,
171172
INSTALLED_BY VARCHAR,
172173
INSTALLED_ON TIMESTAMP_LTZ
173174
)
@@ -316,22 +317,26 @@ def apply_change_script(
316317
checksum = hashlib.sha224(script_content.encode("utf-8")).hexdigest()
317318
execution_time = 0
318319
status = "Success"
319-
320-
# Execute the contents of the script
320+
error_message = ""
321+
error: Exception | None = None
322+
start = time.time()
321323
if len(script_content) > 0:
322-
start = time.time()
323324
self.reset_session(logger=logger)
324325
self.reset_query_tag(extra_tag=script.name, logger=logger)
325326
try:
326327
self.execute_snowflake_query(query=script_content, logger=logger)
327328
except Exception as e:
328-
raise Exception(f"Failed to execute {script.name}") from e
329-
self.reset_query_tag(logger=logger)
330-
self.reset_session(logger=logger)
331-
end = time.time()
332-
execution_time = round(end - start)
329+
status = "Failed"
330+
error = e
331+
error_message = str(e).replace("'", "''")
332+
finally:
333+
self.reset_query_tag(logger=logger)
334+
self.reset_session(logger=logger)
335+
end = time.time()
336+
execution_time = round(end - start)
333337

334338
# Compose and execute the insert statement to the log file
339+
script_version = getattr(script, "version", "")
335340
query = f"""\
336341
INSERT INTO {self.change_history_table.fully_qualified} (
337342
VERSION,
@@ -341,18 +346,40 @@ def apply_change_script(
341346
CHECKSUM,
342347
EXECUTION_TIME,
343348
STATUS,
349+
ERROR_MESSAGE,
344350
INSTALLED_BY,
345351
INSTALLED_ON
346352
) VALUES (
347-
'{getattr(script, "version", "")}',
353+
'{script_version}',
348354
'{script.description}',
349355
'{script.name}',
350356
'{script.type}',
351357
'{checksum}',
352358
{execution_time},
353359
'{status}',
360+
'{error_message}',
354361
'{self.user}',
355362
CURRENT_TIMESTAMP
356363
);
357364
"""
358-
self.execute_snowflake_query(dedent(query), logger=logger)
365+
dedent_query = dedent(query)
366+
try:
367+
self.execute_snowflake_query(dedent_query, logger=logger)
368+
except snowflake.connector.errors.ProgrammingError as e:
369+
if "ERROR_MESSAGE" in str(e):
370+
logger.warning(
371+
"Change history table missing ERROR_MESSAGE column, adding column",
372+
error=str(e),
373+
)
374+
alter_query = (
375+
f"ALTER TABLE {self.change_history_table.fully_qualified} "
376+
"ADD COLUMN IF NOT EXISTS ERROR_MESSAGE VARCHAR"
377+
)
378+
self.execute_snowflake_query(alter_query, logger=logger)
379+
self.execute_snowflake_query(dedent_query, logger=logger)
380+
else:
381+
raise
382+
if status != "Success":
383+
raise Exception(
384+
f"Failed to execute {script.name}: {error_message}"
385+
) from error

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[metadata]
22
name = schemachange
3-
version = 4.0.1
3+
version = 4.1.0
44
description = A Database Change Management tool for Snowflake
55
long_description = file: README.md
66
long_description_content_type = text/markdown

tests/config/schemachange-config-full.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,7 @@ create-change-history-table: false
1717
autocommit: false
1818
verbose: false
1919
dry-run: false
20+
continue-versioned-on-error: false
21+
continue-repeatable-on-error: false
22+
continue-always-on-error: false
2023
query-tag: 'query-tag-from-yaml'

0 commit comments

Comments
 (0)