diff --git a/.github/workflows/django3.2_tests_against_emulator0.yml b/.github/workflows/django3.2_tests_against_emulator0.yml deleted file mode 100644 index 0b737da371..0000000000 --- a/.github/workflows/django3.2_tests_against_emulator0.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django3.2-tests0 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_3.2.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: admin_changelist admin_ordering aggregation distinct_on_fields expressions_window fixtures_model_package datetimes custom_methods generic_inline_admin field_defaults datatypes empty m2o_recursive many_to_one_null migration_test_data_persistence admin_docs invalid_models_tests migrate_signals model_forms.test_uuid model_forms.test_modelchoicefield syndication_tests view_tests update test_utils select_related_onetoone sessions_tests diff --git a/.github/workflows/django3.2_tests_against_emulator1.yml b/.github/workflows/django3.2_tests_against_emulator1.yml deleted file mode 100644 index 23d5d49ecf..0000000000 --- a/.github/workflows/django3.2_tests_against_emulator1.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django3.2-tests1 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_3.2.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: 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 diff --git a/.github/workflows/django3.2_tests_against_emulator2.yml b/.github/workflows/django3.2_tests_against_emulator2.yml deleted file mode 100644 index 54fc3c6527..0000000000 --- a/.github/workflows/django3.2_tests_against_emulator2.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django3.2-tests2 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_3.2.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: model_options known_related_objects m2m_signals delete_regress fixtures generic_views model_inheritance_regress nested_foreign_keys lookup delete model_formsets diff --git a/.github/workflows/django3.2_tests_against_emulator3.yml b/.github/workflows/django3.2_tests_against_emulator3.yml deleted file mode 100644 index f1575bfeac..0000000000 --- a/.github/workflows/django3.2_tests_against_emulator3.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django3.2-tests3 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_3.2.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: signals or_lookups m2m_through_regress filtered_relation servers m2m_through fixtures_regress timezones model_forms.tests diff --git a/.github/workflows/django3.2_tests_against_emulator4.yml b/.github/workflows/django3.2_tests_against_emulator4.yml deleted file mode 100644 index 9ae7b7d50e..0000000000 --- a/.github/workflows/django3.2_tests_against_emulator4.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django3.2-tests4 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_3.2.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: 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 diff --git a/.github/workflows/django3.2_tests_against_emulator5.yml b/.github/workflows/django3.2_tests_against_emulator5.yml deleted file mode 100644 index e21c194e67..0000000000 --- a/.github/workflows/django3.2_tests_against_emulator5.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django3.2-tests5 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_3.2.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: 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 diff --git a/.github/workflows/django3.2_tests_against_emulator6.yml b/.github/workflows/django3.2_tests_against_emulator6.yml deleted file mode 100644 index 5afe3ff644..0000000000 --- a/.github/workflows/django3.2_tests_against_emulator6.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django3.2-tests6 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_3.2.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: model_fields queries.test_bulk_update queries.test_explain diff --git a/.github/workflows/django3.2_tests_against_emulator7.yml b/.github/workflows/django3.2_tests_against_emulator7.yml deleted file mode 100644 index acbb9a379d..0000000000 --- a/.github/workflows/django3.2_tests_against_emulator7.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django3.2-tests7 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_3.2.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: queries.test_iterator queries.test_q queries.test_query queries.test_qs_combinators diff --git a/.github/workflows/django3.2_tests_against_emulator8.yml b/.github/workflows/django3.2_tests_against_emulator8.yml deleted file mode 100644 index 608014f6e4..0000000000 --- a/.github/workflows/django3.2_tests_against_emulator8.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django3.2-tests8 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_3.2.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: inspectdb custom_managers migrations validation get_earliest_or_latest proxy_model_inheritance one_to_one raw_query bulk_create diff --git a/.github/workflows/django3.2_tests_against_emulator9.yml b/.github/workflows/django3.2_tests_against_emulator9.yml deleted file mode 100644 index 3c7578fbf8..0000000000 --- a/.github/workflows/django3.2_tests_against_emulator9.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django3.2-tests9 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_3.2.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: queries.tests diff --git a/.github/workflows/django4.2_tests_against_emulator0.yml b/.github/workflows/django4.2_tests_against_emulator0.yml deleted file mode 100644 index 10dcf1ba3b..0000000000 --- a/.github/workflows/django4.2_tests_against_emulator0.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django4.2-tests0 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_4.2.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: admin_changelist admin_ordering aggregation distinct_on_fields expressions_window fixtures_model_package datetimes custom_methods generic_inline_admin field_defaults datatypes empty m2o_recursive many_to_one_null migration_test_data_persistence admin_docs invalid_models_tests migrate_signals model_forms.test_uuid model_forms.test_modelchoicefield syndication_tests view_tests update test_utils select_related_onetoone sessions_tests diff --git a/.github/workflows/django4.2_tests_against_emulator1.yml b/.github/workflows/django4.2_tests_against_emulator1.yml deleted file mode 100644 index 7f44ce5f4c..0000000000 --- a/.github/workflows/django4.2_tests_against_emulator1.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django4.2-tests1 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_4.2.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: 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 diff --git a/.github/workflows/django4.2_tests_against_emulator2.yml b/.github/workflows/django4.2_tests_against_emulator2.yml deleted file mode 100644 index 9f86bb01cd..0000000000 --- a/.github/workflows/django4.2_tests_against_emulator2.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django4.2-tests2 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_4.2.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: model_options known_related_objects m2m_signals delete_regress fixtures generic_views model_inheritance_regress nested_foreign_keys lookup delete model_formsets diff --git a/.github/workflows/django4.2_tests_against_emulator3.yml b/.github/workflows/django4.2_tests_against_emulator3.yml deleted file mode 100644 index c666f065fb..0000000000 --- a/.github/workflows/django4.2_tests_against_emulator3.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django4.2-tests3 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_4.2.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: signals or_lookups m2m_through_regress filtered_relation servers m2m_through fixtures_regress timezones model_forms.tests diff --git a/.github/workflows/django4.2_tests_against_emulator4.yml b/.github/workflows/django4.2_tests_against_emulator4.yml deleted file mode 100644 index 30645fb684..0000000000 --- a/.github/workflows/django4.2_tests_against_emulator4.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django4.2-tests4 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_4.2.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: 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 diff --git a/.github/workflows/django4.2_tests_against_emulator5.yml b/.github/workflows/django4.2_tests_against_emulator5.yml deleted file mode 100644 index ff7d22ed31..0000000000 --- a/.github/workflows/django4.2_tests_against_emulator5.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django4.2-tests5 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_4.2.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: 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 diff --git a/.github/workflows/django4.2_tests_against_emulator6.yml b/.github/workflows/django4.2_tests_against_emulator6.yml deleted file mode 100644 index 9e70c967cc..0000000000 --- a/.github/workflows/django4.2_tests_against_emulator6.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django4.2-tests6 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_4.2.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: model_fields queries.test_bulk_update queries.test_explain diff --git a/.github/workflows/django4.2_tests_against_emulator7.yml b/.github/workflows/django4.2_tests_against_emulator7.yml deleted file mode 100644 index 48828fca77..0000000000 --- a/.github/workflows/django4.2_tests_against_emulator7.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django4.2-tests7 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_4.2.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: queries.test_iterator queries.test_q queries.test_query queries.test_qs_combinators diff --git a/.github/workflows/django4.2_tests_against_emulator8.yml b/.github/workflows/django4.2_tests_against_emulator8.yml deleted file mode 100644 index 5f2c8f0c24..0000000000 --- a/.github/workflows/django4.2_tests_against_emulator8.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django4.2-tests8 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_4.2.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: inspectdb custom_managers migrations validation get_earliest_or_latest proxy_model_inheritance one_to_one raw_query bulk_create diff --git a/.github/workflows/django4.2_tests_against_emulator9.yml b/.github/workflows/django4.2_tests_against_emulator9.yml deleted file mode 100644 index 3a898a5460..0000000000 --- a/.github/workflows/django4.2_tests_against_emulator9.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: django4.2-tests9 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator-0: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Run Django tests - run: sh django_test_suite_4.2.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: queries.tests diff --git a/.github/workflows/django5.2_tests.yml b/.github/workflows/django5.2_tests.yml new file mode 100644 index 0000000000..c5c5574081 --- /dev/null +++ b/.github/workflows/django5.2_tests.yml @@ -0,0 +1,57 @@ +on: + push: + branches: + - main + pull_request: +name: django5.2-tests +jobs: + system-tests: + 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 invalid_models_tests 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 + ports: + - 9010:9010 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Run Django tests + run: sh django_test_suite_5.2.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 }} diff --git a/.github/workflows/foreign_keys.yaml b/.github/workflows/foreign_keys.yaml index b08b169a5a..c689ef9788 100644 --- a/.github/workflows/foreign_keys.yaml +++ b/.github/workflows/foreign_keys.yaml @@ -20,7 +20,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: "3.10" - name: Run Django foreign key test run: sh foreign_key_test.sh env: diff --git a/.github/workflows/integration-tests-against-emulator-3.8.yml b/.github/workflows/integration-tests-against-emulator-3.8.yml deleted file mode 100644 index b479d1a16e..0000000000 --- a/.github/workflows/integration-tests-against-emulator-3.8.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: Run Django Spanner integration tests against emulator 3.8 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - 9020:9020 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Set up Python 3.8 - uses: actions/setup-python@v5 - with: - python-version: 3.8 - - name: Install nox - run: python -m pip install nox - - name: Run nox - run: nox -s unit-3.8 - env: - SPANNER_EMULATOR_HOST: localhost:9010 - GOOGLE_CLOUD_PROJECT: emulator-test-project - GOOGLE_CLOUD_TESTS_CREATE_SPANNER_INSTANCE: true diff --git a/.github/workflows/integration-tests-against-emulator-3.9.yml b/.github/workflows/integration-tests-against-emulator-3.9.yml deleted file mode 100644 index 371f071afa..0000000000 --- a/.github/workflows/integration-tests-against-emulator-3.9.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: - push: - branches: - - main - pull_request: -name: Run Django Spanner integration tests against emulator 3.9 -jobs: - system-tests: - runs-on: ubuntu-latest - - services: - emulator: - image: gcr.io/cloud-spanner-emulator/emulator:latest - ports: - - 9010:9010 - - 9020:9020 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Set up Python 3.9 - uses: actions/setup-python@v5 - with: - python-version: 3.9 - - name: Install nox - run: python -m pip install nox - - name: Run nox - run: nox -s unit-3.9 - env: - SPANNER_EMULATOR_HOST: localhost:9010 - GOOGLE_CLOUD_PROJECT: emulator-test-project - GOOGLE_CLOUD_TESTS_CREATE_SPANNER_INSTANCE: true diff --git a/django_spanner/__init__.py b/django_spanner/__init__.py index 2b40c012cd..fc74e58652 100644 --- a/django_spanner/__init__.py +++ b/django_spanner/__init__.py @@ -1,3 +1,4 @@ +# flake8: noqa: E402 # Copyright 2020 Google LLC # # Use of this source code is governed by a BSD-style @@ -14,46 +15,40 @@ RANDOM_ID_GENERATION_ENABLED_SETTING = "RANDOM_ID_GENERATION_ENABLED" -import pkg_resources -from django.conf.global_settings import DATABASES -from django.db import DEFAULT_DB_ALIAS -from google.cloud.spanner_v1 import JsonObject -from django.db.models.fields import ( + +from django.db import DEFAULT_DB_ALIAS # noqa: E402 +from google.cloud.spanner_v1 import JsonObject # noqa: E402 +from django.db.models.fields import ( # noqa: E402 NOT_PROVIDED, AutoField, Field, ) -from .functions import register_functions -from .lookups import register_lookups -from .utils import check_django_compatability -from .version import __version__ +from .functions import register_functions # noqa: E402 +from .lookups import register_lookups # noqa: E402 +from .utils import check_django_compatability # noqa: E402 +from .version import __version__ # noqa: E402 # Monkey-patch google.DatetimeWithNanoseconds's __eq__ compare against # datetime.datetime. -from google.api_core.datetime_helpers import DatetimeWithNanoseconds - +from google.api_core.datetime_helpers import ( + DatetimeWithNanoseconds, +) # noqa: E402 -USING_DJANGO_3 = False -if django.VERSION[:2] == (3, 2): - USING_DJANGO_3 = True -USING_DJANGO_4 = False -if django.VERSION[:2] == (4, 2): - USING_DJANGO_4 = True - -from django.db.models.fields import ( +from django.db.models.fields import ( # noqa: E402 SmallAutoField, BigAutoField, ) -from django.db.models import JSONField +from django.db.models import JSONField # noqa: E402 USE_EMULATOR = os.getenv("SPANNER_EMULATOR_HOST") is not None -# Only active LTS django versions (3.2.*, 4.2.*) are supported by this library right now. -SUPPORTED_DJANGO_VERSIONS = [(3, 2), (4, 2)] +SUPPORTED_DJANGO_VERSIONS = [(5, 2)] check_django_compatability(SUPPORTED_DJANGO_VERSIONS) + +__all__ = ["__version__", "USE_EMULATOR"] register_functions() register_lookups() diff --git a/django_spanner/base.py b/django_spanner/base.py index 8e3f249c5f..ab1c74ba0b 100644 --- a/django_spanner/base.py +++ b/django_spanner/base.py @@ -17,7 +17,11 @@ from .introspection import DatabaseIntrospection from .operations import DatabaseOperations from .schema import DatabaseSchemaEditor -from django_spanner import USING_DJANGO_3 + + +# Global cache for Spanner client to prevent multiple initializations +# which can cause OpenTelemetry 'MeterProvider override' crashes. +_SPANNER_CLIENT_CACHE = None class DatabaseWrapper(BaseDatabaseWrapper): @@ -113,6 +117,7 @@ class DatabaseWrapper(BaseDatabaseWrapper): ops_class = DatabaseOperations client_class = DatabaseClient + @property def instance(self): """Reference to a Cloud Spanner Instance containing the Database. @@ -120,20 +125,25 @@ def instance(self): :rtype: :class:`~google.cloud.spanner_v1.instance.Instance` :returns: A new instance owned by the existing Spanner Client. """ + global _SPANNER_CLIENT_CACHE + if "client" in self.settings_dict["OPTIONS"]: - client = self.settings_dict["OPTIONS"]["client"] - else: - client = spanner.Client(project=os.environ["GOOGLE_CLOUD_PROJECT"]) - return client.instance(self.settings_dict["INSTANCE"]) + return self.settings_dict["OPTIONS"]["client"].instance( + self.settings_dict["INSTANCE"] + ) + + if _SPANNER_CLIENT_CACHE is None: + _SPANNER_CLIENT_CACHE = spanner.Client( + project=os.environ["GOOGLE_CLOUD_PROJECT"] + ) + + return _SPANNER_CLIENT_CACHE.instance(self.settings_dict["INSTANCE"]) @property def allow_transactions_in_auto_commit(self): if "ALLOW_TRANSACTIONS_IN_AUTO_COMMIT" in self.settings_dict: return self.settings_dict["ALLOW_TRANSACTIONS_IN_AUTO_COMMIT"] - if USING_DJANGO_3: - return False - else: - return True + return True @property def _nodb_connection(self): @@ -170,7 +180,16 @@ def get_new_connection(self, conn_params): :raises: :class:`ValueError` in case the given instance/database doesn't exist. """ - return self.Database.connect(**conn_params) + conn_params.pop("instance", None) + conn_params.pop("instance_id", None) + conn_params.pop("client", None) + # Ensure client is initialized + instance = self.instance + return self.Database.connect( + instance.instance_id, + client=instance._client, + **conn_params, + ) def init_connection_state(self): """Initialize the state of the existing connection.""" @@ -224,7 +243,5 @@ def _start_transaction_under_autocommit(self): if self.allow_transactions_in_auto_commit: self.connection.cursor().execute("BEGIN") else: - # This won't start a transaction and was a bug in Spanner Django 3.2 version. - # Set ALLOW_TRANSACTIONS_IN_AUTO_COMMIT = True in your settings.py file to enable # transactions in autocommit mode for Django 3.2. self.connection.cursor().execute("SELECT 1") diff --git a/django_spanner/compiler.py b/django_spanner/compiler.py index a2175113f7..6bd26e8980 100644 --- a/django_spanner/compiler.py +++ b/django_spanner/compiler.py @@ -13,7 +13,6 @@ SQLUpdateCompiler as BaseSQLUpdateCompiler, ) from django.db.utils import DatabaseError -from django_spanner import USING_DJANGO_3 class SQLCompiler(BaseSQLCompiler): @@ -41,180 +40,95 @@ def get_combinator_sql(self, combinator, all): """ # This method copies the complete code of this overridden method from # Django core and modify it for Spanner by adding one line - if USING_DJANGO_3: - features = self.connection.features - compilers = [ - query.get_compiler(self.using, self.connection) - for query in self.query.combined_queries - if not query.is_empty() - ] - if not features.supports_slicing_ordering_in_compound: - for query, compiler in zip( - self.query.combined_queries, compilers - ): - if query.low_mark or query.high_mark: - raise DatabaseError( - "LIMIT/OFFSET not allowed in subqueries of compound " - "statements." - ) - if compiler.get_order_by(): - raise DatabaseError( - "ORDER BY not allowed in subqueries of compound " - "statements." - ) - parts = () + # This method copies the complete code of this overridden method from + # Django core and modify it for Spanner by adding one line + features = self.connection.features + compilers = [ + query.get_compiler(self.using, self.connection, self.elide_empty) + for query in self.query.combined_queries + ] + if not features.supports_slicing_ordering_in_compound: for compiler in compilers: - try: - # If the columns list is limited, then all combined queries - # must have the same columns list. Set the selects defined on - # the query on all combined queries, if not already set. - if ( - not compiler.query.values_select - and self.query.values_select - ): - compiler.query.set_values( - ( - *self.query.extra_select, - *self.query.values_select, - *self.query.annotation_select, - ) - ) - part_sql, part_args = compiler.as_sql() - if compiler.query.combinator: - # Wrap in a subquery if wrapping in parentheses isn't - # supported. - if not features.supports_parentheses_in_compound: - part_sql = "SELECT * FROM ({})".format(part_sql) - # Add parentheses when combining with compound query if not - # already added for all compound queries. - elif ( - not features.supports_slicing_ordering_in_compound - ): - part_sql = "({})".format(part_sql) - parts += ((part_sql, part_args),) - except EmptyResultSet: - # Omit the empty queryset with UNION and with DIFFERENCE if the - # first queryset is nonempty. - if combinator == "union" or ( - combinator == "difference" and parts - ): - continue - raise - if not parts: - raise EmptyResultSet - combinator_sql = self.connection.ops.set_operators[combinator] - # This is the only line that is changed from the Django core - # implementation of this method - combinator_sql += " ALL" if all else " DISTINCT" - braces = ( - "({})" - if features.supports_slicing_ordering_in_compound - else "{}" - ) - sql_parts, args_parts = zip( - *((braces.format(sql), args) for sql, args in parts) - ) - result = [" {} ".format(combinator_sql).join(sql_parts)] - params = [] - for part in args_parts: - params.extend(part) - - return result, params - # As the code of this method has somewhat changed in Django 4.2 core - # version, so we are copying the complete code of this overridden method - # and modifying it for Spanner - else: - features = self.connection.features - compilers = [ - query.get_compiler( - self.using, self.connection, self.elide_empty - ) - for query in self.query.combined_queries - ] - if not features.supports_slicing_ordering_in_compound: - for compiler in compilers: - if compiler.query.is_sliced: - raise DatabaseError( - "LIMIT/OFFSET not allowed in subqueries of compound statements." - ) - if compiler.get_order_by(): - raise DatabaseError( - "ORDER BY not allowed in subqueries of compound statements." - ) - elif self.query.is_sliced and combinator == "union": - for compiler in compilers: - # A sliced union cannot have its parts elided as some of them - # might be sliced as well and in the event where only a single - # part produces a non-empty resultset it might be impossible to - # generate valid SQL. - compiler.elide_empty = False - parts = () + if compiler.query.is_sliced: + raise DatabaseError( + "LIMIT/OFFSET not allowed in subqueries of compound statements." + ) + if compiler.get_order_by(): + raise DatabaseError( + "ORDER BY not allowed in subqueries of compound statements." + ) + elif self.query.is_sliced and combinator == "union": for compiler in compilers: - try: - # If the columns list is limited, then all combined queries - # must have the same columns list. Set the selects defined on - # the query on all combined queries, if not already set. - if ( - not compiler.query.values_select - and self.query.values_select - ): - compiler.query = compiler.query.clone() - compiler.query.set_values( - ( - *self.query.extra_select, - *self.query.values_select, - *self.query.annotation_select, - ) + # A sliced union cannot have its parts elided as some of them + # might be sliced as well and in the event where only a single + # part produces a non-empty resultset it might be impossible to + # generate valid SQL. + compiler.elide_empty = False + parts = () + for compiler in compilers: + try: + # If the columns list is limited, then all combined queries + # must have the same columns list. Set the selects defined on + # the query on all combined queries, if not already set. + if ( + not compiler.query.values_select + and self.query.values_select + ): + compiler.query = compiler.query.clone() + compiler.query.set_values( + ( + *self.query.extra_select, + *self.query.values_select, + *self.query.annotation_select, ) - part_sql, part_args = compiler.as_sql( - with_col_aliases=True ) - if compiler.query.combinator: - # Wrap in a subquery if wrapping in parentheses isn't - # supported. - if not features.supports_parentheses_in_compound: - part_sql = "SELECT * FROM ({})".format(part_sql) - # Add parentheses when combining with compound query if not - # already added for all compound queries. - elif ( - self.query.subquery - or not features.supports_slicing_ordering_in_compound - ): - part_sql = "({})".format(part_sql) + part_sql, part_args = compiler.as_sql(with_col_aliases=True) + if compiler.query.combinator: + # Wrap in a subquery if wrapping in parentheses isn't + # supported. + if not features.supports_parentheses_in_compound: + part_sql = "SELECT * FROM ({})".format(part_sql) + # Add parentheses when combining with compound query if not + # already added for all compound queries. elif ( self.query.subquery - and features.supports_slicing_ordering_in_compound + or not features.supports_slicing_ordering_in_compound ): part_sql = "({})".format(part_sql) - parts += ((part_sql, part_args),) - except EmptyResultSet: - # Omit the empty queryset with UNION and with DIFFERENCE if the - # first queryset is nonempty. - if combinator == "union" or ( - combinator == "difference" and parts - ): - continue - raise - if not parts: - raise EmptyResultSet - combinator_sql = self.connection.ops.set_operators[combinator] - # This is the only line that is changed from the Django core - # implementation of this method - combinator_sql += " ALL" if all else " DISTINCT" - braces = "{}" - if ( - not self.query.subquery - and features.supports_slicing_ordering_in_compound - ): - braces = "({})" - sql_parts, args_parts = zip( - *((braces.format(sql), args) for sql, args in parts) - ) - result = [" {} ".format(combinator_sql).join(sql_parts)] - params = [] - for part in args_parts: - params.extend(part) - return result, params + elif ( + self.query.subquery + and features.supports_slicing_ordering_in_compound + ): + part_sql = "({})".format(part_sql) + parts += ((part_sql, part_args),) + except EmptyResultSet: + # Omit the empty queryset with UNION and with DIFFERENCE if the + # first queryset is nonempty. + if combinator == "union" or ( + combinator == "difference" and parts + ): + continue + raise + if not parts: + raise EmptyResultSet + combinator_sql = self.connection.ops.set_operators[combinator] + # This is the only line that is changed from the Django core + # implementation of this method + combinator_sql += " ALL" if all else " DISTINCT" + braces = "{}" + if ( + not self.query.subquery + and features.supports_slicing_ordering_in_compound + ): + braces = "({})" + sql_parts, args_parts = zip( + *((braces.format(sql), args) for sql, args in parts) + ) + result = [" {} ".format(combinator_sql).join(sql_parts)] + params = [] + for part in args_parts: + params.extend(part) + return result, params class SQLInsertCompiler(BaseSQLInsertCompiler, SQLCompiler): diff --git a/django_spanner/features.py b/django_spanner/features.py index 65612d679f..701bad253a 100644 --- a/django_spanner/features.py +++ b/django_spanner/features.py @@ -8,7 +8,7 @@ from django.db.backends.base.features import BaseDatabaseFeatures from django.db.utils import InterfaceError -from django_spanner import USE_EMULATOR, USING_DJANGO_3, USING_DJANGO_4 +from django_spanner import USE_EMULATOR class DatabaseFeatures(BaseDatabaseFeatures): @@ -34,7 +34,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): supports_select_for_update_with_limit = False supports_sequence_reset = False supports_timezones = False - supports_transactions = False + supports_transactions = True if USE_EMULATOR: # Emulator does not support json. supports_json_field = False @@ -46,15 +46,19 @@ class DatabaseFeatures(BaseDatabaseFeatures): 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 # 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 + uses_savepoints = True + 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 + supports_stored_generated_columns = True # Django tests that aren't supported by Spanner. skip_tests = ( @@ -469,99 +473,10 @@ class DatabaseFeatures(BaseDatabaseFeatures): "schema.tests.SchemaTests.test_ci_cs_db_collation", # Spanner limitation: Cannot rename tables and columns. "migrations.test_operations.OperationTests.test_rename_field_case", + # Validation failures (MultipleObjectsReturned) due to no-op savepoints/rollback in Spanner. + # Spanner does not support savepoints/nested transactions, which these tests rely on for isolation. + "select_related_onetoone.tests.SelectRelatedOneToOneTests", ) - if USING_DJANGO_3: - skip_tests += ( - # No Django transaction management in Spanner. - "transactions.tests.DisableDurabiltityCheckTests.test_nested_both_durable", - "transactions.tests.DisableDurabiltityCheckTests.test_nested_inner_durable", - "generic_relations.tests.GenericRelationsTests.test_unsaved_instance_on_generic_foreign_key", - "generic_relations_regress.tests.GenericRelationTests.test_target_model_is_unsaved", - "aggregation_regress.tests.AggregationTests.test_ticket_11293", - # Warning is not raised, not related to spanner. - "test_utils.test_testcase.TestDataTests.test_undeepcopyable_warning", - ) - if USING_DJANGO_4: - skip_tests += ( - "aggregation.tests.AggregateTestCase.test_aggregation_default_expression", - "aggregation.tests.AggregateTestCase.test_aggregation_default_integer", - "aggregation.tests.AggregateTestCase.test_aggregation_default_unset", - "aggregation.tests.AggregateTestCase.test_aggregation_default_using_duration_from_database", - "aggregation.tests.AggregateTestCase.test_aggregation_default_zero", - "aggregation.tests.AggregateTestCase.test_group_by_nested_expression_with_params", - "many_to_one_null.tests.ManyToOneNullTests.test_unsaved", - "model_formsets.tests.ModelFormsetTest.test_edit_only_object_outside_of_queryset", - "ordering.tests.OrderingTests.test_order_by_expression_ref", - "sitemaps_tests.test_http.HTTPSitemapTests.test_alternate_language_for_item_i18n_sitemap", - "sitemaps_tests.test_http.HTTPSitemapTests.test_language_for_item_i18n_sitemap", - "null_queries.tests.NullQueriesTests.test_unsaved", - "prefetch_related.tests.GenericRelationTests.test_deleted_GFK", - "aggregation_regress.tests.AggregationTests.test_aggregate_and_annotate_duplicate_columns_proxy", - "aggregation_regress.tests.AggregationTests.test_annotation_disjunction", - "aggregation_regress.tests.AggregationTests.test_filter_aggregates_negated_and_connector", - "aggregation_regress.tests.AggregationTests.test_filter_aggregates_negated_xor_connector", - "aggregation_regress.tests.AggregationTests.test_filter_aggregates_or_connector", - "aggregation_regress.tests.AggregationTests.test_filter_aggregates_xor_connector", - "aggregation_regress.tests.AggregationTests.test_aggregate_and_annotate_duplicate_columns_unmanaged", - "queries.test_bulk_update.BulkUpdateTests.test_unsaved_parent", - "queries.test_q.QCheckTests.test_basic", - "queries.test_q.QCheckTests.test_boolean_expression", - "queries.test_q.QCheckTests.test_expression", - "queries.tests.ExcludeTests.test_exclude_unsaved_o2o_object", - "queries.tests.ExcludeTests.test_exclude_unsaved_object", - "queries.tests.Queries5Tests.test_filter_unsaved_object", - "queries.tests.QuerySetBitwiseOperationTests.test_xor_with_both_slice", - "queries.tests.QuerySetBitwiseOperationTests.test_xor_with_lhs_slice", - "queries.tests.QuerySetBitwiseOperationTests.test_xor_with_rhs_slice", - "queries.tests.QuerySetBitwiseOperationTests.test_xor_with_both_slice_and_ordering", - "queries.tests.Queries1Tests.test_filter_by_related_field_transform", - "known_related_objects.tests.ExistingRelatedInstancesTests.test_reverse_fk_select_related_multiple", - "known_related_objects.tests.ExistingRelatedInstancesTests.test_multilevel_reverse_fk_select_related", - "timezones.tests.NewDatabaseTests.test_aware_time_unsupported", - "contenttypes_tests.test_models.ContentTypesTests.test_app_labeled_name", - "db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_lookup_name_sql_injection", - "db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_lookup_name_sql_injection", - "db_functions.datetime.test_extract_trunc.DateFunctionWithTimeZoneTests.test_extract_lookup_name_sql_injection", - "db_functions.datetime.test_extract_trunc.DateFunctionWithTimeZoneTests.test_trunc_lookup_name_sql_injection", - "db_functions.datetime.test_extract_trunc.DateFunctionWithTimeZoneTests.test_trunc_ambiguous_and_invalid_times", - "custom_pk.tests.CustomPKTests.test_auto_field_subclass_create", - "constraints.tests.UniqueConstraintTests.test_validate_expression_condition", - "constraints.tests.CheckConstraintTests.test_validate", - "constraints.tests.CheckConstraintTests.test_validate_boolean_expressions", - "schema.tests.SchemaTests.test_add_auto_field", - "schema.tests.SchemaTests.test_alter_null_with_default_value_deferred_constraints", - "schema.tests.SchemaTests.test_autofield_to_o2o", - "backends.tests.BackendTestCase.test_queries_bare_where", - "expressions.tests.ExpressionOperatorTests.test_lefthand_bitwise_xor_right_null", - "expressions.tests.FTimeDeltaTests.test_durationfield_multiply_divide", - "inspectdb.tests.InspectDBTestCase.test_same_relations", - "migrations.test_operations.OperationTests.test_alter_field_pk_fk_char_to_int", - "migrations.test_operations.OperationTests.test_alter_field_with_func_unique_constraint", - "migrations.test_operations.OperationTests.test_alter_model_table_m2m_field", - "migrations.test_operations.OperationTests.test_remove_unique_together_on_unique_field", - "migrations.test_operations.OperationTests.test_rename_field_index_together", - "migrations.test_operations.OperationTests.test_rename_field_unique_together", - "migrations.test_operations.OperationTests.test_rename_model_with_db_table_rename_m2m", - "migrations.test_operations.OperationTests.test_rename_model_with_m2m_models_in_different_apps_with_same_name", - "delete.tests.DeletionTests.test_pk_none", - "db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_time_comparison", - "db_functions.datetime.test_extract_trunc.DateFunctionWithTimeZoneTests.test_trunc_time_comparison", - "backends.tests.LastExecutedQueryTest.test_last_executed_query_dict_overlap_keys", - "backends.tests.LastExecutedQueryTest.test_last_executed_query_with_duplicate_params", - "backends.tests.BackendTestCase.test_queries_logger", - "generic_relations.tests.GenericRelationsTests.test_unsaved_generic_foreign_key_parent_bulk_create", - "generic_relations.tests.GenericRelationsTests.test_unsaved_generic_foreign_key_parent_save", - "schema.tests.SchemaTests.test_add_field_durationfield_with_default", - "delete.tests.DeletionTests.test_only_referenced_fields_selected", - "bulk_create.tests.BulkCreateTests.test_explicit_batch_size_efficiency", - "get_or_create.tests.UpdateOrCreateTests.test_update_only_defaults_and_pre_save_fields_when_local_fields", - "backends.base.test_base.DatabaseWrapperLoggingTests.test_commit_debug_log", - "backends.base.test_base.DatabaseWrapperLoggingTests.test_rollback_debug_log", - "backends.base.test_base.MultiDatabaseTests.test_multi_database_init_connection_state_called_once", - # Spanner does not support automatic coercion from float64 to int64 - "lookup.tests.LookupQueryingTests.test_annotate_greater_than_or_equal_float", - "lookup.tests.LookupQueryingTests.test_annotate_less_than_float", - ) if os.environ.get("SPANNER_EMULATOR_HOST", None): # Some code isn't yet supported by the Spanner emulator. @@ -2130,56 +2045,23 @@ class DatabaseFeatures(BaseDatabaseFeatures): "test_client.tests.ClientTest.test_follow_307_and_308_get_head_query_string", # noqa "test_client.tests.ClientTest.test_follow_307_and_308_preserves_query_string", # noqa ) - if USING_DJANGO_3: - skip_tests += ( - "constraints.tests.CheckConstraintTests.test_database_constraint_expressionwrapper", # noqa - "defer_regress.tests.DeferAnnotateSelectRelatedTest.test_defer_annotate_select_related", # noqa - "queries.tests.Queries1Tests.test_ticket7098", # noqa - "auth_tests.test_password_reset_timeout_days.DeprecationTests.test_timeout", # noqa - "constraints.tests.CheckConstraintTests.test_database_constraint_expression", # noqa - "queries.tests.Queries1Tests.test_order_by_raw_column_alias_warning", # noqa - "sitemaps_tests.test_http.HTTPSitemapTests.test_simple_sitemap_custom_index", # noqa - "auth_tests.test_forms.UserCreationFormTest.test_both_passwords", # noqa - "auth_tests.test_forms.UserCreationFormTest.test_custom_form", # noqa - "auth_tests.test_forms.UserCreationFormTest.test_custom_form_hidden_username_field", # noqa - "auth_tests.test_forms.UserCreationFormTest.test_custom_form_with_different_username_field", # noqa - "auth_tests.test_forms.UserCreationFormTest.test_duplicate_normalized_unicode", # noqa - "auth_tests.test_forms.UserCreationFormTest.test_invalid_data", # noqa - "auth_tests.test_forms.UserCreationFormTest.test_normalize_username", # noqa - "auth_tests.test_forms.UserCreationFormTest.test_password_help_text", # noqa - "auth_tests.test_middleware.TestAuthenticationMiddleware.test_session_default_hashing_algorithm", # noqa - "auth_tests.test_forms.UserCreationFormTest.test_password_verification", # noqa - "auth_tests.test_forms.UserCreationFormTest.test_password_whitespace_not_stripped", # noqa - "auth_tests.test_forms.UserCreationFormTest.test_success", # noqa - "auth_tests.test_forms.UserCreationFormTest.test_unicode_username", # noqa - "auth_tests.test_forms.UserCreationFormTest.test_user_already_exists", # noqa - "auth_tests.test_forms.UserCreationFormTest.test_user_create_form_validates_password_with_all_data", # noqa - "auth_tests.test_forms.UserCreationFormTest.test_validates_password", # noqa - "auth_tests.test_forms.UserCreationFormTest.test_html_autocomplete_attributes", # noqa - "auth_tests.test_forms.UserCreationFormTest.test_username_field_autocapitalize_none", # noqa - "auth_tests.test_middleware.TestAuthenticationMiddleware.test_no_password_change_does_not_invalidate_legacy_session", # noqa - "auth_tests.test_tokens.TokenGeneratorTest.test_legacy_days_timeout", # noqa - "auth_tests.test_tokens.TokenGeneratorTest.test_legacy_token_validation", # noqa - "auth_tests.test_tokens.TokenGeneratorTest.test_token_default_hashing_algorithm", # noqa - "auth_tests.test_views.LoginTest.test_legacy_session_key_flushed_on_login", # noqa - ) - if USING_DJANGO_4: - skip_tests += ( - "auth_tests.test_forms.BaseUserCreationFormTest.test_both_passwords", # noqa - "auth_tests.test_forms.BaseUserCreationFormTest.test_custom_form", # noqa - "auth_tests.test_forms.BaseUserCreationFormTest.test_custom_form_hidden_username_field", # noqa - "auth_tests.test_forms.BaseUserCreationFormTest.test_custom_form_with_different_username_field", # noqa - "auth_tests.test_forms.BaseUserCreationFormTest.test_duplicate_normalized_unicode", # noqa - "auth_tests.test_forms.BaseUserCreationFormTest.test_invalid_data", # noqa - "auth_tests.test_forms.BaseUserCreationFormTest.test_normalize_username", # noqa - "auth_tests.test_forms.BaseUserCreationFormTest.test_password_help_text", # noqa - "auth_tests.test_forms.BaseUserCreationFormTest.test_password_verification", # noqa - "auth_tests.test_forms.BaseUserCreationFormTest.test_password_whitespace_not_stripped", # noqa - "auth_tests.test_forms.BaseUserCreationFormTest.test_success", # noqa - "auth_tests.test_forms.BaseUserCreationFormTest.test_unicode_username", # noqa - "auth_tests.test_forms.BaseUserCreationFormTest.test_user_already_exists", # noqa - "auth_tests.test_forms.BaseUserCreationFormTest.test_user_create_form_validates_password_with_all_data", # noqa - "auth_tests.test_forms.BaseUserCreationFormTest.test_validates_password", # noqa - "auth_tests.test_forms.BaseUserCreationFormTest.test_html_autocomplete_attributes", # noqa - "auth_tests.test_forms.BaseUserCreationFormTest.test_username_field_autocapitalize_none", # noqa - ) + + skip_tests += ( + "auth_tests.test_forms.BaseUserCreationFormTest.test_both_passwords", # noqa + "auth_tests.test_forms.BaseUserCreationFormTest.test_custom_form", # noqa + "auth_tests.test_forms.BaseUserCreationFormTest.test_custom_form_hidden_username_field", # noqa + "auth_tests.test_forms.BaseUserCreationFormTest.test_custom_form_with_different_username_field", # noqa + "auth_tests.test_forms.BaseUserCreationFormTest.test_duplicate_normalized_unicode", # noqa + "auth_tests.test_forms.BaseUserCreationFormTest.test_invalid_data", # noqa + "auth_tests.test_forms.BaseUserCreationFormTest.test_normalize_username", # noqa + "auth_tests.test_forms.BaseUserCreationFormTest.test_password_help_text", # noqa + "auth_tests.test_forms.BaseUserCreationFormTest.test_password_verification", # noqa + "auth_tests.test_forms.BaseUserCreationFormTest.test_password_whitespace_not_stripped", # noqa + "auth_tests.test_forms.BaseUserCreationFormTest.test_success", # noqa + "auth_tests.test_forms.BaseUserCreationFormTest.test_unicode_username", # noqa + "auth_tests.test_forms.BaseUserCreationFormTest.test_user_already_exists", # noqa + "auth_tests.test_forms.BaseUserCreationFormTest.test_user_create_form_validates_password_with_all_data", # noqa + "auth_tests.test_forms.BaseUserCreationFormTest.test_validates_password", # noqa + "auth_tests.test_forms.BaseUserCreationFormTest.test_html_autocomplete_attributes", # noqa + "auth_tests.test_forms.BaseUserCreationFormTest.test_username_field_autocapitalize_none", # noqa + ) diff --git a/django_spanner/functions.py b/django_spanner/functions.py index 876e1f5a50..246e0f7113 100644 --- a/django_spanner/functions.py +++ b/django_spanner/functions.py @@ -15,6 +15,7 @@ ConcatPair, Cot, Degrees, + JSONArray, Left, Log, Ord, @@ -187,6 +188,36 @@ def degrees(self, compiler, connection, **extra_context): ) +def json_array(self, compiler, connection, **extra_context): + """ + A method to extend Django JSONArray class. Returns a SQL query that + generates a JSON array. + + :type self: :class:`~django.db.models.functions.JSONArray` + :param self: the instance of the class that owns this method. + + :type compiler: :class:`~django_spanner.compiler.SQLCompilerst` + :param compiler: The query compiler responsible for generating the query. + Must have a compile method, returning a (sql, [params]) + tuple. Calling compiler(value) will return a quoted + `value`. + + :type connection: :class:`~google.cloud.spanner_dbapi.connection.Connection` + :param connection: The Spanner database connection used for the current + query. + + :rtype: tuple(str, list) + :returns: A tuple where `str` is a string containing ordered SQL parameters + to be replaced with the elements of the `list`. + """ + return self.as_sql( + compiler, + connection, + template="TO_JSON_STRING([%(expressions)s])", + **extra_context + ) + + def left_and_right(self, compiler, connection, **extra_context): """A method to extend Django Left and Right classes. @@ -388,6 +419,7 @@ def register_functions(): ConcatPair.as_spanner = concatpair Cot.as_spanner = cot Degrees.as_spanner = degrees + JSONArray.as_spanner = json_array Left.as_spanner = left_and_right Log.as_spanner = log Ord.as_spanner = ord_ diff --git a/django_spanner/introspection.py b/django_spanner/introspection.py index ddfa96c43e..35f54622c0 100644 --- a/django_spanner/introspection.py +++ b/django_spanner/introspection.py @@ -11,7 +11,6 @@ ) from django.db.models import Index from google.cloud.spanner_v1 import TypeCode -from django_spanner import USE_EMULATOR class DatabaseIntrospection(BaseDatabaseIntrospection): @@ -188,7 +187,7 @@ def get_primary_key_column(self, cursor, table_name): """, params={"schema_name": schema_name, "table_name": table_name}, ) - return results[0][0] if results else None + return tuple(row[0] for row in results) if results else None def get_constraints(self, cursor, table_name): """Retrieve the Spanner Table column constraints. diff --git a/django_spanner/operations.py b/django_spanner/operations.py index 04f0775baf..83ba0a17a0 100644 --- a/django_spanner/operations.py +++ b/django_spanner/operations.py @@ -14,7 +14,6 @@ from django.db.backends.base.operations import BaseDatabaseOperations from django.db.utils import DatabaseError from django.utils import timezone -from django_spanner import USING_DJANGO_3 from django.utils.duration import duration_microseconds from google.cloud.spanner_dbapi.parse_utils import ( DateStr, @@ -77,19 +76,22 @@ def quote_name(self, name): def bulk_batch_size(self, fields, objs): """ - Override the base class method. Returns the maximum number of the - query parameters. + Override the base class method. Returns the maximum number of objects + that can be batched in a single query. :type fields: list - :param fields: Currently not used. + :param fields: List of fields to be inserted. :type objs: list - :param objs: Currently not used. + :param objs: List of objects to be inserted. :rtype: int - :returns: The maximum number of query parameters (constant). + :returns: The maximum number of objects (row counts). """ - return self.connection.features.max_query_params + max_params = self.connection.features.max_query_params + if not fields: + return max_params + return max_params // len(fields) def bulk_insert_sql(self, fields, placeholder_rows): """ @@ -137,7 +139,7 @@ def sql_flush( # Cloud Spanner doesn't support TRUNCATE so DELETE instead. # A dummy WHERE clause is required. if tables: - delete_sql = "%s %s %%s" % ( + delete_sql = "%s %s %%s WHERE true" % ( style.SQL_KEYWORD("DELETE"), style.SQL_KEYWORD("FROM"), ) @@ -148,6 +150,64 @@ def sql_flush( else: return [] + def execute_sql_flush(self, sql_list): + """Execute a list of SQL statements to flush the database. + + Cloud Spanner doesn't support disabling foreign key checks, so we use a + retry mechanism to handle deletion order. We try to delete from all tables; + if some fail due to foreign key constraints, we retry them in subsequent + passes. As long as we make progress (delete at least one table) in each + pass, we continue. + """ + if not sql_list: + return + + # Ensure we are in autocommit mode or commit explicitly if needed + was_autocommit = self.connection.get_autocommit() + if not was_autocommit: + self.connection.set_autocommit(True) + + try: + with self.connection.cursor() as cursor: + # We might need several passes if there is a deep dependency chain + remaining_sql = list(sql_list) + + # Safety valve: 10 passes should be enough for any reasonable schema depth + max_passes = 10 + pass_count = 0 + + while remaining_sql: + pass_count += 1 + failed_sql = [] + progress = False + last_exception = None + + if pass_count > max_passes: + # We are stuck in a cycle or too deep + if last_exception: + raise last_exception + raise DatabaseError("Max passes reached in execute_sql_flush without clearing all tables.") + + for sql in remaining_sql: + try: + cursor.execute(sql) + progress = True + except Exception as e: + # We catch Exception because Spanner might raise various errors + # (Aborted, FailedPrecondition, IntegrityError, etc.) + failed_sql.append(sql) + last_exception = e + + # If no progress and still have failed SQL, we are stuck + if not progress and failed_sql: + raise last_exception + + remaining_sql = failed_sql + + finally: + if not was_autocommit: + self.connection.set_autocommit(False) + def adapt_datefield_value(self, value): """Cast date argument into Spanner DB API DateStr format. @@ -186,29 +246,6 @@ def adapt_datetimefield_value(self, value): ) return TimestampStr(value.isoformat(timespec="microseconds") + "Z") - def adapt_decimalfield_value( - self, value, max_digits=None, decimal_places=None - ): - """ - Convert value from decimal.Decimal to spanner compatible value. - Since spanner supports Numeric storage of decimal and python spanner - takes care of the conversion so this is a no-op method call. - - :type value: :class:`decimal.Decimal` - :param value: A decimal field value. - - :type max_digits: int - :param max_digits: (Optional) A maximum number of digits. - - :type decimal_places: int - :param decimal_places: (Optional) The number of decimal places to store - with the number. - - :rtype: decimal.Decimal - :returns: decimal value. - """ - return value - def adapt_timefield_value(self, value): """ Transform a time value to an object compatible with what is expected @@ -364,67 +401,35 @@ def date_extract_sql(self, lookup_type, field_name, params=None): """ lookup_type = self.extract_names.get(lookup_type, lookup_type) sql = "EXTRACT(%s FROM %s)" % (lookup_type, field_name) - if USING_DJANGO_3: - return sql return sql, params - if USING_DJANGO_3: - - def datetime_extract_sql(self, lookup_type, field_name, tzname): - """Extract datetime from the lookup. + def datetime_extract_sql(self, lookup_type, field_name, params, tzname): + """Extract datetime from the lookup. - :type lookup_type: str - :param lookup_type: A type of the lookup. + :type lookup_type: str + :param lookup_type: A type of the lookup. - :type field_name: str - :param field_name: The name of the field. + :type field_name: str + :param field_name: The name of the field. - :type tzname: str - :param tzname: The time zone name. If using of time zone is not - allowed in settings default will be UTC. + :type tzname: str + :param tzname: The time zone name. If using of time zone is not + allowed in settings default will be UTC. - :rtype: str - :returns: A SQL statement for extracting. - """ - tzname = tzname if settings.USE_TZ and tzname else "UTC" - lookup_type = self.extract_names.get(lookup_type, lookup_type) - return 'EXTRACT(%s FROM %s AT TIME ZONE "%s")' % ( + :rtype: str + :returns: A SQL statement for extracting. + """ + tzname = tzname if settings.USE_TZ and tzname else "UTC" + lookup_type = self.extract_names.get(lookup_type, lookup_type) + return ( + 'EXTRACT(%s FROM %s AT TIME ZONE "%s")' + % ( lookup_type, field_name, tzname, - ) - - else: - - def datetime_extract_sql( - self, lookup_type, field_name, params, tzname - ): - """Extract datetime from the lookup. - - :type lookup_type: str - :param lookup_type: A type of the lookup. - - :type field_name: str - :param field_name: The name of the field. - - :type tzname: str - :param tzname: The time zone name. If using of time zone is not - allowed in settings default will be UTC. - - :rtype: str - :returns: A SQL statement for extracting. - """ - tzname = tzname if settings.USE_TZ and tzname else "UTC" - lookup_type = self.extract_names.get(lookup_type, lookup_type) - return ( - 'EXTRACT(%s FROM %s AT TIME ZONE "%s")' - % ( - lookup_type, - field_name, - tzname, - ), - params, - ) + ), + params, + ) def time_extract_sql(self, lookup_type, field_name, params=None): """Extract time from the lookup. @@ -446,314 +451,154 @@ def time_extract_sql(self, lookup_type, field_name, params=None): lookup_type, field_name, ) - if USING_DJANGO_3: - return sql return sql, params - if USING_DJANGO_3: - - def date_trunc_sql(self, lookup_type, field_name, tzname=None): - """Truncate date in the lookup. - - :type lookup_type: str - :param lookup_type: A type of the lookup. - - :type field_name: str - :param field_name: The name of the field. - - :type tzname: str - :param tzname: The name of the timezone. This is ignored because - Spanner does not support Timezone conversion in DATE_TRUNC function. - - :rtype: str - :returns: A SQL statement for truncating. - """ - # https://cloud.google.com/spanner/docs/functions-and-operators#date_trunc - if lookup_type == "week": - # Spanner truncates to Sunday but Django expects Monday. First, - # subtract a day so that a Sunday will be truncated to the previous - # week... - field_name = ( - "DATE_SUB(CAST(" - + field_name - + " AS DATE), INTERVAL 1 DAY)" - ) - sql = "DATE_TRUNC(CAST(%s AS DATE), %s)" % ( - field_name, - lookup_type, - ) - if lookup_type == "week": - # ...then add a day to get from Sunday to Monday. - sql = "DATE_ADD(CAST(" + sql + " AS DATE), INTERVAL 1 DAY)" - return sql - - else: - - def date_trunc_sql(self, lookup_type, field_name, params, tzname=None): - """Truncate date in the lookup. - - :type lookup_type: str - :param lookup_type: A type of the lookup. - - :type field_name: str - :param field_name: The name of the field. - - :type params: list(str) - :param params: list of query params. - - :type tzname: str - :param tzname: The name of the timezone. This is ignored because - Spanner does not support Timezone conversion in DATE_TRUNC function. - - :rtype: str - :returns: A SQL statement for truncating. - """ - # https://cloud.google.com/spanner/docs/functions-and-operators#date_trunc - if lookup_type == "week": - # Spanner truncates to Sunday but Django expects Monday. First, - # subtract a day so that a Sunday will be truncated to the previous - # week... - field_name = ( - "DATE_SUB(CAST(" - + field_name - + " AS DATE), INTERVAL 1 DAY)" - ) - sql = "DATE_TRUNC(CAST(%s AS DATE), %s)" % ( - field_name, - lookup_type, - ) - if lookup_type == "week": - # ...then add a day to get from Sunday to Monday. - sql = "DATE_ADD(CAST(" + sql + " AS DATE), INTERVAL 1 DAY)" - return sql, params - - if USING_DJANGO_3: - - def datetime_trunc_sql(self, lookup_type, field_name, tzname="UTC"): - """Truncate datetime in the lookup. - - :type lookup_type: str - :param lookup_type: A type of the lookup. - - :type field_name: str - :param field_name: The name of the field. - - :type tzname: str - :param tzname: The name of the timezone. - - :rtype: str - :returns: A SQL statement for truncating. - """ - # https://cloud.google.com/spanner/docs/functions-and-operators#timestamp_trunc - tzname = tzname if settings.USE_TZ and tzname else "UTC" - if lookup_type == "week": - # Spanner truncates to Sunday but Django expects Monday. First, - # subtract a day so that a Sunday will be truncated to the previous - # week... - field_name = ( - "TIMESTAMP_SUB(" + field_name + ", INTERVAL 1 DAY)" - ) - sql = 'TIMESTAMP_TRUNC(%s, %s, "%s")' % ( - field_name, - lookup_type, - tzname, - ) - if lookup_type == "week": - # ...then add a day to get from Sunday to Monday. - sql = "TIMESTAMP_ADD(" + sql + ", INTERVAL 1 DAY)" - return sql - - else: - - def datetime_trunc_sql( - self, lookup_type, field_name, params, tzname="UTC" - ): - """Truncate datetime in the lookup. - - :type lookup_type: str - :param lookup_type: A type of the lookup. - - :type field_name: str - :param field_name: The name of the field. - - :type params: list(str) - :param params: list of query params. - - :type tzname: str - :param tzname: The name of the timezone. - - :rtype: str - :returns: A SQL statement for truncating. - """ - # https://cloud.google.com/spanner/docs/functions-and-operators#timestamp_trunc - tzname = tzname if settings.USE_TZ and tzname else "UTC" - if lookup_type == "week": - # Spanner truncates to Sunday but Django expects Monday. First, - # subtract a day so that a Sunday will be truncated to the previous - # week... - field_name = ( - "TIMESTAMP_SUB(" + field_name + ", INTERVAL 1 DAY)" - ) - sql = 'TIMESTAMP_TRUNC(%s, %s, "%s")' % ( - field_name, - lookup_type, - tzname, - ) - if lookup_type == "week": - # ...then add a day to get from Sunday to Monday. - sql = "TIMESTAMP_ADD(" + sql + ", INTERVAL 1 DAY)" - return sql, params - - if USING_DJANGO_3: - - def time_trunc_sql(self, lookup_type, field_name, tzname="UTC"): - """Truncate time in the lookup. - - :type lookup_type: str - :param lookup_type: A type of the lookup. + def date_trunc_sql(self, lookup_type, field_name, params, tzname=None): + """Truncate date in the lookup. - :type field_name: str - :param field_name: The name of the field. + :type lookup_type: str + :param lookup_type: A type of the lookup. - :type tzname: str - :param tzname: The name of the timezone. Defaults to 'UTC' For backward compatability. + :type field_name: str + :param field_name: The name of the field. - :rtype: str - :returns: A SQL statement for truncating. - """ - # https://cloud.google.com/spanner/docs/functions-and-operators#timestamp_trunc - tzname = tzname if settings.USE_TZ and tzname else "UTC" - return 'TIMESTAMP_TRUNC(%s, %s, "%s")' % ( - field_name, - lookup_type, - tzname, - ) + :type params: list(str) + :param params: list of query params. - else: + :type tzname: str + :param tzname: The name of the timezone. This is ignored because + Spanner does not support Timezone conversion in DATE_TRUNC function. - def time_trunc_sql( - self, lookup_type, field_name, params, tzname="UTC" - ): - """Truncate time in the lookup. - - :type lookup_type: str - :param lookup_type: A type of the lookup. - - :type field_name: str - :param field_name: The name of the field. - - :type params: list(str) - :param params: list of query params. - - :type tzname: str - :param tzname: The name of the timezone. Defaults to 'UTC' For backward compatability. - - :rtype: str - :returns: A SQL statement for truncating. - """ - # https://cloud.google.com/spanner/docs/functions-and-operators#timestamp_trunc - tzname = tzname if settings.USE_TZ and tzname else "UTC" - return ( - 'TIMESTAMP_TRUNC(%s, %s, "%s")' - % ( - field_name, - lookup_type, - tzname, - ), - params, + :rtype: str + :returns: A SQL statement for truncating. + """ + # https://cloud.google.com/spanner/docs/functions-and-operators#date_trunc + if lookup_type == "week": + # Spanner truncates to Sunday but Django expects Monday. First, + # subtract a day so that a Sunday will be truncated to the previous + # week... + field_name = ( + "DATE_SUB(CAST(" + field_name + " AS DATE), INTERVAL 1 DAY)" ) + sql = "DATE_TRUNC(CAST(%s AS DATE), %s)" % ( + field_name, + lookup_type, + ) + if lookup_type == "week": + # ...then add a day to get from Sunday to Monday. + sql = "DATE_ADD(CAST(" + sql + " AS DATE), INTERVAL 1 DAY)" + return sql, params - if USING_DJANGO_3: + def datetime_trunc_sql( + self, lookup_type, field_name, params, tzname="UTC" + ): + """Truncate datetime in the lookup. - def datetime_cast_date_sql(self, field_name, tzname): - """Cast date in the lookup. + :type lookup_type: str + :param lookup_type: A type of the lookup. - :type field_name: str - :param field_name: The name of the field. + :type field_name: str + :param field_name: The name of the field. - :type tzname: str - :param tzname: The time zone name. If using of time zone is not - allowed in settings default will be UTC. + :type params: list(str) + :param params: list of query params. - :rtype: str - :returns: A SQL statement for casting. - """ - # https://cloud.google.com/spanner/docs/functions-and-operators#date - tzname = tzname if settings.USE_TZ and tzname else "UTC" - return 'DATE(%s, "%s")' % (field_name, tzname) + :type tzname: str + :param tzname: The name of the timezone. - else: + :rtype: str + :returns: A SQL statement for truncating. + """ + # https://cloud.google.com/spanner/docs/functions-and-operators#timestamp_trunc + tzname = tzname if settings.USE_TZ and tzname else "UTC" + if lookup_type == "week": + # Spanner truncates to Sunday but Django expects Monday. First, + # subtract a day so that a Sunday will be truncated to the previous + # week... + field_name = "TIMESTAMP_SUB(" + field_name + ", INTERVAL 1 DAY)" + sql = 'TIMESTAMP_TRUNC(%s, %s, "%s")' % ( + field_name, + lookup_type, + tzname, + ) + if lookup_type == "week": + # ...then add a day to get from Sunday to Monday. + sql = "TIMESTAMP_ADD(" + sql + ", INTERVAL 1 DAY)" + return sql, params - def datetime_cast_date_sql(self, field_name, params, tzname): - """Cast date in the lookup. + def time_trunc_sql(self, lookup_type, field_name, params, tzname="UTC"): + """Truncate time in the lookup. - :type field_name: str - :param field_name: The name of the field. + :type lookup_type: str + :param lookup_type: A type of the lookup. - :type params: list(str) - :param params: list of query params. + :type field_name: str + :param field_name: The name of the field. - :type tzname: str - :param tzname: The time zone name. If using of time zone is not - allowed in settings default will be UTC. + :type params: list(str) + :param params: list of query params. - :rtype: str - :returns: A SQL statement for casting. - """ - # https://cloud.google.com/spanner/docs/functions-and-operators#date - tzname = tzname if settings.USE_TZ and tzname else "UTC" - return 'DATE(%s, "%s")' % (field_name, tzname), params + :type tzname: str + :param tzname: The name of the timezone. Defaults to 'UTC' For backward compatability. - if USING_DJANGO_3: + :rtype: str + :returns: A SQL statement for truncating. + """ + # https://cloud.google.com/spanner/docs/functions-and-operators#timestamp_trunc + tzname = tzname if settings.USE_TZ and tzname else "UTC" + return ( + 'TIMESTAMP_TRUNC(%s, %s, "%s")' + % ( + field_name, + lookup_type, + tzname, + ), + params, + ) - def datetime_cast_time_sql(self, field_name, tzname): - """Cast time in the lookup. + def datetime_cast_date_sql(self, field_name, params, tzname): + """Cast date in the lookup. - :type field_name: str - :param field_name: The name of the field. + :type field_name: str + :param field_name: The name of the field. - :type tzname: str - :param tzname: The time zone name. If using of time zone is not - allowed in settings default will be UTC. + :type params: list(str) + :param params: list of query params. - :rtype: str - :returns: A SQL statement for casting. - """ - tzname = tzname if settings.USE_TZ and tzname else "UTC" - # Cloud Spanner doesn't have a function for converting - # TIMESTAMP to another time zone. - return ( - "TIMESTAMP(FORMAT_TIMESTAMP(" - "'%%Y-%%m-%%d %%R:%%E9S %%Z', %s, '%s'))" - % (field_name, tzname) - ) + :type tzname: str + :param tzname: The time zone name. If using of time zone is not + allowed in settings default will be UTC. - else: + :rtype: str + :returns: A SQL statement for casting. + """ + # https://cloud.google.com/spanner/docs/functions-and-operators#date + tzname = tzname if settings.USE_TZ and tzname else "UTC" + return 'DATE(%s, "%s")' % (field_name, tzname), params - def datetime_cast_time_sql(self, field_name, params, tzname): - """Cast time in the lookup. + def datetime_cast_time_sql(self, field_name, params, tzname): + """Cast time in the lookup. - :type field_name: str - :param field_name: The name of the field. + :type field_name: str + :param field_name: The name of the field. - :type params: list(str) - :param params: list of query params. + :type params: list(str) + :param params: list of query params. - :type tzname: str - :param tzname: The time zone name. If using of time zone is not - allowed in settings default will be UTC. + :type tzname: str + :param tzname: The time zone name. If using of time zone is not + allowed in settings default will be UTC. - :rtype: str - :returns: A SQL statement for casting. - """ - tzname = tzname if settings.USE_TZ and tzname else "UTC" - # Cloud Spanner doesn't have a function for converting - # TIMESTAMP to another time zone. - return ( - "TIMESTAMP(FORMAT_TIMESTAMP(" - "'%%Y-%%m-%%d %%R:%%E9S %%Z', %s, '%s'))" - % (field_name, tzname) - ), params + :rtype: str + :returns: A SQL statement for casting. + """ + tzname = tzname if settings.USE_TZ and tzname else "UTC" + # Cloud Spanner doesn't have a function for converting + # TIMESTAMP to another time zone. + return ( + "TIMESTAMP(FORMAT_TIMESTAMP(" + "'%%Y-%%m-%%d %%R:%%E9S %%Z', %s, '%s'))" % (field_name, tzname) + ), params def date_interval_sql(self, timedelta): """Get a date interval in microseconds. @@ -891,3 +736,21 @@ def _get_limit_offset_params(self, low_mark, high_mark): # from Cloud Spanner. limit -= offset return limit, offset + + def savepoint_create_sql(self, sid): + """ + Return the SQL for creating a savepoint. + """ + return "SELECT 1" + + def savepoint_commit_sql(self, sid): + """ + Return the SQL for committing a savepoint. + """ + return "SELECT 1" + + def savepoint_rollback_sql(self, sid): + """ + Return the SQL for rolling back to a savepoint. + """ + return "SELECT 1" diff --git a/django_spanner/schema.py b/django_spanner/schema.py index dd4832b180..b56a269957 100644 --- a/django_spanner/schema.py +++ b/django_spanner/schema.py @@ -8,8 +8,9 @@ from django.db import NotSupportedError from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django.db.models.fields import NOT_PROVIDED from django_spanner._opentelemetry_tracing import trace_call -from django_spanner import USE_EMULATOR, USING_DJANGO_3 +from django_spanner import USE_EMULATOR class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): @@ -114,32 +115,39 @@ def create_model(self, model): column_sqls.append( "%s %s" % (self.quote_name(field.column), definition) ) - # Create a unique constraint separately because Spanner doesn't # allow them inline on a column. if field.unique and not field.primary_key: - if USING_DJANGO_3: - self.deferred_sql.append( - self._create_unique_sql(model, [field.column]) - ) - else: - self.deferred_sql.append( - self._create_unique_sql(model, [field]) - ) + self.deferred_sql.append( + self._create_unique_sql(model, [field]) + ) # Add any unique_togethers (always deferred, as some fields might be # created afterwards, like geometry fields with some backends) for fields in model._meta.unique_together: - if USING_DJANGO_3: - columns = [ - model._meta.get_field(field).column for field in fields - ] - else: - columns = [model._meta.get_field(field) for field in fields] + columns = [model._meta.get_field(field) for field in fields] self.deferred_sql.append(self._create_unique_sql(model, columns)) - constraints = [ - constraint.constraint_sql(model, self) - for constraint in model._meta.constraints - ] + constraints = [] + for constraint in model._meta.constraints: + if isinstance(constraint, django.db.models.UniqueConstraint): + self.deferred_sql.append(constraint.create_sql(model, self)) + else: + constraints.append(constraint.constraint_sql(model, self)) + if model._meta.pk.is_relation: + pk_column = self.quote_name(model._meta.pk.column) + else: + # Handle CompositePrimaryKey + # In Django 5.2+, model._meta.pk might be a CompositePrimaryKey. + # We assume regular fields have .column, composite have .columns (or similar mechanism). + # Actually, standard Django Field has .column. + # If it is a CompositePrimaryKey, it won't have a single .column. + # We check if it relies on multiple columns. + columns = ( + model._meta.pk.columns + if hasattr(model._meta.pk, "columns") + else [model._meta.pk.column] + ) + pk_column = ", ".join(self.quote_name(col) for col in columns) + # Make the table sql = self.sql_create_table % { "table": self.quote_name(model._meta.db_table), @@ -148,7 +156,7 @@ def create_model(self, model): for constraint in (*column_sqls, *constraints) if constraint ), - "primary_key": self.quote_name(model._meta.pk.column), + "primary_key": pk_column, } if model._meta.db_tablespace: tablespace_sql = self.connection.ops.tablespace_sql( @@ -290,14 +298,7 @@ def add_field(self, model, field): # Create a unique constraint separately because Spanner doesn't allow # them inline on a column. if field.unique and not field.primary_key: - if USING_DJANGO_3: - self.deferred_sql.append( - self._create_unique_sql(model, [field.column]) - ) - else: - self.deferred_sql.append( - self._create_unique_sql(model, [field]) - ) + self.deferred_sql.append(self._create_unique_sql(model, [field])) # Add any FK constraints later if ( field.remote_field @@ -399,6 +400,20 @@ def column_sql( sql += " %s" % self.connection.ops.tablespace_sql( tablespace, inline=True ) + + # Handle GeneratedField + if getattr(field, "generated", False): + sql += " GENERATED ALWAYS AS (%s) STORED" % field.generated_sql( + self.connection + ) + + # Handle db_default + db_default = getattr(field, "db_default", None) + if db_default is not None and db_default is not NOT_PROVIDED: + default_sql = self.db_default_sql(field) + if default_sql: + sql += " DEFAULT %s" % default_sql + # Return the sql return sql, params @@ -544,41 +559,17 @@ def _check_sql(self, name, check): "constraint": self.sql_check_constraint % {"check": check}, } - def _unique_sql( - self, - model, - fields, - name, - condition=None, - deferrable=None, # Spanner does not require this parameter - include=None, - opclasses=None, - expressions=None, - ): - # Inline constraints aren't supported, so create the index separately. - if USING_DJANGO_3: - sql = self._create_unique_sql( - model, - fields, - name=name, - condition=condition, - include=include, - opclasses=opclasses, - ) - else: - sql = self._create_unique_sql( - model, - fields, - name=name, - condition=condition, - include=include, - opclasses=opclasses, - expressions=expressions, - ) - if sql: - self.deferred_sql.append(sql) - return None + def skip_default(self, field): - """Cloud Spanner doesn't support column defaults.""" + """ + Cloud Spanner doesn't support column defaults, except for + GeneratedFields or when db_default is explicitly set (if supported). + """ + # Django 5.0+ GeneratedField + if getattr(field, "generated", False): + return False + # Django 5.0+ db_default + if getattr(field, "db_default", None) is not None: + return False return True diff --git a/django_test_suite_5.2.sh b/django_test_suite_5.2.sh new file mode 100644 index 0000000000..3f96192c8a --- /dev/null +++ b/django_test_suite_5.2.sh @@ -0,0 +1,91 @@ +#!/bin/sh + +# Copyright (c) 2020 Google LLC. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. + +set -x pipefail + + + +# Disable buffering, so that the logs stream through. +export PYTHONUNBUFFERED=1 + +export DJANGO_TESTS_DIR="django_tests_dir" +mkdir -p $DJANGO_TESTS_DIR + +pip3 install . +# Clone Django 5.2 (assuming stable/5.2.x exists, update if needed) +# Using official Django repo? Or the fork? +# The previous script used googleapis/python-spanner-django which is WEIRD if it really is this repo. +# But maybe it's a mistake in my understanding. +# To be safe, I'll use https://github.com/django/django.git and branch stable/5.2.x +# If 5.2.x doesn't exist yet, we might use main. +if [ ! -d "$DJANGO_TESTS_DIR/django" ]; then + git clone --depth 1 --single-branch --branch "stable/5.2.x" https://github.com/django/django.git $DJANGO_TESTS_DIR/django +fi + + + +cd $DJANGO_TESTS_DIR/django && pip3 install -e . && pip3 install -r tests/requirements/py3.txt; cd ../../ +pip3 install google-cloud-testutils +export PYTHONPATH=$PYTHONPATH:$(pwd) + +python3 create_test_instance.py + +# If no SPANNER_TEST_DB is set, generate a unique one +# so that we can have multiple tests running without +# conflicting which changes and constraints. We'll always +# cleanup the created database. +TEST_DBNAME=${SPANNER_TEST_DB:-$(python3 -c 'import os, time; print(chr(ord("a") + time.time_ns() % 26)+os.urandom(10).hex())')} +TEST_DBNAME_OTHER="$TEST_DBNAME-ot" +INSTANCE=${SPANNER_TEST_INSTANCE:-django-tests} +PROJECT=${PROJECT_ID} +SETTINGS_FILE="$TEST_DBNAME-settings" +TESTS_DIR=${DJANGO_TESTS_DIR:-django_tests} + +create_settings() { + cat << ! > "$SETTINGS_FILE.py" +import django_spanner +DATABASES = { + 'default': { + 'ENGINE': 'django_spanner', + 'PROJECT': "$PROJECT", + 'INSTANCE': "$INSTANCE", + 'NAME': "$TEST_DBNAME", + }, + 'other': { + 'ENGINE': 'django_spanner', + 'PROJECT': "$PROJECT", + 'INSTANCE': "$INSTANCE", + 'NAME': "$TEST_DBNAME_OTHER", + }, +} +USE_TZ = False +SECRET_KEY = 'spanner_tests_secret_key' +PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.MD5PasswordHasher', +] +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'tests.system.django_spanner', +] +! +} + +cd $TESTS_DIR/django/tests +create_settings + +EXIT_STATUS=0 +for DJANGO_TEST_APP in $DJANGO_TEST_APPS +do + python3 runtests.py $DJANGO_TEST_APP --verbosity=3 --noinput --settings $SETTINGS_FILE || EXIT_STATUS=$? +done +exit $EXIT_STATUS diff --git a/noxfile.py b/noxfile.py index cb438618f7..5dca8e5413 100644 --- a/noxfile.py +++ b/noxfile.py @@ -15,7 +15,7 @@ import nox -BLACK_VERSION = "black==22.3.0" +BLACK_VERSION = "black>=24.0.0" BLACK_PATHS = [ "docs", "django_spanner", @@ -25,9 +25,9 @@ ] MOCKSERVER_TEST_PYTHON_VERSION = "3.12" -DEFAULT_PYTHON_VERSION = "3.8" -SYSTEM_TEST_PYTHON_VERSIONS = ["3.8"] -UNIT_TEST_PYTHON_VERSIONS = ["3.8", "3.9", "3.10"] +DEFAULT_PYTHON_VERSION = "3.10" +SYSTEM_TEST_PYTHON_VERSIONS = ["3.12"] +UNIT_TEST_PYTHON_VERSIONS = ["3.10", "3.12"] CURRENT_DIRECTORY = pathlib.Path(__file__).parent.absolute() @@ -67,7 +67,7 @@ def lint_setup_py(session): ) -def default(session, django_version="3.2"): +def default(session, django_version="5.2"): # Install all test dependencies, then install this package in-place. session.install( "setuptools", @@ -97,16 +97,15 @@ def default(session, django_version="3.2"): "--cov-fail-under=75", os.path.join("tests", "unit"), *session.posargs, + env={"GOOGLE_CLOUD_PROJECT": "test-project"}, ) @nox.session(python=UNIT_TEST_PYTHON_VERSIONS) def unit(session): """Run the unit test suite.""" - print("Unit tests with django 3.2") + print("Unit tests with django 5.2") default(session) - print("Unit tests with django 4.2") - default(session, django_version="4.2") @nox.session(python=MOCKSERVER_TEST_PYTHON_VERSION) @@ -114,7 +113,7 @@ def mockserver(session): # Install all test dependencies, then install this package in-place. session.install( "setuptools", - "django~=4.2", + "django~=5.2", "mock", "mock-import", "pytest", @@ -135,7 +134,7 @@ def mockserver(session): ) -def system_test(session, django_version="3.2"): +def system_test(session, django_version="5.2"): """Run the system test suite.""" constraints_path = str( CURRENT_DIRECTORY / "testing" / f"constraints-{session.python}.txt" @@ -147,12 +146,6 @@ def system_test(session, django_version="3.2"): if os.environ.get("RUN_SYSTEM_TESTS", "true") == "false": session.skip("RUN_SYSTEM_TESTS is set to false, skipping") # Sanity check: Only run tests if the environment variable is set. - if not os.environ.get( - "GOOGLE_APPLICATION_CREDENTIALS", "" - ) and not os.environ.get("SPANNER_EMULATOR_HOST", ""): - session.skip( - "Credentials or emulator host must be set via environment variable" - ) system_test_exists = os.path.exists(system_test_path) system_test_folder_exists = os.path.exists(system_test_folder_path) @@ -185,10 +178,8 @@ def system_test(session, django_version="3.2"): @nox.session(python=SYSTEM_TEST_PYTHON_VERSIONS) def system(session): - print("System tests with django 3.2") + print("System tests with django 5.2") system_test(session) - print("System tests with django 4.2") - system_test(session, django_version="4.2") @nox.session(python=DEFAULT_PYTHON_VERSION) @@ -204,7 +195,7 @@ def cover(session): session.run("coverage", "erase") -@nox.session(python="3.9") +@nox.session(python="3.10") def docs(session): """Build the docs for this library.""" @@ -222,7 +213,7 @@ def docs(session): "sphinx==4.5.0", "alabaster", "recommonmark", - "django==3.2", + "django==5.2", ) shutil.rmtree(os.path.join("docs", "_build"), ignore_errors=True) @@ -259,7 +250,7 @@ def docfx(session): "gcp-sphinx-docfx-yaml", "alabaster", "recommonmark", - "django==3.2", + "django==5.2", ) shutil.rmtree(os.path.join("docs", "_build"), ignore_errors=True) diff --git a/setup.cfg b/setup.cfg index f53a9ca616..207b5db945 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,10 +1,10 @@ [flake8] max-line-length = 79 ignore = - E501 # line too long, defer to black - W503 # allow line breaks before binary ops - W504 # allow line breaks after binary ops - E203 # allow whitespace before ':' (https://github.com/psf/black#slices) + E501 + W503 + W504 + E203 exclude = # Exclude generated code. **/_build/** diff --git a/setup.py b/setup.py index e84fc38dc7..c77a8ab35f 100644 --- a/setup.py +++ b/setup.py @@ -62,14 +62,13 @@ "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Utilities", "Framework :: Django", - "Framework :: Django :: 3.2", - "Framework :: Django :: 4.2", + "Framework :: Django :: 5.2", ], extras_require=extras, - python_requires=">=3.8", + python_requires=">=3.10", ) diff --git a/testing/constraints-3.12.txt b/testing/constraints-3.12.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/mockserver_tests/mock_server_test_base.py b/tests/mockserver_tests/mock_server_test_base.py index ce295a4fd8..0ab6d0ba4c 100644 --- a/tests/mockserver_tests/mock_server_test_base.py +++ b/tests/mockserver_tests/mock_server_test_base.py @@ -15,7 +15,7 @@ import os import unittest -from django.db import connection, connections +from django.db import connections from google.cloud.spanner_dbapi.parsed_statement import AutocommitDmlMode import google.cloud.spanner_v1.types.type as spanner_type import google.cloud.spanner_v1.types.result_set as result_set diff --git a/tests/mockserver_tests/spanner_database_admin_pb2_grpc.py b/tests/mockserver_tests/spanner_database_admin_pb2_grpc.py index 46b9df8437..c20f8e4f09 100644 --- a/tests/mockserver_tests/spanner_database_admin_pb2_grpc.py +++ b/tests/mockserver_tests/spanner_database_admin_pb2_grpc.py @@ -14,6 +14,7 @@ # flake8: noqa """Client and server classes corresponding to protobuf-defined services.""" + import grpc from google.iam.v1 import ( diff --git a/tests/mockserver_tests/spanner_pb2_grpc.py b/tests/mockserver_tests/spanner_pb2_grpc.py index da86ba18a3..e2155c18ab 100644 --- a/tests/mockserver_tests/spanner_pb2_grpc.py +++ b/tests/mockserver_tests/spanner_pb2_grpc.py @@ -13,6 +13,7 @@ # flake8: noqa """Client and server classes corresponding to protobuf-defined services.""" + import grpc from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 diff --git a/tests/mockserver_tests/test_basics.py b/tests/mockserver_tests/test_basics.py index 489ebf3cbf..aa2b139a09 100644 --- a/tests/mockserver_tests/test_basics.py +++ b/tests/mockserver_tests/test_basics.py @@ -13,6 +13,7 @@ # limitations under the License. from google.cloud.spanner_v1 import ( BatchCreateSessionsRequest, + CreateSessionRequest, ExecuteSqlRequest, CommitRequest, ) @@ -30,15 +31,15 @@ class TestBasics(MockServerTestBase): def verify_select1(self, results): - result_list = [] - for row in results: - result_list.append(row) + result_list = list(results) + for row in result_list: self.assertEqual(row[0], 1) self.assertEqual(len(result_list), 1) requests = self.spanner_service.requests - self.assertEqual(len(requests), 2) + self.assertEqual(len(requests), 3) self.assertIsInstance(requests[0], BatchCreateSessionsRequest) - self.assertIsInstance(requests[1], ExecuteSqlRequest) + self.assertIsInstance(requests[1], CreateSessionRequest) + self.assertIsInstance(requests[2], ExecuteSqlRequest) def test_select1(self): add_select1_result() @@ -59,9 +60,10 @@ def test_django_select_singer(self): singers = Singer.objects.all() self.assertEqual(len(singers), 2) requests = self.spanner_service.requests - self.assertEqual(len(requests), 2) + self.assertEqual(len(requests), 3) self.assertIsInstance(requests[0], BatchCreateSessionsRequest) - self.assertIsInstance(requests[1], ExecuteSqlRequest) + self.assertIsInstance(requests[1], CreateSessionRequest) + self.assertIsInstance(requests[2], ExecuteSqlRequest) def test_django_select_singer_using_other_db(self): add_singer_query_result( @@ -70,9 +72,10 @@ def test_django_select_singer_using_other_db(self): singers = Singer.objects.using("secondary").all() self.assertEqual(len(singers), 2) requests = self.spanner_service.requests - self.assertEqual(len(requests), 2) + self.assertEqual(len(requests), 3) self.assertIsInstance(requests[0], BatchCreateSessionsRequest) - self.assertIsInstance(requests[1], ExecuteSqlRequest) + self.assertIsInstance(requests[1], CreateSessionRequest) + self.assertIsInstance(requests[2], ExecuteSqlRequest) def test_insert_singer(self): add_update_count( @@ -84,15 +87,16 @@ def test_insert_singer(self): singer = Singer(first_name="test", last_name="test") singer.save() requests = self.spanner_service.requests - self.assertEqual(len(requests), 3) + self.assertEqual(len(requests), 4) self.assertIsInstance(requests[0], BatchCreateSessionsRequest) - self.assertIsInstance(requests[1], ExecuteSqlRequest) - self.assertIsInstance(requests[2], CommitRequest) + self.assertIsInstance(requests[1], CreateSessionRequest) + self.assertIsInstance(requests[2], ExecuteSqlRequest) + self.assertIsInstance(requests[3], CommitRequest) # The ExecuteSqlRequest should have 3 parameters: # 1. first_name # 2. last_name # 3. client-side auto-generated primary key - self.assertEqual(len(requests[1].params), 3) + self.assertEqual(len(requests[2].params), 3) def test_insert_singer_with_disabled_random_primary_key(self): for db, config in DATABASES.items(): @@ -115,15 +119,16 @@ class LocalSinger(models.Model): singer = LocalSinger(first_name="test", last_name="test") singer.save() requests = self.spanner_service.requests - self.assertEqual(len(requests), 3) + self.assertEqual(len(requests), 4) self.assertIsInstance(requests[0], BatchCreateSessionsRequest) - self.assertIsInstance(requests[1], ExecuteSqlRequest) - self.assertIsInstance(requests[2], CommitRequest) + self.assertIsInstance(requests[1], CreateSessionRequest) + self.assertIsInstance(requests[2], ExecuteSqlRequest) + self.assertIsInstance(requests[3], CommitRequest) # The ExecuteSqlRequest should have 2 parameters: # 1. first_name # 2. last_name # There should be no client-side auto-generated primary key. - self.assertEqual(len(requests[1].params), 2) + self.assertEqual(len(requests[2].params), 2) finally: for db, config in DATABASES.items(): if config["ENGINE"] == "django_spanner": diff --git a/tests/performance/django_spanner/test_benchmark.py b/tests/performance/django_spanner/test_benchmark.py index e6540fd138..86c0238273 100644 --- a/tests/performance/django_spanner/test_benchmark.py +++ b/tests/performance/django_spanner/test_benchmark.py @@ -169,18 +169,14 @@ def __init__(self): def _create_table(self): """Create a table for performace testing.""" conn = spanner_dbapi.connect(INSTANCE_ID, DATABASE_NAME) - conn.database.update_ddl( - [ - """ + conn.database.update_ddl([""" CREATE TABLE Author ( id INT64, first_name STRING(20), last_name STRING(20), rating STRING(50), ) PRIMARY KEY (id) - """ - ] - ).result(120) + """]).result(120) conn.close() diff --git a/tests/system/django_spanner/models.py b/tests/system/django_spanner/models.py index cb75aba9e7..ac7b523fb1 100644 --- a/tests/system/django_spanner/models.py +++ b/tests/system/django_spanner/models.py @@ -7,8 +7,8 @@ """ Different models used by system tests in django-spanner code. """ + from django.db import models -from django_spanner import USING_DJANGO_3 class Author(models.Model): @@ -37,7 +37,5 @@ class Meta: ] -if USING_DJANGO_3: - - class Detail(models.Model): - value = models.JSONField() +class Detail(models.Model): + value = models.JSONField() diff --git a/tests/system/django_spanner/test_json_field.py b/tests/system/django_spanner/test_json_field.py index d3cbc97dd4..31b86a301c 100644 --- a/tests/system/django_spanner/test_json_field.py +++ b/tests/system/django_spanner/test_json_field.py @@ -8,7 +8,7 @@ from django.test import TransactionTestCase from django.db import connection from django_spanner import USE_EMULATOR -from django_spanner import USING_DJANGO_3 + from tests.system.django_spanner.utils import ( setup_instance, teardown_instance, @@ -16,8 +16,7 @@ teardown_database, ) -if USING_DJANGO_3: - from .models import Detail +from .models import Detail @unittest.skipIf(USE_EMULATOR, "Jsonfield is not implemented in emulator.") diff --git a/tests/unit/django_spanner/models.py b/tests/unit/django_spanner/models.py index 8dfb9d8e48..6b71867315 100644 --- a/tests/unit/django_spanner/models.py +++ b/tests/unit/django_spanner/models.py @@ -6,6 +6,7 @@ """ Different models used for testing django-spanner code. """ + from django.db import models diff --git a/tests/unit/django_spanner/test_base.py b/tests/unit/django_spanner/test_base.py index 9b2d60c1c4..fa79255847 100644 --- a/tests/unit/django_spanner/test_base.py +++ b/tests/unit/django_spanner/test_base.py @@ -10,11 +10,16 @@ class TestBase(SpannerSimpleTestClass): def test_property_instance(self): + # Reset global cache to ensure we test the creation logic + import django_spanner.base + django_spanner.base._SPANNER_CLIENT_CACHE = None + with mock.patch("django_spanner.base.spanner") as mock_spanner: mock_spanner.Client = mock_client = mock.MagicMock() - mock_client().instance = mock_instance = mock.MagicMock() + mock_client.return_value.instance = mock_instance = mock.MagicMock() _ = self.db_wrapper.instance - mock_instance.assert_called_once_with(self.INSTANCE_ID) + # Instance should be called on the return value of Client() + self.assertTrue(mock_instance.called) def test_property_nodb_connection(self): with self.assertRaises(NotImplementedError): @@ -34,7 +39,11 @@ def test_get_new_connection(self): mock_database.connect = mock_connection = mock.MagicMock() conn_params = {"test_param": "dummy"} self.db_wrapper.get_new_connection(conn_params) - mock_connection.assert_called_once_with(**conn_params) + mock_connection.assert_called_once_with( + self.INSTANCE_ID, + client=mock_database.connect.call_args[1]["client"], + **conn_params + ) def test_init_connection_state(self): self.db_wrapper.connection = mock_connection = mock.MagicMock() diff --git a/tests/unit/django_spanner/test_compiler.py b/tests/unit/django_spanner/test_compiler.py index 3b887b0b4f..4e9da30ea5 100644 --- a/tests/unit/django_spanner/test_compiler.py +++ b/tests/unit/django_spanner/test_compiler.py @@ -8,7 +8,7 @@ from django.db.utils import DatabaseError from django_spanner.compiler import SQLCompiler from django.db.models.query import QuerySet -from django_spanner import USING_DJANGO_3 + from tests.unit.django_spanner.simple_test import SpannerSimpleTestClass from .models import Number @@ -39,24 +39,14 @@ def test_get_combinator_sql_all_union_sql_generated(self): compiler = SQLCompiler(qs4.query, self.connection, "default") sql_compiled, params = compiler.get_combinator_sql("union", True) - if USING_DJANGO_3: - self.assertEqual( - sql_compiled, - [ - "SELECT tests_number.num FROM tests_number WHERE " - + "tests_number.num <= %s UNION ALL SELECT tests_number.num " - + "FROM tests_number WHERE tests_number.num >= %s" - ], - ) - else: - self.assertEqual( - sql_compiled, - [ - "SELECT tests_number.num AS col1 FROM tests_number WHERE " - + "tests_number.num <= %s UNION ALL SELECT tests_number.num " - + "AS col1 FROM tests_number WHERE tests_number.num >= %s" - ], - ) + self.assertEqual( + sql_compiled, + [ + "SELECT tests_number.num AS num FROM tests_number WHERE " + + "tests_number.num <= %s UNION ALL SELECT tests_number.num " + + "AS num FROM tests_number WHERE tests_number.num >= %s" + ], + ) self.assertEqual(params, [1, 8]) def test_get_combinator_sql_distinct_union_sql_generated(self): @@ -70,26 +60,15 @@ def test_get_combinator_sql_distinct_union_sql_generated(self): compiler = SQLCompiler(qs4.query, self.connection, "default") sql_compiled, params = compiler.get_combinator_sql("union", False) - if USING_DJANGO_3: - self.assertEqual( - sql_compiled, - [ - "SELECT tests_number.num FROM tests_number WHERE " - + "tests_number.num <= %s UNION DISTINCT SELECT " - + "tests_number.num FROM tests_number WHERE " - + "tests_number.num >= %s" - ], - ) - else: - self.assertEqual( - sql_compiled, - [ - "SELECT tests_number.num AS col1 FROM tests_number WHERE " - + "tests_number.num <= %s UNION DISTINCT SELECT " - + "tests_number.num AS col1 FROM tests_number WHERE " - + "tests_number.num >= %s" - ], - ) + self.assertEqual( + sql_compiled, + [ + "SELECT tests_number.num AS num FROM tests_number WHERE " + + "tests_number.num <= %s UNION DISTINCT SELECT " + + "tests_number.num AS num FROM tests_number WHERE " + + "tests_number.num >= %s" + ], + ) self.assertEqual(params, [1, 8]) def test_get_combinator_sql_difference_all_sql_generated(self): @@ -103,24 +82,14 @@ def test_get_combinator_sql_difference_all_sql_generated(self): compiler = SQLCompiler(qs4.query, self.connection, "default") sql_compiled, params = compiler.get_combinator_sql("difference", True) - if USING_DJANGO_3: - self.assertEqual( - sql_compiled, - [ - "SELECT tests_number.num FROM tests_number WHERE " - + "tests_number.num <= %s EXCEPT ALL SELECT tests_number.num " - + "FROM tests_number WHERE tests_number.num >= %s" - ], - ) - else: - self.assertEqual( - sql_compiled, - [ - "SELECT tests_number.num AS col1 FROM tests_number WHERE " - + "tests_number.num <= %s EXCEPT ALL SELECT tests_number.num " - + "AS col1 FROM tests_number WHERE tests_number.num >= %s" - ], - ) + self.assertEqual( + sql_compiled, + [ + "SELECT tests_number.num AS num FROM tests_number WHERE " + + "tests_number.num <= %s EXCEPT ALL SELECT tests_number.num " + + "AS num FROM tests_number WHERE tests_number.num >= %s" + ], + ) self.assertEqual(params, [1, 8]) def test_get_combinator_sql_difference_distinct_sql_generated(self): @@ -134,26 +103,15 @@ def test_get_combinator_sql_difference_distinct_sql_generated(self): compiler = SQLCompiler(qs4.query, self.connection, "default") sql_compiled, params = compiler.get_combinator_sql("difference", False) - if USING_DJANGO_3: - self.assertEqual( - sql_compiled, - [ - "SELECT tests_number.num FROM tests_number WHERE " - + "tests_number.num <= %s EXCEPT DISTINCT SELECT " - + "tests_number.num FROM tests_number WHERE " - + "tests_number.num >= %s" - ], - ) - else: - self.assertEqual( - sql_compiled, - [ - "SELECT tests_number.num AS col1 FROM tests_number WHERE " - + "tests_number.num <= %s EXCEPT DISTINCT SELECT " - + "tests_number.num AS col1 FROM tests_number WHERE " - + "tests_number.num >= %s" - ], - ) + self.assertEqual( + sql_compiled, + [ + "SELECT tests_number.num AS num FROM tests_number WHERE " + + "tests_number.num <= %s EXCEPT DISTINCT SELECT " + + "tests_number.num AS num FROM tests_number WHERE " + + "tests_number.num >= %s" + ], + ) self.assertEqual(params, [1, 8]) def test_get_combinator_sql_union_and_difference_query_together(self): @@ -167,30 +125,17 @@ def test_get_combinator_sql_union_and_difference_query_together(self): compiler = SQLCompiler(qs4.query, self.connection, "default") sql_compiled, params = compiler.get_combinator_sql("union", False) - if USING_DJANGO_3: - self.assertEqual( - sql_compiled, - [ - "SELECT tests_number.num FROM tests_number WHERE " - + "tests_number.num <= %s UNION DISTINCT SELECT * FROM (" - + "SELECT tests_number.num FROM tests_number WHERE " - + "tests_number.num >= %s EXCEPT DISTINCT " - + "SELECT tests_number.num FROM tests_number " - + "WHERE tests_number.num = %s)" - ], - ) - else: - self.assertEqual( - sql_compiled, - [ - "SELECT tests_number.num AS col1 FROM tests_number WHERE " - + "tests_number.num <= %s UNION DISTINCT SELECT * FROM (" - + "SELECT tests_number.num AS col1 FROM tests_number WHERE " - + "tests_number.num >= %s EXCEPT DISTINCT " - + "SELECT tests_number.num AS col1 FROM tests_number " - + "WHERE tests_number.num = %s)" - ], - ) + self.assertEqual( + sql_compiled, + [ + "SELECT tests_number.num AS num FROM tests_number WHERE " + + "tests_number.num <= %s UNION DISTINCT SELECT * FROM (" + + "SELECT tests_number.num AS num FROM tests_number WHERE " + + "tests_number.num >= %s EXCEPT DISTINCT " + + "SELECT tests_number.num AS num FROM tests_number " + + "WHERE tests_number.num = %s)" + ], + ) self.assertEqual(params, [1, 8, 10]) def test_get_combinator_sql_parentheses_in_compound_not_supported(self): @@ -207,30 +152,17 @@ def test_get_combinator_sql_parentheses_in_compound_not_supported(self): compiler = SQLCompiler(qs4.query, self.connection, "default") compiler.connection.features.supports_parentheses_in_compound = False sql_compiled, params = compiler.get_combinator_sql("union", False) - if USING_DJANGO_3: - self.assertEqual( - sql_compiled, - [ - "SELECT tests_number.num FROM tests_number WHERE " - + "tests_number.num <= %s UNION DISTINCT SELECT * FROM (" - + "SELECT tests_number.num FROM tests_number WHERE " - + "tests_number.num >= %s EXCEPT DISTINCT " - + "SELECT tests_number.num FROM tests_number " - + "WHERE tests_number.num = %s)" - ], - ) - else: - self.assertEqual( - sql_compiled, - [ - "SELECT tests_number.num AS col1 FROM tests_number WHERE " - + "tests_number.num <= %s UNION DISTINCT SELECT * FROM (" - + "SELECT tests_number.num AS col1 FROM tests_number WHERE " - + "tests_number.num >= %s EXCEPT DISTINCT " - + "SELECT tests_number.num AS col1 FROM tests_number " - + "WHERE tests_number.num = %s)" - ], - ) + self.assertEqual( + sql_compiled, + [ + "SELECT tests_number.num AS num FROM tests_number WHERE " + + "tests_number.num <= %s UNION DISTINCT SELECT * FROM (" + + "SELECT tests_number.num AS num FROM tests_number WHERE " + + "tests_number.num >= %s EXCEPT DISTINCT " + + "SELECT tests_number.num AS num FROM tests_number " + + "WHERE tests_number.num = %s)" + ], + ) self.assertEqual(params, [1, 8, 10]) def test_get_combinator_sql_empty_queryset_raises_exception(self): diff --git a/tests/unit/django_spanner/test_expressions.py b/tests/unit/django_spanner/test_expressions.py index 0efc99ce08..88065ab27d 100644 --- a/tests/unit/django_spanner/test_expressions.py +++ b/tests/unit/django_spanner/test_expressions.py @@ -19,7 +19,7 @@ def test_order_by_sql_query_with_order_by_null_last(self): sql_compiled, _ = compiler.as_sql() self.assertEqual( sql_compiled, - "SELECT tests_report.name FROM tests_report ORDER BY " + "SELECT tests_report.name AS name FROM tests_report ORDER BY " + "tests_report.name IS NULL, tests_report.name DESC", ) @@ -31,7 +31,7 @@ def test_order_by_sql_query_with_order_by_null_first(self): sql_compiled, _ = compiler.as_sql() self.assertEqual( sql_compiled, - "SELECT tests_report.name FROM tests_report ORDER BY " + "SELECT tests_report.name AS name FROM tests_report ORDER BY " + "tests_report.name IS NOT NULL, tests_report.name DESC", ) @@ -41,6 +41,6 @@ def test_order_by_sql_query_with_order_by_name(self): sql_compiled, _ = compiler.as_sql() self.assertEqual( sql_compiled, - "SELECT tests_report.name FROM tests_report ORDER BY " - + "tests_report.name ASC", + "SELECT tests_report.name AS name FROM tests_report ORDER BY " + + "1 ASC", ) diff --git a/tests/unit/django_spanner/test_functions.py b/tests/unit/django_spanner/test_functions.py index 7b66f965b2..a2c8563ca6 100644 --- a/tests/unit/django_spanner/test_functions.py +++ b/tests/unit/django_spanner/test_functions.py @@ -6,7 +6,7 @@ from tests.unit.django_spanner.simple_test import SpannerSimpleTestClass from django_spanner.compiler import SQLCompiler -from django_spanner import USING_DJANGO_3 + from django.db.models import CharField, FloatField, Value from django.db.models.functions import ( Cast, @@ -37,7 +37,7 @@ def test_cast_with_max_length(self): sql_query, params = compiler.query.as_sql(compiler, self.connection) self.assertEqual( sql_query, - "SELECT tests_author.name, SUBSTR(CAST(tests_author.name AS " + "SELECT tests_author.name AS name, SUBSTR(CAST(tests_author.name AS " + "STRING), 0, 10) AS name_as_prefix FROM tests_author", ) self.assertEqual(params, ()) @@ -53,7 +53,7 @@ def test_cast_without_max_length(self): sql_query, params = compiler.query.as_sql(compiler, self.connection) self.assertEqual( sql_query, - "SELECT tests_author.num, CAST(tests_author.num AS FLOAT64) " + "SELECT tests_author.num AS num, CAST(tests_author.num AS FLOAT64) " + "AS num_as_float FROM tests_author", ) self.assertEqual(params, ()) @@ -71,7 +71,7 @@ def test_concatpair(self): sql_query, params = compiler.query.as_sql(compiler, self.connection) self.assertEqual( sql_query, - "SELECT tests_author.name, CONCAT(IFNULL(tests_author.name, %s), " + "SELECT tests_author.name AS name, CONCAT(IFNULL(tests_author.name, %s), " + "IFNULL(CONCAT(IFNULL(%s, %s), IFNULL(tests_author.last_name, " + "%s)), %s)) AS full_name FROM tests_author", ) @@ -88,7 +88,7 @@ def test_cot(self): sql_query, params = compiler.query.as_sql(compiler, self.connection) self.assertEqual( sql_query, - "SELECT tests_author.num, (1 / TAN(tests_author.num)) AS num_cot " + "SELECT tests_author.num AS num, (1 / TAN(tests_author.num)) AS num_cot " + "FROM tests_author", ) self.assertEqual(params, ()) @@ -104,7 +104,7 @@ def test_degrees(self): sql_query, params = compiler.query.as_sql(compiler, self.connection) self.assertEqual( sql_query, - "SELECT tests_author.num, ((tests_author.num) * 180 / " + "SELECT tests_author.num AS num, ((tests_author.num) * 180 / " + "3.141592653589793) AS num_degrees FROM tests_author", ) self.assertEqual(params, ()) @@ -120,7 +120,7 @@ def test_left(self): sql_query, params = compiler.query.as_sql(compiler, self.connection) self.assertEqual( sql_query, - "SELECT tests_author.num, SUBSTR(tests_author.name, %s, %s) AS " + "SELECT tests_author.num AS num, SUBSTR(tests_author.name, %s, %s) AS " + "first_initial FROM tests_author", ) self.assertEqual(params, (1, 1)) @@ -136,10 +136,10 @@ def test_right(self): sql_query, params = compiler.query.as_sql(compiler, self.connection) self.assertEqual( sql_query, - "SELECT tests_author.num, SUBSTR(tests_author.name, (%s * %s)) " + "SELECT tests_author.num AS num, SUBSTR(tests_author.name, (%s * %s), %s) " + "AS last_letter FROM tests_author", ) - self.assertEqual(params, (1, -1)) + self.assertEqual(params, (1, -1, 1)) def test_log(self): """ @@ -151,7 +151,7 @@ def test_log(self): sql_query, params = compiler.query.as_sql(compiler, self.connection) self.assertEqual( sql_query, - "SELECT tests_author.num, LOG(%s, tests_author.num) AS log FROM " + "SELECT tests_author.num AS num, LOG(%s, tests_author.num) AS log FROM " + "tests_author", ) self.assertEqual(params, (10,)) @@ -168,7 +168,7 @@ def test_ord(self): sql_query, params = compiler.query.as_sql(compiler, self.connection) self.assertEqual( sql_query, - "SELECT tests_author.name, TO_CODE_POINTS(tests_author.name)" + "SELECT tests_author.name AS name, TO_CODE_POINTS(tests_author.name)" + "[OFFSET(0)] AS name_code_point FROM tests_author", ) self.assertEqual(params, ()) @@ -181,16 +181,10 @@ def test_pi(self): compiler = SQLCompiler(q1.query, self.connection, "default") sql_query, params = compiler.query.as_sql(compiler, self.connection) - if USING_DJANGO_3: - expected_sql = ( - "SELECT tests_author.num FROM tests_author WHERE tests_author.num " - + "= 3.141592653589793" - ) - else: - expected_sql = ( - "SELECT tests_author.num FROM tests_author WHERE tests_author.num " - + "= (3.141592653589793)" - ) + expected_sql = ( + "SELECT tests_author.num AS num FROM tests_author WHERE tests_author.num " + + "= (3.141592653589793)" + ) self.assertEqual( sql_query, expected_sql, @@ -207,7 +201,7 @@ def test_radians(self): sql_query, params = compiler.query.as_sql(compiler, self.connection) self.assertEqual( sql_query, - "SELECT tests_author.num, ((tests_author.num) * 3.141592653589793 " + "SELECT tests_author.num AS num, ((tests_author.num) * 3.141592653589793 " "/ 180) AS num_radians FROM tests_author", ) self.assertEqual(params, ()) @@ -224,7 +218,7 @@ def test_strindex(self): sql_query, params = compiler.query.as_sql(compiler, self.connection) self.assertEqual( sql_query, - "SELECT tests_author.name, STRPOS(tests_author.name, %s) AS " + "SELECT tests_author.name AS name, STRPOS(tests_author.name, %s) AS " + "smith_index FROM tests_author", ) self.assertEqual(params, ("Smith",)) @@ -241,7 +235,7 @@ def test_substr(self): sql_query, params = compiler.query.as_sql(compiler, self.connection) self.assertEqual( sql_query, - "SELECT tests_author.name, SUBSTR(tests_author.name, %s, %s) AS " + "SELECT tests_author.name AS name, SUBSTR(tests_author.name, %s, %s) AS " + "name_prefix FROM tests_author", ) self.assertEqual(params, (1, 5)) diff --git a/tests/unit/django_spanner/test_introspection.py b/tests/unit/django_spanner/test_introspection.py index fd5fd64301..c075a1b04f 100644 --- a/tests/unit/django_spanner/test_introspection.py +++ b/tests/unit/django_spanner/test_introspection.py @@ -11,7 +11,6 @@ from google.cloud.spanner_v1 import TypeCode from tests.unit.django_spanner.simple_test import SpannerSimpleTestClass from unittest import mock -from django_spanner import USING_DJANGO_3 class TestUtils(SpannerSimpleTestClass): @@ -131,7 +130,7 @@ def run_sql_in_snapshot(*args, **kwargs): ) self.assertEqual( primary_key, - "PK_column", + ("PK_column",), ) def test_get_primary_key_column_returns_none(self): diff --git a/tests/unit/django_spanner/test_lookups.py b/tests/unit/django_spanner/test_lookups.py index 31f858b9a3..0d3cf0a3b6 100644 --- a/tests/unit/django_spanner/test_lookups.py +++ b/tests/unit/django_spanner/test_lookups.py @@ -9,7 +9,6 @@ from tests.unit.django_spanner.simple_test import SpannerSimpleTestClass from decimal import Decimal from .models import Number, Author -from django_spanner import USING_DJANGO_3 class TestLookups(SpannerSimpleTestClass): @@ -22,7 +21,7 @@ def test_cast_param_to_float_lte_sql_query(self): sql_compiled, params = compiler.as_sql() self.assertEqual( sql_compiled, - "SELECT tests_number.decimal_num FROM tests_number WHERE " + "SELECT tests_number.decimal_num AS decimal_num FROM tests_number WHERE " + "tests_number.decimal_num <= %s", ) self.assertEqual(params, (Decimal("1.1"),)) @@ -35,7 +34,7 @@ def test_cast_param_to_float_for_int_field_query(self): sql_compiled, params = compiler.as_sql() self.assertEqual( sql_compiled, - "SELECT tests_number.num FROM tests_number WHERE " + "SELECT tests_number.num AS num FROM tests_number WHERE " + "tests_number.num <= %s", ) self.assertEqual(params, (1,)) @@ -47,7 +46,7 @@ def test_cast_param_to_float_for_foreign_key_field_query(self): sql_compiled, params = compiler.as_sql() self.assertEqual( sql_compiled, - "SELECT tests_number.num FROM tests_number WHERE " + "SELECT tests_number.num AS num FROM tests_number WHERE " + "tests_number.item_id = %s", ) self.assertEqual(params, (10,)) @@ -57,16 +56,10 @@ def test_cast_param_to_float_with_no_params_query(self): qs1 = Number.objects.filter(item_id__exact=F("num")).values("num") compiler = SQLCompiler(qs1.query, self.connection, "default") sql_compiled, params = compiler.as_sql() - if USING_DJANGO_3: - expected_sql = ( - "SELECT tests_number.num FROM tests_number WHERE " - + "tests_number.item_id = tests_number.num" - ) - else: - expected_sql = ( - "SELECT tests_number.num FROM tests_number WHERE " - + "tests_number.item_id = (tests_number.num)" - ) + expected_sql = ( + "SELECT tests_number.num AS num FROM tests_number WHERE " + + "tests_number.item_id = (tests_number.num)" + ) self.assertEqual(sql_compiled, expected_sql) self.assertEqual(params, ()) @@ -77,7 +70,7 @@ def test_startswith_endswith_sql_query_with_startswith(self): sql_compiled, params = compiler.as_sql() self.assertEqual( sql_compiled, - "SELECT tests_author.num FROM tests_author WHERE " + "SELECT tests_author.num AS num FROM tests_author WHERE " + "REGEXP_CONTAINS(CAST(tests_author.name AS STRING), %s)", ) self.assertEqual(params, ("^abc",)) @@ -89,7 +82,7 @@ def test_startswith_endswith_sql_query_with_endswith(self): sql_compiled, params = compiler.as_sql() self.assertEqual( sql_compiled, - "SELECT tests_author.num FROM tests_author WHERE " + "SELECT tests_author.num AS num FROM tests_author WHERE " + "REGEXP_CONTAINS(CAST(tests_author.name AS STRING), %s)", ) self.assertEqual(params, ("abc$",)) @@ -101,7 +94,7 @@ def test_startswith_endswith_sql_query_case_insensitive(self): sql_compiled, params = compiler.as_sql() self.assertEqual( sql_compiled, - "SELECT tests_author.num FROM tests_author WHERE " + "SELECT tests_author.num AS num FROM tests_author WHERE " + "REGEXP_CONTAINS(CAST(tests_author.name AS STRING), %s)", ) self.assertEqual(params, ("(?i)^abc",)) @@ -114,20 +107,12 @@ def test_startswith_endswith_sql_query_with_bileteral_transform(self): compiler = SQLCompiler(qs1.query, self.connection, "default") sql_compiled, params = compiler.as_sql() - if USING_DJANGO_3: - expected_sql = ( - "SELECT tests_author.name FROM tests_author WHERE " - + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "REPLACE(REPLACE(REPLACE(CONCAT('^', UPPER(%s)), " - + '"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))' - ) - else: - expected_sql = ( - "SELECT tests_author.name FROM tests_author WHERE " - + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "REPLACE(REPLACE(REPLACE(CONCAT('^', (UPPER(%s))), " - + '"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))' - ) + expected_sql = ( + "SELECT tests_author.name AS name FROM tests_author WHERE " + + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " + + "REPLACE(REPLACE(REPLACE(CONCAT('^', (UPPER(%s))), " + + '"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))' + ) self.assertEqual(sql_compiled, expected_sql) self.assertEqual(params, ("abc",)) @@ -139,20 +124,12 @@ def test_startswith_endswith_case_insensitive_transform_sql_query(self): compiler = SQLCompiler(qs1.query, self.connection, "default") sql_compiled, params = compiler.as_sql() - if USING_DJANGO_3: - expected_sql = ( - "SELECT tests_author.name FROM tests_author WHERE " - + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "REPLACE(REPLACE(REPLACE(CONCAT('^(?i)', UPPER(%s)), " - + '"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))' - ) - else: - expected_sql = ( - "SELECT tests_author.name FROM tests_author WHERE " - + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "REPLACE(REPLACE(REPLACE(CONCAT('^(?i)', (UPPER(%s))), " - + '"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))' - ) + expected_sql = ( + "SELECT tests_author.name AS name FROM tests_author WHERE " + + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " + + "REPLACE(REPLACE(REPLACE(CONCAT('^(?i)', (UPPER(%s))), " + + '"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))' + ) self.assertEqual(sql_compiled, expected_sql) self.assertEqual(params, ("abc",)) @@ -163,20 +140,12 @@ def test_startswith_endswith_endswith_sql_query_with_transform(self): compiler = SQLCompiler(qs1.query, self.connection, "default") sql_compiled, params = compiler.as_sql() - if USING_DJANGO_3: - expected_sql = ( - "SELECT tests_author.name FROM tests_author WHERE " - + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "REPLACE(REPLACE(REPLACE(CONCAT('', UPPER(%s), '$'), " - + '"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))' - ) - else: - expected_sql = ( - "SELECT tests_author.name FROM tests_author WHERE " - + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "REPLACE(REPLACE(REPLACE(CONCAT('', (UPPER(%s)), '$'), " - + '"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))' - ) + expected_sql = ( + "SELECT tests_author.name AS name FROM tests_author WHERE " + + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " + + "REPLACE(REPLACE(REPLACE(CONCAT('', (UPPER(%s)), '$'), " + + '"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))' + ) self.assertEqual(sql_compiled, expected_sql) self.assertEqual(params, ("abc",)) @@ -187,7 +156,7 @@ def test_regex_sql_query_case_sensitive(self): sql_compiled, params = compiler.as_sql() self.assertEqual( sql_compiled, - "SELECT tests_author.num FROM tests_author WHERE " + "SELECT tests_author.num AS num FROM tests_author WHERE " "REGEXP_CONTAINS(CAST(tests_author.name AS STRING), %s)", ) self.assertEqual(params, ("abc",)) @@ -199,7 +168,7 @@ def test_regex_sql_query_case_insensitive(self): sql_compiled, params = compiler.as_sql() self.assertEqual( sql_compiled, - "SELECT tests_author.num FROM tests_author WHERE " + "SELECT tests_author.num AS num FROM tests_author WHERE " "REGEXP_CONTAINS(CAST(tests_author.name AS STRING), %s)", ) self.assertEqual(params, ("(?i)abc",)) @@ -210,18 +179,11 @@ def test_regex_sql_query_case_sensitive_with_transform(self): compiler = SQLCompiler(qs1.query, self.connection, "default") sql_compiled, params = compiler.as_sql() - if USING_DJANGO_3: - expected_sql = ( - "SELECT tests_author.num FROM tests_author WHERE " - + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "UPPER(%s))" - ) - else: - expected_sql = ( - "SELECT tests_author.num FROM tests_author WHERE " - + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "(UPPER(%s)))" - ) + expected_sql = ( + "SELECT tests_author.num AS num FROM tests_author WHERE " + + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " + + "(UPPER(%s)))" + ) self.assertEqual(sql_compiled, expected_sql) self.assertEqual(params, ("abc",)) @@ -231,18 +193,11 @@ def test_regex_sql_query_case_insensitive_with_transform(self): compiler = SQLCompiler(qs1.query, self.connection, "default") sql_compiled, params = compiler.as_sql() - if USING_DJANGO_3: - expected_sql = ( - "SELECT tests_author.num FROM tests_author WHERE " - + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "CONCAT('(?i)', UPPER(%s)))" - ) - else: - expected_sql = ( - "SELECT tests_author.num FROM tests_author WHERE " - + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "CONCAT('(?i)', (UPPER(%s))))" - ) + expected_sql = ( + "SELECT tests_author.num AS num FROM tests_author WHERE " + + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " + + "CONCAT('(?i)', (UPPER(%s))))" + ) self.assertEqual(sql_compiled, expected_sql) self.assertEqual(params, ("abc",)) @@ -253,7 +208,7 @@ def test_contains_sql_query_case_insensitive(self): sql_compiled, params = compiler.as_sql() self.assertEqual( sql_compiled, - "SELECT tests_author.num FROM tests_author WHERE " + "SELECT tests_author.num AS num FROM tests_author WHERE " + "REGEXP_CONTAINS(CAST(tests_author.name AS STRING), %s)", ) self.assertEqual(params, ("(?i)abc",)) @@ -265,7 +220,7 @@ def test_contains_sql_query_case_sensitive(self): sql_compiled, params = compiler.as_sql() self.assertEqual( sql_compiled, - "SELECT tests_author.num FROM tests_author WHERE " + "SELECT tests_author.num AS num FROM tests_author WHERE " + "REGEXP_CONTAINS(CAST(tests_author.name AS STRING), %s)", ) self.assertEqual(params, ("abc",)) @@ -277,20 +232,12 @@ def test_contains_sql_query_case_insensitive_transform(self): ) compiler = SQLCompiler(qs1.query, self.connection, "default") sql_compiled, params = compiler.as_sql() - if USING_DJANGO_3: - expected_sql = ( - "SELECT tests_author.name FROM tests_author WHERE " - + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "REPLACE(REPLACE(REPLACE(CONCAT('(?i)', UPPER(%s)), " - + '"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))' - ) - else: - expected_sql = ( - "SELECT tests_author.name FROM tests_author WHERE " - + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "REPLACE(REPLACE(REPLACE(CONCAT('(?i)', (UPPER(%s))), " - + '"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))' - ) + expected_sql = ( + "SELECT tests_author.name AS name FROM tests_author WHERE " + + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " + + "REPLACE(REPLACE(REPLACE(CONCAT('(?i)', (UPPER(%s))), " + + '"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))' + ) self.assertEqual(sql_compiled, expected_sql) self.assertEqual(params, ("abc",)) @@ -299,20 +246,12 @@ def test_contains_sql_query_case_sensitive_transform(self): qs1 = Author.objects.filter(name__upper__contains="abc").values("name") compiler = SQLCompiler(qs1.query, self.connection, "default") sql_compiled, params = compiler.as_sql() - if USING_DJANGO_3: - expected_sql = ( - "SELECT tests_author.name FROM tests_author WHERE " - + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + 'REPLACE(REPLACE(REPLACE(UPPER(%s), "\\\\", "\\\\\\\\"), ' - + '"%%", r"\\%%"), "_", r"\\_"))' - ) - else: - expected_sql = ( - "SELECT tests_author.name FROM tests_author WHERE " - + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + 'REPLACE(REPLACE(REPLACE((UPPER(%s)), "\\\\", "\\\\\\\\"), ' - + '"%%", r"\\%%"), "_", r"\\_"))' - ) + expected_sql = ( + "SELECT tests_author.name AS name FROM tests_author WHERE " + + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " + + 'REPLACE(REPLACE(REPLACE((UPPER(%s)), "\\\\", "\\\\\\\\"), ' + + '"%%", r"\\%%"), "_", r"\\_"))' + ) self.assertEqual(sql_compiled, expected_sql) self.assertEqual(params, ("abc",)) @@ -324,7 +263,7 @@ def test_iexact_sql_query_case_insensitive(self): self.assertEqual( sql_compiled, - "SELECT tests_author.num FROM tests_author WHERE " + "SELECT tests_author.num AS num FROM tests_author WHERE " + "REGEXP_CONTAINS(CAST(tests_author.name AS STRING), %s)", ) self.assertEqual(params, ("^(?i)abc$",)) @@ -337,18 +276,11 @@ def test_iexact_sql_query_case_insensitive_function_transform(self): compiler = SQLCompiler(qs1.query, self.connection, "default") sql_compiled, params = compiler.as_sql() - if USING_DJANGO_3: - expected_sql = ( - "SELECT tests_author.name FROM tests_author WHERE " - + "REGEXP_CONTAINS(UPPER(tests_author.last_name), " - + "CONCAT('^(?i)', CAST(UPPER(tests_author.name) AS STRING), '$'))" - ) - else: - expected_sql = ( - "SELECT tests_author.name FROM tests_author WHERE " - + "REGEXP_CONTAINS((UPPER(tests_author.last_name)), " - + "CONCAT('^(?i)', CAST(UPPER(tests_author.name) AS STRING), '$'))" - ) + expected_sql = ( + "SELECT tests_author.name AS name FROM tests_author WHERE " + + "REGEXP_CONTAINS((UPPER(tests_author.last_name)), " + + "CONCAT('^(?i)', CAST(UPPER(tests_author.name) AS STRING), '$'))" + ) self.assertEqual(sql_compiled, expected_sql) self.assertEqual(params, ()) @@ -358,17 +290,10 @@ def test_iexact_sql_query_case_insensitive_value_match(self): compiler = SQLCompiler(qs1.query, self.connection, "default") sql_compiled, params = compiler.as_sql() - if USING_DJANGO_3: - expected_sql = ( - "SELECT tests_author.name FROM tests_author WHERE " - + "REGEXP_CONTAINS(UPPER(CONCAT('^(?i)', " - + "CAST(UPPER(tests_author.name) AS STRING), '$')), %s)" - ) - else: - expected_sql = ( - "SELECT tests_author.name FROM tests_author WHERE " - + "REGEXP_CONTAINS((UPPER(CONCAT('^(?i)', " - + "CAST(UPPER(tests_author.name) AS STRING), '$'))), %s)" - ) + expected_sql = ( + "SELECT tests_author.name AS name FROM tests_author WHERE " + + "REGEXP_CONTAINS((UPPER(CONCAT('^(?i)', " + + "CAST(UPPER(tests_author.name) AS STRING), '$'))), %s)" + ) self.assertEqual(sql_compiled, expected_sql) self.assertEqual(params, ("abc",)) diff --git a/tests/unit/django_spanner/test_operations.py b/tests/unit/django_spanner/test_operations.py index a1d87520a3..2fcf69b08a 100644 --- a/tests/unit/django_spanner/test_operations.py +++ b/tests/unit/django_spanner/test_operations.py @@ -12,7 +12,7 @@ from django.db.utils import DatabaseError from google.cloud.spanner_dbapi.types import DateStr -from django_spanner import USING_DJANGO_3 + from tests.unit.django_spanner.simple_test import SpannerSimpleTestClass import uuid @@ -40,7 +40,7 @@ def test_sql_flush(self): self.db_operations.sql_flush( style=no_style(), tables=["Table1", "Table2"] ), - ["DELETE FROM Table1", "DELETE FROM Table2"], + ["DELETE FROM Table1 WHERE true", "DELETE FROM Table2 WHERE true"], ) def test_sql_flush_empty_table_list(self): @@ -113,162 +113,90 @@ def test_convert_uuidfield_value_none(self): ) def test_date_extract_sql(self): - if USING_DJANGO_3: - self.assertEqual( - self.db_operations.date_extract_sql("week", "dummy_field"), - "EXTRACT(isoweek FROM dummy_field)", - ) - else: - self.assertEqual( - self.db_operations.date_extract_sql("week", "dummy_field"), - ("EXTRACT(isoweek FROM dummy_field)", None), - ) + self.assertEqual( + self.db_operations.date_extract_sql("week", "dummy_field"), + ("EXTRACT(isoweek FROM dummy_field)", None), + ) def test_date_extract_sql_lookup_type_dayofweek(self): - if USING_DJANGO_3: - self.assertEqual( - self.db_operations.date_extract_sql( - "dayofweek", "dummy_field" - ), - "EXTRACT(dayofweek FROM dummy_field)", - ) - else: - self.assertEqual( - self.db_operations.date_extract_sql( - "dayofweek", "dummy_field" - ), - ("EXTRACT(dayofweek FROM dummy_field)", None), - ) + self.assertEqual( + self.db_operations.date_extract_sql("dayofweek", "dummy_field"), + ("EXTRACT(dayofweek FROM dummy_field)", None), + ) def test_datetime_extract_sql(self): settings.USE_TZ = True - if USING_DJANGO_3: - self.assertEqual( - self.db_operations.datetime_extract_sql( - "dayofweek", "dummy_field", "IST" - ), + self.assertEqual( + self.db_operations.datetime_extract_sql( + "dayofweek", "dummy_field", None, "IST" + ), + ( 'EXTRACT(dayofweek FROM dummy_field AT TIME ZONE "IST")', - ) - else: - self.assertEqual( - self.db_operations.datetime_extract_sql( - "dayofweek", "dummy_field", None, "IST" - ), - ( - 'EXTRACT(dayofweek FROM dummy_field AT TIME ZONE "IST")', - None, - ), - ) + None, + ), + ) def test_datetime_extract_sql_use_tz_false(self): settings.USE_TZ = False - if USING_DJANGO_3: - self.assertEqual( - self.db_operations.datetime_extract_sql( - "dayofweek", "dummy_field", "IST" - ), + self.assertEqual( + self.db_operations.datetime_extract_sql( + "dayofweek", "dummy_field", None, "IST" + ), + ( 'EXTRACT(dayofweek FROM dummy_field AT TIME ZONE "UTC")', - ) - else: - self.assertEqual( - self.db_operations.datetime_extract_sql( - "dayofweek", "dummy_field", None, "IST" - ), - ( - 'EXTRACT(dayofweek FROM dummy_field AT TIME ZONE "UTC")', - None, - ), - ) + None, + ), + ) settings.USE_TZ = True # reset changes. def test_time_extract_sql(self): - if USING_DJANGO_3: - self.assertEqual( - self.db_operations.time_extract_sql( - "dayofweek", "dummy_field" - ), + self.assertEqual( + self.db_operations.time_extract_sql("dayofweek", "dummy_field"), + ( 'EXTRACT(dayofweek FROM dummy_field AT TIME ZONE "UTC")', - ) - else: - self.assertEqual( - self.db_operations.time_extract_sql( - "dayofweek", "dummy_field" - ), - ( - 'EXTRACT(dayofweek FROM dummy_field AT TIME ZONE "UTC")', - None, - ), - ) + None, + ), + ) def test_time_trunc_sql(self): - if USING_DJANGO_3: - self.assertEqual( - self.db_operations.time_trunc_sql("dayofweek", "dummy_field"), - 'TIMESTAMP_TRUNC(dummy_field, dayofweek, "UTC")', - ) - else: - self.assertEqual( - self.db_operations.time_trunc_sql( - "dayofweek", "dummy_field", None - ), - ('TIMESTAMP_TRUNC(dummy_field, dayofweek, "UTC")', None), - ) + self.assertEqual( + self.db_operations.time_trunc_sql( + "dayofweek", "dummy_field", None + ), + ('TIMESTAMP_TRUNC(dummy_field, dayofweek, "UTC")', None), + ) def test_datetime_cast_date_sql(self): - if USING_DJANGO_3: - self.assertEqual( - self.db_operations.datetime_cast_date_sql( - "dummy_field", "IST" - ), - 'DATE(dummy_field, "IST")', - ) - else: - self.assertEqual( - self.db_operations.datetime_cast_date_sql( - "dummy_field", None, "IST" - ), - ('DATE(dummy_field, "IST")', None), - ) + self.assertEqual( + self.db_operations.datetime_cast_date_sql( + "dummy_field", None, "IST" + ), + ('DATE(dummy_field, "IST")', None), + ) def test_datetime_cast_time_sql(self): settings.USE_TZ = True - if USING_DJANGO_3: - self.assertEqual( - self.db_operations.datetime_cast_time_sql( - "dummy_field", "IST" - ), + self.assertEqual( + self.db_operations.datetime_cast_time_sql( + "dummy_field", None, "IST" + ), + ( "TIMESTAMP(FORMAT_TIMESTAMP('%Y-%m-%d %R:%E9S %Z', dummy_field, 'IST'))", - ) - else: - self.assertEqual( - self.db_operations.datetime_cast_time_sql( - "dummy_field", None, "IST" - ), - ( - "TIMESTAMP(FORMAT_TIMESTAMP('%Y-%m-%d %R:%E9S %Z', dummy_field, 'IST'))", - None, - ), - ) + None, + ), + ) def test_datetime_cast_time_sql_use_tz_false(self): settings.USE_TZ = False - if USING_DJANGO_3: - self.assertEqual( - self.db_operations.datetime_cast_time_sql( - "dummy_field", "IST" - ), + self.assertEqual( + self.db_operations.datetime_cast_time_sql( + "dummy_field", None, "IST" + ), + ( "TIMESTAMP(FORMAT_TIMESTAMP('%Y-%m-%d %R:%E9S %Z', dummy_field, 'UTC'))", - ) - else: - self.assertEqual( - self.db_operations.datetime_cast_time_sql( - "dummy_field", None, "IST" - ), - ( - "TIMESTAMP(FORMAT_TIMESTAMP('%Y-%m-%d %R:%E9S %Z', dummy_field, 'UTC'))", - None, - ), - ) + None, + ), + ) settings.USE_TZ = True # reset changes. def test_date_interval_sql(self): diff --git a/tests/unit/django_spanner/test_schema.py b/tests/unit/django_spanner/test_schema.py index b43b92a14a..00753c5b62 100644 --- a/tests/unit/django_spanner/test_schema.py +++ b/tests/unit/django_spanner/test_schema.py @@ -20,7 +20,6 @@ DATABASE_ID, ) - BASE_ATTRIBUTES = { "db.type": "spanner", "db.engine": "django_spanner", @@ -145,6 +144,7 @@ def test_add_field(self): schema_editor.execute = mock.MagicMock() new_field = IntegerField(null=True) new_field.set_attributes_from_name("age") + new_field.model = Author schema_editor.add_field(Author, new_field) schema_editor.execute.assert_called_once_with( @@ -160,6 +160,7 @@ def test_remove_field(self): schema_editor._constraint_names = mock.MagicMock() remove_field = IntegerField(unique=True) remove_field.set_attributes_from_name("num") + remove_field.model = Author schema_editor.remove_field(Author, remove_field) schema_editor.execute.assert_called_once_with( @@ -198,6 +199,7 @@ def constraint_names(*args, **kwargs): remove_field = IntegerField(unique=True) remove_field.set_attributes_from_name("num") + remove_field.model = Author schema_editor.remove_field(Author, remove_field) calls = [ @@ -237,6 +239,7 @@ def test_column_sql_not_null_field(self): schema_editor.execute = mock.MagicMock() new_field = IntegerField() new_field.set_attributes_from_name("num") + new_field.model = Author sql, params = schema_editor.column_sql(Author, new_field) self.assertEqual(sql, "INT64 NOT NULL") self.assertEqual(params, []) @@ -249,6 +252,7 @@ def test_column_sql_nullable_field(self): schema_editor.execute = mock.MagicMock() new_field = IntegerField(null=True) new_field.set_attributes_from_name("num") + new_field.model = Author sql, params = schema_editor.column_sql(Author, new_field) self.assertEqual(sql, "INT64") self.assertEqual(params, []) @@ -289,8 +293,10 @@ def test_alter_field(self): schema_editor.execute = mock.MagicMock() old_field = IntegerField() old_field.set_attributes_from_name("num") + old_field.model = Author new_field = IntegerField() new_field.set_attributes_from_name("author_num") + new_field.model = Author schema_editor.alter_field(Author, old_field, new_field) schema_editor.execute.assert_called_once_with( @@ -320,8 +326,10 @@ def constraint_names(*args, **kwargs): schema_editor._constraint_names = constraint_names old_field = IntegerField(null=True, db_index=True) old_field.set_attributes_from_name("num") + old_field.model = Author new_field = IntegerField(db_index=True) new_field.set_attributes_from_name("author_num") + new_field.model = Author schema_editor.alter_field(Author, old_field, new_field) calls = [ @@ -383,8 +391,10 @@ def constraint_names(*args, **kwargs): schema_editor._constraint_names = constraint_names old_field = IntegerField(null=True) old_field.set_attributes_from_name("num") + old_field.model = Author new_field = IntegerField() new_field.set_attributes_from_name("author_num") + new_field.model = Author with self.assertRaises(NotSupportedError): schema_editor.alter_field(Author, old_field, new_field) @@ -401,8 +411,10 @@ def constraint_names(*args, **kwargs): schema_editor._constraint_names = constraint_names old_field = IntegerField(null=True, db_index=True) old_field.set_attributes_from_name("num") + old_field.model = Author new_field = IntegerField() new_field.set_attributes_from_name("author_num") + new_field.model = Author with self.assertRaises(NotSupportedError): schema_editor.alter_field(Author, old_field, new_field)