Skip to content

Commit 207d947

Browse files
feat(django-google-spanner): support Django 6.0 (#18128)
## Description This PR adds support for **Django 6.0** in `django-google-spanner` while maintaining full backwards compatibility with **Django 5.2**. Fixes #18053 --- ### Key Changes - **Covering Indexes (`STORING`)**: Added support for `include` clauses in index creation, generating Cloud Spanner GoogleSQL `STORING (col1, col2)` DDL; declared `supports_covering_indexes = True`. - **Composite Primary Keys**: Declared support for Django 6.0 composite primary keys (`supports_composite_primary_keys = True`). - **DML Returning Clauses**: Enabled `can_return_columns_from_insert = True` and updated `returning_columns()` in `operations.py` to safely handle column names and expression objects, allowing Django to automatically populate database defaults and `GeneratedField` values on `INSERT ... THEN RETURN`. --- ### Behavioral Notes for Users - **DML `THEN RETURN`**: With `can_return_columns_from_insert = True`, Django will now generate `THEN RETURN` clauses for models with database-generated defaults or `GeneratedField` columns upon `.save()`. Applications wishing to preserve legacy behavior can opt out via `AppConfig`: ```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 Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/google-cloud-python/issues) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes #18053 🦕
1 parent c8ac135 commit 207d947

24 files changed

Lines changed: 611 additions & 91 deletions

.github/workflows/django-spanner-django5.2_tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
3232
with:
3333
persist-credentials: false
34-
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
34+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
3535
id: filter
3636
with:
3737
filters: |
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 }}

.github/workflows/django-spanner-foreign_keys.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ jobs:
3939
needs: check_changes
4040
if: ${{ needs.check_changes.outputs.run_django_spanner == 'true' }}
4141
runs-on: ubuntu-latest
42+
strategy:
43+
fail-fast: false
44+
matrix:
45+
python-version: ["3.10", "3.12"]
4246

4347
services:
4448
emulator-0:
@@ -54,7 +58,7 @@ jobs:
5458
- name: Setup Python
5559
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
5660
with:
57-
python-version: "3.10"
61+
python-version: ${{ matrix.python-version }}
5862
- name: Run Django foreign key test
5963
run: sh foreign_key_test.sh
6064
env:

.github/workflows/django-spanner-integration-tests-against-emulator-3.10.yml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ jobs:
3939
needs: check_changes
4040
if: ${{ needs.check_changes.outputs.run_django_spanner == 'true' }}
4141
runs-on: ubuntu-latest
42+
strategy:
43+
fail-fast: false
44+
matrix:
45+
python-version: ["3.10", "3.12"]
4246

4347
services:
4448
emulator:
@@ -52,14 +56,14 @@ jobs:
5256
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
5357
with:
5458
persist-credentials: false
55-
- name: Set up Python 3.10
59+
- name: Set up Python ${{ matrix.python-version }}
5660
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
5761
with:
58-
python-version: "3.10"
62+
python-version: ${{ matrix.python-version }}
5963
- name: Install nox
6064
run: python -m pip install nox
6165
- name: Run nox
62-
run: nox -s unit-3.10
66+
run: nox -s "unit-${{ matrix.python-version }}"
6367
env:
6468
SPANNER_EMULATOR_HOST: localhost:9010
6569
GOOGLE_CLOUD_PROJECT: emulator-test-project

packages/django-google-spanner/README.rst

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,13 @@ Supported versions
6565
~~~~~~~~~~~~~~~~~~
6666

6767
The library supports `Django 5.2
68-
<https://docs.djangoproject.com/en/5.2/>`_.
68+
<https://docs.djangoproject.com/en/5.2/>`_ and `Django 6.0
69+
<https://docs.djangoproject.com/en/6.0/>`_.
6970
The minimum required Python version is 3.10.
7071

7172
.. code:: shell
7273
73-
pip3 install django==5.2
74+
pip3 install "django>=5.2,<6.1"
7475
7576
7677
Installing the package
@@ -88,7 +89,7 @@ To install from source:
8889
.. code:: shell
8990
9091
git clone git@github.com:googleapis/google-cloud-python.git
91-
cd python-spanner-django
92+
cd packages/django-google-spanner
9293
pip3 install -e .
9394
9495
@@ -266,6 +267,26 @@ By participating in this project you agree to abide by its terms. See the `Code
266267
of Conduct <https://github.com/googleapis/google-cloud-python/blob/main/CODE_OF_CONDUCT.md>`_ for more information.
267268

268269

270+
DML RETURNING Behavior
271+
~~~~~~~~~~~~~~~~~~~~~~
272+
273+
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.
274+
275+
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``:
276+
277+
.. code:: python
278+
279+
from django.apps import AppConfig
280+
281+
class MyAppConfig(AppConfig):
282+
name = "myapp"
283+
284+
def ready(self):
285+
from django_spanner.features import DatabaseFeatures
286+
287+
DatabaseFeatures.can_return_columns_from_insert = False
288+
289+
269290
Limitations
270291
~~~~~~~~~~~
271292

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: 8 additions & 1 deletion
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
@@ -15,6 +16,7 @@
1516
from .introspection import DatabaseIntrospection
1617
from .operations import DatabaseOperations
1718
from .schema import DatabaseSchemaEditor
19+
from .version import __version__
1820

1921
# Global cache for Spanner client to prevent multiple initializations
2022
# which can cause OpenTelemetry 'MeterProvider override' crashes.
@@ -159,7 +161,7 @@ def get_connection_params(self):
159161
"project": self._get_project_id(),
160162
"instance_id": self.settings_dict["INSTANCE"],
161163
"database_id": self.settings_dict["NAME"],
162-
"user_agent": "django_spanner/2.2.0a1",
164+
"user_agent": f"django_spanner/{__version__}",
163165
**self.settings_dict["OPTIONS"],
164166
}
165167

@@ -216,6 +218,11 @@ def _set_autocommit(self, autocommit):
216218
with self.wrap_database_errors:
217219
self.connection.autocommit = autocommit
218220

221+
async def _a_set_autocommit(self, autocommit):
222+
return await sync_to_async(self._set_autocommit, thread_sensitive=True)(
223+
autocommit
224+
)
225+
219226
def is_usable(self):
220227
"""Check whether the connection is valid.
221228

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

Lines changed: 52 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,8 @@ 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
74+
supports_covering_indexes = True
5675
# Spanner does not support SELECTing an arbitrary expression that also
5776
# appears in the GROUP BY clause.
5877
supports_subqueries_in_group_by = False
@@ -196,6 +215,11 @@ class DatabaseFeatures(BaseDatabaseFeatures):
196215
"many_to_one_null.tests.ManyToOneNullTests.test_set_clear_non_bulk",
197216
"many_to_one_null.tests.ManyToOneNullTests.test_unsaved",
198217
"foreign_object.tests.MultiColumnFKTests.test_prefetch_foreignobject_reverse",
218+
# Indexes tests: Spanner uses STORING instead of PostgreSQL's INCLUDE syntax
219+
# and does not support partial (WHERE) indexes. Upstream test assertions hardcode
220+
# the literal string 'INCLUDE', causing string assertion failures against Spanner's STORING clause.
221+
"indexes.tests.CoveringIndexTests.test_covering_index",
222+
"indexes.tests.CoveringIndexTests.test_covering_partial_index",
199223
# Admin ChangeList tests
200224
"admin_changelist.tests.ChangeListTests.test_custom_lookup_in_search_fields",
201225
"admin_changelist.tests.ChangeListTests.test_deterministic_order_for_model_ordered_by_its_manager",
@@ -2256,3 +2280,30 @@ class DatabaseFeatures(BaseDatabaseFeatures):
22562280
"expressions.tests.BasicExpressionsTests.test_outerref_mixed_case_table_name",
22572281
"db_functions.text.test_concat.ConcatTests.test_concat_non_str",
22582282
)
2283+
2284+
django_6_0_skip_tests = (
2285+
# Spanner uses random int64 IDs; test assumes monotonic ordering matching insertion order.
2286+
"prefetch_related.tests.PrefetchRelatedMTICacheTests.test_parent_m2m_available_in_child",
2287+
# Client-side AutoField ID generation sets pk before save; _is_pk_set() triggers refresh_from_db instead of AttributeError.
2288+
"defer_regress.tests.DeferCopyInstanceTests.test_bulk_create",
2289+
"defer_regress.tests.DeferCopyInstanceTests.test_save",
2290+
# Spanner does not support nested transactions/savepoints; raising inside atomic() aborts the whole transaction.
2291+
"update_only_fields.tests.UpdateOnlyFieldsTests.test_update_fields_not_updated",
2292+
# Test checks warning caller stacklevel; wrapping create_test_db shifts frame depth.
2293+
"backends.base.test_creation.TestDbCreationTests.test_serialize_deprecation",
2294+
# Runtime client-side AutoField initialization defaults trigger false-positive diffs in makemigrations autodetector.
2295+
"migrations.test_commands.MakeMigrationsTests.test_makemigrations_check_no_changes",
2296+
"migrations.test_commands.MakeMigrationsTests.test_makemigrations_model_rename_interactive",
2297+
"migrations.test_commands.MakeMigrationsTests.test_makemigrations_no_changes",
2298+
# Spanner query parameter limit (max_query_params = 900) limits batch chunk size.
2299+
"bulk_create.tests.BulkCreateTests.test_max_batch_size",
2300+
# Query count assertions mismatch due to Spanner batch DML execution behavior.
2301+
"bulk_create.tests.BulkCreateTransactionTests.test_multiple_batches",
2302+
# All objects get client-side PKs, collapsing multi-query insertion into a single batch query.
2303+
"bulk_create.tests.BulkCreateTransactionTests.test_objs_with_and_without_pk",
2304+
# Tie-breaker ordering on pk assumes sequential integer IDs; Spanner uses random IDs.
2305+
"ordering.tests.OrderingTests.test_order_by_case_when_constant_value",
2306+
)
2307+
2308+
if django.VERSION >= (6, 0):
2309+
skip_tests += django_6_0_skip_tests

0 commit comments

Comments
 (0)