diff --git a/packages/django-google-spanner/django_spanner/schema.py b/packages/django-google-spanner/django_spanner/schema.py index da57122bb73d..a90a56c72723 100644 --- a/packages/django-google-spanner/django_spanner/schema.py +++ b/packages/django-google-spanner/django_spanner/schema.py @@ -3,6 +3,7 @@ # Use of this source code is governed by a BSD-style # license that can be found in the LICENSE file or at # https://developers.google.com/open-source/licenses/bsd +import datetime import os import uuid @@ -448,11 +449,33 @@ def add_index(self, model, index): super().add_index(model, index) def quote_value(self, value): - # A more complete implementation isn't currently required. + if value is None: + return "NULL" if isinstance(value, str): - return "'%s'" % value.replace("'", "''") + # GoogleSQL string literals use backslash escaping; '' quote + # doubling is not recognized, so escape the backslash first and + # then the quote (matching the db_default/generated inlining above). + # Literal newlines are not allowed inside the quotes either. + return "'%s'" % ( + value.replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\n", "\\n") + .replace("\r", "\\r") + ) if isinstance(value, bool): return "TRUE" if value else "FALSE" + if isinstance(value, (bytes, bytearray, memoryview)): + # GoogleSQL bytes literal. The quote, the backslash and anything + # outside printable ASCII are emitted as \x escapes. + escaped = "".join( + chr(b) if 0x20 <= b <= 0x7E and b not in (0x27, 0x5C) else "\\x%02x" % b + for b in bytes(value) + ) + return "b'%s'" % escaped + if isinstance(value, datetime.datetime): + return "'%s'" % value.isoformat(sep=" ") + if isinstance(value, datetime.date): + return "'%s'" % value.isoformat() return str(value) def prepare_default(self, value): diff --git a/packages/django-google-spanner/tests/unit/django_spanner/test_schema.py b/packages/django-google-spanner/tests/unit/django_spanner/test_schema.py index b7ef7cec39ec..90fda59cbe7a 100644 --- a/packages/django-google-spanner/tests/unit/django_spanner/test_schema.py +++ b/packages/django-google-spanner/tests/unit/django_spanner/test_schema.py @@ -5,6 +5,7 @@ # https://developers.google.com/open-source/licenses/bsd +import datetime from unittest import mock from django.db import NotSupportedError, connection, connections @@ -40,6 +41,63 @@ def test_quote_value(self): schema_editor = DatabaseSchemaEditor(self.connection) self.assertEqual(schema_editor.quote_value(value=1.1), "1.1") + def test_quote_value_escapes_string(self): + """ + String literals must be backslash-escaped for GoogleSQL. A quote or + backslash in the value must not be able to terminate the literal. + """ + schema_editor = DatabaseSchemaEditor(self.connection) + self.assertEqual(schema_editor.quote_value(value="o'brien"), "'o\\'brien'") + self.assertEqual(schema_editor.quote_value(value="a\\b"), "'a\\\\b'") + self.assertEqual( + schema_editor.quote_value(value="\\'; DROP TABLE t; --"), + "'\\\\\\'; DROP TABLE t; --'", + ) + + def test_quote_value_escapes_newlines_and_carriage_returns(self): + schema_editor = DatabaseSchemaEditor(self.connection) + self.assertEqual( + schema_editor.quote_value("line1\nline2"), + "'line1\\nline2'", + ) + self.assertEqual( + schema_editor.quote_value("line1\r\nline2"), + "'line1\\r\\nline2'", + ) + + def test_quote_value_handles_none(self): + schema_editor = DatabaseSchemaEditor(self.connection) + self.assertEqual(schema_editor.quote_value(None), "NULL") + + def test_quote_value_handles_date_and_datetime(self): + schema_editor = DatabaseSchemaEditor(self.connection) + self.assertEqual( + schema_editor.quote_value(datetime.date(2026, 9, 4)), + "'2026-09-04'", + ) + self.assertEqual( + schema_editor.quote_value(datetime.datetime(2026, 9, 4, 12, 0, 0)), + "'2026-09-04 12:00:00'", + ) + + def test_quote_value_handles_bytes(self): + schema_editor = DatabaseSchemaEditor(self.connection) + self.assertEqual(schema_editor.quote_value(b"abc"), "b'abc'") + self.assertEqual( + schema_editor.quote_value(b"\x00'\\\n\xff"), + "b'\\x00\\x27\\x5c\\x0a\\xff'", + ) + + def test_quote_value_booleans(self): + schema_editor = DatabaseSchemaEditor(self.connection) + self.assertEqual(schema_editor.quote_value(True), "TRUE") + self.assertEqual(schema_editor.quote_value(False), "FALSE") + + def test_prepare_default_delegates_to_quote_value(self): + schema_editor = DatabaseSchemaEditor(self.connection) + self.assertEqual(schema_editor.prepare_default("o'brien"), "'o\\'brien'") + self.assertEqual(schema_editor.prepare_default(True), "TRUE") + def test_skip_default(self): """ Tries skipping default as Cloud spanner doesn't support it.