From 68aae90a66fa43e1ea01a05f5f1a63434c7bd1a7 Mon Sep 17 00:00:00 2001 From: bibi samina Date: Wed, 26 Aug 2026 13:09:53 +0530 Subject: [PATCH 1/2] fix(sqlalchemy-spanner): quote and escape identifiers in generated DDL --- .../sqlalchemy_spanner/sqlalchemy_spanner.py | 14 +++++++-- .../tests/unit/test_dialect.py | 29 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py b/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py index ee8e72eb5665..4d2b3a5de1c2 100644 --- a/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py +++ b/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py @@ -264,6 +264,16 @@ def __init__(self, dialect): dialect, initial_quote="`", final_quote="`" ) + def _escape_identifier(self, value): + """Escape backslashes and backticks inside a backtick-quoted identifier. + + The base preparer only doubles the ANSI double-quote, which does not + neutralize a backtick in a Spanner backtick-quoted identifier. Match the + backslash escaping used by ``parse_utils.escape_name`` so a name that + carries a backtick cannot terminate the quoted identifier. + """ + return value.replace("\\", "\\\\").replace("`", "\\`") + def _requires_quotes(self, value): """Return True if the given identifier requires quoting.""" lc_value = value.lower() @@ -697,7 +707,7 @@ def post_create_table(self, table): Returns: str: primary key difinition to add to the table CREATE request. """ - cols = [col.name for col in table.primary_key.columns] + cols = [self.preparer.quote(col.name) for col in table.primary_key.columns] post_cmds = " PRIMARY KEY ({})".format(", ".join(cols)) if "TEMPORARY" in table._prefixes: @@ -705,7 +715,7 @@ def post_create_table(self, table): if table.kwargs.get("spanner_interleave_in"): post_cmds += ",\nINTERLEAVE IN PARENT {}".format( - table.kwargs["spanner_interleave_in"] + self.preparer.quote(table.kwargs["spanner_interleave_in"]) ) if table.kwargs.get("spanner_interleave_on_delete_cascade"): diff --git a/packages/sqlalchemy-spanner/tests/unit/test_dialect.py b/packages/sqlalchemy-spanner/tests/unit/test_dialect.py index 86e0907137f1..9cc228ac9b72 100644 --- a/packages/sqlalchemy-spanner/tests/unit/test_dialect.py +++ b/packages/sqlalchemy-spanner/tests/unit/test_dialect.py @@ -13,6 +13,8 @@ # limitations under the License. from unittest.mock import MagicMock +from sqlalchemy import Column, Integer, MetaData, Table +from sqlalchemy.schema import CreateTable from sqlalchemy.testing import eq_ from sqlalchemy.testing.plugin.plugin_base import fixtures from google.cloud.sqlalchemy_spanner.sqlalchemy_spanner import SpannerDialect @@ -98,3 +100,30 @@ def test_max_size_exported(self): eq_(SpannerDialect.max_size, MAX_SIZE) eq_(int_from_size("MAX"), 2621440) eq_(int_from_size("100"), 100) + + def _compile_create_table(self, column): + """Compile ``CREATE TABLE`` for a one-column primary key table.""" + table = Table("some_table", MetaData(), column) + return str(CreateTable(table).compile(dialect=SpannerDialect())) + + def test_primary_key_reserved_word_is_quoted(self): + """A reserved-word primary key column is quoted in the PRIMARY KEY clause.""" + ddl = self._compile_create_table(Column("from", Integer, primary_key=True)) + assert "PRIMARY KEY (`from`)" in ddl + + def test_primary_key_backtick_is_escaped(self): + """A backtick in a primary key column name cannot terminate the identifier.""" + name = "id`) STORING (x); DROP TABLE t; --" + ddl = self._compile_create_table(Column(name, Integer, primary_key=True)) + assert "PRIMARY KEY (`id\\`) STORING (x); DROP TABLE t; --`)" in ddl + + def test_primary_key_plain_name_is_unquoted(self): + """A regular identifier is left unquoted, so existing DDL is unchanged.""" + ddl = self._compile_create_table(Column("user_id", Integer, primary_key=True)) + assert "PRIMARY KEY (user_id)" in ddl + + def test_preparer_escapes_backtick_and_backslash(self): + """The identifier preparer backslash-escapes backticks and backslashes.""" + preparer = SpannerDialect().identifier_preparer + eq_(preparer.quote("a`b"), "`a\\`b`") + eq_(preparer.quote("a\\b"), "`a\\\\b`") From f6a670511de05a4340fdb24c201fa3564c6c0989 Mon Sep 17 00:00:00 2001 From: bibi samina Date: Wed, 26 Aug 2026 22:22:02 +0530 Subject: [PATCH 2/2] fix(sqlalchemy-spanner): accept Table object as interleave parent Signed-off-by: bibi samina --- .../sqlalchemy_spanner/sqlalchemy_spanner.py | 7 ++++-- .../tests/unit/test_dialect.py | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py b/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py index 4d2b3a5de1c2..703195efcd70 100644 --- a/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py +++ b/packages/sqlalchemy-spanner/google/cloud/sqlalchemy_spanner/sqlalchemy_spanner.py @@ -713,9 +713,12 @@ def post_create_table(self, table): if "TEMPORARY" in table._prefixes: raise NotImplementedError("Temporary tables are not supported.") - if table.kwargs.get("spanner_interleave_in"): + parent = table.kwargs.get("spanner_interleave_in") + if parent is not None and hasattr(parent, "name"): + parent = parent.name + if parent: post_cmds += ",\nINTERLEAVE IN PARENT {}".format( - self.preparer.quote(table.kwargs["spanner_interleave_in"]) + self.preparer.quote(parent) ) if table.kwargs.get("spanner_interleave_on_delete_cascade"): diff --git a/packages/sqlalchemy-spanner/tests/unit/test_dialect.py b/packages/sqlalchemy-spanner/tests/unit/test_dialect.py index 9cc228ac9b72..0130ca3d08e7 100644 --- a/packages/sqlalchemy-spanner/tests/unit/test_dialect.py +++ b/packages/sqlalchemy-spanner/tests/unit/test_dialect.py @@ -127,3 +127,26 @@ def test_preparer_escapes_backtick_and_backslash(self): preparer = SpannerDialect().identifier_preparer eq_(preparer.quote("a`b"), "`a\\`b`") eq_(preparer.quote("a\\b"), "`a\\\\b`") + + def test_interleave_in_parent_string_is_quoted(self): + """A string interleave parent is routed through the identifier preparer.""" + table = Table( + "child", + MetaData(), + Column("id", Integer, primary_key=True), + spanner_interleave_in="from", + ) + ddl = str(CreateTable(table).compile(dialect=SpannerDialect())) + assert "INTERLEAVE IN PARENT `from`" in ddl + + def test_interleave_in_parent_table_object_is_quoted(self): + """A Table interleave parent is quoted by its name rather than repr.""" + parent = Table("from", MetaData(), Column("id", Integer, primary_key=True)) + table = Table( + "child", + MetaData(), + Column("id", Integer, primary_key=True), + spanner_interleave_in=parent, + ) + ddl = str(CreateTable(table).compile(dialect=SpannerDialect())) + assert "INTERLEAVE IN PARENT `from`" in ddl