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 }}
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
6 changes: 6 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,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
47 changes: 46 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
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,7 @@ 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
# 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 @@ -2256,3 +2274,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
57 changes: 33 additions & 24 deletions packages/django-google-spanner/django_spanner/lookups.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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),
)


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


Expand Down Expand Up @@ -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"):
Expand All @@ -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():
Expand Down
11 changes: 11 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,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
Expand Down
Loading
Loading