Skip to content

Commit 311d5d1

Browse files
feat(django-google-spanner): support Django 6.0
1 parent 7f8a552 commit 311d5d1

13 files changed

Lines changed: 440 additions & 30 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
permissions:
2+
contents: read
3+
4+
on:
5+
pull_request:
6+
paths:
7+
- 'packages/django-google-spanner/**'
8+
- '.github/workflows/django-spanner-django6.0_tests.yml'
9+
push:
10+
branches:
11+
- main
12+
paths:
13+
- 'packages/django-google-spanner/**'
14+
- '.github/workflows/django-spanner-django6.0_tests.yml'
15+
16+
defaults:
17+
run:
18+
working-directory: packages/django-google-spanner
19+
20+
name: django-spanner-django6.0-tests
21+
jobs:
22+
check_changes:
23+
runs-on: ubuntu-latest
24+
outputs:
25+
run_django_spanner: ${{ steps.filter.outputs.django_spanner }}
26+
steps:
27+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
28+
with:
29+
persist-credentials: false
30+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
31+
id: filter
32+
with:
33+
filters: |
34+
django_spanner:
35+
- 'packages/django-google-spanner/**'
36+
- '.github/workflows/django-spanner-django6.0_tests.yml'
37+
38+
system-tests:
39+
needs: check_changes
40+
if: ${{ needs.check_changes.outputs.run_django_spanner == 'true' }}
41+
runs-on: ubuntu-latest
42+
strategy:
43+
fail-fast: false
44+
matrix:
45+
chunk: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
46+
include:
47+
- chunk: 0
48+
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
49+
- chunk: 1
50+
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
51+
- chunk: 2
52+
apps: model_options known_related_objects m2m_signals delete_regress fixtures generic_views model_inheritance_regress nested_foreign_keys lookup delete model_formsets
53+
- chunk: 3
54+
apps: signals or_lookups m2m_through_regress filtered_relation servers m2m_through fixtures_regress timezones model_forms.tests
55+
- chunk: 4
56+
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
57+
- chunk: 5
58+
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
59+
- chunk: 6
60+
apps: model_fields queries.test_bulk_update queries.test_explain
61+
- chunk: 7
62+
apps: queries.test_iterator queries.test_q queries.test_query queries.test_qs_combinators
63+
- chunk: 8
64+
apps: inspectdb custom_managers migrations validation get_earliest_or_latest proxy_model_inheritance one_to_one raw_query bulk_create
65+
- chunk: 9
66+
apps: queries.tests
67+
68+
services:
69+
emulator:
70+
image: gcr.io/cloud-spanner-emulator/emulator:latest # zizmor: ignore[unpinned-images]
71+
ports:
72+
- 9010:9010
73+
74+
steps:
75+
- name: Checkout code
76+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
77+
with:
78+
persist-credentials: false
79+
- name: Setup Python
80+
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
81+
with:
82+
python-version: "3.12"
83+
- name: Run Django tests
84+
run: sh django_test_suite_6.0.sh
85+
env:
86+
SPANNER_EMULATOR_HOST: localhost:9010
87+
GOOGLE_CLOUD_PROJECT: emulator-test-project
88+
GOOGLE_CLOUD_TESTS_CREATE_SPANNER_INSTANCE: true
89+
RUNNING_SPANNER_BACKEND_TESTS: 1
90+
SPANNER_TEST_INSTANCE: google-cloud-django-backend-tests
91+
DJANGO_TEST_APPS: ${{ matrix.apps }}

packages/django-google-spanner/django_spanner/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838

3939
USE_EMULATOR = os.getenv("SPANNER_EMULATOR_HOST") is not None
4040

41-
SUPPORTED_DJANGO_VERSIONS = [(5, 2)]
41+
SUPPORTED_DJANGO_VERSIONS = [(6, 0), (5, 2)]
4242

4343
check_django_compatability(SUPPORTED_DJANGO_VERSIONS)
4444

@@ -74,7 +74,7 @@ def autofield_init(self, *args, **kwargs):
7474
== "true"
7575
):
7676
self.default = gen_rand_int64
77-
self.db_returning = False
77+
self.db_returning = True
7878
self.validators = []
7979
break
8080

packages/django-google-spanner/django_spanner/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import os
88

99
from django.db.backends.base.base import BaseDatabaseWrapper
10+
from asgiref.sync import sync_to_async
1011
from google.cloud import spanner, spanner_dbapi
1112

1213
from .client import DatabaseClient
@@ -216,6 +217,9 @@ def _set_autocommit(self, autocommit):
216217
with self.wrap_database_errors:
217218
self.connection.autocommit = autocommit
218219

220+
async def _a_set_autocommit(self, autocommit):
221+
return await sync_to_async(self._set_autocommit)(autocommit)
222+
219223
def is_usable(self):
220224
"""Check whether the connection is valid.
221225

packages/django-google-spanner/django_spanner/features.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import os
88

9+
import django
910
from django.db.backends.base.features import BaseDatabaseFeatures
1011
from django.db.utils import InterfaceError
1112

@@ -14,7 +15,23 @@
1415

1516
class DatabaseFeatures(BaseDatabaseFeatures):
1617
can_introspect_big_integer_field = False
18+
19+
@property
20+
def introspected_field_types(self):
21+
return {
22+
**super().introspected_field_types,
23+
"BigIntegerField": "IntegerField",
24+
"BigAutoField": "AutoField",
25+
"SmallAutoField": "AutoField",
26+
"SmallIntegerField": "IntegerField",
27+
"PositiveBigIntegerField": "IntegerField",
28+
"PositiveIntegerField": "IntegerField",
29+
"PositiveSmallIntegerField": "IntegerField",
30+
"DurationField": "IntegerField",
31+
}
32+
1733
can_introspect_duration_field = False
34+
can_return_columns_from_insert = True
1835
can_introspect_foreign_keys = False
1936
# TimeField is introspected as DateTimeField because they both use
2037
# TIMESTAMP.
@@ -42,7 +59,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
4259
if USE_EMULATOR:
4360
# Emulator does not support json.
4461
supports_json_field = False
45-
# Emulator does not support check constrints.
62+
# Emulator does not support check constraints.
4663
supports_column_check_constraints = False
4764
supports_table_check_constraints = False
4865
else:
@@ -53,6 +70,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
5370
supports_composite_primary_keys = True
5471
# Spanner does not support order by null modifiers.
5572
supports_order_by_nulls_modifier = False
73+
supports_any_value = True
5674
# Spanner does not support SELECTing an arbitrary expression that also
5775
# appears in the GROUP BY clause.
5876
supports_subqueries_in_group_by = False
@@ -2256,3 +2274,30 @@ class DatabaseFeatures(BaseDatabaseFeatures):
22562274
"expressions.tests.BasicExpressionsTests.test_outerref_mixed_case_table_name",
22572275
"db_functions.text.test_concat.ConcatTests.test_concat_non_str",
22582276
)
2277+
2278+
django_6_0_skip_tests = (
2279+
# Spanner uses random int64 IDs; test assumes monotonic ordering matching insertion order.
2280+
"prefetch_related.tests.PrefetchRelatedMTICacheTests.test_parent_m2m_available_in_child",
2281+
# Client-side AutoField ID generation sets pk before save; _is_pk_set() triggers refresh_from_db instead of AttributeError.
2282+
"defer_regress.tests.DeferCopyInstanceTests.test_bulk_create",
2283+
"defer_regress.tests.DeferCopyInstanceTests.test_save",
2284+
# Spanner does not support nested transactions/savepoints; raising inside atomic() aborts the whole transaction.
2285+
"update_only_fields.tests.UpdateOnlyFieldsTests.test_update_fields_not_updated",
2286+
# Test checks warning caller stacklevel; wrapping create_test_db shifts frame depth.
2287+
"backends.base.test_creation.TestDbCreationTests.test_serialize_deprecation",
2288+
# Runtime client-side AutoField initialization defaults trigger false-positive diffs in makemigrations autodetector.
2289+
"migrations.test_commands.MakeMigrationsTests.test_makemigrations_check_no_changes",
2290+
"migrations.test_commands.MakeMigrationsTests.test_makemigrations_model_rename_interactive",
2291+
"migrations.test_commands.MakeMigrationsTests.test_makemigrations_no_changes",
2292+
# Spanner query parameter limit (max_query_params = 900) limits batch chunk size.
2293+
"bulk_create.tests.BulkCreateTests.test_max_batch_size",
2294+
# Query count assertions mismatch due to Spanner batch DML execution behavior.
2295+
"bulk_create.tests.BulkCreateTransactionTests.test_multiple_batches",
2296+
# All objects get client-side PKs, collapsing multi-query insertion into a single batch query.
2297+
"bulk_create.tests.BulkCreateTransactionTests.test_objs_with_and_without_pk",
2298+
# Tie-breaker ordering on pk assumes sequential integer IDs; Spanner uses random IDs.
2299+
"ordering.tests.OrderingTests.test_order_by_case_when_constant_value",
2300+
)
2301+
2302+
if django.VERSION >= (6, 0):
2303+
skip_tests += django_6_0_skip_tests

packages/django-google-spanner/django_spanner/lookups.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ def contains(self, compiler, connection):
4242
:rtype: tuple[str, str]
4343
:returns: A tuple of the SQL request and parameters.
4444
"""
45-
lhs_sql, params = self.process_lhs(compiler, connection)
45+
lhs_sql, lhs_params = self.process_lhs(compiler, connection)
4646
rhs_sql, rhs_params = self.process_rhs(compiler, connection)
47+
params = list(lhs_params)
4748
params.extend(rhs_params)
4849
is_icontains = self.lookup_name.startswith("i")
4950
if self.rhs_is_direct_value() and params and not self.bilateral_transforms:
@@ -55,7 +56,7 @@ def contains(self, compiler, connection):
5556
if is_icontains:
5657
params[0] = "(?i)" + params[0]
5758
# rhs_sql is REGEXP_CONTAINS(%s, %%s), and lhs_sql is the column name.
58-
return rhs_sql % lhs_sql, params
59+
return rhs_sql % lhs_sql, tuple(params)
5960
else:
6061
# rhs_sql is the expression/column to use as the base of the regular
6162
# expression.
@@ -64,7 +65,7 @@ def contains(self, compiler, connection):
6465
return (
6566
"REGEXP_CONTAINS(%s, %s)"
6667
% (lhs_sql, connection.pattern_esc.format(rhs_sql)),
67-
params,
68+
tuple(params),
6869
)
6970

7071

@@ -89,8 +90,9 @@ def iexact(self, compiler, connection):
8990
:rtype: tuple[str, str]
9091
:returns: A tuple of the SQL request and parameters.
9192
"""
92-
lhs_sql, params = self.process_lhs(compiler, connection)
93+
lhs_sql, lhs_params = self.process_lhs(compiler, connection)
9394
rhs_sql, rhs_params = self.process_rhs(compiler, connection)
95+
params = list(lhs_params)
9496
params.extend(rhs_params)
9597
rhs_sql = self.get_rhs_op(connection, rhs_sql)
9698
# Wrap the parameter in ^ and $ to restrict the regex to an exact match.
@@ -113,7 +115,7 @@ def iexact(self, compiler, connection):
113115
rhs_sql = rhs_sql.replace("%s", "%%s")
114116
rhs_sql = rhs_sql.replace("__PLACEHOLDER_FOR_LHS_SQL__", "%s")
115117
# rhs_sql is REGEXP_CONTAINS(%s, %%s), and lhs_sql is the column name.
116-
return rhs_sql % lhs_sql, params
118+
return rhs_sql % lhs_sql, tuple(params)
117119

118120

119121
def regex(self, compiler, connection):
@@ -136,8 +138,9 @@ def regex(self, compiler, connection):
136138
:rtype: tuple[str, str]
137139
:returns: A tuple of the SQL request and parameters.
138140
"""
139-
lhs_sql, params = self.process_lhs(compiler, connection)
141+
lhs_sql, lhs_params = self.process_lhs(compiler, connection)
140142
rhs_sql, rhs_params = self.process_rhs(compiler, connection)
143+
params = list(lhs_params)
141144
params.extend(rhs_params)
142145
is_iregex = self.lookup_name.startswith("i")
143146
if self.rhs_is_direct_value() and params and not self.bilateral_transforms:
@@ -147,13 +150,13 @@ def regex(self, compiler, connection):
147150
else:
148151
params[0] = str(params[0])
149152
# rhs_sql is REGEXP_CONTAINS(%s, %%s), and lhs_sql is the column name.
150-
return rhs_sql % lhs_sql, params
153+
return rhs_sql % lhs_sql, tuple(params)
151154
else:
152155
# rhs_sql is the expression/column to use as the base of the regular
153156
# expression.
154157
if is_iregex:
155158
rhs_sql = "CONCAT('(?i)', " + rhs_sql + ")"
156-
return "REGEXP_CONTAINS(%s, %s)" % (lhs_sql, rhs_sql), params
159+
return "REGEXP_CONTAINS(%s, %s)" % (lhs_sql, rhs_sql), tuple(params)
157160

158161

159162
def startswith_endswith(self, compiler, connection):
@@ -179,8 +182,9 @@ def startswith_endswith(self, compiler, connection):
179182
:rtype: tuple[str, str]
180183
:returns: A tuple of the SQL request and parameters.
181184
"""
182-
lhs_sql, params = self.process_lhs(compiler, connection)
185+
lhs_sql, lhs_params = self.process_lhs(compiler, connection)
183186
rhs_sql, rhs_params = self.process_rhs(compiler, connection)
187+
params = list(lhs_params)
184188
params.extend(rhs_params)
185189
is_startswith = "startswith" in self.lookup_name
186190
is_endswith = "endswith" in self.lookup_name
@@ -197,7 +201,7 @@ def startswith_endswith(self, compiler, connection):
197201
if is_insensitive:
198202
params[0] = "(?i)" + params[0]
199203
# rhs_sql is REGEXP_CONTAINS(%s, %%s), and lhs_sql is the column name.
200-
return rhs_sql % lhs_sql, params
204+
return rhs_sql % lhs_sql, tuple(params)
201205
else:
202206
# rhs_sql is the expression/column to use as the base of the regular
203207
# expression.
@@ -212,7 +216,7 @@ def startswith_endswith(self, compiler, connection):
212216
sql += ")"
213217
return (
214218
"REGEXP_CONTAINS(%s, %s)" % (lhs_sql, connection.pattern_esc.format(sql)),
215-
params,
219+
tuple(params),
216220
)
217221

218222

@@ -241,6 +245,7 @@ def cast_param_to_float(self, compiler, connection):
241245
:returns: A tuple of the SQL request and float parameters.
242246
"""
243247
sql, params = self.as_sql(compiler, connection)
248+
params = list(params) if params else []
244249
if params:
245250
# Cast remote field lookups that must be integer but come in as string.
246251
if hasattr(self.lhs.output_field, "get_path_info"):
@@ -251,7 +256,7 @@ def cast_param_to_float(self, compiler, connection):
251256
params[i], str
252257
):
253258
params[i] = int(params[i])
254-
return sql, params
259+
return sql, tuple(params)
255260

256261

257262
def register_lookups():

packages/django-google-spanner/django_spanner/operations.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ class DatabaseOperations(BaseDatabaseOperations):
4343
cast_char_field_without_max_length = "STRING"
4444
compiler_module = "django_spanner.compiler"
4545

46+
def returning_columns(self, fields):
47+
if not fields:
48+
return "", ()
49+
columns = [self.quote_name(field.column) for field in fields]
50+
return "THEN RETURN %s" % ", ".join(columns), ()
51+
52+
# In Django <= 5.2, this method was named return_insert_columns
53+
return_insert_columns = returning_columns
54+
4655
# Django's lookup names that require a different name in Spanner's
4756
# EXTRACT() function.
4857
# https://cloud.google.com/spanner/docs/functions-and-operators#extract

packages/django-google-spanner/django_spanner/schema.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
3535
# Spanner doesn't support partial indexes. This string omits the
3636
# %(condition)s placeholder so that partial indexes are ignored.
3737
sql_create_index = (
38-
"CREATE INDEX %(name)s ON %(table)s%(using)s (%(columns)s)%(extra)s"
38+
"CREATE INDEX %(name)s ON %(table)s%(using)s (%(columns)s)%(include)s%(extra)s"
3939
)
40+
sql_create_index_include = " STORING (%(columns)s)"
4041
sql_create_unique = (
4142
"CREATE UNIQUE NULL_FILTERED INDEX %(name)s ON %(table)s (%(columns)s)"
4243
)
@@ -123,13 +124,19 @@ def create_model(self, model):
123124
# created afterwards, like geometry fields with some backends)
124125
for fields in model._meta.unique_together:
125126
columns = [model._meta.get_field(field) for field in fields]
126-
self.deferred_sql.append(self._create_unique_sql(model, columns))
127+
sql = self._create_unique_sql(model, columns)
128+
if sql:
129+
self.deferred_sql.append(sql)
127130
constraints = []
128131
for constraint in model._meta.constraints:
129132
if isinstance(constraint, django.db.models.UniqueConstraint):
130-
self.deferred_sql.append(constraint.create_sql(model, self))
133+
sql = constraint.create_sql(model, self)
134+
if sql:
135+
self.deferred_sql.append(sql)
131136
else:
132-
constraints.append(constraint.constraint_sql(model, self))
137+
c_sql = constraint.constraint_sql(model, self)
138+
if c_sql:
139+
constraints.append(c_sql)
133140
if model._meta.pk.is_relation:
134141
pk_column = self.quote_name(model._meta.pk.column)
135142
else:
@@ -605,3 +612,12 @@ def skip_default(self, field):
605612
if getattr(field, "db_default", None) is not None:
606613
return False
607614
return True
615+
616+
def _index_include_sql(self, model, include):
617+
if not include:
618+
return ""
619+
columns = [
620+
self.quote_name(field.column if hasattr(field, "column") else str(field))
621+
for field in include
622+
]
623+
return self.sql_create_index_include % {"columns": ", ".join(columns)}

0 commit comments

Comments
 (0)