From 5d6e2a0556dd63cd3bcb845a4fd16291e17a07a7 Mon Sep 17 00:00:00 2001 From: Jaco de Groot Date: Wed, 26 Aug 2026 22:55:04 +0100 Subject: [PATCH 1/5] fix(schema): quote ALTER SCHEMA SET values instead of hand-rolling the literal update_schema built its SQL literal with a bare f"'{new_value}'", so an apostrophe in the value ended the string early and the rest arrived as bare SQL: 001003 (42000): SQL compilation error: syntax error line 1 at position 39 unexpected 'comment'. on ALTER SCHEMA MY_DB.MY_SCHEMA SET comment = '... Any schema comment containing an apostrophe therefore fails to apply. Only the ALTER path is affected -- CREATE already renders through props.quote_value, which dollar-quotes and needs no escaping. update_schema was the one branch that hand-rolled its quoting; it now goes through quote_value too. quote_value itself had the mirror-image gap: $$ is a safe delimiter only while the value does not contain $$. It now falls back to a single-quoted literal with ' and backslash doubled in that case, so a value cannot break out by either route. Co-Authored-By: Claude Opus 5 --- snowcap/lifecycle.py | 8 ++++++-- snowcap/props.py | 8 ++++++++ tests/test_lifecycle.py | 16 ++++++++++++++++ tests/test_props.py | 9 +++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/snowcap/lifecycle.py b/snowcap/lifecycle.py index 701f4f5..93660a8 100644 --- a/snowcap/lifecycle.py +++ b/snowcap/lifecycle.py @@ -6,7 +6,7 @@ from .builder import tidy_sql from .enums import GrantType, ResourceType from .identifiers import FQN, URN -from .props import BoolProp, IntProp, Props, StringProp +from .props import BoolProp, IntProp, Props, StringProp, quote_value from .resource_name import ResourceName __this__ = sys.modules[__name__] @@ -535,7 +535,11 @@ def update_schema(urn: URN, data: dict, props: Props) -> str: elif attr == "managed_access": return tidy_sql("ALTER SCHEMA", urn.fqn, "ENABLE" if new_value else "DISABLE", "MANAGED ACCESS") else: - new_value = f"'{new_value}'" if isinstance(new_value, str) else new_value + # quote_value, not an f-string. A raw f"'{new_value}'" ends the string literal at + # the first apostrophe in the value, so a comment containing one reaches Snowflake + # as a syntax error instead of being set. Every other property renderer already + # goes through quote_value; this branch was the one that hand-rolled its quoting. + new_value = quote_value(new_value) if isinstance(new_value, str) else new_value return tidy_sql("ALTER SCHEMA", urn.fqn, "SET", attr, "=", new_value) diff --git a/snowcap/props.py b/snowcap/props.py index d478bf1..6cb12ac 100644 --- a/snowcap/props.py +++ b/snowcap/props.py @@ -27,6 +27,14 @@ def quote_value(value: str): if value is None or value == "": return "''" + # Dollar-quoting stays the default -- it needs no escaping for the apostrophes and + # backslashes that turn up in free-text comments -- but it is unusable when the value + # itself contains the $$ delimiter. Fall back to a single-quoted literal there, with + # ' and backslash doubled, so a value carrying $$ cannot break out of the literal + # either. + if "$$" in str(value): + escaped = str(value).replace("\\", "\\\\").replace("'", "''") + return f"'{escaped}'" return f"$${value}$$" diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 1d08006..30a81b5 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -937,6 +937,22 @@ def test_set_other_property(self): result = update_schema(urn, data, props) assert "SET data_retention_time_in_days = 7" in result + def test_set_comment_with_apostrophe(self): + """A comment containing an apostrophe must not break out of the literal.""" + urn = make_urn(ResourceType.SCHEMA, "MY_SCHEMA", database="MY_DB") + data = {"comment": "the database's two-limb test"} + props = MockProps("") + result = update_schema(urn, data, props) + assert result == "ALTER SCHEMA MY_DB.MY_SCHEMA SET comment = $$the database's two-limb test$$" + + def test_set_comment_containing_dollar_quote(self): + """A comment containing $$ falls back to a single-quoted literal.""" + urn = make_urn(ResourceType.SCHEMA, "MY_SCHEMA", database="MY_DB") + data = {"comment": "costs $$ and it's dear"} + props = MockProps("") + result = update_schema(urn, data, props) + assert result == "ALTER SCHEMA MY_DB.MY_SCHEMA SET comment = 'costs $$ and it''s dear'" + class TestUpdateTable: """Tests for update_table function.""" diff --git a/tests/test_props.py b/tests/test_props.py index e31ca48..d15c34b 100644 --- a/tests/test_props.py +++ b/tests/test_props.py @@ -147,6 +147,15 @@ def test_quote_value_multiline(self): result = quote_value("line1\nline2") assert result == "$$line1\nline2$$" + def test_quote_value_containing_dollar_quote(self): + # Dollar-quoting cannot carry a value that holds the delimiter itself. + result = quote_value("costs $$ and it's dear") + assert result == "'costs $$ and it''s dear'" + + def test_quote_value_containing_dollar_quote_and_backslash(self): + result = quote_value("$$ path C:\\tmp") + assert result == "'$$ path C:\\\\tmp'" + class TestBoolPropExtended: """Extended tests for BoolProp class.""" From 226787e3e49260cd909bae8e5e34cb5ed6218214 Mon Sep 17 00:00:00 2001 From: Jaco de Groot Date: Thu, 27 Aug 2026 07:05:17 +0100 Subject: [PATCH 2/5] fix(props): escape control characters in the single-quoted fallback Snowflake needs escape sequences, not raw control characters, inside a single-quoted literal, so the $$ fallback regressed multiline values. Co-Authored-By: Claude Opus 5 --- snowcap/lifecycle.py | 4 ---- snowcap/props.py | 15 +++++++++------ tests/test_props.py | 9 ++++++++- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/snowcap/lifecycle.py b/snowcap/lifecycle.py index 93660a8..4689a27 100644 --- a/snowcap/lifecycle.py +++ b/snowcap/lifecycle.py @@ -535,10 +535,6 @@ def update_schema(urn: URN, data: dict, props: Props) -> str: elif attr == "managed_access": return tidy_sql("ALTER SCHEMA", urn.fqn, "ENABLE" if new_value else "DISABLE", "MANAGED ACCESS") else: - # quote_value, not an f-string. A raw f"'{new_value}'" ends the string literal at - # the first apostrophe in the value, so a comment containing one reaches Snowflake - # as a syntax error instead of being set. Every other property renderer already - # goes through quote_value; this branch was the one that hand-rolled its quoting. new_value = quote_value(new_value) if isinstance(new_value, str) else new_value return tidy_sql("ALTER SCHEMA", urn.fqn, "SET", attr, "=", new_value) diff --git a/snowcap/props.py b/snowcap/props.py index 6cb12ac..c2e15af 100644 --- a/snowcap/props.py +++ b/snowcap/props.py @@ -27,13 +27,16 @@ def quote_value(value: str): if value is None or value == "": return "''" - # Dollar-quoting stays the default -- it needs no escaping for the apostrophes and - # backslashes that turn up in free-text comments -- but it is unusable when the value - # itself contains the $$ delimiter. Fall back to a single-quoted literal there, with - # ' and backslash doubled, so a value carrying $$ cannot break out of the literal - # either. + # Dollar-quoting is the default; it cannot carry a value containing the $$ delimiter. if "$$" in str(value): - escaped = str(value).replace("\\", "\\\\").replace("'", "''") + escaped = ( + str(value) + .replace("\\", "\\\\") + .replace("'", "''") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) return f"'{escaped}'" return f"$${value}$$" diff --git a/tests/test_props.py b/tests/test_props.py index d15c34b..f091fb7 100644 --- a/tests/test_props.py +++ b/tests/test_props.py @@ -148,7 +148,6 @@ def test_quote_value_multiline(self): assert result == "$$line1\nline2$$" def test_quote_value_containing_dollar_quote(self): - # Dollar-quoting cannot carry a value that holds the delimiter itself. result = quote_value("costs $$ and it's dear") assert result == "'costs $$ and it''s dear'" @@ -156,6 +155,14 @@ def test_quote_value_containing_dollar_quote_and_backslash(self): result = quote_value("$$ path C:\\tmp") assert result == "'$$ path C:\\\\tmp'" + def test_quote_value_containing_dollar_quote_and_newline(self): + result = quote_value("costs $$\nper line") + assert result == "'costs $$\\nper line'" + + def test_quote_value_containing_dollar_quote_and_carriage_return_tab(self): + result = quote_value("$$\r\tx") + assert result == "'$$\\r\\tx'" + class TestBoolPropExtended: """Extended tests for BoolProp class.""" From feaf12c712250797aa5c7850b476a20f405fe6ec Mon Sep 17 00:00:00 2001 From: Jaco de Groot Date: Thu, 27 Aug 2026 07:06:12 +0100 Subject: [PATCH 3/5] chore(props): drop the remaining comment on the $$ fallback Co-Authored-By: Claude Opus 5 --- snowcap/props.py | 1 - 1 file changed, 1 deletion(-) diff --git a/snowcap/props.py b/snowcap/props.py index c2e15af..b36ead5 100644 --- a/snowcap/props.py +++ b/snowcap/props.py @@ -27,7 +27,6 @@ def quote_value(value: str): if value is None or value == "": return "''" - # Dollar-quoting is the default; it cannot carry a value containing the $$ delimiter. if "$$" in str(value): escaped = ( str(value) From 940860b4d38a24f9c88b721704c947185c659a1d Mon Sep 17 00:00:00 2001 From: Jaco de Groot Date: Fri, 28 Aug 2026 13:26:11 +0100 Subject: [PATCH 4/5] fix(props): escape control characters via json in the $$ fallback The hand-rolled replacement chain covered newline, carriage return and tab but left backspace, form feed, NUL and the rest of the C0 range raw in the single-quoted literal. json.dumps escapes the whole range using the same backslash syntax Snowflake accepts, so the fallback now only has to double apostrophes on top of it. Co-Authored-By: Claude Opus 5 --- snowcap/props.py | 10 ++-------- tests/test_props.py | 8 ++++++++ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/snowcap/props.py b/snowcap/props.py index b36ead5..acab577 100644 --- a/snowcap/props.py +++ b/snowcap/props.py @@ -28,14 +28,8 @@ def quote_value(value: str): if value is None or value == "": return "''" if "$$" in str(value): - escaped = ( - str(value) - .replace("\\", "\\\\") - .replace("'", "''") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") - ) + # JSON and Snowflake share backslash escape syntax for control characters + escaped = json.dumps(str(value), ensure_ascii=False)[1:-1].replace("'", "''") return f"'{escaped}'" return f"$${value}$$" diff --git a/tests/test_props.py b/tests/test_props.py index f091fb7..4c4f073 100644 --- a/tests/test_props.py +++ b/tests/test_props.py @@ -163,6 +163,14 @@ def test_quote_value_containing_dollar_quote_and_carriage_return_tab(self): result = quote_value("$$\r\tx") assert result == "'$$\\r\\tx'" + def test_quote_value_containing_dollar_quote_and_backspace_form_feed(self): + result = quote_value("$$\b\fx") + assert result == "'$$\\b\\fx'" + + def test_quote_value_containing_dollar_quote_and_nul(self): + result = quote_value("$$\0x") + assert result == "'$$\\u0000x'" + class TestBoolPropExtended: """Extended tests for BoolProp class.""" From 37b0aecab6c6493a57c18f14151d5dfbbc2793df Mon Sep 17 00:00:00 2001 From: Jaco de Groot Date: Thu, 1 Oct 2026 08:46:39 +0100 Subject: [PATCH 5/5] fix(scanner_package): quote SET_CONFIGURATION values with quote_value Same hand-rolled f"'{new_value}'" as update_schema, same fix. Adds a test with a `"` in the $$ fallback and collapses the quote_value tests into one parametrized case. Co-Authored-By: Claude Fable 5.1 --- snowcap/lifecycle.py | 4 +-- tests/test_lifecycle.py | 10 ++++++- tests/test_props.py | 66 +++++++++++++---------------------------- 3 files changed, 31 insertions(+), 49 deletions(-) diff --git a/snowcap/lifecycle.py b/snowcap/lifecycle.py index 4689a27..05545af 100644 --- a/snowcap/lifecycle.py +++ b/snowcap/lifecycle.py @@ -508,9 +508,9 @@ def update_scanner_package(urn: URN, data: dict, props: Props) -> str: package_name = f"'{urn.fqn.name}'" attr, new_value = data.popitem() if attr == "schedule": - new_value = f"'USING CRON {new_value}'" + new_value = quote_value(f"USING CRON {new_value}") else: - new_value = f"'{new_value}'" + new_value = quote_value(new_value) return tidy_sql( "CALL SNOWFLAKE.TRUST_CENTER.SET_CONFIGURATION(", f"'{attr}',", diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 30a81b5..eda88e3 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -874,7 +874,15 @@ def test_update_other_property(self): props = MockProps("") result = update_scanner_package(urn, data, props) assert "'enabled'" in result - assert "'TRUE'" in result + assert "$$TRUE$$" in result + + def test_update_comment_with_apostrophe(self): + """A value containing an apostrophe must not break out of the literal.""" + urn = make_urn(ResourceType.SCANNER_PACKAGE, "CIS_BENCHMARKS") + data = {"comment": "the account's weekly scan"} + props = MockProps("") + result = update_scanner_package(urn, data, props) + assert "$$the account's weekly scan$$" in result class TestUpdateSchema: diff --git a/tests/test_props.py b/tests/test_props.py index 4c4f073..9c77b56 100644 --- a/tests/test_props.py +++ b/tests/test_props.py @@ -124,52 +124,26 @@ def test_props_render(self): # ============================================================================ -class TestQuoteValue: - """Tests for the quote_value helper function.""" - - def test_quote_value_normal_string(self): - result = quote_value("hello world") - assert result == "$$hello world$$" - - def test_quote_value_empty_string(self): - result = quote_value("") - assert result == "''" - - def test_quote_value_none(self): - result = quote_value(None) - assert result == "''" - - def test_quote_value_with_quotes(self): - result = quote_value('it\'s a "test"') - assert result == '$$it\'s a "test"$$' - - def test_quote_value_multiline(self): - result = quote_value("line1\nline2") - assert result == "$$line1\nline2$$" - - def test_quote_value_containing_dollar_quote(self): - result = quote_value("costs $$ and it's dear") - assert result == "'costs $$ and it''s dear'" - - def test_quote_value_containing_dollar_quote_and_backslash(self): - result = quote_value("$$ path C:\\tmp") - assert result == "'$$ path C:\\\\tmp'" - - def test_quote_value_containing_dollar_quote_and_newline(self): - result = quote_value("costs $$\nper line") - assert result == "'costs $$\\nper line'" - - def test_quote_value_containing_dollar_quote_and_carriage_return_tab(self): - result = quote_value("$$\r\tx") - assert result == "'$$\\r\\tx'" - - def test_quote_value_containing_dollar_quote_and_backspace_form_feed(self): - result = quote_value("$$\b\fx") - assert result == "'$$\\b\\fx'" - - def test_quote_value_containing_dollar_quote_and_nul(self): - result = quote_value("$$\0x") - assert result == "'$$\\u0000x'" +@pytest.mark.parametrize( + "value, expected", + [ + ("hello world", "$$hello world$$"), + ("", "''"), + (None, "''"), + ('it\'s a "test"', '$$it\'s a "test"$$'), + ("line1\nline2", "$$line1\nline2$$"), + # $$ in the value forces the single-quoted fallback + ("costs $$ and it's dear", "'costs $$ and it''s dear'"), + ('$$ say "hi"', "'$$ say \\\"hi\\\"'"), + ("$$ path C:\\tmp", "'$$ path C:\\\\tmp'"), + ("costs $$\nper line", "'costs $$\\nper line'"), + ("$$\r\tx", "'$$\\r\\tx'"), + ("$$\b\fx", "'$$\\b\\fx'"), + ("$$\0x", "'$$\\u0000x'"), + ], +) +def test_quote_value(value, expected): + assert quote_value(value) == expected class TestBoolPropExtended: