Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions packages/django-google-spanner/django_spanner/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

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.

Not directly part of this change, but still: quote_value falls through to str(value) when value is None, returning the literal string "None" instead of SQL "NULL". In GoogleSQL DDL, DEFAULT (None) causes a syntax/name-resolution error. Please handle None explicitly by returning "NULL".

Similar problems also occur for Date, Timestamp and Bytes.

Add these tests to verify:

def test_quote_value_handles_none(self):
    schema_editor = DatabaseSchemaEditor(self.connection)
    self.assertEqual(schema_editor.quote_value(None), "NULL")

import datetime
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'",
    )

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 in e109b70. None now returns NULL, date/datetime are quoted via isoformat (space separator for datetime, so your tests pass as written), and bytes become a GoogleSQL b'...' literal with the quote, backslash and anything outside printable ASCII written as \xNN, so a bytes value can't close the literal either.

One note on bytes: effective_default already runs a BinaryField default through Database.Binary, which base64-encodes it, so what reaches quote_value on that path is the base64 text. That matches what convert_binaryfield_value decodes on read. Tests added for None, date/datetime and bytes.


def prepare_default(self, value):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):

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.

Also add:

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")

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.

Added both in e109b70.

"""
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.
Expand Down
Loading