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..5ce6925a1d4a
--- /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@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
+ 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/README.rst b/packages/django-google-spanner/README.rst
index 1a00489842d4..9e3e8cbd50ab 100644
--- a/packages/django-google-spanner/README.rst
+++ b/packages/django-google-spanner/README.rst
@@ -65,12 +65,13 @@ Supported versions
~~~~~~~~~~~~~~~~~~
The library supports `Django 5.2
-`_.
+`_ and `Django 6.0
+`_.
The minimum required Python version is 3.10.
.. code:: shell
- pip3 install django==5.2
+ pip3 install "django>=5.2,<6.1"
Installing the package
@@ -88,7 +89,7 @@ To install from source:
.. code:: shell
git clone git@github.com:googleapis/google-cloud-python.git
- cd python-spanner-django
+ cd packages/django-google-spanner
pip3 install -e .
@@ -266,6 +267,26 @@ By participating in this project you agree to abide by its terms. See the `Code
of Conduct `_ for more information.
+DML RETURNING Behavior
+~~~~~~~~~~~~~~~~~~~~~~
+
+Starting with Django 6.0 compatibility, ``can_return_columns_from_insert = True`` is enabled. Django will generate ``THEN RETURN`` clauses for insert statements that create model instances with database-generated defaults or ``GeneratedField`` columns.
+
+If your application relies on the previous behavior (where returned columns were not queried automatically upon insert), you can disable it in your Django ``AppConfig``:
+
+.. code:: python
+
+ from django.apps import AppConfig
+
+ class MyAppConfig(AppConfig):
+ name = "myapp"
+
+ def ready(self):
+ from django_spanner.features import DatabaseFeatures
+
+ DatabaseFeatures.can_return_columns_from_insert = False
+
+
Limitations
~~~~~~~~~~~
diff --git a/packages/django-google-spanner/django_spanner/__init__.py b/packages/django-google-spanner/django_spanner/__init__.py
index 303837ae0a34..fc8cb56b2104 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)
@@ -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/base.py b/packages/django-google-spanner/django_spanner/base.py
index 1706e77dc4c4..a286d17d090f 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
@@ -15,6 +16,7 @@
from .introspection import DatabaseIntrospection
from .operations import DatabaseOperations
from .schema import DatabaseSchemaEditor
+from .version import __version__
# Global cache for Spanner client to prevent multiple initializations
# which can cause OpenTelemetry 'MeterProvider override' crashes.
@@ -159,7 +161,7 @@ def get_connection_params(self):
"project": self._get_project_id(),
"instance_id": self.settings_dict["INSTANCE"],
"database_id": self.settings_dict["NAME"],
- "user_agent": "django_spanner/2.2.0a1",
+ "user_agent": f"django_spanner/{__version__}",
**self.settings_dict["OPTIONS"],
}
@@ -216,6 +218,11 @@ 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, thread_sensitive=True)(
+ autocommit
+ )
+
def is_usable(self):
"""Check whether the connection is valid.
diff --git a/packages/django-google-spanner/django_spanner/features.py b/packages/django-google-spanner/django_spanner/features.py
index de3eeece0649..8b182ebdb4e0 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
@@ -14,7 +15,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.
@@ -42,7 +59,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
if USE_EMULATOR:
# Emulator does not support json.
supports_json_field = False
- # Emulator does not support check constrints.
+ # Emulator does not support check constraints.
supports_column_check_constraints = False
supports_table_check_constraints = False
else:
@@ -53,6 +70,8 @@ class DatabaseFeatures(BaseDatabaseFeatures):
supports_composite_primary_keys = True
# Spanner does not support order by null modifiers.
supports_order_by_nulls_modifier = False
+ 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
@@ -196,6 +215,11 @@ class DatabaseFeatures(BaseDatabaseFeatures):
"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: Spanner uses STORING instead of PostgreSQL's INCLUDE syntax
+ # and does not support partial (WHERE) indexes. Upstream test assertions hardcode
+ # the literal string 'INCLUDE', causing string assertion failures against Spanner's STORING clause.
+ "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",
@@ -2256,3 +2280,30 @@ class DatabaseFeatures(BaseDatabaseFeatures):
"expressions.tests.BasicExpressionsTests.test_outerref_mixed_case_table_name",
"db_functions.text.test_concat.ConcatTests.test_concat_non_str",
)
+
+ django_6_0_skip_tests = (
+ # 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
diff --git a/packages/django-google-spanner/django_spanner/lookups.py b/packages/django-google-spanner/django_spanner/lookups.py
index 74c3b28ac96d..7346ec83bb17 100644
--- a/packages/django-google-spanner/django_spanner/lookups.py
+++ b/packages/django-google-spanner/django_spanner/lookups.py
@@ -42,20 +42,22 @@ 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:
+ if self.rhs_is_direct_value() and rhs_params and not self.bilateral_transforms:
rhs_sql = self.get_rhs_op(connection, rhs_sql)
+ rhs_idx = len(lhs_params)
# Chop the leading and trailing percent signs that Django adds to the
# param since this isn't a LIKE query as Django expects.
- params[0] = params[0][1:-1]
+ params[rhs_idx] = params[rhs_idx][1:-1]
# Add the case insensitive flag for icontains.
if is_icontains:
- params[0] = "(?i)" + params[0]
+ params[rhs_idx] = "(?i)" + params[rhs_idx]
# 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 +66,7 @@ def contains(self, compiler, connection):
return (
"REGEXP_CONTAINS(%s, %s)"
% (lhs_sql, connection.pattern_esc.format(rhs_sql)),
- params,
+ tuple(params),
)
@@ -89,13 +91,15 @@ 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.
- if self.rhs_is_direct_value() and params and not self.bilateral_transforms:
- params[0] = "^(?i)%s$" % params[0]
+ if self.rhs_is_direct_value() and rhs_params and not self.bilateral_transforms:
+ rhs_idx = len(lhs_params)
+ params[rhs_idx] = "^(?i)%s$" % params[rhs_idx]
else:
# lhs_sql is the expression/column to use as the regular expression.
# Use concat to make the value case-insensitive.
@@ -113,7 +117,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,24 +140,26 @@ 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:
+ if self.rhs_is_direct_value() and rhs_params and not self.bilateral_transforms:
rhs_sql = self.get_rhs_op(connection, rhs_sql)
+ rhs_idx = len(lhs_params)
if is_iregex:
- params[0] = "(?i)%s" % params[0]
+ params[rhs_idx] = "(?i)%s" % params[rhs_idx]
else:
- params[0] = str(params[0])
+ params[rhs_idx] = str(params[rhs_idx])
# 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,25 +185,27 @@ 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
is_insensitive = self.lookup_name.startswith("i")
# Chop the leading (endswith) or trailing (startswith) percent sign that
# Django adds to the param since this isn't a LIKE query as Django expects.
- if self.rhs_is_direct_value() and params and not self.bilateral_transforms:
+ if self.rhs_is_direct_value() and rhs_params and not self.bilateral_transforms:
rhs_sql = self.get_rhs_op(connection, rhs_sql)
+ rhs_idx = len(lhs_params)
if is_endswith:
- params[0] = str(params[0][1:]) + "$"
+ params[rhs_idx] = str(params[rhs_idx][1:]) + "$"
else:
- params[0] = "^" + str(params[0][:-1])
+ params[rhs_idx] = "^" + str(params[rhs_idx][:-1])
# Add the case insensitive flag for istartswith or iendswith.
if is_insensitive:
- params[0] = "(?i)" + params[0]
+ params[rhs_idx] = "(?i)" + params[rhs_idx]
# 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 +220,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 +249,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 +260,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..5a5b8e3e4612 100644
--- a/packages/django-google-spanner/django_spanner/operations.py
+++ b/packages/django-google-spanner/django_spanner/operations.py
@@ -43,6 +43,17 @@ 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(getattr(field, "column", str(field))) 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_spanner/schema.py b/packages/django-google-spanner/django_spanner/schema.py
index da57122bb73d..15c5b3ce1816 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)"
)
@@ -123,13 +124,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:
@@ -605,3 +612,16 @@ 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 ""
+ columns = [
+ self.quote_name(
+ field.column
+ if hasattr(field, "column")
+ else model._meta.get_field(field).column
+ )
+ for field in include
+ ]
+ return self.sql_create_index_include % {"columns": ", ".join(columns)}
diff --git a/packages/django-google-spanner/django_spanner/utils.py b/packages/django-google-spanner/django_spanner/utils.py
index f7ec28eb8d88..877de815b057 100644
--- a/packages/django-google-spanner/django_spanner/utils.py
+++ b/packages/django-google-spanner/django_spanner/utils.py
@@ -13,7 +13,7 @@ def check_django_compatability(supported_django_versions):
"""
Verify that this version of django-spanner is compatible with the installed
version of Django. For example, django-spanner is compatible
- with Django 2.2.y and 3.2.z
+ with Django 5.2.x and 6.0.y
"""
from . import __version__
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..eeab3d35e62d
--- /dev/null
+++ b/packages/django-google-spanner/django_test_suite_6.0.sh
@@ -0,0 +1,90 @@
+#!/bin/bash
+
+# Copyright (c) 2026 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
+
+# Disable buffering, so that the logs stream through.
+export PYTHONUNBUFFERED=1
+
+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 || true))
+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',
+]
+
+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/docs/samples.rst b/packages/django-google-spanner/docs/samples.rst
index 09d8c37590c9..d0bcee580117 100644
--- a/packages/django-google-spanner/docs/samples.rst
+++ b/packages/django-google-spanner/docs/samples.rst
@@ -9,4 +9,4 @@ This `Example `_ shows how to use django-spanner for
django-spanner on healthchecks.io
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-This `Example `_ shows how to use django-spanner for Cloud Spanner as a backend database for `Django's tutorials `_
+This `Example `_ shows how to use django-spanner for Cloud Spanner as a backend database for `Django's tutorials `_
diff --git a/packages/django-google-spanner/noxfile.py b/packages/django-google-spanner/noxfile.py
index 612fafe793f9..863fba316245 100644
--- a/packages/django-google-spanner/noxfile.py
+++ b/packages/django-google-spanner/noxfile.py
@@ -56,13 +56,13 @@
]
UNIT_TEST_DEPENDENCIES = [
- "django~=5.2",
- "sqlparse==0.3.1",
+ "django>=5.2,<6.1",
+ "sqlparse>=0.3.1",
]
UNIT_TEST_MOCKSERVER_DEPENDENCIES = [
- "django~=5.2",
- "google-cloud-spanner>=3.55.0",
+ "django>=5.2,<6.1",
+ "google-cloud-spanner>=3.69.1",
"sqlparse>=0.4.4",
]
diff --git a/packages/django-google-spanner/run_testing_worker.py b/packages/django-google-spanner/run_testing_worker.py
index 5972c0d7d133..06c295b73650 100644
--- a/packages/django-google-spanner/run_testing_worker.py
+++ b/packages/django-google-spanner/run_testing_worker.py
@@ -67,9 +67,10 @@ def __exit__(self, exc, exc_value, traceback):
print("creating instance with delay: {} seconds".format(delay))
time.sleep(delay)
+suite = os.environ.get("DJANGO_TEST_SUITE", "./django_test_suite_6.0.sh")
with TestInstance() as instance_name:
os.system(
- """DJANGO_TEST_APPS="{apps}" SPANNER_TEST_INSTANCE={instance} bash ./django_test_suite_4.2.sh""".format(
- apps=" ".join(test_apps), instance=instance_name
+ """DJANGO_TEST_APPS="{apps}" SPANNER_TEST_INSTANCE={instance} bash {suite}""".format(
+ apps=" ".join(test_apps), instance=instance_name, suite=suite
)
)
diff --git a/packages/django-google-spanner/setup.py b/packages/django-google-spanner/setup.py
index d988d600ea31..ec1db797382d 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 >= 5.2, < 6.0",
+ "google-cloud-spanner >= 3.69.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",
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..209f831c704c 100644
--- a/packages/django-google-spanner/tests/mockserver_tests/test_basics.py
+++ b/packages/django-google-spanner/tests/mockserver_tests/test_basics.py
@@ -17,13 +17,14 @@
CommitRequest,
CreateSessionRequest,
ExecuteSqlRequest,
+ TypeCode,
)
from tests.mockserver_tests.mock_server_test_base import (
MockServerTestBase,
add_select1_result,
add_singer_query_result,
- add_update_count,
+ add_single_result,
)
from tests.mockserver_tests.models import Singer
from tests.settings import DATABASES
@@ -78,11 +79,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)",
- 1,
+ "VALUES (@a0, @a1, @a2) "
+ "THEN RETURN id",
+ "id",
+ TypeCode.INT64,
+ [("1",)],
)
singer = Singer(first_name="test", last_name="test")
singer.save()
@@ -110,11 +114,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)",
- 1,
+ "VALUES (@a0, @a1) "
+ "THEN RETURN id",
+ "id",
+ TypeCode.INT64,
+ [("1",)],
)
singer = LocalSinger(first_name="test", last_name="test")
singer.save()
diff --git a/packages/django-google-spanner/tests/unit/django_spanner/simple_test.py b/packages/django-google-spanner/tests/unit/django_spanner/simple_test.py
index 34c890936d06..0d2f9a7ad20a 100644
--- a/packages/django-google-spanner/tests/unit/django_spanner/simple_test.py
+++ b/packages/django-google-spanner/tests/unit/django_spanner/simple_test.py
@@ -9,6 +9,7 @@
from django_spanner.base import DatabaseWrapper
from django_spanner.client import DatabaseClient
from django_spanner.operations import DatabaseOperations
+from django_spanner.version import __version__
# from unittest import TestCase
from tests._helpers import OpenTelemetryBase
@@ -22,7 +23,7 @@ def setUpClass(cls):
cls.INSTANCE_ID = "instance_id"
cls.DATABASE_ID = "database_id"
- cls.USER_AGENT = "django_spanner/2.2.0a1"
+ cls.USER_AGENT = f"django_spanner/{__version__}"
cls.OPTIONS = {"option": "dummy"}
cls.settings_dict = {
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_lookups.py b/packages/django-google-spanner/tests/unit/django_spanner/test_lookups.py
index deab4191bcc3..7b8045d7f806 100644
--- a/packages/django-google-spanner/tests/unit/django_spanner/test_lookups.py
+++ b/packages/django-google-spanner/tests/unit/django_spanner/test_lookups.py
@@ -6,7 +6,8 @@
from decimal import Decimal
-from django.db.models import F
+from django.db.models import F, Value
+from django.db.models.functions import Concat
from django_spanner.compiler import SQLCompiler
from tests.unit.django_spanner.simple_test import SpannerSimpleTestClass
@@ -271,3 +272,53 @@ def test_iexact_sql_query_case_insensitive_value_match(self):
)
self.assertEqual(sql_compiled, expected_sql)
self.assertEqual(params, ("abc",))
+
+ def test_icontains_with_lhs_params(self):
+ qs = (
+ Author.objects.annotate(greeting=Concat(Value("User: "), "name"))
+ .filter(greeting__icontains="john")
+ .values("name")
+ )
+ compiler = SQLCompiler(qs.query, self.connection, "default")
+ sql, params = compiler.as_sql()
+ self.assertEqual(params, ("User: ", "", "", "(?i)john"))
+
+ def test_iexact_with_lhs_params(self):
+ qs = (
+ Author.objects.annotate(greeting=Concat(Value("User: "), "name"))
+ .filter(greeting__iexact="john")
+ .values("name")
+ )
+ compiler = SQLCompiler(qs.query, self.connection, "default")
+ sql, params = compiler.as_sql()
+ self.assertEqual(params, ("User: ", "", "", "^(?i)john$"))
+
+ def test_istartswith_with_lhs_params(self):
+ qs = (
+ Author.objects.annotate(greeting=Concat(Value("User: "), "name"))
+ .filter(greeting__istartswith="john")
+ .values("name")
+ )
+ compiler = SQLCompiler(qs.query, self.connection, "default")
+ sql, params = compiler.as_sql()
+ self.assertEqual(params, ("User: ", "", "", "(?i)^john"))
+
+ def test_iendswith_with_lhs_params(self):
+ qs = (
+ Author.objects.annotate(greeting=Concat(Value("User: "), "name"))
+ .filter(greeting__iendswith="john")
+ .values("name")
+ )
+ compiler = SQLCompiler(qs.query, self.connection, "default")
+ sql, params = compiler.as_sql()
+ self.assertEqual(params, ("User: ", "", "", "(?i)john$"))
+
+ def test_iregex_with_lhs_params(self):
+ qs = (
+ Author.objects.annotate(greeting=Concat(Value("User: "), "name"))
+ .filter(greeting__iregex="^john")
+ .values("name")
+ )
+ compiler = SQLCompiler(qs.query, self.connection, "default")
+ sql, params = compiler.as_sql()
+ self.assertEqual(params, ("User: ", "", "", "(?i)^john"))
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..520d268bce10 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,83 @@ 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_with_strings(self):
+ sql, params = self.db_operations.returning_columns(["id", "created_at"])
+ self.assertEqual(sql, "THEN RETURN id, created_at")
+ 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..754baa60c735 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,42 @@ 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_with_field_objects(self):
+ """Tests _index_include_sql with Field instances possessing a .column attribute."""
+ schema_editor = DatabaseSchemaEditor(self.connection)
+ field_mock = mock.MagicMock(column="custom_col")
+ sql = schema_editor._index_include_sql(Author, [field_mock])
+ self.assertEqual(sql, " STORING (custom_col)")
+
+ def test_index_include_sql_includes_all_fields(self):
+ """Tests _index_include_sql formats all included columns directly."""
+ schema_editor = DatabaseSchemaEditor(self.connection)
+ 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."""
+ 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))
diff --git a/packages/django-google-spanner/tests/unit/django_spanner/test_utils.py b/packages/django-google-spanner/tests/unit/django_spanner/test_utils.py
index 30771fae17da..341eae3e4161 100644
--- a/packages/django-google-spanner/tests/unit/django_spanner/test_utils.py
+++ b/packages/django-google-spanner/tests/unit/django_spanner/test_utils.py
@@ -15,23 +15,23 @@
class TestUtils(SpannerSimpleTestClass):
SQL_WITH_WHERE = "Select 1 from Table WHERE 1=1"
SQL_WITHOUT_WHERE = "Select 1 from Table"
- # Only active LTS django versions (2.2.*, 3.2.*) are supported by this library right now.
- SUPPORTED_DJANGO_VERSIONS = [(2, 2), (3, 2)]
+ # Supported Django versions (5.2.*, 6.0.*) are supported by this library right now.
+ SUPPORTED_DJANGO_VERSIONS = [(5, 2), (6, 0)]
def test_check_django_compatability_match(self):
"""
Checks django compatibility match.
"""
- django_spanner.__version__ = "2.2"
- django.VERSION = (2, 2, 19, "alpha", 0)
+ django_spanner.__version__ = "5.2"
+ django.VERSION = (5, 2, 0, "final", 0)
check_django_compatability(self.SUPPORTED_DJANGO_VERSIONS)
def test_check_django_compatability_mismatch(self):
"""
Checks django compatibility mismatch.
"""
- django_spanner.__version__ = "2.2"
- django.VERSION = (3, 1, 19, "alpha", 0)
+ django_spanner.__version__ = "5.2"
+ django.VERSION = (4, 2, 0, "final", 0)
with self.assertRaises(ImproperlyConfigured):
check_django_compatability(self.SUPPORTED_DJANGO_VERSIONS)