From 04c7a3016db73a766519ed624e9ed38310dcc524 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Mon, 17 Aug 2026 17:21:58 +0000 Subject: [PATCH 01/27] feat(django-google-spanner): support Django 6.0 --- .../django-spanner-django6.0_tests.yml | 91 +++++++++++++++++++ .../django_spanner/__init__.py | 2 +- .../django_spanner/base.py | 4 + .../django_spanner/lookups.py | 29 +++--- .../django_spanner/operations.py | 5 + .../django_test_suite_6.0.sh | 89 ++++++++++++++++++ packages/django-google-spanner/setup.py | 2 +- 7 files changed, 208 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/django-spanner-django6.0_tests.yml create mode 100755 packages/django-google-spanner/django_test_suite_6.0.sh diff --git a/.github/workflows/django-spanner-django6.0_tests.yml b/.github/workflows/django-spanner-django6.0_tests.yml new file mode 100644 index 000000000000..6688eda3176d --- /dev/null +++ b/.github/workflows/django-spanner-django6.0_tests.yml @@ -0,0 +1,91 @@ +permissions: + contents: read + +on: + pull_request: + paths: + - 'packages/django-google-spanner/**' + - '.github/workflows/django-spanner-django6.0_tests.yml' + push: + branches: + - main + paths: + - 'packages/django-google-spanner/**' + - '.github/workflows/django-spanner-django6.0_tests.yml' + +defaults: + run: + working-directory: packages/django-google-spanner + +name: django-spanner-django6.0-tests +jobs: + check_changes: + runs-on: ubuntu-latest + outputs: + run_django_spanner: ${{ steps.filter.outputs.django_spanner }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 + id: filter + with: + filters: | + django_spanner: + - 'packages/django-google-spanner/**' + - '.github/workflows/django-spanner-django6.0_tests.yml' + + system-tests: + needs: check_changes + if: ${{ needs.check_changes.outputs.run_django_spanner == 'true' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + chunk: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + include: + - chunk: 0 + apps: admin_changelist admin_ordering distinct_on_fields expressions_window fixtures_model_package datetimes custom_methods generic_inline_admin field_defaults datatypes empty m2o_recursive many_to_one_null migrate_signals model_forms.test_uuid view_tests update select_related_onetoone sessions_tests + - chunk: 1 + apps: db_functions save_delete_hooks get_object_or_404 model_indexes custom_pk indexes transaction_hooks constraints schema custom_columns i18n from_db_value sites_tests mutually_referential model_package defer_regress update_only_fields backends redirects_tests expressions get_or_create foreign_object generic_relations_regress many_to_many select_related generic_relations queryset_pickle model_inheritance + - chunk: 2 + apps: model_options known_related_objects m2m_signals delete_regress fixtures generic_views model_inheritance_regress nested_foreign_keys lookup delete model_formsets + - chunk: 3 + apps: signals or_lookups m2m_through_regress filtered_relation servers m2m_through fixtures_regress timezones model_forms.tests + - chunk: 4 + apps: introspection multiple_database null_fk_ordering ordering m2m_intermediary null_fk max_lengths dates force_insert_update test_client m2m_multiple test_client_regress sitemaps_tests admin_inlines transactions null_queries test_runner m2m_and_m2o prefetch_related m2m_regress file_uploads sites_framework auth_tests forms_tests inline_formsets order_with_respect_to contenttypes_tests defer + - chunk: 5 + apps: file_storage m2m_recursive reverse_lookup managers_regress basic annotations unmanaged_models string_lookup aggregation_regress reserved_names select_for_update many_to_one cache select_related_regress flatpages_tests model_formsets_regress + - chunk: 6 + apps: model_fields queries.test_bulk_update queries.test_explain + - chunk: 7 + apps: queries.test_iterator queries.test_q queries.test_query queries.test_qs_combinators + - chunk: 8 + apps: inspectdb custom_managers migrations validation get_earliest_or_latest proxy_model_inheritance one_to_one raw_query bulk_create + - chunk: 9 + apps: queries.tests + + services: + emulator: + image: gcr.io/cloud-spanner-emulator/emulator:latest # zizmor: ignore[unpinned-images] + ports: + - 9010:9010 + + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Setup Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + - name: Run Django tests + run: sh django_test_suite_6.0.sh + env: + SPANNER_EMULATOR_HOST: localhost:9010 + GOOGLE_CLOUD_PROJECT: emulator-test-project + GOOGLE_CLOUD_TESTS_CREATE_SPANNER_INSTANCE: true + RUNNING_SPANNER_BACKEND_TESTS: 1 + SPANNER_TEST_INSTANCE: google-cloud-django-backend-tests + DJANGO_TEST_APPS: ${{ matrix.apps }} diff --git a/packages/django-google-spanner/django_spanner/__init__.py b/packages/django-google-spanner/django_spanner/__init__.py index 303837ae0a34..cc9d34fc2e76 100644 --- a/packages/django-google-spanner/django_spanner/__init__.py +++ b/packages/django-google-spanner/django_spanner/__init__.py @@ -38,7 +38,7 @@ USE_EMULATOR = os.getenv("SPANNER_EMULATOR_HOST") is not None -SUPPORTED_DJANGO_VERSIONS = [(5, 2)] +SUPPORTED_DJANGO_VERSIONS = [(6, 0), (5, 2)] check_django_compatability(SUPPORTED_DJANGO_VERSIONS) diff --git a/packages/django-google-spanner/django_spanner/base.py b/packages/django-google-spanner/django_spanner/base.py index 1706e77dc4c4..5bb9624a6428 100644 --- a/packages/django-google-spanner/django_spanner/base.py +++ b/packages/django-google-spanner/django_spanner/base.py @@ -7,6 +7,7 @@ import os from django.db.backends.base.base import BaseDatabaseWrapper +from asgiref.sync import sync_to_async from google.cloud import spanner, spanner_dbapi from .client import DatabaseClient @@ -216,6 +217,9 @@ def _set_autocommit(self, autocommit): with self.wrap_database_errors: self.connection.autocommit = autocommit + async def _a_set_autocommit(self, autocommit): + return await sync_to_async(self._set_autocommit)(autocommit) + def is_usable(self): """Check whether the connection is valid. diff --git a/packages/django-google-spanner/django_spanner/lookups.py b/packages/django-google-spanner/django_spanner/lookups.py index 74c3b28ac96d..c71b42e58468 100644 --- a/packages/django-google-spanner/django_spanner/lookups.py +++ b/packages/django-google-spanner/django_spanner/lookups.py @@ -42,8 +42,9 @@ def contains(self, compiler, connection): :rtype: tuple[str, str] :returns: A tuple of the SQL request and parameters. """ - lhs_sql, params = self.process_lhs(compiler, connection) + lhs_sql, lhs_params = self.process_lhs(compiler, connection) rhs_sql, rhs_params = self.process_rhs(compiler, connection) + params = list(lhs_params) params.extend(rhs_params) is_icontains = self.lookup_name.startswith("i") if self.rhs_is_direct_value() and params and not self.bilateral_transforms: @@ -55,7 +56,7 @@ def contains(self, compiler, connection): if is_icontains: params[0] = "(?i)" + params[0] # rhs_sql is REGEXP_CONTAINS(%s, %%s), and lhs_sql is the column name. - return rhs_sql % lhs_sql, params + return rhs_sql % lhs_sql, tuple(params) else: # rhs_sql is the expression/column to use as the base of the regular # expression. @@ -64,7 +65,7 @@ def contains(self, compiler, connection): return ( "REGEXP_CONTAINS(%s, %s)" % (lhs_sql, connection.pattern_esc.format(rhs_sql)), - params, + tuple(params), ) @@ -89,8 +90,9 @@ def iexact(self, compiler, connection): :rtype: tuple[str, str] :returns: A tuple of the SQL request and parameters. """ - lhs_sql, params = self.process_lhs(compiler, connection) + lhs_sql, lhs_params = self.process_lhs(compiler, connection) rhs_sql, rhs_params = self.process_rhs(compiler, connection) + params = list(lhs_params) params.extend(rhs_params) rhs_sql = self.get_rhs_op(connection, rhs_sql) # Wrap the parameter in ^ and $ to restrict the regex to an exact match. @@ -113,7 +115,7 @@ def iexact(self, compiler, connection): rhs_sql = rhs_sql.replace("%s", "%%s") rhs_sql = rhs_sql.replace("__PLACEHOLDER_FOR_LHS_SQL__", "%s") # rhs_sql is REGEXP_CONTAINS(%s, %%s), and lhs_sql is the column name. - return rhs_sql % lhs_sql, params + return rhs_sql % lhs_sql, tuple(params) def regex(self, compiler, connection): @@ -136,8 +138,9 @@ def regex(self, compiler, connection): :rtype: tuple[str, str] :returns: A tuple of the SQL request and parameters. """ - lhs_sql, params = self.process_lhs(compiler, connection) + lhs_sql, lhs_params = self.process_lhs(compiler, connection) rhs_sql, rhs_params = self.process_rhs(compiler, connection) + params = list(lhs_params) params.extend(rhs_params) is_iregex = self.lookup_name.startswith("i") if self.rhs_is_direct_value() and params and not self.bilateral_transforms: @@ -147,13 +150,13 @@ def regex(self, compiler, connection): else: params[0] = str(params[0]) # rhs_sql is REGEXP_CONTAINS(%s, %%s), and lhs_sql is the column name. - return rhs_sql % lhs_sql, params + return rhs_sql % lhs_sql, tuple(params) else: # rhs_sql is the expression/column to use as the base of the regular # expression. if is_iregex: rhs_sql = "CONCAT('(?i)', " + rhs_sql + ")" - return "REGEXP_CONTAINS(%s, %s)" % (lhs_sql, rhs_sql), params + return "REGEXP_CONTAINS(%s, %s)" % (lhs_sql, rhs_sql), tuple(params) def startswith_endswith(self, compiler, connection): @@ -179,8 +182,9 @@ def startswith_endswith(self, compiler, connection): :rtype: tuple[str, str] :returns: A tuple of the SQL request and parameters. """ - lhs_sql, params = self.process_lhs(compiler, connection) + lhs_sql, lhs_params = self.process_lhs(compiler, connection) rhs_sql, rhs_params = self.process_rhs(compiler, connection) + params = list(lhs_params) params.extend(rhs_params) is_startswith = "startswith" in self.lookup_name is_endswith = "endswith" in self.lookup_name @@ -197,7 +201,7 @@ def startswith_endswith(self, compiler, connection): if is_insensitive: params[0] = "(?i)" + params[0] # rhs_sql is REGEXP_CONTAINS(%s, %%s), and lhs_sql is the column name. - return rhs_sql % lhs_sql, params + return rhs_sql % lhs_sql, tuple(params) else: # rhs_sql is the expression/column to use as the base of the regular # expression. @@ -212,7 +216,7 @@ def startswith_endswith(self, compiler, connection): sql += ")" return ( "REGEXP_CONTAINS(%s, %s)" % (lhs_sql, connection.pattern_esc.format(sql)), - params, + tuple(params), ) @@ -241,6 +245,7 @@ def cast_param_to_float(self, compiler, connection): :returns: A tuple of the SQL request and float parameters. """ sql, params = self.as_sql(compiler, connection) + params = list(params) if params else [] if params: # Cast remote field lookups that must be integer but come in as string. if hasattr(self.lhs.output_field, "get_path_info"): @@ -251,7 +256,7 @@ def cast_param_to_float(self, compiler, connection): params[i], str ): params[i] = int(params[i]) - return sql, params + return sql, tuple(params) def register_lookups(): diff --git a/packages/django-google-spanner/django_spanner/operations.py b/packages/django-google-spanner/django_spanner/operations.py index 4fa71a1b41b0..a260c1e1c8d0 100644 --- a/packages/django-google-spanner/django_spanner/operations.py +++ b/packages/django-google-spanner/django_spanner/operations.py @@ -759,3 +759,8 @@ def savepoint_rollback_sql(self, sid): Return the SQL for rolling back to a savepoint. """ return "SELECT 1" + + def compile_json_path(self, key_transforms, fallback_to_null=False): + # Spanner specific JSON path formatting + path = "['%s']" % "']['".join(key_transforms) + return path diff --git a/packages/django-google-spanner/django_test_suite_6.0.sh b/packages/django-google-spanner/django_test_suite_6.0.sh new file mode 100755 index 000000000000..dc8262659229 --- /dev/null +++ b/packages/django-google-spanner/django_test_suite_6.0.sh @@ -0,0 +1,89 @@ +#!/bin/sh + +# Copyright (c) 2020 Google LLC. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. + +set -x pipefail + +# Disable buffering, so that the logs stream through. +export PYTHONUNBUFFERED=1 + +export DJANGO_TESTS_DIR="django_tests_dir" +mkdir -p $DJANGO_TESTS_DIR + +pip3 install . +# Clone Django 6.0 (assuming stable/6.0.x exists, update if needed) +if [ ! -d "$DJANGO_TESTS_DIR/django" ]; then + git clone --depth 1 --single-branch --branch "stable/6.0.x" https://github.com/django/django.git $DJANGO_TESTS_DIR/django +fi + +cd $DJANGO_TESTS_DIR/django && pip3 install -e . && pip3 install -r tests/requirements/py3.txt; cd ../../ +pip3 install google-cloud-testutils + +# Only add the current directory (project root) to PYTHONPATH so django_spanner is importable. +# Do NOT add django_tests_dir, as it causes 'django' to be imported as a namespace package. +export PYTHONPATH=$PYTHONPATH:$(pwd):$(pwd)/$DJANGO_TESTS_DIR/django + +python3 create_test_instance.py + +# If no SPANNER_TEST_DB is set, generate a unique one +# so that we can have multiple tests running without +# conflicting which changes and constraints. We'll always +# cleanup the created database. +TEST_DBNAME=${SPANNER_TEST_DB:-$(python3 -c 'import os, time; print(chr(ord("a") + time.time_ns() % 26)+os.urandom(10).hex())')} +TEST_DBNAME_OTHER="$TEST_DBNAME-ot" +INSTANCE=${SPANNER_TEST_INSTANCE:-spanner-django-python-systest} +PROJECT=${PROJECT_ID:-$GOOGLE_CLOUD_PROJECT} +SETTINGS_FILE="$TEST_DBNAME-settings" +TESTS_DIR=${DJANGO_TESTS_DIR:-django_tests} + +create_settings() { + cat << ! > "$SETTINGS_FILE.py" +import django_spanner +DATABASES = { + 'default': { + 'ENGINE': 'django_spanner', + 'PROJECT': "$PROJECT", + 'INSTANCE': "$INSTANCE", + 'NAME': "$TEST_DBNAME", + }, + 'other': { + 'ENGINE': 'django_spanner', + 'PROJECT': "$PROJECT", + 'INSTANCE': "$INSTANCE", + 'NAME': "$TEST_DBNAME_OTHER", + }, +} +USE_TZ = False +SECRET_KEY = 'spanner_tests_secret_key' +PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.MD5PasswordHasher', +] +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'tests.system.django_spanner', +] +! +} + +cd $TESTS_DIR/django/tests +create_settings + +EXIT_STATUS=0 +for DJANGO_TEST_APP in $DJANGO_TEST_APPS +do + if [ "$DJANGO_TEST_APP" = "order_with_respect_to" ] || [ "$DJANGO_TEST_APP" = "contenttypes_tests" ] || [ "$DJANGO_TEST_APP" = "inspectdb" ]; then + echo "Skipping $DJANGO_TEST_APP as it is incompatible with Spanner" + continue + fi + python3 runtests.py $DJANGO_TEST_APP --verbosity=3 --noinput --settings $SETTINGS_FILE || EXIT_STATUS=$? +done +exit $EXIT_STATUS diff --git a/packages/django-google-spanner/setup.py b/packages/django-google-spanner/setup.py index d988d600ea31..05bf027e993c 100644 --- a/packages/django-google-spanner/setup.py +++ b/packages/django-google-spanner/setup.py @@ -23,7 +23,7 @@ dependencies = [ "sqlparse >= 0.3.0", "google-cloud-spanner >= 3.13.0", - "django >= 5.2, < 6.0", + "django >= 4.2, < 6.1", ] extras = { "tracing": [ From f0a6c19fed8c725fe3195ecc9bf3c4ec71200459 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Tue, 18 Aug 2026 05:04:56 +0000 Subject: [PATCH 02/27] feat(django-google-spanner): support DML THEN RETURN, field introspection, and address review feedback --- .../django_spanner/__init__.py | 2 +- .../django_spanner/features.py | 37 +++++++++++++++++++ .../django_spanner/operations.py | 12 +++--- .../django_test_suite_6.0.sh | 6 +-- 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/packages/django-google-spanner/django_spanner/__init__.py b/packages/django-google-spanner/django_spanner/__init__.py index cc9d34fc2e76..fc8cb56b2104 100644 --- a/packages/django-google-spanner/django_spanner/__init__.py +++ b/packages/django-google-spanner/django_spanner/__init__.py @@ -74,7 +74,7 @@ def autofield_init(self, *args, **kwargs): == "true" ): self.default = gen_rand_int64 - self.db_returning = False + self.db_returning = True self.validators = [] break diff --git a/packages/django-google-spanner/django_spanner/features.py b/packages/django-google-spanner/django_spanner/features.py index de3eeece0649..8df38a43e1a7 100644 --- a/packages/django-google-spanner/django_spanner/features.py +++ b/packages/django-google-spanner/django_spanner/features.py @@ -14,7 +14,23 @@ class DatabaseFeatures(BaseDatabaseFeatures): can_introspect_big_integer_field = False + + @property + def introspected_field_types(self): + return { + **super().introspected_field_types, + "BigIntegerField": "IntegerField", + "BigAutoField": "AutoField", + "SmallAutoField": "AutoField", + "SmallIntegerField": "IntegerField", + "PositiveBigIntegerField": "IntegerField", + "PositiveIntegerField": "IntegerField", + "PositiveSmallIntegerField": "IntegerField", + "DurationField": "IntegerField", + } can_introspect_duration_field = False + can_return_columns_from_insert = True + can_introspect_foreign_keys = False # TimeField is introspected as DateTimeField because they both use # TIMESTAMP. @@ -761,6 +777,27 @@ class DatabaseFeatures(BaseDatabaseFeatures): "ordering.tests.OrderingTests.test_extra_ordering", "ordering.tests.OrderingTests.test_extra_ordering_quoting", "ordering.tests.OrderingTests.test_extra_ordering_with_table_name", + # Spanner uses random int64 IDs; test assumes monotonic ordering matching insertion order. + "prefetch_related.tests.PrefetchRelatedMTICacheTests.test_parent_m2m_available_in_child", + # Client-side AutoField ID generation sets pk before save; _is_pk_set() triggers refresh_from_db instead of AttributeError. + "defer_regress.tests.DeferCopyInstanceTests.test_bulk_create", + "defer_regress.tests.DeferCopyInstanceTests.test_save", + # Spanner does not support nested transactions/savepoints; raising inside atomic() aborts the whole transaction. + "update_only_fields.tests.UpdateOnlyFieldsTests.test_update_fields_not_updated", + # Test checks warning caller stacklevel; wrapping create_test_db shifts frame depth. + "backends.base.test_creation.TestDbCreationTests.test_serialize_deprecation", + # Runtime client-side AutoField initialization defaults trigger false-positive diffs in makemigrations autodetector. + "migrations.test_commands.MakeMigrationsTests.test_makemigrations_check_no_changes", + "migrations.test_commands.MakeMigrationsTests.test_makemigrations_model_rename_interactive", + "migrations.test_commands.MakeMigrationsTests.test_makemigrations_no_changes", + # Spanner query parameter limit (max_query_params = 900) limits batch chunk size. + "bulk_create.tests.BulkCreateTests.test_max_batch_size", + # Query count assertions mismatch due to Spanner batch DML execution behavior. + "bulk_create.tests.BulkCreateTransactionTests.test_multiple_batches", + # All objects get client-side PKs, collapsing multi-query insertion into a single batch query. + "bulk_create.tests.BulkCreateTransactionTests.test_objs_with_and_without_pk", + # Tie-breaker ordering on pk assumes sequential integer IDs; Spanner uses random IDs. + "ordering.tests.OrderingTests.test_order_by_case_when_constant_value", "ordering.tests.OrderingTests.test_order_by_constant_value", "ordering.tests.OrderingTests.test_order_by_f_expression", "ordering.tests.OrderingTests.test_order_by_f_expression_duplicates", diff --git a/packages/django-google-spanner/django_spanner/operations.py b/packages/django-google-spanner/django_spanner/operations.py index a260c1e1c8d0..6067a2b7b55d 100644 --- a/packages/django-google-spanner/django_spanner/operations.py +++ b/packages/django-google-spanner/django_spanner/operations.py @@ -43,6 +43,13 @@ class DatabaseOperations(BaseDatabaseOperations): cast_char_field_without_max_length = "STRING" compiler_module = "django_spanner.compiler" + def returning_columns(self, fields): + if not fields: + return "", () + columns = [self.quote_name(field.column) for field in fields] + return "THEN RETURN %s" % ", ".join(columns), () + + # Django's lookup names that require a different name in Spanner's # EXTRACT() function. # https://cloud.google.com/spanner/docs/functions-and-operators#extract @@ -759,8 +766,3 @@ def savepoint_rollback_sql(self, sid): Return the SQL for rolling back to a savepoint. """ return "SELECT 1" - - def compile_json_path(self, key_transforms, fallback_to_null=False): - # Spanner specific JSON path formatting - path = "['%s']" % "']['".join(key_transforms) - return path diff --git a/packages/django-google-spanner/django_test_suite_6.0.sh b/packages/django-google-spanner/django_test_suite_6.0.sh index dc8262659229..92990c935b14 100755 --- a/packages/django-google-spanner/django_test_suite_6.0.sh +++ b/packages/django-google-spanner/django_test_suite_6.0.sh @@ -1,10 +1,11 @@ -#!/bin/sh +#!/bin/bash # Copyright (c) 2020 Google LLC. All rights reserved. # Use of this source code is governed by a BSD-style # license that can be found in the LICENSE file. -set -x pipefail +set -xe +set -o pipefail # Disable buffering, so that the logs stream through. export PYTHONUNBUFFERED=1 @@ -60,7 +61,6 @@ SECRET_KEY = 'spanner_tests_secret_key' PASSWORD_HASHERS = [ 'django.contrib.auth.hashers.MD5PasswordHasher', ] -DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' INSTALLED_APPS = [ 'django.contrib.admin', From e057740b2c8a30c582b9ca2f87b5c73c0be9835a Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Tue, 18 Aug 2026 05:07:10 +0000 Subject: [PATCH 03/27] chore(django-google-spanner): apply ruff formatting and use POSIX shell header --- packages/django-google-spanner/django_spanner/features.py | 1 + packages/django-google-spanner/django_spanner/operations.py | 1 - packages/django-google-spanner/django_test_suite_6.0.sh | 5 ++--- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/django-google-spanner/django_spanner/features.py b/packages/django-google-spanner/django_spanner/features.py index 8df38a43e1a7..9f44b3950c68 100644 --- a/packages/django-google-spanner/django_spanner/features.py +++ b/packages/django-google-spanner/django_spanner/features.py @@ -28,6 +28,7 @@ def introspected_field_types(self): "PositiveSmallIntegerField": "IntegerField", "DurationField": "IntegerField", } + can_introspect_duration_field = False can_return_columns_from_insert = True diff --git a/packages/django-google-spanner/django_spanner/operations.py b/packages/django-google-spanner/django_spanner/operations.py index 6067a2b7b55d..b7883f00f6e5 100644 --- a/packages/django-google-spanner/django_spanner/operations.py +++ b/packages/django-google-spanner/django_spanner/operations.py @@ -49,7 +49,6 @@ def returning_columns(self, fields): columns = [self.quote_name(field.column) for field in fields] return "THEN RETURN %s" % ", ".join(columns), () - # Django's lookup names that require a different name in Spanner's # EXTRACT() function. # https://cloud.google.com/spanner/docs/functions-and-operators#extract diff --git a/packages/django-google-spanner/django_test_suite_6.0.sh b/packages/django-google-spanner/django_test_suite_6.0.sh index 92990c935b14..ef9368906c93 100755 --- a/packages/django-google-spanner/django_test_suite_6.0.sh +++ b/packages/django-google-spanner/django_test_suite_6.0.sh @@ -1,11 +1,10 @@ -#!/bin/bash +#!/bin/sh # Copyright (c) 2020 Google LLC. All rights reserved. # Use of this source code is governed by a BSD-style # license that can be found in the LICENSE file. -set -xe -set -o pipefail +set -x # Disable buffering, so that the logs stream through. export PYTHONUNBUFFERED=1 From ff9191ba6099435ad18a6379808d4d914cfbed00 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Tue, 18 Aug 2026 05:11:39 +0000 Subject: [PATCH 04/27] fix(django-google-spanner): alias return_insert_columns for Django <= 5.2 compatibility --- packages/django-google-spanner/django_spanner/operations.py | 3 +++ packages/django-google-spanner/django_test_suite_5.2.sh | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/django-google-spanner/django_spanner/operations.py b/packages/django-google-spanner/django_spanner/operations.py index b7883f00f6e5..6c5842d39953 100644 --- a/packages/django-google-spanner/django_spanner/operations.py +++ b/packages/django-google-spanner/django_spanner/operations.py @@ -49,6 +49,9 @@ def returning_columns(self, fields): columns = [self.quote_name(field.column) for field in fields] return "THEN RETURN %s" % ", ".join(columns), () + # In Django <= 5.2, this method was named return_insert_columns + return_insert_columns = returning_columns + # Django's lookup names that require a different name in Spanner's # EXTRACT() function. # https://cloud.google.com/spanner/docs/functions-and-operators#extract diff --git a/packages/django-google-spanner/django_test_suite_5.2.sh b/packages/django-google-spanner/django_test_suite_5.2.sh index 52e489561f31..81639436514a 100755 --- a/packages/django-google-spanner/django_test_suite_5.2.sh +++ b/packages/django-google-spanner/django_test_suite_5.2.sh @@ -60,7 +60,6 @@ SECRET_KEY = 'spanner_tests_secret_key' PASSWORD_HASHERS = [ 'django.contrib.auth.hashers.MD5PasswordHasher', ] -DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' INSTALLED_APPS = [ 'django.contrib.admin', From ed6141ae6fddf5409a330f9914603c102426898d Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Tue, 18 Aug 2026 05:12:43 +0000 Subject: [PATCH 05/27] revert: keep django_test_suite_5.2.sh unchanged --- packages/django-google-spanner/django_test_suite_5.2.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/django-google-spanner/django_test_suite_5.2.sh b/packages/django-google-spanner/django_test_suite_5.2.sh index 81639436514a..52e489561f31 100755 --- a/packages/django-google-spanner/django_test_suite_5.2.sh +++ b/packages/django-google-spanner/django_test_suite_5.2.sh @@ -60,6 +60,7 @@ SECRET_KEY = 'spanner_tests_secret_key' PASSWORD_HASHERS = [ 'django.contrib.auth.hashers.MD5PasswordHasher', ] +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' INSTALLED_APPS = [ 'django.contrib.admin', From a4eada0a2302d4067eddafdb11a347a26fca0b3c Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Tue, 18 Aug 2026 05:26:51 +0000 Subject: [PATCH 06/27] fix(django-google-spanner): fix deferred_sql None handling, mark_skips ImportError, mockserver tests, and zizmor pin --- .github/workflows/django-spanner-django6.0_tests.yml | 2 +- .../django-google-spanner/django_spanner/creation.py | 6 +++--- .../django-google-spanner/django_spanner/schema.py | 12 +++++++++--- .../tests/mockserver_tests/test_basics.py | 6 ++++-- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/django-spanner-django6.0_tests.yml b/.github/workflows/django-spanner-django6.0_tests.yml index 6688eda3176d..5ce6925a1d4a 100644 --- a/.github/workflows/django-spanner-django6.0_tests.yml +++ b/.github/workflows/django-spanner-django6.0_tests.yml @@ -27,7 +27,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: filter with: filters: | diff --git a/packages/django-google-spanner/django_spanner/creation.py b/packages/django-google-spanner/django_spanner/creation.py index 219240195c5f..b54cefcae7f3 100644 --- a/packages/django-google-spanner/django_spanner/creation.py +++ b/packages/django-google-spanner/django_spanner/creation.py @@ -26,16 +26,16 @@ def mark_skips(self): test_app = test_name.split(".")[0] # Importing a test app that isn't installed raises RuntimeError. if test_app in settings.INSTALLED_APPS: - test_case = import_string(test_case_name) try: + test_case = import_string(test_case_name) method = getattr(test_case, method_name) setattr( test_case, method_name, skip("unsupported by Spanner")(method), ) - except AttributeError: - # The test method might not exist in this version of Django. + except (AttributeError, ImportError): + # The test case or method might not exist in this version of Django. pass def create_test_db(self, *args, **kwargs): diff --git a/packages/django-google-spanner/django_spanner/schema.py b/packages/django-google-spanner/django_spanner/schema.py index da57122bb73d..c06ee0b08b5c 100644 --- a/packages/django-google-spanner/django_spanner/schema.py +++ b/packages/django-google-spanner/django_spanner/schema.py @@ -123,13 +123,19 @@ def create_model(self, model): # created afterwards, like geometry fields with some backends) for fields in model._meta.unique_together: columns = [model._meta.get_field(field) for field in fields] - self.deferred_sql.append(self._create_unique_sql(model, columns)) + sql = self._create_unique_sql(model, columns) + if sql: + self.deferred_sql.append(sql) constraints = [] for constraint in model._meta.constraints: if isinstance(constraint, django.db.models.UniqueConstraint): - self.deferred_sql.append(constraint.create_sql(model, self)) + sql = constraint.create_sql(model, self) + if sql: + self.deferred_sql.append(sql) else: - constraints.append(constraint.constraint_sql(model, self)) + c_sql = constraint.constraint_sql(model, self) + if c_sql: + constraints.append(c_sql) if model._meta.pk.is_relation: pk_column = self.quote_name(model._meta.pk.column) else: diff --git a/packages/django-google-spanner/tests/mockserver_tests/test_basics.py b/packages/django-google-spanner/tests/mockserver_tests/test_basics.py index 8d53e081a745..29566e7e77ae 100644 --- a/packages/django-google-spanner/tests/mockserver_tests/test_basics.py +++ b/packages/django-google-spanner/tests/mockserver_tests/test_basics.py @@ -81,7 +81,8 @@ def test_insert_singer(self): add_update_count( "INSERT INTO tests_singer " "(id, first_name, last_name) " - "VALUES (@a0, @a1, @a2)", + "VALUES (@a0, @a1, @a2) " + "THEN RETURN id", 1, ) singer = Singer(first_name="test", last_name="test") @@ -113,7 +114,8 @@ class LocalSinger(models.Model): add_update_count( "INSERT INTO tests_localsinger " "(first_name, last_name) " - "VALUES (@a0, @a1)", + "VALUES (@a0, @a1) " + "THEN RETURN id", 1, ) singer = LocalSinger(first_name="test", last_name="test") From fb0aaafc1458731a0bb012252fd7d4300a80f880 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Tue, 18 Aug 2026 05:29:43 +0000 Subject: [PATCH 07/27] test(django-google-spanner): update mockserver insert tests to expect returned id row --- .../tests/mockserver_tests/test_basics.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/django-google-spanner/tests/mockserver_tests/test_basics.py b/packages/django-google-spanner/tests/mockserver_tests/test_basics.py index 29566e7e77ae..ad8a84087dcf 100644 --- a/packages/django-google-spanner/tests/mockserver_tests/test_basics.py +++ b/packages/django-google-spanner/tests/mockserver_tests/test_basics.py @@ -17,12 +17,14 @@ CommitRequest, CreateSessionRequest, ExecuteSqlRequest, + TypeCode, ) from tests.mockserver_tests.mock_server_test_base import ( MockServerTestBase, add_select1_result, add_singer_query_result, + add_single_result, add_update_count, ) from tests.mockserver_tests.models import Singer @@ -78,12 +80,14 @@ def test_django_select_singer_using_other_db(self): self.assertIsInstance(requests[2], ExecuteSqlRequest) def test_insert_singer(self): - add_update_count( + add_single_result( "INSERT INTO tests_singer " "(id, first_name, last_name) " "VALUES (@a0, @a1, @a2) " "THEN RETURN id", - 1, + "id", + TypeCode.INT64, + [("1",)], ) singer = Singer(first_name="test", last_name="test") singer.save() @@ -111,12 +115,14 @@ class LocalSinger(models.Model): last_name = models.CharField(max_length=200) try: - add_update_count( + add_single_result( "INSERT INTO tests_localsinger " "(first_name, last_name) " "VALUES (@a0, @a1) " "THEN RETURN id", - 1, + "id", + TypeCode.INT64, + [("1",)], ) singer = LocalSinger(first_name="test", last_name="test") singer.save() From 03a3aab49bd169cd8ca37c349bb3333b19bd3b10 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Tue, 18 Aug 2026 05:32:18 +0000 Subject: [PATCH 08/27] chore(django-google-spanner): remove unused import in mockserver tests --- .../django-google-spanner/tests/mockserver_tests/test_basics.py | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/django-google-spanner/tests/mockserver_tests/test_basics.py b/packages/django-google-spanner/tests/mockserver_tests/test_basics.py index ad8a84087dcf..209f831c704c 100644 --- a/packages/django-google-spanner/tests/mockserver_tests/test_basics.py +++ b/packages/django-google-spanner/tests/mockserver_tests/test_basics.py @@ -25,7 +25,6 @@ add_select1_result, add_singer_query_result, add_single_result, - add_update_count, ) from tests.mockserver_tests.models import Singer from tests.settings import DATABASES From d4005dcf5f639666ddeb6b254b145160d7ac0ad4 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Tue, 18 Aug 2026 07:04:24 +0000 Subject: [PATCH 09/27] feat(django-spanner): disable supports_over_clause and isolate test suite runners --- packages/django-google-spanner/create_test_instance.py | 8 +++++--- .../django-google-spanner/django_spanner/features.py | 1 + .../django-google-spanner/django_test_suite_5.2.sh | 10 ++++++---- .../django-google-spanner/django_test_suite_6.0.sh | 8 +++++--- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/django-google-spanner/create_test_instance.py b/packages/django-google-spanner/create_test_instance.py index 38a71533642b..aa8b9ee22fd9 100644 --- a/packages/django-google-spanner/create_test_instance.py +++ b/packages/django-google-spanner/create_test_instance.py @@ -28,6 +28,8 @@ config = f"{client.project_name}/instanceConfigs/regional-us-central1" - instance = client.instance("google-cloud-django-backend-tests", config) - created_op = instance.create() - created_op.result(30) # block until completion + instance_id = os.getenv("SPANNER_TEST_INSTANCE", "google-cloud-django-backend-tests") + instance = client.instance(instance_id, config) + if not instance.exists(): + created_op = instance.create() + created_op.result(30) # block until completion diff --git a/packages/django-google-spanner/django_spanner/features.py b/packages/django-google-spanner/django_spanner/features.py index 9f44b3950c68..5268b1bc1212 100644 --- a/packages/django-google-spanner/django_spanner/features.py +++ b/packages/django-google-spanner/django_spanner/features.py @@ -50,6 +50,7 @@ def introspected_field_types(self): supports_foreign_keys = True can_create_inline_fk = False supports_ignore_conflicts = False + supports_over_clause = False supports_partial_indexes = False supports_regex_backreferencing = False supports_select_for_update_with_limit = False diff --git a/packages/django-google-spanner/django_test_suite_5.2.sh b/packages/django-google-spanner/django_test_suite_5.2.sh index 52e489561f31..d51de0d230b1 100755 --- a/packages/django-google-spanner/django_test_suite_5.2.sh +++ b/packages/django-google-spanner/django_test_suite_5.2.sh @@ -1,24 +1,26 @@ -#!/bin/sh +#!/bin/bash # Copyright (c) 2020 Google LLC. All rights reserved. # Use of this source code is governed by a BSD-style # license that can be found in the LICENSE file. -set -x pipefail +set -x # Disable buffering, so that the logs stream through. export PYTHONUNBUFFERED=1 -export DJANGO_TESTS_DIR="django_tests_dir" +export DJANGO_TESTS_DIR="${DJANGO_TESTS_DIR:-django_tests_dir_5.2}" mkdir -p $DJANGO_TESTS_DIR pip3 install . # Clone Django 5.2 (assuming stable/5.2.x exists, update if needed) if [ ! -d "$DJANGO_TESTS_DIR/django" ]; then git clone --depth 1 --single-branch --branch "stable/5.2.x" https://github.com/django/django.git $DJANGO_TESTS_DIR/django +else + (cd $DJANGO_TESTS_DIR/django && git fetch --depth 1 origin stable/5.2.x:stable/5.2.x 2>/dev/null || true && git checkout stable/5.2.x) fi -cd $DJANGO_TESTS_DIR/django && pip3 install -e . && pip3 install -r tests/requirements/py3.txt; cd ../../ +(cd $DJANGO_TESTS_DIR/django && pip3 install -e . && (pip3 install -r tests/requirements/py3.txt || true)) pip3 install google-cloud-testutils # Only add the current directory (project root) to PYTHONPATH so django_spanner is importable. diff --git a/packages/django-google-spanner/django_test_suite_6.0.sh b/packages/django-google-spanner/django_test_suite_6.0.sh index ef9368906c93..52fff8e47dd6 100755 --- a/packages/django-google-spanner/django_test_suite_6.0.sh +++ b/packages/django-google-spanner/django_test_suite_6.0.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash # Copyright (c) 2020 Google LLC. All rights reserved. # Use of this source code is governed by a BSD-style @@ -9,16 +9,18 @@ set -x # Disable buffering, so that the logs stream through. export PYTHONUNBUFFERED=1 -export DJANGO_TESTS_DIR="django_tests_dir" +export DJANGO_TESTS_DIR="${DJANGO_TESTS_DIR:-django_tests_dir_6.0}" mkdir -p $DJANGO_TESTS_DIR pip3 install . # Clone Django 6.0 (assuming stable/6.0.x exists, update if needed) if [ ! -d "$DJANGO_TESTS_DIR/django" ]; then git clone --depth 1 --single-branch --branch "stable/6.0.x" https://github.com/django/django.git $DJANGO_TESTS_DIR/django +else + (cd $DJANGO_TESTS_DIR/django && git fetch --depth 1 origin stable/6.0.x:stable/6.0.x 2>/dev/null || true && git checkout stable/6.0.x) fi -cd $DJANGO_TESTS_DIR/django && pip3 install -e . && pip3 install -r tests/requirements/py3.txt; cd ../../ +(cd $DJANGO_TESTS_DIR/django && pip3 install -e . && (pip3 install -r tests/requirements/py3.txt || true)) pip3 install google-cloud-testutils # Only add the current directory (project root) to PYTHONPATH so django_spanner is importable. From f88d0b661dc499618c3bbcdc2d168e051e28e2c2 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Tue, 18 Aug 2026 07:32:40 +0000 Subject: [PATCH 10/27] revert(django-spanner): restore django_test_suite_5.2.sh to original state --- .../django-google-spanner/django_test_suite_5.2.sh | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/django-google-spanner/django_test_suite_5.2.sh b/packages/django-google-spanner/django_test_suite_5.2.sh index d51de0d230b1..52e489561f31 100755 --- a/packages/django-google-spanner/django_test_suite_5.2.sh +++ b/packages/django-google-spanner/django_test_suite_5.2.sh @@ -1,26 +1,24 @@ -#!/bin/bash +#!/bin/sh # Copyright (c) 2020 Google LLC. All rights reserved. # Use of this source code is governed by a BSD-style # license that can be found in the LICENSE file. -set -x +set -x pipefail # Disable buffering, so that the logs stream through. export PYTHONUNBUFFERED=1 -export DJANGO_TESTS_DIR="${DJANGO_TESTS_DIR:-django_tests_dir_5.2}" +export DJANGO_TESTS_DIR="django_tests_dir" mkdir -p $DJANGO_TESTS_DIR pip3 install . # Clone Django 5.2 (assuming stable/5.2.x exists, update if needed) if [ ! -d "$DJANGO_TESTS_DIR/django" ]; then git clone --depth 1 --single-branch --branch "stable/5.2.x" https://github.com/django/django.git $DJANGO_TESTS_DIR/django -else - (cd $DJANGO_TESTS_DIR/django && git fetch --depth 1 origin stable/5.2.x:stable/5.2.x 2>/dev/null || true && git checkout stable/5.2.x) fi -(cd $DJANGO_TESTS_DIR/django && pip3 install -e . && (pip3 install -r tests/requirements/py3.txt || true)) +cd $DJANGO_TESTS_DIR/django && pip3 install -e . && pip3 install -r tests/requirements/py3.txt; cd ../../ pip3 install google-cloud-testutils # Only add the current directory (project root) to PYTHONPATH so django_spanner is importable. From 04bffbf32ad2ab9882b583b1742630f5889e1f93 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Tue, 18 Aug 2026 07:32:53 +0000 Subject: [PATCH 11/27] revert(django-spanner): restore create_test_instance.py to original state --- packages/django-google-spanner/create_test_instance.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/django-google-spanner/create_test_instance.py b/packages/django-google-spanner/create_test_instance.py index aa8b9ee22fd9..38a71533642b 100644 --- a/packages/django-google-spanner/create_test_instance.py +++ b/packages/django-google-spanner/create_test_instance.py @@ -28,8 +28,6 @@ config = f"{client.project_name}/instanceConfigs/regional-us-central1" - instance_id = os.getenv("SPANNER_TEST_INSTANCE", "google-cloud-django-backend-tests") - instance = client.instance(instance_id, config) - if not instance.exists(): - created_op = instance.create() - created_op.result(30) # block until completion + instance = client.instance("google-cloud-django-backend-tests", config) + created_op = instance.create() + created_op.result(30) # block until completion From 59b8a0450774f5ea11914c6cdb3f6bf1c8a2fcc4 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Tue, 18 Aug 2026 07:59:50 +0000 Subject: [PATCH 12/27] feat(django-spanner): enable supports_any_value, check constraints, covering indexes, and null ordering --- .../django_spanner/features.py | 10 ++++---- .../django_spanner/schema.py | 24 ++++++++++++++++++- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/django-google-spanner/django_spanner/features.py b/packages/django-google-spanner/django_spanner/features.py index 5268b1bc1212..a552f0d6afd5 100644 --- a/packages/django-google-spanner/django_spanner/features.py +++ b/packages/django-google-spanner/django_spanner/features.py @@ -57,20 +57,20 @@ def introspected_field_types(self): supports_sequence_reset = False supports_timezones = False supports_transactions = True + supports_column_check_constraints = True + supports_table_check_constraints = True if USE_EMULATOR: # Emulator does not support json. supports_json_field = False - # Emulator does not support check constrints. - supports_column_check_constraints = False - supports_table_check_constraints = False else: - supports_column_check_constraints = True - supports_table_check_constraints = True supports_json_field = True supports_primitives_in_json_field = False supports_composite_primary_keys = True # Spanner does not support order by null modifiers. supports_order_by_nulls_modifier = False + order_by_nulls_first = True + supports_any_value = True + supports_covering_indexes = True # Spanner does not support SELECTing an arbitrary expression that also # appears in the GROUP BY clause. supports_subqueries_in_group_by = False diff --git a/packages/django-google-spanner/django_spanner/schema.py b/packages/django-google-spanner/django_spanner/schema.py index c06ee0b08b5c..feb0a0c79d04 100644 --- a/packages/django-google-spanner/django_spanner/schema.py +++ b/packages/django-google-spanner/django_spanner/schema.py @@ -35,8 +35,9 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): # Spanner doesn't support partial indexes. This string omits the # %(condition)s placeholder so that partial indexes are ignored. sql_create_index = ( - "CREATE INDEX %(name)s ON %(table)s%(using)s (%(columns)s)%(extra)s" + "CREATE INDEX %(name)s ON %(table)s%(using)s (%(columns)s)%(include)s%(extra)s" ) + sql_create_index_include = " STORING (%(columns)s)" sql_create_unique = ( "CREATE UNIQUE NULL_FILTERED INDEX %(name)s ON %(table)s (%(columns)s)" ) @@ -611,3 +612,24 @@ def skip_default(self, field): if getattr(field, "db_default", None) is not None: return False return True + + def _index_include_sql(self, model, include): + if not include: + return "" + pk_names = {model._meta.pk.name, model._meta.pk.column} + include_fields = [ + field + for field in self._index_include_fields(model, include) + if (field.column if hasattr(field, "column") else field) not in pk_names + and (field.name if hasattr(field, "name") else field) not in pk_names + ] + if not include_fields: + return "" + columns = [ + self.quote_name( + field.column if hasattr(field, "column") else field + ) + for field in include_fields + ] + return self.sql_create_index_include % {"columns": ", ".join(columns)} + From aae0baaa91693327d742468e1e95cf0b540a346b Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Tue, 18 Aug 2026 08:05:17 +0000 Subject: [PATCH 13/27] fix(django-spanner): fix _index_include_sql and format schema.py --- .../django_spanner/features.py | 7 ++++-- .../django_spanner/schema.py | 22 +++++++------------ 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/django-google-spanner/django_spanner/features.py b/packages/django-google-spanner/django_spanner/features.py index a552f0d6afd5..78b8a21d4f15 100644 --- a/packages/django-google-spanner/django_spanner/features.py +++ b/packages/django-google-spanner/django_spanner/features.py @@ -57,12 +57,15 @@ def introspected_field_types(self): supports_sequence_reset = False supports_timezones = False supports_transactions = True - supports_column_check_constraints = True - supports_table_check_constraints = True if USE_EMULATOR: # Emulator does not support json. supports_json_field = False + # Emulator does not support check constraints. + supports_column_check_constraints = False + supports_table_check_constraints = False else: + supports_column_check_constraints = True + supports_table_check_constraints = True supports_json_field = True supports_primitives_in_json_field = False supports_composite_primary_keys = True diff --git a/packages/django-google-spanner/django_spanner/schema.py b/packages/django-google-spanner/django_spanner/schema.py index feb0a0c79d04..efc53cbe1fdf 100644 --- a/packages/django-google-spanner/django_spanner/schema.py +++ b/packages/django-google-spanner/django_spanner/schema.py @@ -617,19 +617,13 @@ def _index_include_sql(self, model, include): if not include: return "" pk_names = {model._meta.pk.name, model._meta.pk.column} - include_fields = [ - field - for field in self._index_include_fields(model, include) - if (field.column if hasattr(field, "column") else field) not in pk_names - and (field.name if hasattr(field, "name") else field) not in pk_names - ] - if not include_fields: + filtered_include = [] + for field in include: + col_name = field.column if hasattr(field, "column") else str(field) + name = field.name if hasattr(field, "name") else str(field) + if col_name not in pk_names and name not in pk_names: + filtered_include.append(col_name) + if not filtered_include: return "" - columns = [ - self.quote_name( - field.column if hasattr(field, "column") else field - ) - for field in include_fields - ] + columns = [self.quote_name(col) for col in filtered_include] return self.sql_create_index_include % {"columns": ", ".join(columns)} - From e47906fe7f70815c9b0891bf336f637b190a56b5 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Tue, 18 Aug 2026 08:10:32 +0000 Subject: [PATCH 14/27] test(django-spanner): skip upstream covering index tests requiring INCLUDE keyword --- packages/django-google-spanner/django_spanner/features.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/django-google-spanner/django_spanner/features.py b/packages/django-google-spanner/django_spanner/features.py index 78b8a21d4f15..468162d76692 100644 --- a/packages/django-google-spanner/django_spanner/features.py +++ b/packages/django-google-spanner/django_spanner/features.py @@ -217,6 +217,9 @@ def introspected_field_types(self): "many_to_one_null.tests.ManyToOneNullTests.test_set_clear_non_bulk", "many_to_one_null.tests.ManyToOneNullTests.test_unsaved", "foreign_object.tests.MultiColumnFKTests.test_prefetch_foreignobject_reverse", + # Indexes tests + "indexes.tests.CoveringIndexTests.test_covering_index", + "indexes.tests.CoveringIndexTests.test_covering_partial_index", # Admin ChangeList tests "admin_changelist.tests.ChangeListTests.test_custom_lookup_in_search_fields", "admin_changelist.tests.ChangeListTests.test_deterministic_order_for_model_ordered_by_its_manager", From 311e25ab8e0a2c8f85b7382b9334776eabbe7fb0 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Tue, 18 Aug 2026 09:12:17 +0000 Subject: [PATCH 15/27] fix(django-spanner): retain order_by_nulls_first = False to generate IS NULL sort expressions --- packages/django-google-spanner/django_spanner/features.py | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/django-google-spanner/django_spanner/features.py b/packages/django-google-spanner/django_spanner/features.py index 468162d76692..ad826c9948ac 100644 --- a/packages/django-google-spanner/django_spanner/features.py +++ b/packages/django-google-spanner/django_spanner/features.py @@ -71,7 +71,6 @@ def introspected_field_types(self): supports_composite_primary_keys = True # Spanner does not support order by null modifiers. supports_order_by_nulls_modifier = False - order_by_nulls_first = True supports_any_value = True supports_covering_indexes = True # Spanner does not support SELECTing an arbitrary expression that also From bee1cad9519c086f16b4798c6b07faf7fe87bf6a Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Tue, 18 Aug 2026 09:15:00 +0000 Subject: [PATCH 16/27] refactor(django-spanner): remove redundant flags matching BaseDatabaseFeatures defaults --- .../django-google-spanner/django_spanner/features.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/django-google-spanner/django_spanner/features.py b/packages/django-google-spanner/django_spanner/features.py index ad826c9948ac..b1bfb6bd5708 100644 --- a/packages/django-google-spanner/django_spanner/features.py +++ b/packages/django-google-spanner/django_spanner/features.py @@ -37,20 +37,14 @@ def introspected_field_types(self): # TIMESTAMP. can_introspect_time_field = False closed_cursor_error_class = InterfaceError - # Spanner uses REGEXP_CONTAINS which is case-sensitive. - has_case_insensitive_like = False - # https://cloud.google.com/spanner/quotas#query_limits max_query_params = 900 # Spanner does not support parameterized defaults in DDL requires_literal_defaults = True if os.environ.get("RUNNING_SPANNER_BACKEND_TESTS") == "1": supports_foreign_keys = False - else: - supports_foreign_keys = True can_create_inline_fk = False supports_ignore_conflicts = False - supports_over_clause = False supports_partial_indexes = False supports_regex_backreferencing = False supports_select_for_update_with_limit = False @@ -63,10 +57,6 @@ def introspected_field_types(self): # Emulator does not support check constraints. supports_column_check_constraints = False supports_table_check_constraints = False - else: - supports_column_check_constraints = True - supports_table_check_constraints = True - supports_json_field = True supports_primitives_in_json_field = False supports_composite_primary_keys = True # Spanner does not support order by null modifiers. @@ -78,7 +68,6 @@ def introspected_field_types(self): supports_subqueries_in_group_by = False uses_savepoints = False # Spanner does not support savepoints. can_rollback_tests = False # Spanner savepoints are no-ops; rely on flush. - supports_aggregate_filter_clause = False # Spanner does not support expression indexes # example: CREATE INDEX index_name ON table (LOWER(column_name)) supports_expression_indexes = False From bd08914926f8ba8c6d72234546c5547931a070c3 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Wed, 19 Aug 2026 05:30:24 +0000 Subject: [PATCH 17/27] test(django-spanner): add unit tests for schema, operations, and features to achieve coverage --- .../unit/django_spanner/test_features.py | 36 +++++++++ .../unit/django_spanner/test_operations.py | 76 +++++++++++++++++++ .../tests/unit/django_spanner/test_schema.py | 38 ++++++++++ 3 files changed, 150 insertions(+) create mode 100644 packages/django-google-spanner/tests/unit/django_spanner/test_features.py diff --git a/packages/django-google-spanner/tests/unit/django_spanner/test_features.py b/packages/django-google-spanner/tests/unit/django_spanner/test_features.py new file mode 100644 index 000000000000..00023c45ce31 --- /dev/null +++ b/packages/django-google-spanner/tests/unit/django_spanner/test_features.py @@ -0,0 +1,36 @@ +# Copyright 2026 Google LLC +# +# 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 + +from django_spanner.features import DatabaseFeatures +from tests.unit.django_spanner.simple_test import SpannerSimpleTestClass + + +class TestFeatures(SpannerSimpleTestClass): + def test_introspected_field_types(self): + features = DatabaseFeatures(self.connection) + field_types = features.introspected_field_types + self.assertEqual(field_types["BigIntegerField"], "IntegerField") + self.assertEqual(field_types["BigAutoField"], "AutoField") + self.assertEqual(field_types["SmallAutoField"], "AutoField") + self.assertEqual(field_types["SmallIntegerField"], "IntegerField") + self.assertEqual(field_types["PositiveBigIntegerField"], "IntegerField") + self.assertEqual(field_types["PositiveIntegerField"], "IntegerField") + self.assertEqual(field_types["PositiveSmallIntegerField"], "IntegerField") + self.assertEqual(field_types["DurationField"], "IntegerField") + + def test_spanner_specific_feature_flags(self): + features = DatabaseFeatures(self.connection) + self.assertTrue(features.supports_any_value) + self.assertTrue(features.supports_covering_indexes) + self.assertTrue(features.supports_stored_generated_columns) + self.assertTrue(features.supports_composite_primary_keys) + self.assertFalse(features.supports_subqueries_in_group_by) + self.assertFalse(features.supports_order_by_nulls_modifier) + self.assertFalse(features.supports_expression_indexes) + self.assertFalse(features.uses_savepoints) + self.assertFalse(features.can_rollback_tests) + self.assertEqual(features.max_query_params, 900) + self.assertTrue(features.requires_literal_defaults) diff --git a/packages/django-google-spanner/tests/unit/django_spanner/test_operations.py b/packages/django-google-spanner/tests/unit/django_spanner/test_operations.py index bbadf5129e22..6f33559bb4d1 100644 --- a/packages/django-google-spanner/tests/unit/django_spanner/test_operations.py +++ b/packages/django-google-spanner/tests/unit/django_spanner/test_operations.py @@ -8,6 +8,7 @@ from base64 import b64encode from datetime import timedelta from decimal import Decimal +from unittest import mock from django.conf import settings from django.core.management.color import no_style @@ -266,3 +267,78 @@ def test_lookup_cast_unmatched_lookup_type(self): ), "%s", ) + + def test_returning_columns(self): + field1 = mock.MagicMock(column="id") + field2 = mock.MagicMock(column="name") + sql, params = self.db_operations.returning_columns([field1, field2]) + self.assertEqual(sql, "THEN RETURN id, name") + self.assertEqual(params, ()) + + def test_returning_columns_empty(self): + sql, params = self.db_operations.returning_columns([]) + self.assertEqual(sql, "") + self.assertEqual(params, ()) + + def test_return_insert_columns_alias(self): + field = mock.MagicMock(column="id") + sql, params = self.db_operations.return_insert_columns([field]) + self.assertEqual(sql, "THEN RETURN id") + self.assertEqual(params, ()) + + def test_savepoint_sql(self): + self.assertEqual(self.db_operations.savepoint_create_sql("sp1"), "SELECT 1") + self.assertEqual(self.db_operations.savepoint_commit_sql("sp1"), "SELECT 1") + self.assertEqual(self.db_operations.savepoint_rollback_sql("sp1"), "SELECT 1") + + def test_no_limit_value(self): + self.assertEqual(self.db_operations.no_limit_value(), 9223372036854775807) + + def test_get_limit_offset_params(self): + limit, offset = self.db_operations._get_limit_offset_params(10, None) + self.assertEqual(limit, 9223372036854775807 - 10) + self.assertEqual(offset, 10) + + limit, offset = self.db_operations._get_limit_offset_params(0, 5) + self.assertEqual(limit, 5) + self.assertEqual(offset, 0) + + def test_prep_for_like_and_iexact_query(self): + self.assertEqual( + self.db_operations.prep_for_like_query("test.val*"), r"test\.val\*" + ) + self.assertEqual( + self.db_operations.prep_for_iexact_query("test.val*"), r"test\.val\*" + ) + + def test_bulk_insert_sql(self): + fields = [mock.MagicMock(column="col1"), mock.MagicMock(column="col2")] + sql = self.db_operations.bulk_insert_sql(fields, [["%s", "%s"], ["%s", "%s"]]) + self.assertEqual(sql, "VALUES (%s, %s), (%s, %s)") + + def test_date_and_time_trunc_sql(self): + sql, params = self.db_operations.date_trunc_sql("year", "field", None) + self.assertEqual(sql, "DATE_TRUNC(CAST(field AS DATE), year)") + self.assertIsNone(params) + + sql, params = self.db_operations.time_trunc_sql("hour", "field", None) + self.assertEqual(sql, 'TIMESTAMP_TRUNC(field, hour, "UTC")') + self.assertIsNone(params) + + sql, params = self.db_operations.datetime_trunc_sql( + "day", "field", None, tzname="UTC" + ) + self.assertEqual(sql, 'TIMESTAMP_TRUNC(field, day, "UTC")') + self.assertIsNone(params) + + def test_datetime_cast_sql(self): + sql, params = self.db_operations.datetime_cast_date_sql("field", None, "UTC") + self.assertEqual(sql, 'DATE(field, "UTC")') + self.assertIsNone(params) + + sql, params = self.db_operations.datetime_cast_time_sql("field", None, "UTC") + self.assertEqual( + sql, + "TIMESTAMP(FORMAT_TIMESTAMP('%Y-%m-%d %R:%E9S %Z', field, 'UTC'))", + ) + self.assertIsNone(params) 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..2f4b504603ed 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 @@ -464,3 +464,41 @@ def test_autofield_random_generation_disabled(self): field = AutoField(name="field_name") assert gen_rand_int64 != field.default del connections.settings["default"]["RANDOM_ID_GENERATION_ENABLED"] + + def test_index_include_sql(self): + """Tests _index_include_sql with normal fields.""" + schema_editor = DatabaseSchemaEditor(self.connection) + sql = schema_editor._index_include_sql(Author, ["name", "last_name"]) + self.assertEqual(sql, " STORING (name, last_name)") + + def test_index_include_sql_filters_primary_key(self): + """Tests _index_include_sql filters out primary key fields.""" + schema_editor = DatabaseSchemaEditor(self.connection) + sql = schema_editor._index_include_sql(Author, ["id", "name"]) + self.assertEqual(sql, " STORING (name)") + + def test_index_include_sql_all_pk_returns_empty(self): + """Tests _index_include_sql returns empty string when only PK is included.""" + schema_editor = DatabaseSchemaEditor(self.connection) + sql = schema_editor._index_include_sql(Author, ["id"]) + self.assertEqual(sql, "") + + def test_index_include_sql_empty(self): + """Tests _index_include_sql returns empty string when include is empty.""" + schema_editor = DatabaseSchemaEditor(self.connection) + self.assertEqual(schema_editor._index_include_sql(Author, []), "") + self.assertEqual(schema_editor._index_include_sql(Author, None), "") + + def test_skip_default_generated_and_db_default(self): + """Tests skip_default for generated and db_default fields.""" + schema_editor = DatabaseSchemaEditor(self.connection) + generated_field = mock.MagicMock(generated=True) + self.assertFalse(schema_editor.skip_default(generated_field)) + + db_default_field = mock.MagicMock( + generated=False, db_default="CURRENT_TIMESTAMP()" + ) + self.assertFalse(schema_editor.skip_default(db_default_field)) + + normal_field = mock.MagicMock(generated=False, db_default=None) + self.assertTrue(schema_editor.skip_default(normal_field)) From 389bff3b105a2117496719566af27a4cdf667e4a Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Wed, 19 Aug 2026 05:33:52 +0000 Subject: [PATCH 18/27] feat(django-spanner): raise NotSupportedError when primary key is used in STORING clause --- .../django_spanner/schema.py | 15 +++++++++------ .../tests/unit/django_spanner/test_schema.py | 18 ++++++++---------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/django-google-spanner/django_spanner/schema.py b/packages/django-google-spanner/django_spanner/schema.py index efc53cbe1fdf..2b6cad75a256 100644 --- a/packages/django-google-spanner/django_spanner/schema.py +++ b/packages/django-google-spanner/django_spanner/schema.py @@ -617,13 +617,16 @@ def _index_include_sql(self, model, include): if not include: return "" pk_names = {model._meta.pk.name, model._meta.pk.column} - filtered_include = [] for field in include: col_name = field.column if hasattr(field, "column") else str(field) name = field.name if hasattr(field, "name") else str(field) - if col_name not in pk_names and name not in pk_names: - filtered_include.append(col_name) - if not filtered_include: - return "" - columns = [self.quote_name(col) for col in filtered_include] + if col_name in pk_names or name in pk_names: + raise NotSupportedError( + f"Cloud Spanner does not support storing the primary key column '{col_name}' " + f"in a covering index as it is automatically included in all secondary indexes." + ) + columns = [ + self.quote_name(field.column if hasattr(field, "column") else str(field)) + for field in include + ] return self.sql_create_index_include % {"columns": ", ".join(columns)} 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 2f4b504603ed..ee2a66912b5e 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 @@ -471,17 +471,15 @@ def test_index_include_sql(self): sql = schema_editor._index_include_sql(Author, ["name", "last_name"]) self.assertEqual(sql, " STORING (name, last_name)") - def test_index_include_sql_filters_primary_key(self): - """Tests _index_include_sql filters out primary key fields.""" + def test_index_include_sql_raises_error_for_primary_key(self): + """Tests _index_include_sql raises NotSupportedError for primary key fields.""" schema_editor = DatabaseSchemaEditor(self.connection) - sql = schema_editor._index_include_sql(Author, ["id", "name"]) - self.assertEqual(sql, " STORING (name)") - - def test_index_include_sql_all_pk_returns_empty(self): - """Tests _index_include_sql returns empty string when only PK is included.""" - schema_editor = DatabaseSchemaEditor(self.connection) - sql = schema_editor._index_include_sql(Author, ["id"]) - self.assertEqual(sql, "") + with self.assertRaises(NotSupportedError) as ctx: + schema_editor._index_include_sql(Author, ["id", "name"]) + self.assertIn( + "Cloud Spanner does not support storing the primary key column", + str(ctx.exception), + ) def test_index_include_sql_empty(self): """Tests _index_include_sql returns empty string when include is empty.""" From 56598cab7802d6d8899cea3126604ff85ba31685 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Wed, 19 Aug 2026 05:35:40 +0000 Subject: [PATCH 19/27] refactor(django-spanner): delegate STORING validation directly to Cloud Spanner backend --- .../django-google-spanner/django_spanner/schema.py | 9 --------- .../tests/unit/django_spanner/test_schema.py | 12 ++++-------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/packages/django-google-spanner/django_spanner/schema.py b/packages/django-google-spanner/django_spanner/schema.py index 2b6cad75a256..4913d971444d 100644 --- a/packages/django-google-spanner/django_spanner/schema.py +++ b/packages/django-google-spanner/django_spanner/schema.py @@ -616,15 +616,6 @@ def skip_default(self, field): def _index_include_sql(self, model, include): if not include: return "" - pk_names = {model._meta.pk.name, model._meta.pk.column} - for field in include: - col_name = field.column if hasattr(field, "column") else str(field) - name = field.name if hasattr(field, "name") else str(field) - if col_name in pk_names or name in pk_names: - raise NotSupportedError( - f"Cloud Spanner does not support storing the primary key column '{col_name}' " - f"in a covering index as it is automatically included in all secondary indexes." - ) columns = [ self.quote_name(field.column if hasattr(field, "column") else str(field)) for field in include 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 ee2a66912b5e..7095b4d3a929 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 @@ -471,15 +471,11 @@ def test_index_include_sql(self): sql = schema_editor._index_include_sql(Author, ["name", "last_name"]) self.assertEqual(sql, " STORING (name, last_name)") - def test_index_include_sql_raises_error_for_primary_key(self): - """Tests _index_include_sql raises NotSupportedError for primary key fields.""" + def test_index_include_sql_includes_all_fields(self): + """Tests _index_include_sql formats all included columns directly.""" schema_editor = DatabaseSchemaEditor(self.connection) - with self.assertRaises(NotSupportedError) as ctx: - schema_editor._index_include_sql(Author, ["id", "name"]) - self.assertIn( - "Cloud Spanner does not support storing the primary key column", - str(ctx.exception), - ) + sql = schema_editor._index_include_sql(Author, ["id", "name"]) + self.assertEqual(sql, " STORING (id, name)") def test_index_include_sql_empty(self): """Tests _index_include_sql returns empty string when include is empty.""" From c82072886bc063e1c2f55be0c992ed773fb41fc3 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Wed, 19 Aug 2026 05:44:30 +0000 Subject: [PATCH 20/27] feat(django-spanner): isolate Django 6.0 skip_tests via conditional version check --- .../django_spanner/creation.py | 6 +- .../django_spanner/features.py | 55 +++++++++++-------- 2 files changed, 34 insertions(+), 27 deletions(-) diff --git a/packages/django-google-spanner/django_spanner/creation.py b/packages/django-google-spanner/django_spanner/creation.py index b54cefcae7f3..219240195c5f 100644 --- a/packages/django-google-spanner/django_spanner/creation.py +++ b/packages/django-google-spanner/django_spanner/creation.py @@ -26,16 +26,16 @@ def mark_skips(self): test_app = test_name.split(".")[0] # Importing a test app that isn't installed raises RuntimeError. if test_app in settings.INSTALLED_APPS: + test_case = import_string(test_case_name) try: - test_case = import_string(test_case_name) method = getattr(test_case, method_name) setattr( test_case, method_name, skip("unsupported by Spanner")(method), ) - except (AttributeError, ImportError): - # The test case or method might not exist in this version of Django. + except AttributeError: + # The test method might not exist in this version of Django. pass def create_test_db(self, *args, **kwargs): diff --git a/packages/django-google-spanner/django_spanner/features.py b/packages/django-google-spanner/django_spanner/features.py index b1bfb6bd5708..315ce806aef5 100644 --- a/packages/django-google-spanner/django_spanner/features.py +++ b/packages/django-google-spanner/django_spanner/features.py @@ -6,6 +6,7 @@ import os +import django from django.db.backends.base.features import BaseDatabaseFeatures from django.db.utils import InterfaceError @@ -205,9 +206,6 @@ def introspected_field_types(self): "many_to_one_null.tests.ManyToOneNullTests.test_set_clear_non_bulk", "many_to_one_null.tests.ManyToOneNullTests.test_unsaved", "foreign_object.tests.MultiColumnFKTests.test_prefetch_foreignobject_reverse", - # Indexes tests - "indexes.tests.CoveringIndexTests.test_covering_index", - "indexes.tests.CoveringIndexTests.test_covering_partial_index", # Admin ChangeList tests "admin_changelist.tests.ChangeListTests.test_custom_lookup_in_search_fields", "admin_changelist.tests.ChangeListTests.test_deterministic_order_for_model_ordered_by_its_manager", @@ -773,27 +771,6 @@ def introspected_field_types(self): "ordering.tests.OrderingTests.test_extra_ordering", "ordering.tests.OrderingTests.test_extra_ordering_quoting", "ordering.tests.OrderingTests.test_extra_ordering_with_table_name", - # Spanner uses random int64 IDs; test assumes monotonic ordering matching insertion order. - "prefetch_related.tests.PrefetchRelatedMTICacheTests.test_parent_m2m_available_in_child", - # Client-side AutoField ID generation sets pk before save; _is_pk_set() triggers refresh_from_db instead of AttributeError. - "defer_regress.tests.DeferCopyInstanceTests.test_bulk_create", - "defer_regress.tests.DeferCopyInstanceTests.test_save", - # Spanner does not support nested transactions/savepoints; raising inside atomic() aborts the whole transaction. - "update_only_fields.tests.UpdateOnlyFieldsTests.test_update_fields_not_updated", - # Test checks warning caller stacklevel; wrapping create_test_db shifts frame depth. - "backends.base.test_creation.TestDbCreationTests.test_serialize_deprecation", - # Runtime client-side AutoField initialization defaults trigger false-positive diffs in makemigrations autodetector. - "migrations.test_commands.MakeMigrationsTests.test_makemigrations_check_no_changes", - "migrations.test_commands.MakeMigrationsTests.test_makemigrations_model_rename_interactive", - "migrations.test_commands.MakeMigrationsTests.test_makemigrations_no_changes", - # Spanner query parameter limit (max_query_params = 900) limits batch chunk size. - "bulk_create.tests.BulkCreateTests.test_max_batch_size", - # Query count assertions mismatch due to Spanner batch DML execution behavior. - "bulk_create.tests.BulkCreateTransactionTests.test_multiple_batches", - # All objects get client-side PKs, collapsing multi-query insertion into a single batch query. - "bulk_create.tests.BulkCreateTransactionTests.test_objs_with_and_without_pk", - # Tie-breaker ordering on pk assumes sequential integer IDs; Spanner uses random IDs. - "ordering.tests.OrderingTests.test_order_by_case_when_constant_value", "ordering.tests.OrderingTests.test_order_by_constant_value", "ordering.tests.OrderingTests.test_order_by_f_expression", "ordering.tests.OrderingTests.test_order_by_f_expression_duplicates", @@ -2289,3 +2266,33 @@ def introspected_field_types(self): "expressions.tests.BasicExpressionsTests.test_outerref_mixed_case_table_name", "db_functions.text.test_concat.ConcatTests.test_concat_non_str", ) + + django_6_0_skip_tests = ( + # Indexes tests (upstream tests hardcode PostgreSQL's INCLUDE keyword) + "indexes.tests.CoveringIndexTests.test_covering_index", + "indexes.tests.CoveringIndexTests.test_covering_partial_index", + # Spanner uses random int64 IDs; test assumes monotonic ordering matching insertion order. + "prefetch_related.tests.PrefetchRelatedMTICacheTests.test_parent_m2m_available_in_child", + # Client-side AutoField ID generation sets pk before save; _is_pk_set() triggers refresh_from_db instead of AttributeError. + "defer_regress.tests.DeferCopyInstanceTests.test_bulk_create", + "defer_regress.tests.DeferCopyInstanceTests.test_save", + # Spanner does not support nested transactions/savepoints; raising inside atomic() aborts the whole transaction. + "update_only_fields.tests.UpdateOnlyFieldsTests.test_update_fields_not_updated", + # Test checks warning caller stacklevel; wrapping create_test_db shifts frame depth. + "backends.base.test_creation.TestDbCreationTests.test_serialize_deprecation", + # Runtime client-side AutoField initialization defaults trigger false-positive diffs in makemigrations autodetector. + "migrations.test_commands.MakeMigrationsTests.test_makemigrations_check_no_changes", + "migrations.test_commands.MakeMigrationsTests.test_makemigrations_model_rename_interactive", + "migrations.test_commands.MakeMigrationsTests.test_makemigrations_no_changes", + # Spanner query parameter limit (max_query_params = 900) limits batch chunk size. + "bulk_create.tests.BulkCreateTests.test_max_batch_size", + # Query count assertions mismatch due to Spanner batch DML execution behavior. + "bulk_create.tests.BulkCreateTransactionTests.test_multiple_batches", + # All objects get client-side PKs, collapsing multi-query insertion into a single batch query. + "bulk_create.tests.BulkCreateTransactionTests.test_objs_with_and_without_pk", + # Tie-breaker ordering on pk assumes sequential integer IDs; Spanner uses random IDs. + "ordering.tests.OrderingTests.test_order_by_case_when_constant_value", + ) + + if django.VERSION >= (6, 0): + skip_tests += django_6_0_skip_tests From 1e21e5a72dda56da5c44a1ddddae5319d4ab1c70 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Wed, 19 Aug 2026 05:46:28 +0000 Subject: [PATCH 21/27] docs(django-spanner): restore quota documentation comment above max_query_params --- packages/django-google-spanner/django_spanner/features.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/django-google-spanner/django_spanner/features.py b/packages/django-google-spanner/django_spanner/features.py index 315ce806aef5..6e8e38716a23 100644 --- a/packages/django-google-spanner/django_spanner/features.py +++ b/packages/django-google-spanner/django_spanner/features.py @@ -38,6 +38,7 @@ def introspected_field_types(self): # TIMESTAMP. can_introspect_time_field = False closed_cursor_error_class = InterfaceError + # https://cloud.google.com/spanner/quotas#query_limits max_query_params = 900 # Spanner does not support parameterized defaults in DDL requires_literal_defaults = True From 19371626c3f0fb4679274f4bd9c7f9883848705b Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Wed, 19 Aug 2026 05:51:28 +0000 Subject: [PATCH 22/27] fix(django-spanner): restore has_case_insensitive_like, supports_foreign_keys, and supports_aggregate_filter_clause declarations --- packages/django-google-spanner/django_spanner/features.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/django-google-spanner/django_spanner/features.py b/packages/django-google-spanner/django_spanner/features.py index 6e8e38716a23..e5ac57d12b31 100644 --- a/packages/django-google-spanner/django_spanner/features.py +++ b/packages/django-google-spanner/django_spanner/features.py @@ -38,6 +38,8 @@ def introspected_field_types(self): # TIMESTAMP. can_introspect_time_field = False closed_cursor_error_class = InterfaceError + # Spanner uses REGEXP_CONTAINS which is case-sensitive. + has_case_insensitive_like = False # https://cloud.google.com/spanner/quotas#query_limits max_query_params = 900 # Spanner does not support parameterized defaults in DDL @@ -45,6 +47,8 @@ def introspected_field_types(self): if os.environ.get("RUNNING_SPANNER_BACKEND_TESTS") == "1": supports_foreign_keys = False + else: + supports_foreign_keys = True can_create_inline_fk = False supports_ignore_conflicts = False supports_partial_indexes = False @@ -70,6 +74,7 @@ def introspected_field_types(self): supports_subqueries_in_group_by = False uses_savepoints = False # Spanner does not support savepoints. can_rollback_tests = False # Spanner savepoints are no-ops; rely on flush. + supports_aggregate_filter_clause = False # Spanner does not support expression indexes # example: CREATE INDEX index_name ON table (LOWER(column_name)) supports_expression_indexes = False From a86b4c7cc328abaa48631db993882bad578a1b2e Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Wed, 19 Aug 2026 05:53:23 +0000 Subject: [PATCH 23/27] fix(django-spanner): update django dependency range to >=5.2, <6.1 and add 6.0 classifier in setup.py --- packages/django-google-spanner/setup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/django-google-spanner/setup.py b/packages/django-google-spanner/setup.py index 05bf027e993c..5f70cfcbc9b0 100644 --- a/packages/django-google-spanner/setup.py +++ b/packages/django-google-spanner/setup.py @@ -18,12 +18,11 @@ # 'Development Status :: 4 - Beta' # 'Development Status :: 5 - Production/Stable' release_status = "Development Status :: 5 - Production/Stable" -# TODO(https://github.com/googleapis/google-cloud-python/issues/18053): Update upper bound when adding support for Django 6.0+ -# (django_spanner/__init__.py currently enforces SUPPORTED_DJANGO_VERSIONS = [(5, 2)]) +# (django_spanner/__init__.py currently enforces SUPPORTED_DJANGO_VERSIONS = [(6, 0), (5, 2)]) dependencies = [ "sqlparse >= 0.3.0", "google-cloud-spanner >= 3.13.0", - "django >= 4.2, < 6.1", + "django >= 5.2, < 6.1", ] extras = { "tracing": [ @@ -75,6 +74,7 @@ "Topic :: Utilities", "Framework :: Django", "Framework :: Django :: 5.2", + "Framework :: Django :: 6.0", ], extras_require=extras, python_requires=">=3.10", From 2a627455144d6274563592bce0ed396113f5eb77 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Wed, 19 Aug 2026 06:04:27 +0000 Subject: [PATCH 24/27] fix(django-spanner): skip CoveringIndexTests across all Django versions --- packages/django-google-spanner/django_spanner/features.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/django-google-spanner/django_spanner/features.py b/packages/django-google-spanner/django_spanner/features.py index e5ac57d12b31..6b48ff99c3bd 100644 --- a/packages/django-google-spanner/django_spanner/features.py +++ b/packages/django-google-spanner/django_spanner/features.py @@ -212,6 +212,9 @@ def introspected_field_types(self): "many_to_one_null.tests.ManyToOneNullTests.test_set_clear_non_bulk", "many_to_one_null.tests.ManyToOneNullTests.test_unsaved", "foreign_object.tests.MultiColumnFKTests.test_prefetch_foreignobject_reverse", + # Indexes tests + "indexes.tests.CoveringIndexTests.test_covering_index", + "indexes.tests.CoveringIndexTests.test_covering_partial_index", # Admin ChangeList tests "admin_changelist.tests.ChangeListTests.test_custom_lookup_in_search_fields", "admin_changelist.tests.ChangeListTests.test_deterministic_order_for_model_ordered_by_its_manager", @@ -2274,9 +2277,6 @@ def introspected_field_types(self): ) django_6_0_skip_tests = ( - # Indexes tests (upstream tests hardcode PostgreSQL's INCLUDE keyword) - "indexes.tests.CoveringIndexTests.test_covering_index", - "indexes.tests.CoveringIndexTests.test_covering_partial_index", # Spanner uses random int64 IDs; test assumes monotonic ordering matching insertion order. "prefetch_related.tests.PrefetchRelatedMTICacheTests.test_parent_m2m_available_in_child", # Client-side AutoField ID generation sets pk before save; _is_pk_set() triggers refresh_from_db instead of AttributeError. From 856393c79fffd1a0be5272fdf9cbe00cd37ebb60 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Wed, 19 Aug 2026 07:04:16 +0000 Subject: [PATCH 25/27] fix(django-spanner): restore else branch for USE_EMULATOR flags in features.py --- packages/django-google-spanner/django_spanner/features.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/django-google-spanner/django_spanner/features.py b/packages/django-google-spanner/django_spanner/features.py index 6b48ff99c3bd..5b3fe29f45bf 100644 --- a/packages/django-google-spanner/django_spanner/features.py +++ b/packages/django-google-spanner/django_spanner/features.py @@ -63,6 +63,10 @@ def introspected_field_types(self): # Emulator does not support check constraints. supports_column_check_constraints = False supports_table_check_constraints = False + else: + supports_column_check_constraints = True + supports_table_check_constraints = True + supports_json_field = True supports_primitives_in_json_field = False supports_composite_primary_keys = True # Spanner does not support order by null modifiers. From 48ebce55895c892db66d80408008b2661e15fc22 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Wed, 19 Aug 2026 09:34:59 +0000 Subject: [PATCH 26/27] fix(django-spanner): update minimum google-cloud-spanner version to 3.69.2 in setup.py --- packages/django-google-spanner/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/django-google-spanner/setup.py b/packages/django-google-spanner/setup.py index 5f70cfcbc9b0..7feacaa60304 100644 --- a/packages/django-google-spanner/setup.py +++ b/packages/django-google-spanner/setup.py @@ -21,7 +21,7 @@ # (django_spanner/__init__.py currently enforces SUPPORTED_DJANGO_VERSIONS = [(6, 0), (5, 2)]) dependencies = [ "sqlparse >= 0.3.0", - "google-cloud-spanner >= 3.13.0", + "google-cloud-spanner >= 3.69.2", "django >= 5.2, < 6.1", ] extras = { From e70f13b3ebba23045dbaf4ed57ceb510d8db2233 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Wed, 19 Aug 2026 09:44:15 +0000 Subject: [PATCH 27/27] fix(django-spanner): update minimum google-cloud-spanner version to 3.69.1 in setup.py --- packages/django-google-spanner/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/django-google-spanner/setup.py b/packages/django-google-spanner/setup.py index 7feacaa60304..ec1db797382d 100644 --- a/packages/django-google-spanner/setup.py +++ b/packages/django-google-spanner/setup.py @@ -21,7 +21,7 @@ # (django_spanner/__init__.py currently enforces SUPPORTED_DJANGO_VERSIONS = [(6, 0), (5, 2)]) dependencies = [ "sqlparse >= 0.3.0", - "google-cloud-spanner >= 3.69.2", + "google-cloud-spanner >= 3.69.1", "django >= 5.2, < 6.1", ] extras = {