Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d2cd907
feat(django-google-spanner): support Django 6.0
sakthivelmanii Aug 17, 2026
0537232
feat(django-google-spanner): support DML THEN RETURN, field introspec…
sakthivelmanii Aug 18, 2026
d26e0d6
chore(django-google-spanner): apply ruff formatting and use POSIX she…
sakthivelmanii Aug 18, 2026
33d8712
fix(django-google-spanner): alias return_insert_columns for Django <=…
sakthivelmanii Aug 18, 2026
4ec3fc1
revert: keep django_test_suite_5.2.sh unchanged
sakthivelmanii Aug 18, 2026
b20ec44
fix(django-google-spanner): fix deferred_sql None handling, mark_skip…
sakthivelmanii Aug 18, 2026
e1210ae
test(django-google-spanner): update mockserver insert tests to expect…
sakthivelmanii Aug 18, 2026
96e66f4
chore(django-google-spanner): remove unused import in mockserver tests
sakthivelmanii Aug 18, 2026
6c5b187
feat(django-spanner): disable supports_over_clause and isolate test s…
sakthivelmanii Aug 18, 2026
248a835
revert(django-spanner): restore django_test_suite_5.2.sh to original …
sakthivelmanii Aug 18, 2026
8edb3f1
revert(django-spanner): restore create_test_instance.py to original s…
sakthivelmanii Aug 18, 2026
3a2456d
feat(django-spanner): enable supports_any_value, check constraints, c…
sakthivelmanii Aug 18, 2026
01f57df
fix(django-spanner): fix _index_include_sql and format schema.py
sakthivelmanii Aug 18, 2026
934ba54
test(django-spanner): skip upstream covering index tests requiring IN…
sakthivelmanii Aug 18, 2026
dcf1ed9
fix(django-spanner): retain order_by_nulls_first = False to generate …
sakthivelmanii Aug 18, 2026
509271d
refactor(django-spanner): remove redundant flags matching BaseDatabas…
sakthivelmanii Aug 18, 2026
7a34673
test(django-spanner): add unit tests for schema, operations, and feat…
sakthivelmanii Aug 19, 2026
7c4aa58
feat(django-spanner): raise NotSupportedError when primary key is use…
sakthivelmanii Aug 19, 2026
f4e3af6
refactor(django-spanner): delegate STORING validation directly to Clo…
sakthivelmanii Aug 19, 2026
bb15fa6
feat(django-spanner): isolate Django 6.0 skip_tests via conditional v…
sakthivelmanii Aug 19, 2026
5eeb281
docs(django-spanner): restore quota documentation comment above max_q…
sakthivelmanii Aug 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .github/workflows/django-spanner-django6.0_tests.yml
Original file line number Diff line number Diff line change
@@ -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 }}
4 changes: 2 additions & 2 deletions packages/django-google-spanner/django_spanner/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

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

Expand Down
4 changes: 4 additions & 0 deletions packages/django-google-spanner/django_spanner/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment thread
sakthivelmanii marked this conversation as resolved.

def is_usable(self):
"""Check whether the connection is valid.

Expand Down
61 changes: 51 additions & 10 deletions packages/django-google-spanner/django_spanner/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import os

import django
from django.db.backends.base.features import BaseDatabaseFeatures
from django.db.utils import InterfaceError

Expand All @@ -14,23 +15,36 @@

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.
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_partial_indexes = False
Expand All @@ -42,23 +56,20 @@ 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:
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
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
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
Expand Down Expand Up @@ -2256,3 +2267,33 @@ 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 = (
# 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
29 changes: 17 additions & 12 deletions packages/django-google-spanner/django_spanner/lookups.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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),
)


Expand All @@ -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.
Expand All @@ -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):
Expand All @@ -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:
Expand All @@ -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):
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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),
)


Expand Down Expand Up @@ -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"):
Expand All @@ -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():
Expand Down
9 changes: 9 additions & 0 deletions packages/django-google-spanner/django_spanner/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ 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), ()

# 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
Expand Down
Loading
Loading