Skip to content

fix(django-google-spanner): harden identifier quoting - #19069

Merged
olavloite merged 2 commits into
mainfrom
spanner-django-harden-identifier-quoting
Nov 13, 2026
Merged

olavloite merged 2 commits into
mainfrom
spanner-django-harden-identifier-quoting

Conversation

@olavloite

Copy link
Copy Markdown
Contributor

Previously, Django's identifier quoting delegated directly to the DB-API driver's name-escaping helper, which is meant for dot-qualified SQL paths and unconditionally quotes all identifiers in newer versions. That caused single identifiers containing dots to be split into separate parts and broke compatibility with existing Django SQL expectations.

This change implements single-identifier quoting directly in the Django backend:

  • Quotes identifiers as a single unit without splitting on dots.
  • Escapes backslashes, backticks (using GoogleSQL hex escapes so both Spanner DDL and downstream SQL comment/statement parsing keep the identifier intact), and ASCII control characters without rejecting identifiers client-side.
  • Preserves already validly quoted single identifiers idempotently while re-escaping malformed inputs and injection payloads.
  • Keeps unreserved regular ASCII identifiers unquoted for now, with a TODO to switch to unconditional quoting in a follow-up change.
  • Expands unit tests to cover dotted identifiers, control characters, malformed quotes, idempotency, and SQL injection payloads.

Previously, Django's identifier quoting delegated directly to the DB-API
driver's name-escaping helper, which is meant for dot-qualified SQL paths
and unconditionally quotes all identifiers in newer versions. That caused
single identifiers containing dots to be split into separate parts and
broke compatibility with existing Django SQL expectations.

This change implements single-identifier quoting directly in the Django
backend:
- Quotes identifiers as a single unit without splitting on dots.
- Escapes backslashes, backticks (using GoogleSQL hex escapes so both
  Spanner DDL and downstream SQL comment/statement parsing keep the
  identifier intact), and ASCII control characters without rejecting
  identifiers client-side.
- Preserves already validly quoted single identifiers idempotently while
  re-escaping malformed inputs and injection payloads.
- Keeps unreserved regular ASCII identifiers unquoted for now, with a
  TODO to switch to unconditional quoting in a follow-up change.
- Expands unit tests to cover dotted identifiers, control characters,
  malformed quotes, idempotency, and SQL injection payloads.
@olavloite
olavloite requested a review from a team as a code owner November 13, 2026 11:57

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request improves the identifier quoting mechanism in django_spanner/operations.py by rewriting quote_name to safely escape and quote table, index, and column names using robust regular expressions and an escape translation table. This prevents SQL injection and ensures compatibility with GoogleSQL. Additionally, a comprehensive set of unit tests has been added in test_operations.py to cover various edge cases, including reserved keywords, special characters, and SQL injection payloads. There are no review comments, and I have no feedback to provide.

@chalmerlowe chalmerlowe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, pending resolution of Blocker and typehinting.
Leave the preference up to your discretion.

return escape_name(name)
if _QUOTED_IDENTIFIER_RE.match(name):
return name
# TODO: Unconditionally quote all identifiers in a follow-up PR (after

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKER

TODO comments should include a parenthesized issue reference (TODO(issue): ...) for traceability

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@@ -106,28 +127,46 @@ def max_name_length(self):

def quote_name(self, name):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def quote_name(self, name):
def quote_name(self, name: str) -> str:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines +253 to +254
with self.subTest(invalid=invalid), self.assertRaises(TypeError):
self.db_operations.quote_name(invalid)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PREFERENCE

Bare self.assertRaises(TypeError) only checks the exception type and would pass even if TypeError were raised unintentionally by an internal call (e.g., re.match(name) or name.translate(...)) or if the diagnostic message regressed. If we use self.assertRaisesRegex to pin the exact error message format (Identifier must be a string, got <type>)

Suggested change
with self.subTest(invalid=invalid), self.assertRaises(TypeError):
self.db_operations.quote_name(invalid)
expected_msg = (
rf"^Identifier must be a string, got {type(invalid).__name__}$"
)
with self.subTest(invalid=invalid), self.assertRaisesRegex(
TypeError, expected_msg
):
self.db_operations.quote_name(invalid)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@olavloite
olavloite merged commit f896935 into main Nov 13, 2026
85 checks passed
@olavloite
olavloite deleted the spanner-django-harden-identifier-quoting branch November 13, 2026 17:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants