Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 }}
27 changes: 24 additions & 3 deletions packages/django-google-spanner/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,13 @@ Supported versions
~~~~~~~~~~~~~~~~~~

The library supports `Django 5.2
<https://docs.djangoproject.com/en/5.2/>`_.
<https://docs.djangoproject.com/en/5.2/>`_ and `Django 6.0
<https://docs.djangoproject.com/en/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
Expand All @@ -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 .


Expand Down Expand Up @@ -266,6 +267,26 @@ By participating in this project you agree to abide by its terms. See the `Code
of Conduct <https://github.com/googleapis/google-cloud-python/blob/main/CODE_OF_CONDUCT.md>`_ 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
~~~~~~~~~~~

Expand Down
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)]
Comment thread
olavloite marked this conversation as resolved.

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
9 changes: 8 additions & 1 deletion 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 All @@ -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.
Expand Down Expand Up @@ -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"],
}

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

Expand Down
53 changes: 52 additions & 1 deletion 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,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
Comment thread
olavloite marked this conversation as resolved.
Comment thread
olavloite marked this conversation as resolved.
can_introspect_foreign_keys = False
# TimeField is introspected as DateTimeField because they both use
# TIMESTAMP.
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Loading
Loading