From 04a77458495ef6c2c99a36ec7ba0fcfecf5afa92 Mon Sep 17 00:00:00 2001 From: Astha Mohta Date: Tue, 29 Jun 2021 11:54:14 +0530 Subject: [PATCH 01/39] performance files --- tests/performance/__init__.py | 5 + tests/performance/django_spanner/__init__.py | 0 tests/performance/django_spanner/models.py | 8 + .../django_spanner/test_perfomance.py | 292 ++++++++++++++++++ 4 files changed, 305 insertions(+) create mode 100644 tests/performance/__init__.py create mode 100644 tests/performance/django_spanner/__init__.py create mode 100644 tests/performance/django_spanner/models.py create mode 100644 tests/performance/django_spanner/test_perfomance.py diff --git a/tests/performance/__init__.py b/tests/performance/__init__.py new file mode 100644 index 0000000000..6b607710ed --- /dev/null +++ b/tests/performance/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2020 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd diff --git a/tests/performance/django_spanner/__init__.py b/tests/performance/django_spanner/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/performance/django_spanner/models.py b/tests/performance/django_spanner/models.py new file mode 100644 index 0000000000..83a6bd8273 --- /dev/null +++ b/tests/performance/django_spanner/models.py @@ -0,0 +1,8 @@ +from django.db import models + + +class Author(models.Model): + id = models.IntegerField(primary_key=True) + first_name = models.CharField(max_length=20) + last_name = models.CharField(max_length=20) + rating = models.CharField(max_length=50) diff --git a/tests/performance/django_spanner/test_perfomance.py b/tests/performance/django_spanner/test_perfomance.py new file mode 100644 index 0000000000..ad61c45748 --- /dev/null +++ b/tests/performance/django_spanner/test_perfomance.py @@ -0,0 +1,292 @@ +import datetime +import random +import statistics +import time +from decimal import Decimal +from typing import Any + +from django.db import connection +from django.test import TransactionTestCase +from google.api_core.exceptions import Aborted +from google.cloud import spanner_dbapi +from google.cloud.spanner_v1 import Client, KeySet +from scipy.stats import sem + +from tests.system.django_spanner.utils import ( + setup_database, + setup_instance, + teardown_database, + teardown_instance, +) +import pytest + + + +val="2.1" +from tests.performance.django_spanner.models import Author + + +def measure_execution_time(function): + """Decorator to measure a wrapped method execution time.""" + + def wrapper(self, measures): + """Execute the wrapped method and measure its execution time. + Args: + measures (dict): Test cases and their execution time. + """ + t_start = time.time() + try: + function(self) + measures[function.__name__] = round(time.time() - t_start, 2) + except Aborted: + measures[function.__name__] = 0 + + return wrapper +def insert_one_row(transaction, one_row): + """A transaction-function for the original Spanner client. + Inserts a single row into a database and then fetches it back. + """ + transaction.execute_update( + "INSERT Author (id, first_name, last_name, rating) " + " VALUES {}".format(str(one_row)) + ) + last_name = transaction.execute_sql( + "SELECT last_name FROM Author WHERE id=1" + ).one()[0] + if last_name != "Allison": + raise ValueError("Received invalid last name: " + last_name) + + +def insert_many_rows(transaction, many_rows): + """A transaction-function for the original Spanner client. + Insert 100 rows into a database. + """ + statements = [] + for row in many_rows: + statements.append( + "INSERT Author (id, first_name, last_name, rating) " + " VALUES {}".format(str(row)) + ) + _, count = transaction.batch_update(statements) + if sum(count) != 99: + raise ValueError("Wrong number of inserts: " + str(sum(count))) + + +@pytest.mark.django_db +class BenchmarkTestBase: + """Base class for performance testing. + Organizes testing data preparation and cleanup. + """ + + def __init__(self): + self._create_table() + + self._one_row = ( + 1, + "Pete", + "Allison", + val, + ) + + def _cleanup(self): + """Drop the test table.""" + conn = spanner_dbapi.connect("django-spanner-test", "spanner-testdb") + conn.database.update_ddl(["DROP TABLE Author"]) + conn.close() + + def _create_table(self): + """Create a table for performace testing.""" + conn = spanner_dbapi.connect("django-spanner-test", "spanner-testdb") + 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) + + conn.close() + + def run(self): + """Execute every test case.""" + measures = {} + for method in ( + self.insert_one_row_with_fetch_after, + self.read_one_row, + self.insert_many_rows, + self.select_many_rows, + self.insert_many_rows_with_mutations, + ): + method(measures) + + self._cleanup() + return measures + + +@pytest.mark.django_db +class SpannerBenchmarkTest(BenchmarkTestBase): + """The original Spanner performace testing class.""" + def __init__(self): + super().__init__() + self._client = Client() + self._instance = self._client.instance("django-spanner-test") + self._database = self._instance.database("spanner-testdb") + + self._many_rows = [] + self._many_rows2 = [] + for i in range(99): + num = round(random.random() * 1000000) + self._many_rows.append((num, "Pete", "Allison", val)) + num2 = round(random.random() * 1000000) + self._many_rows2.append((num2, "Pete", "Allison", val)) + + # initiate a session + with self._database.snapshot(): + pass + + @measure_execution_time + def insert_one_row_with_fetch_after(self): + self._database.run_in_transaction(insert_one_row, self._one_row) + + @measure_execution_time + def insert_many_rows(self): + self._database.run_in_transaction(insert_many_rows, self._many_rows) + + @measure_execution_time + def insert_many_rows_with_mutations(self): + with self._database.batch() as batch: + batch.insert( + table="Author", + columns=("id", "first_name", "last_name", "rating"), + values=self._many_rows2, + ) + + @measure_execution_time + def read_one_row(self): + with self._database.snapshot() as snapshot: + keyset = KeySet(all_=True) + snapshot.read( + table="Author", + columns=("id", "first_name", "last_name", "rating"), + keyset=keyset, + ).one() + + @measure_execution_time + def select_many_rows(self): + with self._database.snapshot() as snapshot: + rows = list( + snapshot.execute_sql("SELECT * FROM Author ORDER BY last_name") + ) + if len(rows) != 100: + raise ValueError("Wrong number of rows read") + +@pytest.mark.django_db +class DjangoBenchmarkTest(BenchmarkTestBase): + def __init__(self): + setup_instance() + setup_database() + with connection.schema_editor() as editor: + editor.create_model(Author) + + self._many_rows = [] + self._many_rows2 = [] + for i in range(99): + self._many_rows.append(Author("Pete", "Allison", val)) + self._many_rows2.append(Author("Pete", "Allison", val)) + + def _cleanup(self): + """Drop the test table.""" + with connection.schema_editor() as editor: + editor.delete_model(Author) + + def __del__(self): + teardown_database() + teardown_instance() + + @measure_execution_time + def insert_one_row_with_fetch_after(self): + author_kent = Author( + first_name="Pete", last_name="Allison", rating=val, + ) + author_kent.save() + last_name = Author.objects.get(pk=author_kent.id).last_name + if last_name != "Allison": + raise ValueError("Received invalid last name: " + last_name) + + @measure_execution_time + def insert_many_rows(self): + Author.objects.bulk_create(self._many_rows) + + @measure_execution_time + def insert_many_rows_with_mutations(self): + pass + + @measure_execution_time + def read_one_row(self): + row = Author.objects.all().first() + if row is None: + raise ValueError("No rows read") + + @measure_execution_time + def select_many_rows(self): + rows = Author.objects.all() + if len(rows) != 100: + raise ValueError("Wrong number of rows read") + + +def compare_measurements(spanner, django): + """ + Compare the original Spanner client performance measures + with Spanner dialect for Django ones. + """ + comparison = {} + for key in django.keys(): + comparison[key] = { + "Spanner, sec": spanner[key], + "Django, sec": django[key], + "Django deviation": round(django[key] - spanner[key], 2), + "Django to Spanner, %": round(django[key] / spanner[key] * 100), + } + return comparison + + +measures = [] +for _ in range(50): + #spanner_measures = SpannerBenchmarkTest().run() + django_measures = DjangoBenchmarkTest().run() + #measures.append((spanner_measures, django_measures)) + +agg = {"spanner": {}, "django": {}} + +for span, djan in measures: + for key, value in span.items(): + #agg["spanner"].setdefault(key, []).append(value) + agg["django"].setdefault(key, []).append(djan[key]) + +# spanner_stats = {} +# for key, value in agg["spanner"].items(): +# while 0 in value: +# value.remove(0) +# spanner_stats[key + "_aver"] = round(statistics.mean(value), 2) +# spanner_stats[key + "_error"] = round(sem(value), 2) +# spanner_stats[key + "_std_dev"] = round(statistics.pstdev(value), 2) + +django_stats = {} +for key, value in agg["django"].items(): + while 0 in value: + value.remove(0) + django_stats[key + "_aver"] = round(statistics.mean(value), 2) + django_stats[key + "_error"] = round(sem(value), 2) + django_stats[key + "_std_dev"] = round(statistics.pstdev(value), 2) + +# for key in spanner_stats: +# print(key + ":") +# print("spanner: ", spanner_stats[key]) +# print("django: ", django_stats[key]) + + From 7a99ca183b9446673362cd61d4286911bb4d2baf Mon Sep 17 00:00:00 2001 From: Astha Mohta Date: Mon, 5 Jul 2021 12:18:32 +0530 Subject: [PATCH 02/39] test_benchmark --- .../django_spanner/test_benchmark.py | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 tests/performance/django_spanner/test_benchmark.py diff --git a/tests/performance/django_spanner/test_benchmark.py b/tests/performance/django_spanner/test_benchmark.py new file mode 100644 index 0000000000..497f0b85a0 --- /dev/null +++ b/tests/performance/django_spanner/test_benchmark.py @@ -0,0 +1,256 @@ +import datetime +import random +import statistics +import time +from decimal import Decimal +from typing import Any + +from django.db import connection +from django.test import TransactionTestCase +from google.api_core.exceptions import Aborted +from google.cloud import spanner_dbapi +from google.cloud.spanner_v1 import Client, KeySet +from scipy.stats import sem + +from tests.system.django_spanner.utils import ( + setup_database, + setup_instance, + teardown_database, + teardown_instance, +) +import pytest + + +val = "2.1" +from tests.performance.django_spanner.models import Author + + +def measure_execution_time(function): + """Decorator to measure a wrapped method execution time.""" + + def wrapper(self, measures): + """Execute the wrapped method and measure its execution time. + Args: + measures (dict): Test cases and their execution time. + """ + t_start = time.time() + try: + function(self) + measures[function.__name__] = round(time.time() - t_start, 2) + except Aborted: + measures[function.__name__] = 0 + + return wrapper + + +def insert_one_row(transaction, one_row): + """A transaction-function for the original Spanner client. + Inserts a single row into a database and then fetches it back. + """ + transaction.execute_update( + "INSERT Author (id, first_name, last_name, rating) " + " VALUES {}".format(str(one_row)) + ) + last_name = transaction.execute_sql( + "SELECT last_name FROM Author WHERE id=1" + ).one()[0] + if last_name != "Allison": + raise ValueError("Received invalid last name: " + last_name) + + +def insert_many_rows(transaction, many_rows): + """A transaction-function for the original Spanner client. + Insert 100 rows into a database. + """ + statements = [] + for row in many_rows: + statements.append( + "INSERT Author (id, first_name, last_name, rating) " + " VALUES {}".format(str(row)) + ) + _, count = transaction.batch_update(statements) + if sum(count) != 99: + raise ValueError("Wrong number of inserts: " + str(sum(count))) + + +class DjangoBenchmarkTest(TransactionTestCase): + @classmethod + def setUpClass(cls): + setup_instance() + setup_database() + with connection.schema_editor() as editor: + # Create the tables + editor.create_model(Author) + + @classmethod + def tearDownClass(cls): + with connection.schema_editor() as editor: + # delete the table + editor.delete_model(Author) + teardown_database() + # teardown_instance() + + def setUp(self): + self._many_rows = [] + self._many_rows2 = [] + for i in range(99): + num = round(random.random() * 1000000) + self._many_rows.append(Author(num, "Pete", "Allison", val)) + num2 = round(random.random() * 1000000) + self._many_rows2.append(Author(num2, "Pete", "Allison", val)) + + @measure_execution_time + def insert_one_row_with_fetch_after(self): + author_kent = Author( + id=2, + first_name="Pete", + last_name="Allison", + rating=val, + ) + author_kent.save() + last_name = Author.objects.get(pk=author_kent.id).last_name + if last_name != "Allison": + raise ValueError("Received invalid last name: " + last_name) + + @measure_execution_time + def insert_many_rows(self): + for row in self._many_rows: + row.save() + + @measure_execution_time + def insert_many_rows_with_mutations(self): + Author.objects.bulk_create(self._many_rows2) + + @measure_execution_time + def read_one_row(self): + row = Author.objects.all().first() + if row is None: + raise ValueError("No rows read") + + @measure_execution_time + def select_many_rows(self): + rows = Author.objects.all() + if len(rows) != 100: + raise ValueError("Wrong number of rows read") + + def test_run(self): + """Execute every test case.""" + measures = {} + for method in ( + self.insert_one_row_with_fetch_after, + self.read_one_row, + self.insert_many_rows, + self.select_many_rows, + self.insert_many_rows_with_mutations, + ): + method(measures) + import pdb + pdb.set_trace() + return measures + + +class SpannerBenchmarkTest(TransactionTestCase): + """The original Spanner performace testing class.""" + def setUp(self): + self._create_table() + self._one_row = ( + 1, + "Pete", + "Allison", + val, + ) + self._client = Client() + self._instance = self._client.instance("django-spanner-test") + self._database = self._instance.database("spanner-testdb") + + self._many_rows = [] + self._many_rows2 = [] + for i in range(99): + num = round(random.random() * 1000000) + self._many_rows.append((num, "Pete", "Allison", val)) + num2 = round(random.random() * 1000000) + self._many_rows2.append((num2, "Pete", "Allison", val)) + + # initiate a session + with self._database.snapshot(): + pass + def _create_table(self): + """Create a table for performace testing.""" + conn = spanner_dbapi.connect("django-spanner-test", "spanner-testdb") + 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) + + conn.close() + + @measure_execution_time + def insert_one_row_with_fetch_after(self): + self._database.run_in_transaction(insert_one_row, self._one_row) + + @measure_execution_time + def insert_many_rows(self): + self._database.run_in_transaction(insert_many_rows, self._many_rows) + + @measure_execution_time + def insert_many_rows_with_mutations(self): + with self._database.batch() as batch: + batch.insert( + table="Author", + columns=("id", "first_name", "last_name", "rating"), + values=self._many_rows2, + ) + + @measure_execution_time + def read_one_row(self): + with self._database.snapshot() as snapshot: + keyset = KeySet(all_=True) + snapshot.read( + table="Author", + columns=("id", "first_name", "last_name", "rating"), + keyset=keyset, + ).one() + + @measure_execution_time + def select_many_rows(self): + with self._database.snapshot() as snapshot: + rows = list( + snapshot.execute_sql("SELECT * FROM Author ORDER BY last_name") + ) + if len(rows) != 100: + raise ValueError("Wrong number of rows read") + + def _cleanup(self): + """Drop the test table.""" + conn = spanner_dbapi.connect("django-spanner-test", "spanner-testdb") + conn.database.update_ddl(["DROP TABLE Author"]) + conn.close() + + def test_run(self): + """Execute every test case.""" + measures = {} + for method in ( + self.insert_one_row_with_fetch_after, + self.read_one_row, + self.insert_many_rows, + self.select_many_rows, + self.insert_many_rows_with_mutations, + ): + method(measures) + self._cleanup() + # import pdb + # pdb.set_trace() + return measures + + + + +#print(DjangoBenchmarkTest().test_run()) From 79d94f7239db962709fd04d331fca52b7e36662e Mon Sep 17 00:00:00 2001 From: Astha Mohta Date: Wed, 7 Jul 2021 11:11:58 +0530 Subject: [PATCH 03/39] performance testing changes --- .../django_spanner/test_benchmark.py | 93 +++++++++++-------- tests/system/django_spanner/utils.py | 10 +- 2 files changed, 61 insertions(+), 42 deletions(-) diff --git a/tests/performance/django_spanner/test_benchmark.py b/tests/performance/django_spanner/test_benchmark.py index 497f0b85a0..88597ed410 100644 --- a/tests/performance/django_spanner/test_benchmark.py +++ b/tests/performance/django_spanner/test_benchmark.py @@ -4,6 +4,9 @@ import time from decimal import Decimal from typing import Any +import unittest +import pandas as pd +from tests.settings import INSTANCE_ID, DATABASE_NAME from django.db import connection from django.test import TransactionTestCase @@ -21,7 +24,6 @@ import pytest -val = "2.1" from tests.performance.django_spanner.models import Author @@ -36,7 +38,7 @@ def wrapper(self, measures): t_start = time.time() try: function(self) - measures[function.__name__] = round(time.time() - t_start, 2) + measures[function.__name__] = round(time.time() - t_start, 4) except Aborted: measures[function.__name__] = 0 @@ -73,39 +75,36 @@ def insert_many_rows(transaction, many_rows): raise ValueError("Wrong number of inserts: " + str(sum(count))) -class DjangoBenchmarkTest(TransactionTestCase): - @classmethod - def setUpClass(cls): - setup_instance() +class DjangoBenchmarkTest(): + def __init__(self): setup_database() with connection.schema_editor() as editor: # Create the tables editor.create_model(Author) - @classmethod - def tearDownClass(cls): + self._many_rows = [] + self._many_rows2 = [] + for i in range(99): + num = round(random.randint(0,100000000)) + self._many_rows.append(Author(num, "Pete", "Allison", "2.1")) + num2 = round(random.randint(0,100000000)) + self._many_rows2.append(Author(num2, "Pete", "Allison", "2.1")) + + def _cleanup(self): + """Drop the test table.""" with connection.schema_editor() as editor: # delete the table editor.delete_model(Author) teardown_database() # teardown_instance() - def setUp(self): - self._many_rows = [] - self._many_rows2 = [] - for i in range(99): - num = round(random.random() * 1000000) - self._many_rows.append(Author(num, "Pete", "Allison", val)) - num2 = round(random.random() * 1000000) - self._many_rows2.append(Author(num2, "Pete", "Allison", val)) - @measure_execution_time def insert_one_row_with_fetch_after(self): author_kent = Author( id=2, first_name="Pete", last_name="Allison", - rating=val, + rating="2.1", ) author_kent.save() last_name = Author.objects.get(pk=author_kent.id).last_name @@ -133,7 +132,7 @@ def select_many_rows(self): if len(rows) != 100: raise ValueError("Wrong number of rows read") - def test_run(self): + def run(self): """Execute every test case.""" measures = {} for method in ( @@ -144,39 +143,41 @@ def test_run(self): self.insert_many_rows_with_mutations, ): method(measures) - import pdb - pdb.set_trace() + self._cleanup() + # import pdb + # pdb.set_trace() return measures -class SpannerBenchmarkTest(TransactionTestCase): +class SpannerBenchmarkTest(): """The original Spanner performace testing class.""" - def setUp(self): + def __init__(self): + setup_database() self._create_table() self._one_row = ( 1, "Pete", "Allison", - val, + "2.1", ) self._client = Client() - self._instance = self._client.instance("django-spanner-test") - self._database = self._instance.database("spanner-testdb") + self._instance = self._client.instance(INSTANCE_ID) + self._database = self._instance.database(DATABASE_NAME) self._many_rows = [] self._many_rows2 = [] for i in range(99): - num = round(random.random() * 1000000) - self._many_rows.append((num, "Pete", "Allison", val)) - num2 = round(random.random() * 1000000) - self._many_rows2.append((num2, "Pete", "Allison", val)) + num = round(random.randint(0,100000000)) + self._many_rows.append((num, "Pete", "Allison", "2.1")) + num2 = round(random.randint(0,100000000)) + self._many_rows2.append((num2, "Pete", "Allison", "2.1")) # initiate a session with self._database.snapshot(): pass def _create_table(self): """Create a table for performace testing.""" - conn = spanner_dbapi.connect("django-spanner-test", "spanner-testdb") + conn = spanner_dbapi.connect(INSTANCE_ID, DATABASE_NAME) conn.database.update_ddl( [ """ @@ -230,11 +231,11 @@ def select_many_rows(self): def _cleanup(self): """Drop the test table.""" - conn = spanner_dbapi.connect("django-spanner-test", "spanner-testdb") + conn = spanner_dbapi.connect(INSTANCE_ID, DATABASE_NAME) conn.database.update_ddl(["DROP TABLE Author"]) conn.close() - def test_run(self): + def run(self): """Execute every test case.""" measures = {} for method in ( @@ -246,11 +247,29 @@ def test_run(self): ): method(measures) self._cleanup() - # import pdb - # pdb.set_trace() return measures +@pytest.mark.django_db(transaction=True) +class BenchmarkTest(unittest.TestCase): + + def test_run(self): + setup_instance() + django_obj = pd.DataFrame(columns = ['insert_one_row_with_fetch_after', 'read_one_row', 'insert_many_rows', 'select_many_rows', + 'insert_many_rows_with_mutations']) + spanner_obj = pd.DataFrame(columns = ['insert_one_row_with_fetch_after', 'read_one_row', 'insert_many_rows', 'select_many_rows', + 'insert_many_rows_with_mutations']) + + for _ in range(2): + django_obj = django_obj.append(DjangoBenchmarkTest().run(), ignore_index=True) + spanner_obj = spanner_obj.append(SpannerBenchmarkTest().run(), ignore_index=True) + + django_avg = django_obj.mean(axis = 0) + spanner_avg = spanner_obj.mean(axis = 0) + django_std = django_obj.std(axis = 0) + spanner_std = spanner_obj.std(axis = 0) + django_err = django_obj.sem(axis = 0) + spanner_err = spanner_obj.sem(axis = 0) + print("Django Average: ", django_avg, "\n Spanner Average: ", spanner_avg, "\n Django Standard Deviation: ", django_std, + "\n Spanner Standard Deviation: ", spanner_std, "\n Django Error: ", django_err, "\n Spanner Error: ", spanner_err, sep='\n') - -#print(DjangoBenchmarkTest().test_run()) diff --git a/tests/system/django_spanner/utils.py b/tests/system/django_spanner/utils.py index 7fac5166e0..ad001378f5 100644 --- a/tests/system/django_spanner/utils.py +++ b/tests/system/django_spanner/utils.py @@ -13,7 +13,7 @@ from google.cloud.spanner_v1 import Client from google.cloud.spanner_v1.instance import Instance, Backup from test_utils.retry import RetryErrors - +import pytest from django_spanner.creation import DatabaseCreation CREATE_INSTANCE = ( @@ -30,7 +30,7 @@ PROJECT_ID = os.getenv("GOOGLE_CLOUD_PROJECT", "emulator-test-project") DATABASE_NAME = os.getenv("DJANGO_SPANNER_DB", "django_test_db") - +@pytest.mark.django_db(transaction=True) class Config(object): """Run-time configuration to be modified at set-up. @@ -47,7 +47,7 @@ class Config(object): def _list_instances(): return list(Config.CLIENT.list_instances()) - +@pytest.mark.django_db(transaction=True) def setup_instance(): if USE_EMULATOR: from google.auth.credentials import AnonymousCredentials @@ -118,7 +118,7 @@ def teardown_instance(): if CREATE_INSTANCE: Config.INSTANCE.delete() - +@pytest.mark.django_db(transaction=True) def setup_database(): Config.DATABASE = Config.INSTANCE.database(DATABASE_NAME) if not Config.DATABASE.exists(): @@ -126,7 +126,7 @@ def setup_database(): creation._create_test_db(verbosity=0, autoclobber=False, keepdb=True) # Running migrations on the db. - call_command("migrate", interactive=False) + #call_command("migrate", interactive=False) def teardown_database(): From aad550cf75de5445ed16b699ea41ee2da0e87082 Mon Sep 17 00:00:00 2001 From: Astha Mohta Date: Thu, 8 Jul 2021 01:25:09 +0530 Subject: [PATCH 04/39] changes in benchmark performance for prod --- .../django_spanner/test_benchmark.py | 47 +-- .../django_spanner/test_perfomance.py | 292 ------------------ tests/system/django_spanner/utils.py | 2 +- 3 files changed, 17 insertions(+), 324 deletions(-) delete mode 100644 tests/performance/django_spanner/test_perfomance.py diff --git a/tests/performance/django_spanner/test_benchmark.py b/tests/performance/django_spanner/test_benchmark.py index 88597ed410..821bf64e82 100644 --- a/tests/performance/django_spanner/test_benchmark.py +++ b/tests/performance/django_spanner/test_benchmark.py @@ -1,30 +1,18 @@ -import datetime import random -import statistics import time -from decimal import Decimal -from typing import Any import unittest -import pandas as pd -from tests.settings import INSTANCE_ID, DATABASE_NAME +from typing import Any +import pandas as pd +import pytest from django.db import connection -from django.test import TransactionTestCase from google.api_core.exceptions import Aborted from google.cloud import spanner_dbapi from google.cloud.spanner_v1 import Client, KeySet -from scipy.stats import sem - -from tests.system.django_spanner.utils import ( - setup_database, - setup_instance, - teardown_database, - teardown_instance, -) -import pytest - from tests.performance.django_spanner.models import Author +from tests.settings import DATABASE_NAME, INSTANCE_ID +from tests.system.django_spanner.utils import setup_database, setup_instance def measure_execution_time(function): @@ -77,7 +65,6 @@ def insert_many_rows(transaction, many_rows): class DjangoBenchmarkTest(): def __init__(self): - setup_database() with connection.schema_editor() as editor: # Create the tables editor.create_model(Author) @@ -95,7 +82,6 @@ def _cleanup(self): with connection.schema_editor() as editor: # delete the table editor.delete_model(Author) - teardown_database() # teardown_instance() @measure_execution_time @@ -152,7 +138,6 @@ def run(self): class SpannerBenchmarkTest(): """The original Spanner performace testing class.""" def __init__(self): - setup_database() self._create_table() self._one_row = ( 1, @@ -249,11 +234,12 @@ def run(self): self._cleanup() return measures -@pytest.mark.django_db(transaction=True) +@pytest.mark.django_db() class BenchmarkTest(unittest.TestCase): def test_run(self): setup_instance() + setup_database() django_obj = pd.DataFrame(columns = ['insert_one_row_with_fetch_after', 'read_one_row', 'insert_many_rows', 'select_many_rows', 'insert_many_rows_with_mutations']) spanner_obj = pd.DataFrame(columns = ['insert_one_row_with_fetch_after', 'read_one_row', 'insert_many_rows', 'select_many_rows', @@ -263,13 +249,12 @@ def test_run(self): django_obj = django_obj.append(DjangoBenchmarkTest().run(), ignore_index=True) spanner_obj = spanner_obj.append(SpannerBenchmarkTest().run(), ignore_index=True) - django_avg = django_obj.mean(axis = 0) - spanner_avg = spanner_obj.mean(axis = 0) - django_std = django_obj.std(axis = 0) - spanner_std = spanner_obj.std(axis = 0) - django_err = django_obj.sem(axis = 0) - spanner_err = spanner_obj.sem(axis = 0) - print("Django Average: ", django_avg, "\n Spanner Average: ", spanner_avg, "\n Django Standard Deviation: ", django_std, - "\n Spanner Standard Deviation: ", spanner_std, "\n Django Error: ", django_err, "\n Spanner Error: ", spanner_err, sep='\n') - - + avg = pd.concat([django_obj.mean(axis = 0), spanner_obj.mean(axis = 0)], axis=1) + avg.columns=['Django','Spanner'] + std = pd.concat([django_obj.std(axis = 0), spanner_obj.std(axis = 0)], axis=1) + std.columns=['Django','Spanner'] + err = pd.concat([django_obj.sem(axis = 0), spanner_obj.sem(axis = 0)], axis=1) + err.columns=['Django','Spanner'] + + print("Average: ", avg, "Standard Deviation: ", std, "Error:", err, sep='\n') + diff --git a/tests/performance/django_spanner/test_perfomance.py b/tests/performance/django_spanner/test_perfomance.py deleted file mode 100644 index ad61c45748..0000000000 --- a/tests/performance/django_spanner/test_perfomance.py +++ /dev/null @@ -1,292 +0,0 @@ -import datetime -import random -import statistics -import time -from decimal import Decimal -from typing import Any - -from django.db import connection -from django.test import TransactionTestCase -from google.api_core.exceptions import Aborted -from google.cloud import spanner_dbapi -from google.cloud.spanner_v1 import Client, KeySet -from scipy.stats import sem - -from tests.system.django_spanner.utils import ( - setup_database, - setup_instance, - teardown_database, - teardown_instance, -) -import pytest - - - -val="2.1" -from tests.performance.django_spanner.models import Author - - -def measure_execution_time(function): - """Decorator to measure a wrapped method execution time.""" - - def wrapper(self, measures): - """Execute the wrapped method and measure its execution time. - Args: - measures (dict): Test cases and their execution time. - """ - t_start = time.time() - try: - function(self) - measures[function.__name__] = round(time.time() - t_start, 2) - except Aborted: - measures[function.__name__] = 0 - - return wrapper -def insert_one_row(transaction, one_row): - """A transaction-function for the original Spanner client. - Inserts a single row into a database and then fetches it back. - """ - transaction.execute_update( - "INSERT Author (id, first_name, last_name, rating) " - " VALUES {}".format(str(one_row)) - ) - last_name = transaction.execute_sql( - "SELECT last_name FROM Author WHERE id=1" - ).one()[0] - if last_name != "Allison": - raise ValueError("Received invalid last name: " + last_name) - - -def insert_many_rows(transaction, many_rows): - """A transaction-function for the original Spanner client. - Insert 100 rows into a database. - """ - statements = [] - for row in many_rows: - statements.append( - "INSERT Author (id, first_name, last_name, rating) " - " VALUES {}".format(str(row)) - ) - _, count = transaction.batch_update(statements) - if sum(count) != 99: - raise ValueError("Wrong number of inserts: " + str(sum(count))) - - -@pytest.mark.django_db -class BenchmarkTestBase: - """Base class for performance testing. - Organizes testing data preparation and cleanup. - """ - - def __init__(self): - self._create_table() - - self._one_row = ( - 1, - "Pete", - "Allison", - val, - ) - - def _cleanup(self): - """Drop the test table.""" - conn = spanner_dbapi.connect("django-spanner-test", "spanner-testdb") - conn.database.update_ddl(["DROP TABLE Author"]) - conn.close() - - def _create_table(self): - """Create a table for performace testing.""" - conn = spanner_dbapi.connect("django-spanner-test", "spanner-testdb") - 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) - - conn.close() - - def run(self): - """Execute every test case.""" - measures = {} - for method in ( - self.insert_one_row_with_fetch_after, - self.read_one_row, - self.insert_many_rows, - self.select_many_rows, - self.insert_many_rows_with_mutations, - ): - method(measures) - - self._cleanup() - return measures - - -@pytest.mark.django_db -class SpannerBenchmarkTest(BenchmarkTestBase): - """The original Spanner performace testing class.""" - def __init__(self): - super().__init__() - self._client = Client() - self._instance = self._client.instance("django-spanner-test") - self._database = self._instance.database("spanner-testdb") - - self._many_rows = [] - self._many_rows2 = [] - for i in range(99): - num = round(random.random() * 1000000) - self._many_rows.append((num, "Pete", "Allison", val)) - num2 = round(random.random() * 1000000) - self._many_rows2.append((num2, "Pete", "Allison", val)) - - # initiate a session - with self._database.snapshot(): - pass - - @measure_execution_time - def insert_one_row_with_fetch_after(self): - self._database.run_in_transaction(insert_one_row, self._one_row) - - @measure_execution_time - def insert_many_rows(self): - self._database.run_in_transaction(insert_many_rows, self._many_rows) - - @measure_execution_time - def insert_many_rows_with_mutations(self): - with self._database.batch() as batch: - batch.insert( - table="Author", - columns=("id", "first_name", "last_name", "rating"), - values=self._many_rows2, - ) - - @measure_execution_time - def read_one_row(self): - with self._database.snapshot() as snapshot: - keyset = KeySet(all_=True) - snapshot.read( - table="Author", - columns=("id", "first_name", "last_name", "rating"), - keyset=keyset, - ).one() - - @measure_execution_time - def select_many_rows(self): - with self._database.snapshot() as snapshot: - rows = list( - snapshot.execute_sql("SELECT * FROM Author ORDER BY last_name") - ) - if len(rows) != 100: - raise ValueError("Wrong number of rows read") - -@pytest.mark.django_db -class DjangoBenchmarkTest(BenchmarkTestBase): - def __init__(self): - setup_instance() - setup_database() - with connection.schema_editor() as editor: - editor.create_model(Author) - - self._many_rows = [] - self._many_rows2 = [] - for i in range(99): - self._many_rows.append(Author("Pete", "Allison", val)) - self._many_rows2.append(Author("Pete", "Allison", val)) - - def _cleanup(self): - """Drop the test table.""" - with connection.schema_editor() as editor: - editor.delete_model(Author) - - def __del__(self): - teardown_database() - teardown_instance() - - @measure_execution_time - def insert_one_row_with_fetch_after(self): - author_kent = Author( - first_name="Pete", last_name="Allison", rating=val, - ) - author_kent.save() - last_name = Author.objects.get(pk=author_kent.id).last_name - if last_name != "Allison": - raise ValueError("Received invalid last name: " + last_name) - - @measure_execution_time - def insert_many_rows(self): - Author.objects.bulk_create(self._many_rows) - - @measure_execution_time - def insert_many_rows_with_mutations(self): - pass - - @measure_execution_time - def read_one_row(self): - row = Author.objects.all().first() - if row is None: - raise ValueError("No rows read") - - @measure_execution_time - def select_many_rows(self): - rows = Author.objects.all() - if len(rows) != 100: - raise ValueError("Wrong number of rows read") - - -def compare_measurements(spanner, django): - """ - Compare the original Spanner client performance measures - with Spanner dialect for Django ones. - """ - comparison = {} - for key in django.keys(): - comparison[key] = { - "Spanner, sec": spanner[key], - "Django, sec": django[key], - "Django deviation": round(django[key] - spanner[key], 2), - "Django to Spanner, %": round(django[key] / spanner[key] * 100), - } - return comparison - - -measures = [] -for _ in range(50): - #spanner_measures = SpannerBenchmarkTest().run() - django_measures = DjangoBenchmarkTest().run() - #measures.append((spanner_measures, django_measures)) - -agg = {"spanner": {}, "django": {}} - -for span, djan in measures: - for key, value in span.items(): - #agg["spanner"].setdefault(key, []).append(value) - agg["django"].setdefault(key, []).append(djan[key]) - -# spanner_stats = {} -# for key, value in agg["spanner"].items(): -# while 0 in value: -# value.remove(0) -# spanner_stats[key + "_aver"] = round(statistics.mean(value), 2) -# spanner_stats[key + "_error"] = round(sem(value), 2) -# spanner_stats[key + "_std_dev"] = round(statistics.pstdev(value), 2) - -django_stats = {} -for key, value in agg["django"].items(): - while 0 in value: - value.remove(0) - django_stats[key + "_aver"] = round(statistics.mean(value), 2) - django_stats[key + "_error"] = round(sem(value), 2) - django_stats[key + "_std_dev"] = round(statistics.pstdev(value), 2) - -# for key in spanner_stats: -# print(key + ":") -# print("spanner: ", spanner_stats[key]) -# print("django: ", django_stats[key]) - - diff --git a/tests/system/django_spanner/utils.py b/tests/system/django_spanner/utils.py index ad001378f5..189f1fea47 100644 --- a/tests/system/django_spanner/utils.py +++ b/tests/system/django_spanner/utils.py @@ -126,7 +126,7 @@ def setup_database(): creation._create_test_db(verbosity=0, autoclobber=False, keepdb=True) # Running migrations on the db. - #call_command("migrate", interactive=False) + call_command("migrate", interactive=False) def teardown_database(): From ba0faeeb18c3b2a67f177f607751e40507af5fe4 Mon Sep 17 00:00:00 2001 From: Astha Mohta Date: Thu, 8 Jul 2021 01:32:13 +0530 Subject: [PATCH 05/39] changes to number of runs --- tests/performance/django_spanner/test_benchmark.py | 2 +- tests/system/django_spanner/utils.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/performance/django_spanner/test_benchmark.py b/tests/performance/django_spanner/test_benchmark.py index 821bf64e82..27250b34db 100644 --- a/tests/performance/django_spanner/test_benchmark.py +++ b/tests/performance/django_spanner/test_benchmark.py @@ -245,7 +245,7 @@ def test_run(self): spanner_obj = pd.DataFrame(columns = ['insert_one_row_with_fetch_after', 'read_one_row', 'insert_many_rows', 'select_many_rows', 'insert_many_rows_with_mutations']) - for _ in range(2): + for _ in range(50): django_obj = django_obj.append(DjangoBenchmarkTest().run(), ignore_index=True) spanner_obj = spanner_obj.append(SpannerBenchmarkTest().run(), ignore_index=True) diff --git a/tests/system/django_spanner/utils.py b/tests/system/django_spanner/utils.py index 189f1fea47..7fac5166e0 100644 --- a/tests/system/django_spanner/utils.py +++ b/tests/system/django_spanner/utils.py @@ -13,7 +13,7 @@ from google.cloud.spanner_v1 import Client from google.cloud.spanner_v1.instance import Instance, Backup from test_utils.retry import RetryErrors -import pytest + from django_spanner.creation import DatabaseCreation CREATE_INSTANCE = ( @@ -30,7 +30,7 @@ PROJECT_ID = os.getenv("GOOGLE_CLOUD_PROJECT", "emulator-test-project") DATABASE_NAME = os.getenv("DJANGO_SPANNER_DB", "django_test_db") -@pytest.mark.django_db(transaction=True) + class Config(object): """Run-time configuration to be modified at set-up. @@ -47,7 +47,7 @@ class Config(object): def _list_instances(): return list(Config.CLIENT.list_instances()) -@pytest.mark.django_db(transaction=True) + def setup_instance(): if USE_EMULATOR: from google.auth.credentials import AnonymousCredentials @@ -118,7 +118,7 @@ def teardown_instance(): if CREATE_INSTANCE: Config.INSTANCE.delete() -@pytest.mark.django_db(transaction=True) + def setup_database(): Config.DATABASE = Config.INSTANCE.database(DATABASE_NAME) if not Config.DATABASE.exists(): From 155aa13145674c160db86e02d252bdf3e94e6705 Mon Sep 17 00:00:00 2001 From: Astha Mohta Date: Thu, 8 Jul 2021 01:40:31 +0530 Subject: [PATCH 06/39] adding comments --- .../django_spanner/test_benchmark.py | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/tests/performance/django_spanner/test_benchmark.py b/tests/performance/django_spanner/test_benchmark.py index 27250b34db..304cf50ad3 100644 --- a/tests/performance/django_spanner/test_benchmark.py +++ b/tests/performance/django_spanner/test_benchmark.py @@ -64,6 +64,7 @@ def insert_many_rows(transaction, many_rows): class DjangoBenchmarkTest(): + """The Django performace testing class.""" def __init__(self): with connection.schema_editor() as editor: # Create the tables @@ -77,13 +78,6 @@ def __init__(self): num2 = round(random.randint(0,100000000)) self._many_rows2.append(Author(num2, "Pete", "Allison", "2.1")) - def _cleanup(self): - """Drop the test table.""" - with connection.schema_editor() as editor: - # delete the table - editor.delete_model(Author) - # teardown_instance() - @measure_execution_time def insert_one_row_with_fetch_after(self): author_kent = Author( @@ -118,6 +112,12 @@ def select_many_rows(self): if len(rows) != 100: raise ValueError("Wrong number of rows read") + def _cleanup(self): + """Drop the test table.""" + with connection.schema_editor() as editor: + # delete the table + editor.delete_model(Author) + def run(self): """Execute every test case.""" measures = {} @@ -130,13 +130,11 @@ def run(self): ): method(measures) self._cleanup() - # import pdb - # pdb.set_trace() return measures class SpannerBenchmarkTest(): - """The original Spanner performace testing class.""" + """The Spanner performace testing class.""" def __init__(self): self._create_table() self._one_row = ( @@ -236,10 +234,11 @@ def run(self): @pytest.mark.django_db() class BenchmarkTest(unittest.TestCase): - - def test_run(self): + def setUp(self): setup_instance() setup_database() + + def test_run(self): django_obj = pd.DataFrame(columns = ['insert_one_row_with_fetch_after', 'read_one_row', 'insert_many_rows', 'select_many_rows', 'insert_many_rows_with_mutations']) spanner_obj = pd.DataFrame(columns = ['insert_one_row_with_fetch_after', 'read_one_row', 'insert_many_rows', 'select_many_rows', From 25f9a75c6751a484f37f3dcc61962aee9c14e997 Mon Sep 17 00:00:00 2001 From: Astha Mohta Date: Thu, 8 Jul 2021 01:51:56 +0530 Subject: [PATCH 07/39] linting changes --- .../django_spanner/test_benchmark.py | 85 +++++++++++++------ 1 file changed, 58 insertions(+), 27 deletions(-) diff --git a/tests/performance/django_spanner/test_benchmark.py b/tests/performance/django_spanner/test_benchmark.py index 304cf50ad3..1d86e87c48 100644 --- a/tests/performance/django_spanner/test_benchmark.py +++ b/tests/performance/django_spanner/test_benchmark.py @@ -1,7 +1,6 @@ import random import time import unittest -from typing import Any import pandas as pd import pytest @@ -63,8 +62,9 @@ def insert_many_rows(transaction, many_rows): raise ValueError("Wrong number of inserts: " + str(sum(count))) -class DjangoBenchmarkTest(): +class DjangoBenchmarkTest: """The Django performace testing class.""" + def __init__(self): with connection.schema_editor() as editor: # Create the tables @@ -73,18 +73,15 @@ def __init__(self): self._many_rows = [] self._many_rows2 = [] for i in range(99): - num = round(random.randint(0,100000000)) + num = round(random.randint(0, 100000000)) self._many_rows.append(Author(num, "Pete", "Allison", "2.1")) - num2 = round(random.randint(0,100000000)) + num2 = round(random.randint(0, 100000000)) self._many_rows2.append(Author(num2, "Pete", "Allison", "2.1")) @measure_execution_time def insert_one_row_with_fetch_after(self): author_kent = Author( - id=2, - first_name="Pete", - last_name="Allison", - rating="2.1", + id=2, first_name="Pete", last_name="Allison", rating="2.1", ) author_kent.save() last_name = Author.objects.get(pk=author_kent.id).last_name @@ -133,8 +130,9 @@ def run(self): return measures -class SpannerBenchmarkTest(): +class SpannerBenchmarkTest: """The Spanner performace testing class.""" + def __init__(self): self._create_table() self._one_row = ( @@ -150,14 +148,15 @@ def __init__(self): self._many_rows = [] self._many_rows2 = [] for i in range(99): - num = round(random.randint(0,100000000)) + num = round(random.randint(0, 100000000)) self._many_rows.append((num, "Pete", "Allison", "2.1")) - num2 = round(random.randint(0,100000000)) + num2 = round(random.randint(0, 100000000)) self._many_rows2.append((num2, "Pete", "Allison", "2.1")) # initiate a session with self._database.snapshot(): pass + def _create_table(self): """Create a table for performace testing.""" conn = spanner_dbapi.connect(INSTANCE_ID, DATABASE_NAME) @@ -232,6 +231,7 @@ def run(self): self._cleanup() return measures + @pytest.mark.django_db() class BenchmarkTest(unittest.TestCase): def setUp(self): @@ -239,21 +239,52 @@ def setUp(self): setup_database() def test_run(self): - django_obj = pd.DataFrame(columns = ['insert_one_row_with_fetch_after', 'read_one_row', 'insert_many_rows', 'select_many_rows', - 'insert_many_rows_with_mutations']) - spanner_obj = pd.DataFrame(columns = ['insert_one_row_with_fetch_after', 'read_one_row', 'insert_many_rows', 'select_many_rows', - 'insert_many_rows_with_mutations']) + django_obj = pd.DataFrame( + columns=[ + "insert_one_row_with_fetch_after", + "read_one_row", + "insert_many_rows", + "select_many_rows", + "insert_many_rows_with_mutations", + ] + ) + spanner_obj = pd.DataFrame( + columns=[ + "insert_one_row_with_fetch_after", + "read_one_row", + "insert_many_rows", + "select_many_rows", + "insert_many_rows_with_mutations", + ] + ) for _ in range(50): - django_obj = django_obj.append(DjangoBenchmarkTest().run(), ignore_index=True) - spanner_obj = spanner_obj.append(SpannerBenchmarkTest().run(), ignore_index=True) - - avg = pd.concat([django_obj.mean(axis = 0), spanner_obj.mean(axis = 0)], axis=1) - avg.columns=['Django','Spanner'] - std = pd.concat([django_obj.std(axis = 0), spanner_obj.std(axis = 0)], axis=1) - std.columns=['Django','Spanner'] - err = pd.concat([django_obj.sem(axis = 0), spanner_obj.sem(axis = 0)], axis=1) - err.columns=['Django','Spanner'] - - print("Average: ", avg, "Standard Deviation: ", std, "Error:", err, sep='\n') - + django_obj = django_obj.append( + DjangoBenchmarkTest().run(), ignore_index=True + ) + spanner_obj = spanner_obj.append( + SpannerBenchmarkTest().run(), ignore_index=True + ) + + avg = pd.concat( + [django_obj.mean(axis=0), spanner_obj.mean(axis=0)], axis=1 + ) + avg.columns = ["Django", "Spanner"] + std = pd.concat( + [django_obj.std(axis=0), spanner_obj.std(axis=0)], axis=1 + ) + std.columns = ["Django", "Spanner"] + err = pd.concat( + [django_obj.sem(axis=0), spanner_obj.sem(axis=0)], axis=1 + ) + err.columns = ["Django", "Spanner"] + + print( + "Average: ", + avg, + "Standard Deviation: ", + std, + "Error:", + err, + sep="\n", + ) From bd2ae622b6e09ed5be117ed5d0a11cb453f9b74f Mon Sep 17 00:00:00 2001 From: asthamohta Date: Wed, 21 Jul 2021 15:25:23 +0530 Subject: [PATCH 08/39] 3.2 changes --- django_spanner/introspection.py | 1 + django_spanner/utils.py | 14 +++++------ noxfile.py | 8 +++---- tests/unit/django_spanner/test_expressions.py | 4 ++-- tests/unit/django_spanner/test_functions.py | 2 +- .../unit/django_spanner/test_introspection.py | 2 ++ tests/unit/django_spanner/test_lookups.py | 24 +++++++++---------- 7 files changed, 29 insertions(+), 26 deletions(-) diff --git a/django_spanner/introspection.py b/django_spanner/introspection.py index b95ea3e629..f81a3ea953 100644 --- a/django_spanner/introspection.py +++ b/django_spanner/introspection.py @@ -96,6 +96,7 @@ def get_table_description(self, cursor, table_name): None, # scale details.null_ok, None, # default + None, # collation ) ) diff --git a/django_spanner/utils.py b/django_spanner/utils.py index 6fb40db812..de7a91cb05 100644 --- a/django_spanner/utils.py +++ b/django_spanner/utils.py @@ -18,13 +18,13 @@ def check_django_compatability(): """ from . import __version__ - if django.VERSION[:2] != get_version_tuple(__version__)[:2]: - raise ImproperlyConfigured( - "You must use the latest version of django-spanner {A}.{B}.x " - "with Django {A}.{B}.y (found django-spanner {C}).".format( - A=django.VERSION[0], B=django.VERSION[1], C=__version__ - ) - ) + # if django.VERSION[:2] != get_version_tuple(__version__)[:2]: + # raise ImproperlyConfigured( + # "You must use the latest version of django-spanner {A}.{B}.x " + # "with Django {A}.{B}.y (found django-spanner {C}).".format( + # A=django.VERSION[0], B=django.VERSION[1], C=__version__ + # ) + # ) def add_dummy_where(sql): diff --git a/noxfile.py b/noxfile.py index a5c05e7a02..bcfe118ca3 100644 --- a/noxfile.py +++ b/noxfile.py @@ -69,7 +69,7 @@ def lint_setup_py(session): def default(session): # Install all test dependencies, then install this package in-place. session.install( - "django~=2.2", + "django~=3.2", "mock", "mock-import", "pytest", @@ -136,7 +136,7 @@ def system(session): # Install all test dependencies, then install this package into the # virtualenv's dist-packages. session.install( - "django~=2.2", + "django~=3.2", "mock", "pytest", "google-cloud-testutils", @@ -172,7 +172,7 @@ def docs(session): """Build the docs for this library.""" session.install("-e", ".[tracing]") - session.install("sphinx", "alabaster", "recommonmark", "django==2.2") + session.install("sphinx", "alabaster", "recommonmark", "django==3.2") shutil.rmtree(os.path.join("docs", "_build"), ignore_errors=True) # Warnings as errors is disabled for `sphinx-build` because django module @@ -200,7 +200,7 @@ def docfx(session): "alabaster", "recommonmark", "gcp-sphinx-docfx-yaml", - "django==2.2", + "django==3.2", ) shutil.rmtree(os.path.join("docs", "_build"), ignore_errors=True) diff --git a/tests/unit/django_spanner/test_expressions.py b/tests/unit/django_spanner/test_expressions.py index 0efc99ce08..ae5b53f4f8 100644 --- a/tests/unit/django_spanner/test_expressions.py +++ b/tests/unit/django_spanner/test_expressions.py @@ -20,7 +20,7 @@ def test_order_by_sql_query_with_order_by_null_last(self): self.assertEqual( sql_compiled, "SELECT tests_report.name FROM tests_report ORDER BY " - + "tests_report.name IS NULL, tests_report.name DESC", + + "tests_report.name IS NULL, tests_report.name DESC NULLS LAST", ) def test_order_by_sql_query_with_order_by_null_first(self): @@ -32,7 +32,7 @@ def test_order_by_sql_query_with_order_by_null_first(self): self.assertEqual( sql_compiled, "SELECT tests_report.name FROM tests_report ORDER BY " - + "tests_report.name IS NOT NULL, tests_report.name DESC", + + "tests_report.name IS NOT NULL, tests_report.name DESC NULLS FIRST", ) def test_order_by_sql_query_with_order_by_name(self): diff --git a/tests/unit/django_spanner/test_functions.py b/tests/unit/django_spanner/test_functions.py index 00b431b73b..b24a2290e9 100644 --- a/tests/unit/django_spanner/test_functions.py +++ b/tests/unit/django_spanner/test_functions.py @@ -179,7 +179,7 @@ def test_pi(self): self.assertEqual( sql_query, "SELECT tests_author.num FROM tests_author WHERE tests_author.num " - + "= (3.141592653589793)", + + "= 3.141592653589793", ) self.assertEqual(params, ()) diff --git a/tests/unit/django_spanner/test_introspection.py b/tests/unit/django_spanner/test_introspection.py index c90288f3b3..03b5b67ca9 100644 --- a/tests/unit/django_spanner/test_introspection.py +++ b/tests/unit/django_spanner/test_introspection.py @@ -98,6 +98,7 @@ def get_table_column_schema(*args, **kwargs): scale=None, null_ok=False, default=None, + collation=None, ), FieldInfo( name="age", @@ -108,6 +109,7 @@ def get_table_column_schema(*args, **kwargs): scale=None, null_ok=True, default=None, + collation=None, ), ], ) diff --git a/tests/unit/django_spanner/test_lookups.py b/tests/unit/django_spanner/test_lookups.py index 53604691cc..95b8bf29f3 100644 --- a/tests/unit/django_spanner/test_lookups.py +++ b/tests/unit/django_spanner/test_lookups.py @@ -59,7 +59,7 @@ def test_cast_param_to_float_with_no_params_query(self): self.assertEqual( sql_compiled, "SELECT tests_number.num FROM tests_number WHERE " - + "tests_number.item_id = (tests_number.num)", + + "tests_number.item_id = tests_number.num", ) self.assertEqual(params, ()) @@ -111,8 +111,8 @@ def test_startswith_endswith_sql_query_with_bileteral_transform(self): sql_compiled, "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"\\_"))', + + "REPLACE(REPLACE(REPLACE(CONCAT(\'^\', UPPER(%s)), " + +'"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))', ) self.assertEqual(params, ("abc",)) @@ -128,7 +128,7 @@ def test_startswith_endswith_case_insensitive_transform_sql_query(self): sql_compiled, "SELECT tests_author.name FROM tests_author WHERE " + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "REPLACE(REPLACE(REPLACE(CONCAT('^(?i)', (UPPER(%s))), " + + "REPLACE(REPLACE(REPLACE(CONCAT(\'^(?i)\', UPPER(%s)), " + '"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))', ) self.assertEqual(params, ("abc",)) @@ -144,7 +144,7 @@ def test_startswith_endswith_endswith_sql_query_with_transform(self): sql_compiled, "SELECT tests_author.name FROM tests_author WHERE " + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "REPLACE(REPLACE(REPLACE(CONCAT('', (UPPER(%s)), '$'), " + + "REPLACE(REPLACE(REPLACE(CONCAT(\'\', UPPER(%s), \'$\'), " + '"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))', ) self.assertEqual(params, ("abc",)) @@ -183,7 +183,7 @@ def test_regex_sql_query_case_sensitive_with_transform(self): sql_compiled, "SELECT tests_author.num FROM tests_author WHERE " + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "(UPPER(%s)))", + + "UPPER(%s))", ) self.assertEqual(params, ("abc",)) @@ -197,7 +197,7 @@ def test_regex_sql_query_case_insensitive_with_transform(self): sql_compiled, "SELECT tests_author.num FROM tests_author WHERE " + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "CONCAT('(?i)', (UPPER(%s))))", + + "CONCAT('(?i)', UPPER(%s)))", ) self.assertEqual(params, ("abc",)) @@ -236,7 +236,7 @@ def test_contains_sql_query_case_insensitive_transform(self): sql_compiled, "SELECT tests_author.name FROM tests_author WHERE " + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "REPLACE(REPLACE(REPLACE(CONCAT('(?i)', (UPPER(%s))), " + + "REPLACE(REPLACE(REPLACE(CONCAT(\'(?i)\', UPPER(%s)), " + '"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))', ) self.assertEqual(params, ("abc",)) @@ -250,7 +250,7 @@ def test_contains_sql_query_case_sensitive_transform(self): sql_compiled, "SELECT tests_author.name FROM tests_author WHERE " + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + 'REPLACE(REPLACE(REPLACE((UPPER(%s)), "\\\\", "\\\\\\\\"), ' + + 'REPLACE(REPLACE(REPLACE(UPPER(%s), "\\\\", "\\\\\\\\"), ' + '"%%", r"\\%%"), "_", r"\\_"))', ) self.assertEqual(params, ("abc",)) @@ -279,7 +279,7 @@ def test_iexact_sql_query_case_insensitive_function_transform(self): self.assertEqual( sql_compiled, "SELECT tests_author.name FROM tests_author WHERE " - + "REGEXP_CONTAINS((UPPER(tests_author.last_name)), " + + "REGEXP_CONTAINS(UPPER(tests_author.last_name), " + "CONCAT('^(?i)', CAST(UPPER(tests_author.name) AS STRING), '$'))", ) self.assertEqual(params, ()) @@ -293,7 +293,7 @@ def test_iexact_sql_query_case_insensitive_value_match(self): self.assertEqual( sql_compiled, "SELECT tests_author.name FROM tests_author WHERE " - + "REGEXP_CONTAINS((UPPER(CONCAT('^(?i)', " - + "CAST(UPPER(tests_author.name) AS STRING), '$'))), %s)", + + "REGEXP_CONTAINS(UPPER(CONCAT('^(?i)', " + + "CAST(UPPER(tests_author.name) AS STRING), '$')), %s)", ) self.assertEqual(params, ("abc",)) From 9a81474278d00d107504ee4c19c19a249cd37b52 Mon Sep 17 00:00:00 2001 From: asthamohta Date: Wed, 21 Jul 2021 16:26:26 +0530 Subject: [PATCH 09/39] adding version change --- django_spanner/utils.py | 14 +++++++------- version.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/django_spanner/utils.py b/django_spanner/utils.py index de7a91cb05..6fb40db812 100644 --- a/django_spanner/utils.py +++ b/django_spanner/utils.py @@ -18,13 +18,13 @@ def check_django_compatability(): """ from . import __version__ - # if django.VERSION[:2] != get_version_tuple(__version__)[:2]: - # raise ImproperlyConfigured( - # "You must use the latest version of django-spanner {A}.{B}.x " - # "with Django {A}.{B}.y (found django-spanner {C}).".format( - # A=django.VERSION[0], B=django.VERSION[1], C=__version__ - # ) - # ) + if django.VERSION[:2] != get_version_tuple(__version__)[:2]: + raise ImproperlyConfigured( + "You must use the latest version of django-spanner {A}.{B}.x " + "with Django {A}.{B}.y (found django-spanner {C}).".format( + A=django.VERSION[0], B=django.VERSION[1], C=__version__ + ) + ) def add_dummy_where(sql): diff --git a/version.py b/version.py index df949c65d1..3c579da8db 100644 --- a/version.py +++ b/version.py @@ -4,4 +4,4 @@ # license that can be found in the LICENSE file or at # https://developers.google.com/open-source/licenses/bsd -__version__ = "2.2.1b2" +__version__ = "3.2.5" From 069fcc49c8f5c9bd56e5a436b22ec17be3d1145d Mon Sep 17 00:00:00 2001 From: asthamohta Date: Wed, 21 Jul 2021 19:59:46 +0530 Subject: [PATCH 10/39] lint changes and resmoving performance changes --- django_spanner/introspection.py | 2 +- tests/performance/__init__.py | 5 - tests/performance/django_spanner/__init__.py | 0 tests/performance/django_spanner/models.py | 8 - .../django_spanner/test_benchmark.py | 290 ------------------ tests/unit/django_spanner/test_lookups.py | 10 +- 6 files changed, 6 insertions(+), 309 deletions(-) delete mode 100644 tests/performance/__init__.py delete mode 100644 tests/performance/django_spanner/__init__.py delete mode 100644 tests/performance/django_spanner/models.py delete mode 100644 tests/performance/django_spanner/test_benchmark.py diff --git a/django_spanner/introspection.py b/django_spanner/introspection.py index f81a3ea953..95db6723d5 100644 --- a/django_spanner/introspection.py +++ b/django_spanner/introspection.py @@ -96,7 +96,7 @@ def get_table_description(self, cursor, table_name): None, # scale details.null_ok, None, # default - None, # collation + None, # collation ) ) diff --git a/tests/performance/__init__.py b/tests/performance/__init__.py deleted file mode 100644 index 6b607710ed..0000000000 --- a/tests/performance/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# Copyright 2020 Google LLC -# -# Use of this source code is governed by a BSD-style -# license that can be found in the LICENSE file or at -# https://developers.google.com/open-source/licenses/bsd diff --git a/tests/performance/django_spanner/__init__.py b/tests/performance/django_spanner/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/performance/django_spanner/models.py b/tests/performance/django_spanner/models.py deleted file mode 100644 index 83a6bd8273..0000000000 --- a/tests/performance/django_spanner/models.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.db import models - - -class Author(models.Model): - id = models.IntegerField(primary_key=True) - first_name = models.CharField(max_length=20) - last_name = models.CharField(max_length=20) - rating = models.CharField(max_length=50) diff --git a/tests/performance/django_spanner/test_benchmark.py b/tests/performance/django_spanner/test_benchmark.py deleted file mode 100644 index 1d86e87c48..0000000000 --- a/tests/performance/django_spanner/test_benchmark.py +++ /dev/null @@ -1,290 +0,0 @@ -import random -import time -import unittest - -import pandas as pd -import pytest -from django.db import connection -from google.api_core.exceptions import Aborted -from google.cloud import spanner_dbapi -from google.cloud.spanner_v1 import Client, KeySet - -from tests.performance.django_spanner.models import Author -from tests.settings import DATABASE_NAME, INSTANCE_ID -from tests.system.django_spanner.utils import setup_database, setup_instance - - -def measure_execution_time(function): - """Decorator to measure a wrapped method execution time.""" - - def wrapper(self, measures): - """Execute the wrapped method and measure its execution time. - Args: - measures (dict): Test cases and their execution time. - """ - t_start = time.time() - try: - function(self) - measures[function.__name__] = round(time.time() - t_start, 4) - except Aborted: - measures[function.__name__] = 0 - - return wrapper - - -def insert_one_row(transaction, one_row): - """A transaction-function for the original Spanner client. - Inserts a single row into a database and then fetches it back. - """ - transaction.execute_update( - "INSERT Author (id, first_name, last_name, rating) " - " VALUES {}".format(str(one_row)) - ) - last_name = transaction.execute_sql( - "SELECT last_name FROM Author WHERE id=1" - ).one()[0] - if last_name != "Allison": - raise ValueError("Received invalid last name: " + last_name) - - -def insert_many_rows(transaction, many_rows): - """A transaction-function for the original Spanner client. - Insert 100 rows into a database. - """ - statements = [] - for row in many_rows: - statements.append( - "INSERT Author (id, first_name, last_name, rating) " - " VALUES {}".format(str(row)) - ) - _, count = transaction.batch_update(statements) - if sum(count) != 99: - raise ValueError("Wrong number of inserts: " + str(sum(count))) - - -class DjangoBenchmarkTest: - """The Django performace testing class.""" - - def __init__(self): - with connection.schema_editor() as editor: - # Create the tables - editor.create_model(Author) - - self._many_rows = [] - self._many_rows2 = [] - for i in range(99): - num = round(random.randint(0, 100000000)) - self._many_rows.append(Author(num, "Pete", "Allison", "2.1")) - num2 = round(random.randint(0, 100000000)) - self._many_rows2.append(Author(num2, "Pete", "Allison", "2.1")) - - @measure_execution_time - def insert_one_row_with_fetch_after(self): - author_kent = Author( - id=2, first_name="Pete", last_name="Allison", rating="2.1", - ) - author_kent.save() - last_name = Author.objects.get(pk=author_kent.id).last_name - if last_name != "Allison": - raise ValueError("Received invalid last name: " + last_name) - - @measure_execution_time - def insert_many_rows(self): - for row in self._many_rows: - row.save() - - @measure_execution_time - def insert_many_rows_with_mutations(self): - Author.objects.bulk_create(self._many_rows2) - - @measure_execution_time - def read_one_row(self): - row = Author.objects.all().first() - if row is None: - raise ValueError("No rows read") - - @measure_execution_time - def select_many_rows(self): - rows = Author.objects.all() - if len(rows) != 100: - raise ValueError("Wrong number of rows read") - - def _cleanup(self): - """Drop the test table.""" - with connection.schema_editor() as editor: - # delete the table - editor.delete_model(Author) - - def run(self): - """Execute every test case.""" - measures = {} - for method in ( - self.insert_one_row_with_fetch_after, - self.read_one_row, - self.insert_many_rows, - self.select_many_rows, - self.insert_many_rows_with_mutations, - ): - method(measures) - self._cleanup() - return measures - - -class SpannerBenchmarkTest: - """The Spanner performace testing class.""" - - def __init__(self): - self._create_table() - self._one_row = ( - 1, - "Pete", - "Allison", - "2.1", - ) - self._client = Client() - self._instance = self._client.instance(INSTANCE_ID) - self._database = self._instance.database(DATABASE_NAME) - - self._many_rows = [] - self._many_rows2 = [] - for i in range(99): - num = round(random.randint(0, 100000000)) - self._many_rows.append((num, "Pete", "Allison", "2.1")) - num2 = round(random.randint(0, 100000000)) - self._many_rows2.append((num2, "Pete", "Allison", "2.1")) - - # initiate a session - with self._database.snapshot(): - pass - - def _create_table(self): - """Create a table for performace testing.""" - conn = spanner_dbapi.connect(INSTANCE_ID, DATABASE_NAME) - 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) - - conn.close() - - @measure_execution_time - def insert_one_row_with_fetch_after(self): - self._database.run_in_transaction(insert_one_row, self._one_row) - - @measure_execution_time - def insert_many_rows(self): - self._database.run_in_transaction(insert_many_rows, self._many_rows) - - @measure_execution_time - def insert_many_rows_with_mutations(self): - with self._database.batch() as batch: - batch.insert( - table="Author", - columns=("id", "first_name", "last_name", "rating"), - values=self._many_rows2, - ) - - @measure_execution_time - def read_one_row(self): - with self._database.snapshot() as snapshot: - keyset = KeySet(all_=True) - snapshot.read( - table="Author", - columns=("id", "first_name", "last_name", "rating"), - keyset=keyset, - ).one() - - @measure_execution_time - def select_many_rows(self): - with self._database.snapshot() as snapshot: - rows = list( - snapshot.execute_sql("SELECT * FROM Author ORDER BY last_name") - ) - if len(rows) != 100: - raise ValueError("Wrong number of rows read") - - def _cleanup(self): - """Drop the test table.""" - conn = spanner_dbapi.connect(INSTANCE_ID, DATABASE_NAME) - conn.database.update_ddl(["DROP TABLE Author"]) - conn.close() - - def run(self): - """Execute every test case.""" - measures = {} - for method in ( - self.insert_one_row_with_fetch_after, - self.read_one_row, - self.insert_many_rows, - self.select_many_rows, - self.insert_many_rows_with_mutations, - ): - method(measures) - self._cleanup() - return measures - - -@pytest.mark.django_db() -class BenchmarkTest(unittest.TestCase): - def setUp(self): - setup_instance() - setup_database() - - def test_run(self): - django_obj = pd.DataFrame( - columns=[ - "insert_one_row_with_fetch_after", - "read_one_row", - "insert_many_rows", - "select_many_rows", - "insert_many_rows_with_mutations", - ] - ) - spanner_obj = pd.DataFrame( - columns=[ - "insert_one_row_with_fetch_after", - "read_one_row", - "insert_many_rows", - "select_many_rows", - "insert_many_rows_with_mutations", - ] - ) - - for _ in range(50): - django_obj = django_obj.append( - DjangoBenchmarkTest().run(), ignore_index=True - ) - spanner_obj = spanner_obj.append( - SpannerBenchmarkTest().run(), ignore_index=True - ) - - avg = pd.concat( - [django_obj.mean(axis=0), spanner_obj.mean(axis=0)], axis=1 - ) - avg.columns = ["Django", "Spanner"] - std = pd.concat( - [django_obj.std(axis=0), spanner_obj.std(axis=0)], axis=1 - ) - std.columns = ["Django", "Spanner"] - err = pd.concat( - [django_obj.sem(axis=0), spanner_obj.sem(axis=0)], axis=1 - ) - err.columns = ["Django", "Spanner"] - - print( - "Average: ", - avg, - "Standard Deviation: ", - std, - "Error:", - err, - sep="\n", - ) diff --git a/tests/unit/django_spanner/test_lookups.py b/tests/unit/django_spanner/test_lookups.py index 95b8bf29f3..3eb4812a6f 100644 --- a/tests/unit/django_spanner/test_lookups.py +++ b/tests/unit/django_spanner/test_lookups.py @@ -111,8 +111,8 @@ def test_startswith_endswith_sql_query_with_bileteral_transform(self): sql_compiled, "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"\\_"))', + + "REPLACE(REPLACE(REPLACE(CONCAT('^', UPPER(%s)), " + + '"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))', ) self.assertEqual(params, ("abc",)) @@ -128,7 +128,7 @@ def test_startswith_endswith_case_insensitive_transform_sql_query(self): sql_compiled, "SELECT tests_author.name FROM tests_author WHERE " + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "REPLACE(REPLACE(REPLACE(CONCAT(\'^(?i)\', UPPER(%s)), " + + "REPLACE(REPLACE(REPLACE(CONCAT('^(?i)', UPPER(%s)), " + '"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))', ) self.assertEqual(params, ("abc",)) @@ -144,7 +144,7 @@ def test_startswith_endswith_endswith_sql_query_with_transform(self): sql_compiled, "SELECT tests_author.name FROM tests_author WHERE " + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "REPLACE(REPLACE(REPLACE(CONCAT(\'\', UPPER(%s), \'$\'), " + + "REPLACE(REPLACE(REPLACE(CONCAT('', UPPER(%s), '$'), " + '"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))', ) self.assertEqual(params, ("abc",)) @@ -236,7 +236,7 @@ def test_contains_sql_query_case_insensitive_transform(self): sql_compiled, "SELECT tests_author.name FROM tests_author WHERE " + "REGEXP_CONTAINS(CAST(UPPER(tests_author.name) AS STRING), " - + "REPLACE(REPLACE(REPLACE(CONCAT(\'(?i)\', UPPER(%s)), " + + "REPLACE(REPLACE(REPLACE(CONCAT('(?i)', UPPER(%s)), " + '"\\\\", "\\\\\\\\"), "%%", r"\\%%"), "_", r"\\_"))', ) self.assertEqual(params, ("abc",)) From 853526e229c8822f87d73b8cfa019768a501e299 Mon Sep 17 00:00:00 2001 From: asthamohta Date: Wed, 21 Jul 2021 23:42:12 +0530 Subject: [PATCH 11/39] version changes --- django_test_suite.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_test_suite.sh b/django_test_suite.sh index 17173cc2f9..85bc2a1e9b 100755 --- a/django_test_suite.sh +++ b/django_test_suite.sh @@ -18,7 +18,7 @@ mkdir -p $DJANGO_TESTS_DIR if [ $SPANNER_EMULATOR_HOST != 0 ] then pip3 install . - git clone --depth 1 --single-branch --branch "spanner/stable/2.2.x" https://github.com/c24t/django.git $DJANGO_TESTS_DIR/django + git clone --depth 1 --single-branch --branch "spanner/stable/3.2.x" https://github.com/c24t/django.git $DJANGO_TESTS_DIR/django fi # Install dependencies for Django tests. From 11bc9c2b47a3185bd96150ade5cead34d6408b65 Mon Sep 17 00:00:00 2001 From: Bu Sun Kim <8822365+busunkim96@users.noreply.github.com> Date: Thu, 22 Jul 2021 00:46:35 -0600 Subject: [PATCH 12/39] chore: fix release build (#659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: fix release build Fix release build by migrating to secret manager secrets and use templated kokoro configs for docs/ and release/ * fix: fix config names * chore: add populate secrets script * docs: fix license * chore: preserve original year * chore: revert years in manually committed files * 🦉 Updates from OwlBot See https://github.com/googleapis/repo-automation-bots/blob/master/packages/owl-bot/README.md * chore: update lockfile * chore: add .kokoro/docker directory Co-authored-by: Owl Bot --- .github/.OwlBot.lock.yaml | 9 +++++ .github/.OwlBot.yaml | 8 +++++ .kokoro/build.sh | 14 ++------ .kokoro/docker/docs/Dockerfile | 49 +++------------------------ .kokoro/docker/docs/fetch_gpg_keys.sh | 18 +++------- .kokoro/docs/common.cfg | 2 +- .kokoro/populate-secrets.sh | 35 +++++++++++++++++++ .kokoro/publish-docs.sh | 14 ++------ .kokoro/release.sh | 20 +++-------- .kokoro/release/common.cfg | 42 +++-------------------- .kokoro/release/release.cfg | 2 +- .kokoro/trampoline.sh | 31 ++++++++--------- .kokoro/trampoline_v2.sh | 14 ++------ .trampolinerc | 2 +- owlbot.py | 42 +++++++++++++++++++++++ 15 files changed, 139 insertions(+), 163 deletions(-) create mode 100644 .github/.OwlBot.lock.yaml create mode 100644 .github/.OwlBot.yaml create mode 100755 .kokoro/populate-secrets.sh create mode 100644 owlbot.py diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml new file mode 100644 index 0000000000..9ce8396fe0 --- /dev/null +++ b/.github/.OwlBot.lock.yaml @@ -0,0 +1,9 @@ +# Copyright 2021 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd + +docker: + image: gcr.io/cloud-devrel-public-resources/owlbot-python:latest + digest: sha256:82d046d572a11b84d64d4b9af93ad55a1b6a4854917504ba7557acdfce907dde diff --git a/.github/.OwlBot.yaml b/.github/.OwlBot.yaml new file mode 100644 index 0000000000..1bfc664928 --- /dev/null +++ b/.github/.OwlBot.yaml @@ -0,0 +1,8 @@ +# Copyright 2021 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd + +docker: + image: gcr.io/repo-automation-bots/owlbot-python:latest diff --git a/.kokoro/build.sh b/.kokoro/build.sh index 4ac9dd8b76..eea1d84933 100755 --- a/.kokoro/build.sh +++ b/.kokoro/build.sh @@ -1,17 +1,9 @@ #!/bin/bash # Copyright 2020 Google LLC # -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd set -eo pipefail diff --git a/.kokoro/docker/docs/Dockerfile b/.kokoro/docker/docs/Dockerfile index 412b0b56a9..92ade8f99f 100644 --- a/.kokoro/docker/docs/Dockerfile +++ b/.kokoro/docker/docs/Dockerfile @@ -1,16 +1,8 @@ # Copyright 2020 Google LLC # -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd from ubuntu:20.04 @@ -40,6 +32,7 @@ RUN apt-get update \ libssl-dev \ libsqlite3-dev \ portaudio19-dev \ + python3-distutils \ redis-server \ software-properties-common \ ssh \ @@ -59,40 +52,8 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* \ && rm -f /var/cache/apt/archives/*.deb - -COPY fetch_gpg_keys.sh /tmp -# Install the desired versions of Python. -RUN set -ex \ - && export GNUPGHOME="$(mktemp -d)" \ - && echo "disable-ipv6" >> "${GNUPGHOME}/dirmngr.conf" \ - && /tmp/fetch_gpg_keys.sh \ - && for PYTHON_VERSION in 3.7.8 3.8.5; do \ - wget --no-check-certificate -O python-${PYTHON_VERSION}.tar.xz "https://www.python.org/ftp/python/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz" \ - && wget --no-check-certificate -O python-${PYTHON_VERSION}.tar.xz.asc "https://www.python.org/ftp/python/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz.asc" \ - && gpg --batch --verify python-${PYTHON_VERSION}.tar.xz.asc python-${PYTHON_VERSION}.tar.xz \ - && rm -r python-${PYTHON_VERSION}.tar.xz.asc \ - && mkdir -p /usr/src/python-${PYTHON_VERSION} \ - && tar -xJC /usr/src/python-${PYTHON_VERSION} --strip-components=1 -f python-${PYTHON_VERSION}.tar.xz \ - && rm python-${PYTHON_VERSION}.tar.xz \ - && cd /usr/src/python-${PYTHON_VERSION} \ - && ./configure \ - --enable-shared \ - # This works only on Python 2.7 and throws a warning on every other - # version, but seems otherwise harmless. - --enable-unicode=ucs4 \ - --with-system-ffi \ - --without-ensurepip \ - && make -j$(nproc) \ - && make install \ - && ldconfig \ - ; done \ - && rm -rf "${GNUPGHOME}" \ - && rm -rf /usr/src/python* \ - && rm -rf ~/.cache/ - RUN wget -O /tmp/get-pip.py 'https://bootstrap.pypa.io/get-pip.py' \ - && python3.7 /tmp/get-pip.py \ && python3.8 /tmp/get-pip.py \ && rm /tmp/get-pip.py -CMD ["python3.7"] +CMD ["python3.8"] diff --git a/.kokoro/docker/docs/fetch_gpg_keys.sh b/.kokoro/docker/docs/fetch_gpg_keys.sh index d653dd868e..c26695ddf0 100755 --- a/.kokoro/docker/docs/fetch_gpg_keys.sh +++ b/.kokoro/docker/docs/fetch_gpg_keys.sh @@ -1,21 +1,13 @@ #!/bin/bash # Copyright 2020 Google LLC # -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd # A script to fetch gpg keys with retry. # Avoid jinja parsing the file. -# +# function retry { if [[ "${#}" -le 1 ]]; then @@ -42,4 +34,4 @@ retry 3 gpg --keyserver ha.pool.sks-keyservers.net --recv-keys \ retry 3 gpg --keyserver ha.pool.sks-keyservers.net --recv-keys \ E3FF2839C048B25C084DEBE9B26995E310250568 -# +# diff --git a/.kokoro/docs/common.cfg b/.kokoro/docs/common.cfg index 2003193838..cdf8e2a4ef 100644 --- a/.kokoro/docs/common.cfg +++ b/.kokoro/docs/common.cfg @@ -62,4 +62,4 @@ before_action { keyname: "docuploader_service_account" } } -} +} \ No newline at end of file diff --git a/.kokoro/populate-secrets.sh b/.kokoro/populate-secrets.sh new file mode 100755 index 0000000000..7b511c3257 --- /dev/null +++ b/.kokoro/populate-secrets.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Copyright 2020 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd + +set -eo pipefail + +function now { date +"%Y-%m-%d %H:%M:%S" | tr -d '\n' ;} +function msg { println "$*" >&2 ;} +function println { printf '%s\n' "$(now) $*" ;} + + +# Populates requested secrets set in SECRET_MANAGER_KEYS from service account: +# kokoro-trampoline@cloud-devrel-kokoro-resources.iam.gserviceaccount.com +SECRET_LOCATION="${KOKORO_GFILE_DIR}/secret_manager" +msg "Creating folder on disk for secrets: ${SECRET_LOCATION}" +mkdir -p ${SECRET_LOCATION} +for key in $(echo ${SECRET_MANAGER_KEYS} | sed "s/,/ /g") +do + msg "Retrieving secret ${key}" + docker run --entrypoint=gcloud \ + --volume=${KOKORO_GFILE_DIR}:${KOKORO_GFILE_DIR} \ + gcr.io/google.com/cloudsdktool/cloud-sdk \ + secrets versions access latest \ + --project cloud-devrel-kokoro-resources \ + --secret ${key} > \ + "${SECRET_LOCATION}/${key}" + if [[ $? == 0 ]]; then + msg "Secret written to ${SECRET_LOCATION}/${key}" + else + msg "Error retrieving secret ${key}" + fi +done diff --git a/.kokoro/publish-docs.sh b/.kokoro/publish-docs.sh index 8acb14e802..a80ea7c0a0 100755 --- a/.kokoro/publish-docs.sh +++ b/.kokoro/publish-docs.sh @@ -1,17 +1,9 @@ #!/bin/bash # Copyright 2020 Google LLC # -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd set -eo pipefail diff --git a/.kokoro/release.sh b/.kokoro/release.sh index 9f60d5f251..57e2f28ce1 100755 --- a/.kokoro/release.sh +++ b/.kokoro/release.sh @@ -1,19 +1,9 @@ #!/bin/bash # Copyright 2020 Google LLC # -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -#!/bin/bash +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd set -eo pipefail @@ -28,7 +18,7 @@ python3 -m pip install --upgrade twine wheel setuptools export PYTHONUNBUFFERED=1 # Move into the package, build the distribution and upload. -TWINE_PASSWORD=$(cat "${KOKORO_KEYSTORE_DIR}/73713_google_cloud_pypi_password") +TWINE_PASSWORD=$(cat "${KOKORO_GFILE_DIR}/secret_manager/google-cloud-pypi-token") cd github/python-spanner-django python3 setup.py sdist bdist_wheel -twine upload --username gcloudpypi --password "${TWINE_PASSWORD}" dist/* +twine upload --username __token__ --password "${TWINE_PASSWORD}" dist/* diff --git a/.kokoro/release/common.cfg b/.kokoro/release/common.cfg index 31533b977a..2cf743579d 100644 --- a/.kokoro/release/common.cfg +++ b/.kokoro/release/common.cfg @@ -23,42 +23,8 @@ env_vars: { value: "github/python-spanner-django/.kokoro/release.sh" } -# Fetch the token needed for reporting release status to GitHub -before_action { - fetch_keystore { - keystore_resource { - keystore_config_id: 73713 - keyname: "yoshi-automation-github-key" - } - } -} - -# Fetch PyPI password -before_action { - fetch_keystore { - keystore_resource { - keystore_config_id: 73713 - keyname: "google_cloud_pypi_password" - } - } -} - -# Fetch magictoken to use with Magic Github Proxy -before_action { - fetch_keystore { - keystore_resource { - keystore_config_id: 73713 - keyname: "releasetool-magictoken" - } - } -} - -# Fetch api key to use with Magic Github Proxy -before_action { - fetch_keystore { - keystore_resource { - keystore_config_id: 73713 - keyname: "magic-github-proxy-api-key" - } - } +# Tokens needed to report release status back to GitHub +env_vars: { + key: "SECRET_MANAGER_KEYS" + value: "releasetool-publish-reporter-app,releasetool-publish-reporter-googleapis-installation,releasetool-publish-reporter-pem,google-cloud-pypi-token" } diff --git a/.kokoro/release/release.cfg b/.kokoro/release/release.cfg index 18a4c35325..8f43917d92 100644 --- a/.kokoro/release/release.cfg +++ b/.kokoro/release/release.cfg @@ -1 +1 @@ -# Format: //devtools/kokoro/config/proto/build.proto +# Format: //devtools/kokoro/config/proto/build.proto \ No newline at end of file diff --git a/.kokoro/trampoline.sh b/.kokoro/trampoline.sh index 8f0237f322..d42a40acbb 100755 --- a/.kokoro/trampoline.sh +++ b/.kokoro/trampoline.sh @@ -1,23 +1,20 @@ #!/bin/bash -# Copyright 2020 Google LLC. +# Copyright 2017 Google LLC # -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd set -eo pipefail -python3 "${KOKORO_GFILE_DIR}/trampoline_v1.py" || ret_code=$? - -chmod +x ${KOKORO_GFILE_DIR}/trampoline_cleanup.sh -${KOKORO_GFILE_DIR}/trampoline_cleanup.sh || true +# Always run the cleanup script, regardless of the success of bouncing into +# the container. +function cleanup() { + chmod +x ${KOKORO_GFILE_DIR}/trampoline_cleanup.sh + ${KOKORO_GFILE_DIR}/trampoline_cleanup.sh + echo "cleanup"; +} +trap cleanup EXIT -exit ${ret_code} +$(dirname $0)/populate-secrets.sh # Secret Manager secrets. +python3 "${KOKORO_GFILE_DIR}/trampoline_v1.py" \ No newline at end of file diff --git a/.kokoro/trampoline_v2.sh b/.kokoro/trampoline_v2.sh index 4af6cdc26d..591faf816e 100755 --- a/.kokoro/trampoline_v2.sh +++ b/.kokoro/trampoline_v2.sh @@ -1,17 +1,9 @@ #!/usr/bin/env bash # Copyright 2020 Google LLC # -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd # trampoline_v2.sh # diff --git a/.trampolinerc b/.trampolinerc index 28037bbb86..383b6ec89f 100644 --- a/.trampolinerc +++ b/.trampolinerc @@ -1,4 +1,4 @@ -# Copyright 2021 Google LLC +# Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/owlbot.py b/owlbot.py new file mode 100644 index 0000000000..d0087b4c0b --- /dev/null +++ b/owlbot.py @@ -0,0 +1,42 @@ +# Copyright 2021 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd + +"""This script is used to synthesize generated parts of this library.""" +import re + +import synthtool as s +import synthtool.gcp as gcp + +# ---------------------------------------------------------------------------- +# Add templated files +# ---------------------------------------------------------------------------- +templated_files = gcp.CommonTemplates().py_library(microgenerator=True) + +# Just move templates for building docs and releases +# Presubmit and continuous are configured differently +s.move(templated_files / ".trampolinerc") +s.move(templated_files / ".kokoro" / "docker") +s.move(templated_files / ".kokoro" / "docs") +s.move(templated_files / ".kokoro" / "release.sh") +s.move(templated_files / ".kokoro" / "trampoline_v2.sh") +s.move(templated_files / ".kokoro" / "trampoline.sh") +s.move(templated_files / ".kokoro" / "populate-secrets.sh") +s.move(templated_files / ".kokoro" / "release") + +# Replace the Apache Licenses in the `.kokoro` directory +# with the BSD license expected in this repository +s.replace( + ".kokoro/**/*", + "# Copyright.*(\d{4}).*# limitations under the License\.", + """# Copyright \g<1> Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd""", + flags=re.DOTALL +) + +s.shell.run(["nox", "-s", "blacken"], hide_output=False) From 42352c0a0b41b323b40fe81c3f269a4672a81d53 Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Fri, 23 Jul 2021 16:38:04 +0530 Subject: [PATCH 13/39] feat: Added support for check constraint (#679) * feat: Added support for check constraint * fix: change decimal out of scale ProgramingError to ValueError * fix: skip check_constraints tests when running on emmulator * fix: remove check constraint for emulator --- django_spanner/__init__.py | 3 + django_spanner/features.py | 2 +- django_spanner/schema.py | 10 ++- noxfile.py | 2 +- tests/system/django_spanner/models.py | 13 ++++ .../django_spanner/test_check_constraint.py | 64 +++++++++++++++++++ tests/system/django_spanner/test_decimal.py | 11 +--- tests/system/django_spanner/utils.py | 3 +- 8 files changed, 95 insertions(+), 13 deletions(-) create mode 100644 tests/system/django_spanner/test_check_constraint.py diff --git a/django_spanner/__init__.py b/django_spanner/__init__.py index 861e3abb94..a26703d5a5 100644 --- a/django_spanner/__init__.py +++ b/django_spanner/__init__.py @@ -5,6 +5,7 @@ # https://developers.google.com/open-source/licenses/bsd import datetime +import os # Monkey-patch AutoField to generate a random value since Cloud Spanner can't # do that. @@ -24,6 +25,8 @@ __version__ = pkg_resources.get_distribution("django-google-spanner").version +USE_EMULATOR = os.getenv("SPANNER_EMULATOR_HOST") is not None + check_django_compatability() register_expressions() register_functions() diff --git a/django_spanner/features.py b/django_spanner/features.py index af7e4c1131..050ba9c7b9 100644 --- a/django_spanner/features.py +++ b/django_spanner/features.py @@ -184,7 +184,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): "db_functions.comparison.test_cast.CastTests.test_cast_to_decimal_field", "model_fields.test_decimalfield.DecimalFieldTests.test_fetch_from_db_without_float_rounding", "model_fields.test_decimalfield.DecimalFieldTests.test_roundtrip_with_trailing_zeros", - # No CHECK constraints in Spanner. + # Spanner does not support unsigned integer field. "model_fields.test_integerfield.PositiveIntegerFieldTests.test_negative_values", # Spanner doesn't support the variance the standard deviation database # functions: diff --git a/django_spanner/schema.py b/django_spanner/schema.py index d28dcc4f6e..247358857a 100644 --- a/django_spanner/schema.py +++ b/django_spanner/schema.py @@ -7,6 +7,7 @@ from django.db import NotSupportedError from django.db.backends.base.schema import BaseDatabaseSchemaEditor from django_spanner._opentelemetry_tracing import trace_call +from django_spanner import USE_EMULATOR class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): @@ -472,8 +473,13 @@ def _alter_column_type_sql(self, model, old_field, new_field, new_type): ) def _check_sql(self, name, check): - # Spanner doesn't support CHECK constraints. - return None + # Emulator does not support check constraints yet. + if USE_EMULATOR: + return None + return self.sql_constraint % { + "name": self.quote_name(name), + "constraint": self.sql_check_constraint % {"check": check}, + } def _unique_sql(self, model, fields, name, condition=None): # Inline constraints aren't supported, so create the index separately. diff --git a/noxfile.py b/noxfile.py index a5c05e7a02..3b51d73841 100644 --- a/noxfile.py +++ b/noxfile.py @@ -43,7 +43,7 @@ def lint(session): session.run("flake8", "django_spanner", "tests") -@nox.session(python="3.6") +@nox.session(python=DEFAULT_PYTHON_VERSION) def blacken(session): """Run black. diff --git a/tests/system/django_spanner/models.py b/tests/system/django_spanner/models.py index 5524ad8ec9..f7153ba994 100644 --- a/tests/system/django_spanner/models.py +++ b/tests/system/django_spanner/models.py @@ -21,3 +21,16 @@ class Number(models.Model): def __str__(self): return str(self.num) + + +class Event(models.Model): + start_date = models.DateTimeField() + end_date = models.DateTimeField() + + class Meta: + constraints = [ + models.CheckConstraint( + check=models.Q(end_date__gt=models.F("start_date")), + name="check_start_date", + ), + ] diff --git a/tests/system/django_spanner/test_check_constraint.py b/tests/system/django_spanner/test_check_constraint.py new file mode 100644 index 0000000000..9177166ce9 --- /dev/null +++ b/tests/system/django_spanner/test_check_constraint.py @@ -0,0 +1,64 @@ +# Copyright 2021 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd + +from .models import Event +from django.test import TransactionTestCase +import datetime +import unittest +from django.utils import timezone +from google.api_core.exceptions import OutOfRange +from django.db import connection +from django_spanner import USE_EMULATOR +from tests.system.django_spanner.utils import ( + setup_instance, + teardown_instance, + setup_database, + teardown_database, +) + + +@unittest.skipIf( + USE_EMULATOR, "Check Constraint is not implemented in emulator." +) +class TestCheckConstraint(TransactionTestCase): + @classmethod + def setUpClass(cls): + setup_instance() + setup_database() + with connection.schema_editor() as editor: + # Create the table + editor.create_model(Event) + + @classmethod + def tearDownClass(cls): + with connection.schema_editor() as editor: + # delete the table + editor.delete_model(Event) + teardown_database() + teardown_instance() + + def test_insert_valid_value(self): + """ + Tests model object creation with Event model. + """ + now = timezone.now() + now_plus_10 = now + datetime.timedelta(minutes=10) + event_valid = Event(start_date=now, end_date=now_plus_10) + event_valid.save() + qs1 = Event.objects.filter().values("start_date") + self.assertEqual(qs1[0]["start_date"], now) + # Delete data from Event table. + Event.objects.all().delete() + + def test_insert_invalid_value(self): + """ + Tests model object creation with invalid data in Event model. + """ + now = timezone.now() + now_minus_1_day = now - timezone.timedelta(days=1) + event_invalid = Event(start_date=now, end_date=now_minus_1_day) + with self.assertRaises(OutOfRange): + event_invalid.save() diff --git a/tests/system/django_spanner/test_decimal.py b/tests/system/django_spanner/test_decimal.py index 73df7e796b..4155599af1 100644 --- a/tests/system/django_spanner/test_decimal.py +++ b/tests/system/django_spanner/test_decimal.py @@ -6,14 +6,13 @@ from .models import Author, Number from django.test import TransactionTestCase -from django.db import connection, ProgrammingError +from django.db import connection from decimal import Decimal from tests.system.django_spanner.utils import ( setup_instance, teardown_instance, setup_database, teardown_database, - USE_EMULATOR, ) @@ -87,12 +86,8 @@ def test_decimal_precision_limit(self): Tests decimal object precission limit. """ num_val = Number(num=Decimal(1) / Decimal(3)) - if USE_EMULATOR: - with self.assertRaises(ValueError): - num_val.save() - else: - with self.assertRaises(ProgrammingError): - num_val.save() + with self.assertRaises(ValueError): + num_val.save() def test_decimal_update(self): """ diff --git a/tests/system/django_spanner/utils.py b/tests/system/django_spanner/utils.py index 7fac5166e0..3dca9db9b8 100644 --- a/tests/system/django_spanner/utils.py +++ b/tests/system/django_spanner/utils.py @@ -15,11 +15,12 @@ from test_utils.retry import RetryErrors from django_spanner.creation import DatabaseCreation +from django_spanner import USE_EMULATOR CREATE_INSTANCE = ( os.getenv("GOOGLE_CLOUD_TESTS_CREATE_SPANNER_INSTANCE") is not None ) -USE_EMULATOR = os.getenv("SPANNER_EMULATOR_HOST") is not None + SPANNER_OPERATION_TIMEOUT_IN_SECONDS = int( os.getenv("SPANNER_OPERATION_TIMEOUT_IN_SECONDS", 60) ) From 74f22692802337a317d439c97f70c8d000124dda Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Mon, 26 Jul 2021 21:40:33 +0530 Subject: [PATCH 14/39] docs: update docs to show decimal field support and check constraints but no support for unsigned data type (#683) --- README.rst | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/README.rst b/README.rst index e79e23cd91..637ffe6c7e 100644 --- a/README.rst +++ b/README.rst @@ -283,23 +283,16 @@ were created. Spanner does not support ``ON DELETE CASCADE`` when creating foreign-key constraints, so this is not supported in ``django-google-spanner``. -Check constraints aren't supported -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``Unsigned`` datatypes are not supported +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Spanner does not support ``CHECK`` constraints so one isn't created for -`PositiveIntegerField +Spanner does not support ``Unsigned`` datatypes so `PositiveIntegerField `__ -and `CheckConstraint -`__ -can't be used. - -No native support for ``DecimalField`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Spanner's support for `Decimal `__ -types is limited to -`NUMERIC `__ -precision. Higher-precision values can be stored as strings instead. +and `PositiveSmallIntegerField +`__ +are both stored as `Integer type +`__ +. ``Meta.order_with_respect_to`` model option isn't supported ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From f3e8fc2546dd3e1ab62103bb830dc8ef1ec675d3 Mon Sep 17 00:00:00 2001 From: Astha Mohta <35952883+asthamohta@users.noreply.github.com> Date: Tue, 27 Jul 2021 19:07:29 +0530 Subject: [PATCH 15/39] test: Performance Testing (#675) * performance files * test_benchmark * performance testing changes * changes in benchmark performance for prod * changes to number of runs * adding comments * linting changes * changes for 3.2 * Revert "changes for 3.2" This reverts commit 488035c0989595c09f7215c5a03ec6fd42555bda. * adding licence --- tests/performance/__init__.py | 5 + tests/performance/django_spanner/__init__.py | 5 + tests/performance/django_spanner/models.py | 14 + .../django_spanner/test_benchmark.py | 296 ++++++++++++++++++ 4 files changed, 320 insertions(+) create mode 100644 tests/performance/__init__.py create mode 100644 tests/performance/django_spanner/__init__.py create mode 100644 tests/performance/django_spanner/models.py create mode 100644 tests/performance/django_spanner/test_benchmark.py diff --git a/tests/performance/__init__.py b/tests/performance/__init__.py new file mode 100644 index 0000000000..529352b757 --- /dev/null +++ b/tests/performance/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2021 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd diff --git a/tests/performance/django_spanner/__init__.py b/tests/performance/django_spanner/__init__.py new file mode 100644 index 0000000000..529352b757 --- /dev/null +++ b/tests/performance/django_spanner/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2021 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd diff --git a/tests/performance/django_spanner/models.py b/tests/performance/django_spanner/models.py new file mode 100644 index 0000000000..d387a6ef86 --- /dev/null +++ b/tests/performance/django_spanner/models.py @@ -0,0 +1,14 @@ +# Copyright 2021 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd + +from django.db import models + + +class Author(models.Model): + id = models.IntegerField(primary_key=True) + first_name = models.CharField(max_length=20) + last_name = models.CharField(max_length=20) + rating = models.CharField(max_length=50) diff --git a/tests/performance/django_spanner/test_benchmark.py b/tests/performance/django_spanner/test_benchmark.py new file mode 100644 index 0000000000..31dd8f2987 --- /dev/null +++ b/tests/performance/django_spanner/test_benchmark.py @@ -0,0 +1,296 @@ +# Copyright 2021 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd + +import random +import time +import unittest + +import pandas as pd +import pytest +from django.db import connection +from google.api_core.exceptions import Aborted +from google.cloud import spanner_dbapi +from google.cloud.spanner_v1 import Client, KeySet + +from tests.performance.django_spanner.models import Author +from tests.settings import DATABASE_NAME, INSTANCE_ID +from tests.system.django_spanner.utils import setup_database, setup_instance + + +def measure_execution_time(function): + """Decorator to measure a wrapped method execution time.""" + + def wrapper(self, measures): + """Execute the wrapped method and measure its execution time. + Args: + measures (dict): Test cases and their execution time. + """ + t_start = time.time() + try: + function(self) + measures[function.__name__] = round(time.time() - t_start, 4) + except Aborted: + measures[function.__name__] = 0 + + return wrapper + + +def insert_one_row(transaction, one_row): + """A transaction-function for the original Spanner client. + Inserts a single row into a database and then fetches it back. + """ + transaction.execute_update( + "INSERT Author (id, first_name, last_name, rating) " + " VALUES {}".format(str(one_row)) + ) + last_name = transaction.execute_sql( + "SELECT last_name FROM Author WHERE id=1" + ).one()[0] + if last_name != "Allison": + raise ValueError("Received invalid last name: " + last_name) + + +def insert_many_rows(transaction, many_rows): + """A transaction-function for the original Spanner client. + Insert 100 rows into a database. + """ + statements = [] + for row in many_rows: + statements.append( + "INSERT Author (id, first_name, last_name, rating) " + " VALUES {}".format(str(row)) + ) + _, count = transaction.batch_update(statements) + if sum(count) != 99: + raise ValueError("Wrong number of inserts: " + str(sum(count))) + + +class DjangoBenchmarkTest: + """The Django performace testing class.""" + + def __init__(self): + with connection.schema_editor() as editor: + # Create the tables + editor.create_model(Author) + + self._many_rows = [] + self._many_rows2 = [] + for i in range(99): + num = round(random.randint(0, 100000000)) + self._many_rows.append(Author(num, "Pete", "Allison", "2.1")) + num2 = round(random.randint(0, 100000000)) + self._many_rows2.append(Author(num2, "Pete", "Allison", "2.1")) + + @measure_execution_time + def insert_one_row_with_fetch_after(self): + author_kent = Author( + id=2, first_name="Pete", last_name="Allison", rating="2.1", + ) + author_kent.save() + last_name = Author.objects.get(pk=author_kent.id).last_name + if last_name != "Allison": + raise ValueError("Received invalid last name: " + last_name) + + @measure_execution_time + def insert_many_rows(self): + for row in self._many_rows: + row.save() + + @measure_execution_time + def insert_many_rows_with_mutations(self): + Author.objects.bulk_create(self._many_rows2) + + @measure_execution_time + def read_one_row(self): + row = Author.objects.all().first() + if row is None: + raise ValueError("No rows read") + + @measure_execution_time + def select_many_rows(self): + rows = Author.objects.all() + if len(rows) != 100: + raise ValueError("Wrong number of rows read") + + def _cleanup(self): + """Drop the test table.""" + with connection.schema_editor() as editor: + # delete the table + editor.delete_model(Author) + + def run(self): + """Execute every test case.""" + measures = {} + for method in ( + self.insert_one_row_with_fetch_after, + self.read_one_row, + self.insert_many_rows, + self.select_many_rows, + self.insert_many_rows_with_mutations, + ): + method(measures) + self._cleanup() + return measures + + +class SpannerBenchmarkTest: + """The Spanner performace testing class.""" + + def __init__(self): + self._create_table() + self._one_row = ( + 1, + "Pete", + "Allison", + "2.1", + ) + self._client = Client() + self._instance = self._client.instance(INSTANCE_ID) + self._database = self._instance.database(DATABASE_NAME) + + self._many_rows = [] + self._many_rows2 = [] + for i in range(99): + num = round(random.randint(0, 100000000)) + self._many_rows.append((num, "Pete", "Allison", "2.1")) + num2 = round(random.randint(0, 100000000)) + self._many_rows2.append((num2, "Pete", "Allison", "2.1")) + + # initiate a session + with self._database.snapshot(): + pass + + def _create_table(self): + """Create a table for performace testing.""" + conn = spanner_dbapi.connect(INSTANCE_ID, DATABASE_NAME) + 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) + + conn.close() + + @measure_execution_time + def insert_one_row_with_fetch_after(self): + self._database.run_in_transaction(insert_one_row, self._one_row) + + @measure_execution_time + def insert_many_rows(self): + self._database.run_in_transaction(insert_many_rows, self._many_rows) + + @measure_execution_time + def insert_many_rows_with_mutations(self): + with self._database.batch() as batch: + batch.insert( + table="Author", + columns=("id", "first_name", "last_name", "rating"), + values=self._many_rows2, + ) + + @measure_execution_time + def read_one_row(self): + with self._database.snapshot() as snapshot: + keyset = KeySet(all_=True) + snapshot.read( + table="Author", + columns=("id", "first_name", "last_name", "rating"), + keyset=keyset, + ).one() + + @measure_execution_time + def select_many_rows(self): + with self._database.snapshot() as snapshot: + rows = list( + snapshot.execute_sql("SELECT * FROM Author ORDER BY last_name") + ) + if len(rows) != 100: + raise ValueError("Wrong number of rows read") + + def _cleanup(self): + """Drop the test table.""" + conn = spanner_dbapi.connect(INSTANCE_ID, DATABASE_NAME) + conn.database.update_ddl(["DROP TABLE Author"]) + conn.close() + + def run(self): + """Execute every test case.""" + measures = {} + for method in ( + self.insert_one_row_with_fetch_after, + self.read_one_row, + self.insert_many_rows, + self.select_many_rows, + self.insert_many_rows_with_mutations, + ): + method(measures) + self._cleanup() + return measures + + +@pytest.mark.django_db() +class BenchmarkTest(unittest.TestCase): + def setUp(self): + setup_instance() + setup_database() + + def test_run(self): + django_obj = pd.DataFrame( + columns=[ + "insert_one_row_with_fetch_after", + "read_one_row", + "insert_many_rows", + "select_many_rows", + "insert_many_rows_with_mutations", + ] + ) + spanner_obj = pd.DataFrame( + columns=[ + "insert_one_row_with_fetch_after", + "read_one_row", + "insert_many_rows", + "select_many_rows", + "insert_many_rows_with_mutations", + ] + ) + + for _ in range(50): + django_obj = django_obj.append( + DjangoBenchmarkTest().run(), ignore_index=True + ) + spanner_obj = spanner_obj.append( + SpannerBenchmarkTest().run(), ignore_index=True + ) + + avg = pd.concat( + [django_obj.mean(axis=0), spanner_obj.mean(axis=0)], axis=1 + ) + avg.columns = ["Django", "Spanner"] + std = pd.concat( + [django_obj.std(axis=0), spanner_obj.std(axis=0)], axis=1 + ) + std.columns = ["Django", "Spanner"] + err = pd.concat( + [django_obj.sem(axis=0), spanner_obj.sem(axis=0)], axis=1 + ) + err.columns = ["Django", "Spanner"] + + print( + "Average: ", + avg, + "Standard Deviation: ", + std, + "Error:", + err, + sep="\n", + ) From 96a809d88968cc7691bf0587c9ff694c4948998c Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Tue, 27 Jul 2021 22:39:48 +0530 Subject: [PATCH 16/39] chore: release 2.2.1b2 (#685) Release-As: 2.2.1b2 From f5bf52378fcff38caf075052b46962d5e012ea96 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 28 Jul 2021 20:33:03 +0530 Subject: [PATCH 17/39] chore: release 2.2.1b2 (#687) * chore: release 2.2.1b2 * Updated CHANGELOG.md Corrected the change log msg. Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> Co-authored-by: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d1d5d4875..b4b5b1d1cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 2.2.1b2 (2021-07-27) + + +### Features +- Added support for check constraint ([#679](https://www.github.com/googleapis/python-spanner-django/issues/679)) ([42352c0](https://www.github.com/googleapis/python-spanner-django/commit/42352c0)) +- Add open telemetry trace in schema and related unit tests ([#648](https://www.github.com/googleapis/python-spanner-django/issues/648)) ([fc51086](https://www.github.com/googleapis/python-spanner-django/commit/fc51086)) + + +### Bug Fixes +- updated assets to have text background so it works with dark mode ([#674](https://www.github.com/googleapis/python-spanner-django/issues/674)) ([306eeba](https://www.github.com/googleapis/python-spanner-django/commit/306eeba)) +- updated assets to have text background so it works with dark mode ([#671](https://www.github.com/googleapis/python-spanner-django/issues/671)) ([0f99938](https://www.github.com/googleapis/python-spanner-django/commit/0f99938)) +- bump version number after 2.2.1b1 release ([#652](https://www.github.com/googleapis/python-spanner-django/issues/652)) ([287b893](https://www.github.com/googleapis/python-spanner-django/commit/287b893)) + + +### Documentation +- update docs to show decimal field support and check constraints but no support for unsigned data type ([#683](https://www.github.com/googleapis/python-spanner-django/issues/683)) ([74f2269](https://www.github.com/googleapis/python-spanner-django/commit/74f2269)) +- Adding documentation for GA ([#665](https://www.github.com/googleapis/python-spanner-django/issues/665)) ([216c2e0](https://www.github.com/googleapis/python-spanner-django/commit/216c2e0)) + + +### Miscellaneous Chores +- release 2.2.1b2 ([#685](https://www.github.com/googleapis/python-spanner-django/issues/685)) ([96a809d](https://www.github.com/googleapis/python-spanner-django/commit/96a809d)) +- fix release build ([#659](https://www.github.com/googleapis/python-spanner-django/issues/659)) ([11bc9c2](https://www.github.com/googleapis/python-spanner-django/commit/11bc9c2)) + ## 2.2.1b1 (2021-06-17) From 5f9750e4c934ac09c1dc88438063b120ed47fd3e Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Thu, 29 Jul 2021 09:47:07 +0530 Subject: [PATCH 18/39] fix: Bump version number after 2.2.1b2 release (#688) --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index df949c65d1..f270988226 100644 --- a/version.py +++ b/version.py @@ -4,4 +4,4 @@ # license that can be found in the LICENSE file or at # https://developers.google.com/open-source/licenses/bsd -__version__ = "2.2.1b2" +__version__ = "2.2.1b3" From 2144d09901b902a1bc5b4e9ec8483538bb721ba5 Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Fri, 30 Jul 2021 09:32:49 +0530 Subject: [PATCH 19/39] chor: Update repo to say beta release instead of alpha (#691) * fix: Bump version number after 2.2.1b2 release * Update setup.py Current release is beta so updating the same in setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c310fda167..a3143566bb 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ # 'Development Status :: 3 - Alpha' # 'Development Status :: 4 - Beta' # 'Development Status :: 5 - Production/Stable' -release_status = "Development Status :: 3 - Alpha" +release_status = "Development Status :: 4 - Beta" dependencies = ["sqlparse >= 0.3.0", "google-cloud-spanner >= 3.0.0"] extras = { "tracing": [ From e1caf284db1e92e9a73fb824fbd7463ad863ec6e Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Fri, 30 Jul 2021 15:03:47 +0530 Subject: [PATCH 20/39] chore: release 2.2.1b3 (#693) Release-As: 2.2.1b3 From c86dd2664f0bd6dd9a04e962eacb0fdf5be33fb7 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Fri, 30 Jul 2021 23:22:08 +0530 Subject: [PATCH 21/39] chore: release 2.2.1b3 (#694) * chore: release 2.2.1b3 * Updated CHANGELOG.md Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> Co-authored-by: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4b5b1d1cb..a370dd14c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2.2.1b3 (2021-07-30) + +### Miscellaneous Chores +- release 2.2.1b3 ([de23f65](https://www.github.com/googleapis/python-spanner-django/commit/de23f65)) +- Update repo to say beta release instead of alpha ([#691](https://www.github.com/googleapis/python-spanner-django/issues/691)) ([2144d09](https://www.github.com/googleapis/python-spanner-django/commit/2144d09)) + + ## 2.2.1b2 (2021-07-27) From a8f2aac06929152067e39b5aac1cebfd74ef5337 Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Tue, 3 Aug 2021 14:46:08 +0530 Subject: [PATCH 22/39] fix: Bump version number after 2.2.1b3 release (#696) * chore: release 2.2.1b3 Release-As: 2.2.1b3 * fix: Bump version number after 2.2.1b3 release * fix: add test samples script and related kokoro files * fix: correct repo name from python-spanner to python-spanner-django * fix: correct license from Apache to BSD style --- .kokoro/samples/lint/common.cfg | 34 ++++++++ .kokoro/samples/lint/continuous.cfg | 6 ++ .kokoro/samples/lint/periodic.cfg | 6 ++ .kokoro/samples/lint/presubmit.cfg | 6 ++ .kokoro/samples/python3.6/common.cfg | 40 +++++++++ .kokoro/samples/python3.6/continuous.cfg | 7 ++ .kokoro/samples/python3.6/periodic-head.cfg | 11 +++ .kokoro/samples/python3.6/periodic.cfg | 6 ++ .kokoro/samples/python3.6/presubmit.cfg | 6 ++ .kokoro/samples/python3.7/common.cfg | 40 +++++++++ .kokoro/samples/python3.7/continuous.cfg | 6 ++ .kokoro/samples/python3.7/periodic-head.cfg | 11 +++ .kokoro/samples/python3.7/periodic.cfg | 6 ++ .kokoro/samples/python3.7/presubmit.cfg | 6 ++ .kokoro/samples/python3.8/common.cfg | 40 +++++++++ .kokoro/samples/python3.8/continuous.cfg | 6 ++ .kokoro/samples/python3.8/periodic-head.cfg | 11 +++ .kokoro/samples/python3.8/periodic.cfg | 6 ++ .kokoro/samples/python3.8/presubmit.cfg | 6 ++ .kokoro/samples/python3.9/common.cfg | 40 +++++++++ .kokoro/samples/python3.9/continuous.cfg | 6 ++ .kokoro/samples/python3.9/periodic-head.cfg | 11 +++ .kokoro/samples/python3.9/periodic.cfg | 6 ++ .kokoro/samples/python3.9/presubmit.cfg | 6 ++ .kokoro/test-samples-against-head.sh | 21 +++++ .kokoro/test-samples-impl.sh | 94 +++++++++++++++++++++ .kokoro/test-samples.sh | 39 +++++++++ version.py | 2 +- 28 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 .kokoro/samples/lint/common.cfg create mode 100644 .kokoro/samples/lint/continuous.cfg create mode 100644 .kokoro/samples/lint/periodic.cfg create mode 100644 .kokoro/samples/lint/presubmit.cfg create mode 100644 .kokoro/samples/python3.6/common.cfg create mode 100644 .kokoro/samples/python3.6/continuous.cfg create mode 100644 .kokoro/samples/python3.6/periodic-head.cfg create mode 100644 .kokoro/samples/python3.6/periodic.cfg create mode 100644 .kokoro/samples/python3.6/presubmit.cfg create mode 100644 .kokoro/samples/python3.7/common.cfg create mode 100644 .kokoro/samples/python3.7/continuous.cfg create mode 100644 .kokoro/samples/python3.7/periodic-head.cfg create mode 100644 .kokoro/samples/python3.7/periodic.cfg create mode 100644 .kokoro/samples/python3.7/presubmit.cfg create mode 100644 .kokoro/samples/python3.8/common.cfg create mode 100644 .kokoro/samples/python3.8/continuous.cfg create mode 100644 .kokoro/samples/python3.8/periodic-head.cfg create mode 100644 .kokoro/samples/python3.8/periodic.cfg create mode 100644 .kokoro/samples/python3.8/presubmit.cfg create mode 100644 .kokoro/samples/python3.9/common.cfg create mode 100644 .kokoro/samples/python3.9/continuous.cfg create mode 100644 .kokoro/samples/python3.9/periodic-head.cfg create mode 100644 .kokoro/samples/python3.9/periodic.cfg create mode 100644 .kokoro/samples/python3.9/presubmit.cfg create mode 100755 .kokoro/test-samples-against-head.sh create mode 100755 .kokoro/test-samples-impl.sh create mode 100755 .kokoro/test-samples.sh diff --git a/.kokoro/samples/lint/common.cfg b/.kokoro/samples/lint/common.cfg new file mode 100644 index 0000000000..b42e774271 --- /dev/null +++ b/.kokoro/samples/lint/common.cfg @@ -0,0 +1,34 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Build logs will be here +action { + define_artifacts { + regex: "**/*sponge_log.xml" + } +} + +# Specify which tests to run +env_vars: { + key: "RUN_TESTS_SESSION" + value: "lint" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-spanner-django/.kokoro/test-samples.sh" +} + +# Configure the docker image for kokoro-trampoline. +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/python-samples-testing-docker" +} + +# Download secrets for samples +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/python-docs-samples" + +# Download trampoline resources. +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" + +# Use the trampoline script to run in docker. +build_file: "python-spanner-django/.kokoro/trampoline.sh" \ No newline at end of file diff --git a/.kokoro/samples/lint/continuous.cfg b/.kokoro/samples/lint/continuous.cfg new file mode 100644 index 0000000000..a1c8d9759c --- /dev/null +++ b/.kokoro/samples/lint/continuous.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/lint/periodic.cfg b/.kokoro/samples/lint/periodic.cfg new file mode 100644 index 0000000000..50fec96497 --- /dev/null +++ b/.kokoro/samples/lint/periodic.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "False" +} \ No newline at end of file diff --git a/.kokoro/samples/lint/presubmit.cfg b/.kokoro/samples/lint/presubmit.cfg new file mode 100644 index 0000000000..a1c8d9759c --- /dev/null +++ b/.kokoro/samples/lint/presubmit.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.6/common.cfg b/.kokoro/samples/python3.6/common.cfg new file mode 100644 index 0000000000..6e32d40d20 --- /dev/null +++ b/.kokoro/samples/python3.6/common.cfg @@ -0,0 +1,40 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Build logs will be here +action { + define_artifacts { + regex: "**/*sponge_log.xml" + } +} + +# Specify which tests to run +env_vars: { + key: "RUN_TESTS_SESSION" + value: "py-3.6" +} + +# Declare build specific Cloud project. +env_vars: { + key: "BUILD_SPECIFIC_GCLOUD_PROJECT" + value: "python-docs-samples-tests-py36" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-spanner-django/.kokoro/test-samples.sh" +} + +# Configure the docker image for kokoro-trampoline. +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/python-samples-testing-docker" +} + +# Download secrets for samples +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/python-docs-samples" + +# Download trampoline resources. +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" + +# Use the trampoline script to run in docker. +build_file: "python-spanner-django/.kokoro/trampoline.sh" \ No newline at end of file diff --git a/.kokoro/samples/python3.6/continuous.cfg b/.kokoro/samples/python3.6/continuous.cfg new file mode 100644 index 0000000000..7218af1499 --- /dev/null +++ b/.kokoro/samples/python3.6/continuous.cfg @@ -0,0 +1,7 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} + diff --git a/.kokoro/samples/python3.6/periodic-head.cfg b/.kokoro/samples/python3.6/periodic-head.cfg new file mode 100644 index 0000000000..f9cfcd33e0 --- /dev/null +++ b/.kokoro/samples/python3.6/periodic-head.cfg @@ -0,0 +1,11 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-pubsub/.kokoro/test-samples-against-head.sh" +} diff --git a/.kokoro/samples/python3.6/periodic.cfg b/.kokoro/samples/python3.6/periodic.cfg new file mode 100644 index 0000000000..50fec96497 --- /dev/null +++ b/.kokoro/samples/python3.6/periodic.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "False" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.6/presubmit.cfg b/.kokoro/samples/python3.6/presubmit.cfg new file mode 100644 index 0000000000..a1c8d9759c --- /dev/null +++ b/.kokoro/samples/python3.6/presubmit.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.7/common.cfg b/.kokoro/samples/python3.7/common.cfg new file mode 100644 index 0000000000..407c854ff0 --- /dev/null +++ b/.kokoro/samples/python3.7/common.cfg @@ -0,0 +1,40 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Build logs will be here +action { + define_artifacts { + regex: "**/*sponge_log.xml" + } +} + +# Specify which tests to run +env_vars: { + key: "RUN_TESTS_SESSION" + value: "py-3.7" +} + +# Declare build specific Cloud project. +env_vars: { + key: "BUILD_SPECIFIC_GCLOUD_PROJECT" + value: "python-docs-samples-tests-py37" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-spanner-django/.kokoro/test-samples.sh" +} + +# Configure the docker image for kokoro-trampoline. +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/python-samples-testing-docker" +} + +# Download secrets for samples +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/python-docs-samples" + +# Download trampoline resources. +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" + +# Use the trampoline script to run in docker. +build_file: "python-spanner-django/.kokoro/trampoline.sh" \ No newline at end of file diff --git a/.kokoro/samples/python3.7/continuous.cfg b/.kokoro/samples/python3.7/continuous.cfg new file mode 100644 index 0000000000..a1c8d9759c --- /dev/null +++ b/.kokoro/samples/python3.7/continuous.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.7/periodic-head.cfg b/.kokoro/samples/python3.7/periodic-head.cfg new file mode 100644 index 0000000000..f9cfcd33e0 --- /dev/null +++ b/.kokoro/samples/python3.7/periodic-head.cfg @@ -0,0 +1,11 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-pubsub/.kokoro/test-samples-against-head.sh" +} diff --git a/.kokoro/samples/python3.7/periodic.cfg b/.kokoro/samples/python3.7/periodic.cfg new file mode 100644 index 0000000000..50fec96497 --- /dev/null +++ b/.kokoro/samples/python3.7/periodic.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "False" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.7/presubmit.cfg b/.kokoro/samples/python3.7/presubmit.cfg new file mode 100644 index 0000000000..a1c8d9759c --- /dev/null +++ b/.kokoro/samples/python3.7/presubmit.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.8/common.cfg b/.kokoro/samples/python3.8/common.cfg new file mode 100644 index 0000000000..07b0e8fddb --- /dev/null +++ b/.kokoro/samples/python3.8/common.cfg @@ -0,0 +1,40 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Build logs will be here +action { + define_artifacts { + regex: "**/*sponge_log.xml" + } +} + +# Specify which tests to run +env_vars: { + key: "RUN_TESTS_SESSION" + value: "py-3.8" +} + +# Declare build specific Cloud project. +env_vars: { + key: "BUILD_SPECIFIC_GCLOUD_PROJECT" + value: "python-docs-samples-tests-py38" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-spanner-django/.kokoro/test-samples.sh" +} + +# Configure the docker image for kokoro-trampoline. +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/python-samples-testing-docker" +} + +# Download secrets for samples +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/python-docs-samples" + +# Download trampoline resources. +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" + +# Use the trampoline script to run in docker. +build_file: "python-spanner-django/.kokoro/trampoline.sh" \ No newline at end of file diff --git a/.kokoro/samples/python3.8/continuous.cfg b/.kokoro/samples/python3.8/continuous.cfg new file mode 100644 index 0000000000..a1c8d9759c --- /dev/null +++ b/.kokoro/samples/python3.8/continuous.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.8/periodic-head.cfg b/.kokoro/samples/python3.8/periodic-head.cfg new file mode 100644 index 0000000000..f9cfcd33e0 --- /dev/null +++ b/.kokoro/samples/python3.8/periodic-head.cfg @@ -0,0 +1,11 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-pubsub/.kokoro/test-samples-against-head.sh" +} diff --git a/.kokoro/samples/python3.8/periodic.cfg b/.kokoro/samples/python3.8/periodic.cfg new file mode 100644 index 0000000000..50fec96497 --- /dev/null +++ b/.kokoro/samples/python3.8/periodic.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "False" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.8/presubmit.cfg b/.kokoro/samples/python3.8/presubmit.cfg new file mode 100644 index 0000000000..a1c8d9759c --- /dev/null +++ b/.kokoro/samples/python3.8/presubmit.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.9/common.cfg b/.kokoro/samples/python3.9/common.cfg new file mode 100644 index 0000000000..0c8833cfa5 --- /dev/null +++ b/.kokoro/samples/python3.9/common.cfg @@ -0,0 +1,40 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Build logs will be here +action { + define_artifacts { + regex: "**/*sponge_log.xml" + } +} + +# Specify which tests to run +env_vars: { + key: "RUN_TESTS_SESSION" + value: "py-3.9" +} + +# Declare build specific Cloud project. +env_vars: { + key: "BUILD_SPECIFIC_GCLOUD_PROJECT" + value: "python-docs-samples-tests-py39" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-spanner-django/.kokoro/test-samples.sh" +} + +# Configure the docker image for kokoro-trampoline. +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/python-samples-testing-docker" +} + +# Download secrets for samples +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/python-docs-samples" + +# Download trampoline resources. +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" + +# Use the trampoline script to run in docker. +build_file: "python-spanner-django/.kokoro/trampoline.sh" \ No newline at end of file diff --git a/.kokoro/samples/python3.9/continuous.cfg b/.kokoro/samples/python3.9/continuous.cfg new file mode 100644 index 0000000000..a1c8d9759c --- /dev/null +++ b/.kokoro/samples/python3.9/continuous.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.9/periodic-head.cfg b/.kokoro/samples/python3.9/periodic-head.cfg new file mode 100644 index 0000000000..f9cfcd33e0 --- /dev/null +++ b/.kokoro/samples/python3.9/periodic-head.cfg @@ -0,0 +1,11 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-pubsub/.kokoro/test-samples-against-head.sh" +} diff --git a/.kokoro/samples/python3.9/periodic.cfg b/.kokoro/samples/python3.9/periodic.cfg new file mode 100644 index 0000000000..50fec96497 --- /dev/null +++ b/.kokoro/samples/python3.9/periodic.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "False" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.9/presubmit.cfg b/.kokoro/samples/python3.9/presubmit.cfg new file mode 100644 index 0000000000..a1c8d9759c --- /dev/null +++ b/.kokoro/samples/python3.9/presubmit.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/test-samples-against-head.sh b/.kokoro/test-samples-against-head.sh new file mode 100755 index 0000000000..104d0e40bb --- /dev/null +++ b/.kokoro/test-samples-against-head.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Copyright 2021 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd + + +# A customized test runner for samples. +# +# For periodic builds, you can specify this file for testing against head. + +# `-e` enables the script to automatically fail when a command fails +# `-o pipefail` sets the exit code to the rightmost comment to exit with a non-zero +set -eo pipefail +# Enables `**` to include files nested inside sub-folders +shopt -s globstar + +cd github/python-spanner-django + +exec .kokoro/test-samples-impl.sh diff --git a/.kokoro/test-samples-impl.sh b/.kokoro/test-samples-impl.sh new file mode 100755 index 0000000000..ac70148661 --- /dev/null +++ b/.kokoro/test-samples-impl.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# Copyright 2021 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd + + +# `-e` enables the script to automatically fail when a command fails +# `-o pipefail` sets the exit code to the rightmost comment to exit with a non-zero +set -eo pipefail +# Enables `**` to include files nested inside sub-folders +shopt -s globstar + +# Exit early if samples don't exist +if ! find samples -name 'requirements.txt' | grep -q .; then + echo "No tests run. './samples/**/requirements.txt' not found" + exit 0 +fi + +# Disable buffering, so that the logs stream through. +export PYTHONUNBUFFERED=1 + +# Debug: show build environment +env | grep KOKORO + +# Install nox +python3.6 -m pip install --upgrade --quiet nox + +# Use secrets acessor service account to get secrets +if [[ -f "${KOKORO_GFILE_DIR}/secrets_viewer_service_account.json" ]]; then + gcloud auth activate-service-account \ + --key-file="${KOKORO_GFILE_DIR}/secrets_viewer_service_account.json" \ + --project="cloud-devrel-kokoro-resources" +fi + +# This script will create 3 files: +# - testing/test-env.sh +# - testing/service-account.json +# - testing/client-secrets.json +./scripts/decrypt-secrets.sh + +source ./testing/test-env.sh +export GOOGLE_APPLICATION_CREDENTIALS=$(pwd)/testing/service-account.json + +# For cloud-run session, we activate the service account for gcloud sdk. +gcloud auth activate-service-account \ + --key-file "${GOOGLE_APPLICATION_CREDENTIALS}" + +export GOOGLE_CLIENT_SECRETS=$(pwd)/testing/client-secrets.json + +echo -e "\n******************** TESTING PROJECTS ********************" + +# Switch to 'fail at end' to allow all tests to complete before exiting. +set +e +# Use RTN to return a non-zero value if the test fails. +RTN=0 +ROOT=$(pwd) +# Find all requirements.txt in the samples directory (may break on whitespace). +for file in samples/**/requirements.txt; do + cd "$ROOT" + # Navigate to the project folder. + file=$(dirname "$file") + cd "$file" + + echo "------------------------------------------------------------" + echo "- testing $file" + echo "------------------------------------------------------------" + + # Use nox to execute the tests for the project. + python3.6 -m nox -s "$RUN_TESTS_SESSION" + EXIT=$? + + # If this is a periodic build, send the test log to the FlakyBot. + # See https://github.com/googleapis/repo-automation-bots/tree/master/packages/flakybot. + if [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"periodic"* ]]; then + chmod +x $KOKORO_GFILE_DIR/linux_amd64/flakybot + $KOKORO_GFILE_DIR/linux_amd64/flakybot + fi + + if [[ $EXIT -ne 0 ]]; then + RTN=1 + echo -e "\n Testing failed: Nox returned a non-zero exit code. \n" + else + echo -e "\n Testing completed.\n" + fi + +done +cd "$ROOT" + +# Workaround for Kokoro permissions issue: delete secrets +rm testing/{test-env.sh,client-secrets.json,service-account.json} + +exit "$RTN" diff --git a/.kokoro/test-samples.sh b/.kokoro/test-samples.sh new file mode 100755 index 0000000000..64bd21dd92 --- /dev/null +++ b/.kokoro/test-samples.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Copyright 2021 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd + + +# The default test runner for samples. +# +# For periodic builds, we rewinds the repo to the latest release, and +# run test-samples-impl.sh. + +# `-e` enables the script to automatically fail when a command fails +# `-o pipefail` sets the exit code to the rightmost comment to exit with a non-zero +set -eo pipefail +# Enables `**` to include files nested inside sub-folders +shopt -s globstar + +cd github/python-spanner-django + +# Run periodic samples tests at latest release +if [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"periodic"* ]]; then + # preserving the test runner implementation. + cp .kokoro/test-samples-impl.sh "${TMPDIR}/test-samples-impl.sh" + echo "--- IMPORTANT IMPORTANT IMPORTANT ---" + echo "Now we rewind the repo back to the latest release..." + LATEST_RELEASE=$(git describe --abbrev=0 --tags) + git checkout $LATEST_RELEASE + echo "The current head is: " + echo $(git rev-parse --verify HEAD) + echo "--- IMPORTANT IMPORTANT IMPORTANT ---" + # move back the test runner implementation if there's no file. + if [ ! -f .kokoro/test-samples-impl.sh ]; then + cp "${TMPDIR}/test-samples-impl.sh" .kokoro/test-samples-impl.sh + fi +fi + +exec .kokoro/test-samples-impl.sh diff --git a/version.py b/version.py index f270988226..32ec82411a 100644 --- a/version.py +++ b/version.py @@ -4,4 +4,4 @@ # license that can be found in the LICENSE file or at # https://developers.google.com/open-source/licenses/bsd -__version__ = "2.2.1b3" +__version__ = "2.2.1b4" From ed404f57ed4a23c7b60f4f9c238f5b5fa9f81d0c Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Fri, 6 Aug 2021 11:00:29 +0530 Subject: [PATCH 23/39] docs: lint fix for samples (#697) --- docs/samples.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/samples.rst b/docs/samples.rst index 4d9ef417a2..3e20575e24 100644 --- a/docs/samples.rst +++ b/docs/samples.rst @@ -13,12 +13,12 @@ the following two models: from django.db import models class Question(models.Model): - question_text = models.CharField(max_length=200) - pub_date = models.DateTimeField('date published') - def __str__(self): - return str(self.rating) + question_text = models.CharField(max_length=200) + pub_date = models.DateTimeField('date published') + def __str__(self): + return str(self.rating) class Choice(models.Model): - question = models.ForeignKey(Question, on_delete=models.CASCADE) - choice_text = models.CharField(max_length=200) - votes = models.IntegerField(default=0) + question = models.ForeignKey(Question, on_delete=models.CASCADE) + choice_text = models.CharField(max_length=200) + votes = models.IntegerField(default=0) From 08b80cef7723819c22dae53c4ff7f45c4fc8c518 Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Thu, 19 Aug 2021 16:39:03 +0530 Subject: [PATCH 24/39] Docs: fix changelog link and sample examples. (#700) * docs: update docs to show decimal field support and check constraints but no support for unsigned data type * docs: linked changelog correctly * docs: fix doc links for sample examples * fix: skip test cursor_executemany_with_empty_params_list as spanner support is not there --- django_spanner/features.py | 2 ++ docs/changelog.md | 2 +- docs/example_from_scratch.md | 1 + docs/example_healthchecks.md | 1 + docs/samples.rst | 30 +++++++++--------------------- 5 files changed, 14 insertions(+), 22 deletions(-) mode change 100644 => 120000 docs/changelog.md create mode 120000 docs/example_from_scratch.md create mode 120000 docs/example_healthchecks.md diff --git a/django_spanner/features.py b/django_spanner/features.py index 050ba9c7b9..a0ae6299c3 100644 --- a/django_spanner/features.py +++ b/django_spanner/features.py @@ -38,6 +38,8 @@ class DatabaseFeatures(BaseDatabaseFeatures): skip_tests = ( # No foreign key constraints in Spanner. "backends.tests.FkConstraintsTests.test_check_constraints", + # Spanner does not support empty list of DML statement. + "backends.tests.BackendTestCase.test_cursor_executemany_with_empty_params_list", "fixtures_regress.tests.TestFixtures.test_loaddata_raises_error_when_fixture_has_invalid_foreign_key", # No Django transaction management in Spanner. "basic.tests.SelectOnSaveTests.test_select_on_save_lying_update", diff --git a/docs/changelog.md b/docs/changelog.md deleted file mode 100644 index 825c32f0d0..0000000000 --- a/docs/changelog.md +++ /dev/null @@ -1 +0,0 @@ -# Changelog diff --git a/docs/changelog.md b/docs/changelog.md new file mode 120000 index 0000000000..04c99a55ca --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1 @@ +../CHANGELOG.md \ No newline at end of file diff --git a/docs/example_from_scratch.md b/docs/example_from_scratch.md new file mode 120000 index 0000000000..1e40292b5e --- /dev/null +++ b/docs/example_from_scratch.md @@ -0,0 +1 @@ +../examples/from-scratch/README.md \ No newline at end of file diff --git a/docs/example_healthchecks.md b/docs/example_healthchecks.md new file mode 120000 index 0000000000..08983d890b --- /dev/null +++ b/docs/example_healthchecks.md @@ -0,0 +1 @@ +../examples/healthchecks/README.md \ No newline at end of file diff --git a/docs/samples.rst b/docs/samples.rst index 3e20575e24..09d8c37590 100644 --- a/docs/samples.rst +++ b/docs/samples.rst @@ -1,24 +1,12 @@ -Sample Code -#################################### +Sample Examples +############### -Create and register your first model -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -To define your database layout create a models file in your app folder and add the relevant -classes to it. Spanner works exactly like any other database you may have used with Django. -Here is a simple example you can run with Spanner. In our poll application below we create -the following two models: +django-spanner for Django tutorial +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. code:: python +This `Example `_ shows how to use django-spanner for Cloud Spanner as a backend database for `healthchecks.io `_ - from django.db import models - - class Question(models.Model): - question_text = models.CharField(max_length=200) - pub_date = models.DateTimeField('date published') - def __str__(self): - return str(self.rating) - - class Choice(models.Model): - question = models.ForeignKey(Question, on_delete=models.CASCADE) - choice_text = models.CharField(max_length=200) - votes = models.IntegerField(default=0) +django-spanner on healthchecks.io +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This `Example `_ shows how to use django-spanner for Cloud Spanner as a backend database for `Django's tutorials `_ From 4643876219e8a54feb94bf14a79f0fe2fbe3971a Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Sat, 21 Aug 2021 23:49:46 +0530 Subject: [PATCH 25/39] docs: update dbapi location in overview asset file (#702) --- assets/overview.png | Bin 48844 -> 62645 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/overview.png b/assets/overview.png index a556727a18d3f0324be1207f21b67fa3a4a65718..1fc2933e518b77bc32b8cd605ec82787901ba660 100644 GIT binary patch literal 62645 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�T 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 literal 48844 zcmeFY1yEkwvM!1Q2^xY22n3hl?ry=|A@JkQk9%-;_YgF}Ex5aD(BMvRcitpx@3q&y z``mNRu6noLeO2eLqH4}Ldi3nsqq|4<*MkswS#e~9HwX|A5Xh1eB8m_Y&|e@RAoJm2 zfhWe7oOKWos0nV$>JEzf&P29g8)Gv|BO(V^TO%SP7c*lB2$%Vi6bmOjZWR9~d(5gA zP^8!DC|TP>D8@Hu*<{>yVCMR$3WJ)Td&Y5;pTD*FpJjP3AJcn*F_7oXX`18vitj>l*=5^# zkY!Fiy#4g*#1h05D?o%LNiJ%jQbOES;WPU2b3WCt7f_6o!|z!OlpzYf@~63f$V=2ap>z!Ze(CzZh2zN}O{PL}r?Y*hw7QXTQg610os6~p3|3%_oe*Gy?@9XM7` zfvZ$Ct!S&4#AY&y#DC?@EV&(lj>p>j{2Jk~!xRd5@Ig(Py2m^Sr0d--W+DgQOrNj#mu{i| z!ZqrsOZ^&WYAYEE@{Q1LgG zS`-|DoUK07A9$V)))!v7K8#{}XTBtyfkMs>L>$E;ZtkO}EXi-&(!uZu6lq9ogh%m+ zv96+esN6F9Bt5=2lxNwzSByJpY1U6gIvL#@-G#LvJ5Ao5b4>WPsHkn2HfxShvEO+g zBhVVpy)9I9i@LFZRr1K|+qolWzFq%uT?L;g5&KEE^BB7jtQk{LXv#~xFNqs^+rbyf*4&%2KAAO;&Z`_2W0hs4sdiBf zqbgI-W3F40%|euErQ7mqAK?_yErh=%vkkP^lbGeZ_3o^;7e%GD_u=-i5hP;nIuNNK zt=A{YP8BH)7dTf7D%1=iv7mg-n+X9rC$Q8-rJ`7;YH)^9OG!cxSnG6``Y< zG2C+k(qTX6x>A9S`=JK;x-vdie2;L@|E8gJM(1x3>@Iqrr&5>Itt&B`xcAYlJ0_fn zd~-;7e`m9OHYzau=mXliFpIc(xWGGgb?E%>q>OGt2#2KV`mueJD25R3LhvH5YRMzB zGf1EtY(C90NHY)#9Qt6kPv#5as>1bJ>%73zLUYc5-6oq>y{zJ5>HD~b*gNTara^pD z;y5hkN5!2;eFyQd4=*J5f_s|VzH}Ht7WcG;CpT*TD>nyzE;m_Nj_;^A_^Z^b53NIK zO&eh8ahyE3y^c77?>~4o-Yp_v^=+DO#Py~YklP&vpmXIWyr1=N%%%)N3-e3jp6H}w zHK`i`3GhtEILZ5%-!KViA~vcfaxu5jV!IE&=nP|BLFwe;i(&Lmcm zfuz&U9t<@>*4ym( z@Q~DojAXluz7(h2;o|Xv+rlwe&^5+@ESef8v@@T=BlcBO@i~C!cwPB%Ask1L?)Zbs zOV{h}^El7*s;`-pARTbWFS_j7z%|e@N{h4Wz>09r!2*pV48MUGB|eqUUP()N1<6zS z8!KYOp;yyyx{4IO(+yax?I(Qbz(-8_=syFcF-tu#N8_T4jC&$|s=qJHssM#!2@iKF zdp`9uSUk?r#AkbxczXu=sW~XXeBAT+5T2+c0I&Mz18#H|P8i#&-Mi2}jvPKj-DC!) zZ?p!Blq4X!09B0Xo{p6S_DNY~rhDI65mDqA1tBGdr9o!l#Zt-$=(e`RuEuC)>qI3= z5DStN=yBM7ESme2yY^R4@3v%#Rj0xmWT+qSktQq5tkbwO$r_c&nbQw>F}@NieQCJ+ zHiEzdkE!QBm>99g8X>K3dTfHH;ulWwx)~GHwo(W-HHsmc;^cjdq{CuI@2oqelkBd% zgBm;U#~8r6B(h?FH96{q7&lRo@Y!p%9;q>z$fn3mACY;7nUI&k3Py`fL$y!v9If7u zuVZLJZCyzxzF&rJ5R6^fk~ctDuGK4rg0637z&)mCO&IQn3~7VMQ1)Q*{2-0w&NuVb zrB&e7P47?FBM}?b@L+wjF&Pg9M<<7gUiG#{O8t~xa^@c6_pt&aAbWXDR10!4w69$V zi(tYP6B#VKWorhm)lkm{8=HEL#Rl#US;}nMU}7Z{uXQoxlwl&N%XRM+HA7^?LLN_g zWxrz)$%s91ej=0k0ktQgOTE3X0ioT;X|-2a7`RN0T6fbwU+w%_$iV3iV8TyN>wt>+ zSqDdOfUF{d3Z20Y!$enN!=w$XX^55;b3z?Il=l;FUMeRN9Tf2@xKW;OZ2DEr8}z9$ z>%wbn2r_<1JLKt!e4C#uvas_>j1-e=PM7JWISvf!cbgF*^)C7zAvL)WDqmIvLcZ30 zR1qD^l!R^LXa*zaImJ9m^D`=ZhUpYmuDh9ld%JLhplny;;-(cB9`uO<{o3hix&TE10~#wQ(6j;cgHS|R){X`0QqFL%ibUHk?EcvkqE5{sM->F%}z!_eE7C8FW6akXrgfx-W))3|zg^GEf98AH{yXLbJ zA(lcg^h#LhO?4gjM4=0o=>47OI4mskIFS$ghR}FvKuhP=z(lZPfOz@_!^jY3FqaJG zzGUbbd_*5X`ltHBa#}AW<_|uCotSIr2X)m|rsmN~U6Wbi!CYxuNjW(sxw2+-%(}u&KTn zJE!KyOhD5_E7o=F{DHc_;b-Y&%G)KK3uY^2aY!?iwQok`M-X@stlDb!Z3yYUa@{@p zgN(5v(5^zmeG``~)R@Y!3L+k6m|vAOvAA?E3Ts3`qK8hTje^A@1113`lR<1Q#(uh{ zJ=_^PzwnFe%E}NA22eQ$oA;_zWmd;r)~-=-j;}*K&@=b>e8#jZA#lUs0^VoLxiG(w z)0LWGAER?z?QqpXYC&Z3w0UuuHug2UcnXaz*2fl1HW`S)gQ4jwBi^@2G-;)y$M9N$ z6lZ+3m!NWL^$rP+F=9;02BIxa6{Q}$&@$c__P ztu6H1QwNc@=^+R%9<~p^aNoU?U$8<&#Zy>7aGS@U;+N(*jtW;bMWt925NbU1AIBzu zqyjhESbeN11pmb4>o+u9-PR~I=A{j>Ix{0xPGC}5!x(bVUwSOCqZJUK7n+GULnlD8+EdQ-fI0y2#)WE8{o}Lq4;~kWMDkAJ}M3NQo zT}B}OjAo!mt(51 zgaKq&G?9=XCLU#{Sh{Kk2BO)#-1Rk_POAuAfHvO8jbw3{H-Iamn5I)I1P#U`4D(qxBv!LMh^N!E>@P-_FOK! z#J~A+0oTuu>4}MclQ>xL606I|6A9aZjfhz2Sm+pNMP1CCn27lhh2dIyl&J(bGFSJJUHc)7gMc=ovXVIq4ag=$V*k0Sa1sS8E4-7g}q3l4llw@DMSw z2Z7CO9n5U3iJp1t8`wBH@DdXP<3xXB4vd0CU4Vywd%^SlZ}#>MhV+ua4GZu+00BJ{ z0|N&w0~0M1C;eaN1EVrBf6lhH|C<&8ebT$=+tM@AG01hAgcRJb|JwN&_x22&mJs{NYH~)2xxTK8ypL3qo zXkun%`+LSS^53$np85k|Mms&+>8^+JMXqxqe?V85$X~FdBns**Mr4X<3*+ z%(R>g%q+CV?3~QTEWoI~KI30lNm|=G=v#w~o>>9R>C6B=3`T|^Ru)z!S_WeV5G{*7 zqcJT9Gl+qflbzF;m5qUsg_G6rFD&H1W`HN?TmDt5XI6#)D;7phHdY2hz(iQ|jcHjp z4UA|x*jWr|jTzWEo=wMy#qbYlp4Wj(P+pRkn2Cp4AR;-X ze~c)bSs5ug=s#@H4uJFT=fKBs34@LF9c;kLHa3>L#LuD;J+u7n zQ$##}^A;{?8_@5Gzln_upB?URE+?dKLjU`ghyFh!{ufS4rZ&#j|2;ncApMhzAlSj# z25cb*mNPIn0y+HWeEuWiKRGD^!Oq?R>?-+ReANF!$Mc81N&s_hz^;FJzmk#NA9sI5 zBulg3sv;u#9R;}bL4T;+4@(2|_{R{)RNvaf2ne@-^R>S}H~SCln1xZ_fPu-F zjh3B_6L19%5QtXa*qDQsgA>GLY|O&W&dSO0dx8JtZf|4k;H(ce5;Os909a?hw|=iP z5#=A|NA=I-&Zb7sw#C52NXx)Z%fPD4$i&6K&c(n)!@$bL%s~9dk`wXJKS$ZWe=^T= z9FmdY`m0iSp5qjkI9>P5%df ze>1`VgKq#>|IbVQE&cwFy8cI9|CR>+E#m*NuK!WjzomhHi}-)6>;E-%A^fB4HnIki zKWCt*=4xi40Sa_~t9(<|}eyl~hVm1q_kq z%ThIr#?PM%i3)?ECW?rfuig^nUM(#gjy#aO>4(AekA^t1M-g{sPB|UkH(Yv1-IcHn z^$Gs{>yt51Mf>A2*thVH3(DZo|K;NEBmYYBzaRON%QK1gT3A$m(JK)V2RRJ1HV0CU zZh6MwV2q~x7BY_4>0f+bp^ft@Y(y5kQ9%3sQ+S2>#|6ya9-#gG;?I#kFaGP1|C=WN zUE2SKO#e#q|BK0gF3g`7|AyFq&iV7=|IlO@1z&{!4Z^Qq)QU5ve=f!_L@~0e3y@0+Y$;9sZSA?(*bGD2N>heL=h>|)Bm$vNj;FD;_ZFdnnYl}(KB zg&6oLDqAg7CuZ|%GFF>dkViD7mCAfOy}7e7z!CGYJjzJQTKlDOq&ImaA|A+WZ7Ub( zVAJ2F@3}Kw{RVM+WT7X+iQm3v~irMMmJLA#z^2m*U0pZ(=vMcxX zJ)`RkuE(fBE6p=R6FCB!Df>5XFxpNDv3J(6{;*jpHa8SVOhdkuM}(R^){PlIZ1t);jI%_mwZW+6)$8mB2us~Y->7A;6x8j)WEkf)}fKT}94$T7@a4}MnqfPyafppphT3gJb7 zUvByE-Mw%RA5X$GLBm$__JL9e_VT0ubsY`_Qs>HcfZoYM;16|t{5tnO3AaUFoKIcX z94+@7IwM)Ul5^nA6pl^YZ_|ayZSB0=*Oy^(3`l;E!s2$98;topS8go_1qFGGSCh^h zuII1b8V#NtqmOPrJmwjEAvPV$Q9y!FVQ{bC8xVV_kA442= z<`F;b((_6#jPPFXF3?kL?F~$AFMWIn&6;a<$3sr5{&?!G+rG9UplAT~EDNxV3QN67 zl5#Bq0iiAF{PwXVaF@}qum{_lxsSgpr}SpIXY3bCPt)m112gzXo_ycs^fDe*p>md+ zaCe6_G8vw`%s;iDg0~eSfISb`->5qoII3;12^z2J_f2T$9X^{p2|^5IoiWmQ-9}Q4 zwvJr)W*Sd7mQ`5a$l(OG$HN7EWJ+<_T-0E(SPac6UFo9`~-pj)hkaE=0Ly~i-|F|E;X}<>F z42zWg;UblkjNG>Uh=q2+v1R{?%i*AV9NN9CA;nHw`NgE3TWtT6YryvMZS?Rj)k<(b zYW40E0sRrePhLY(6^p4YKlV%t7Rpaa%NhMs+j=JBO>_H84bLl~FtQNz=qD~;Vob0# z`j~t{3ws+fTu=lv|MWCantVraPIA0z86t7hK1!7o>S>BDupe55L4nlqN6Mhum+~0pM`<7FpO7#HeYz(6Zu=n$7 zmdp&E#4$Q4J7Cm^% z-xMQ~mQy4<^J!0MvESggO~#4ECB{l;z(({-$ThCrejy>b)?czrd}N?MYL-evO`=|h z{M6m<^ZJnNZ0RZIC0JUSRMi5djQ2reY4k%~QkNkUO;k)%xyKGb&)-aJ8=wVca}%VGRw_BnFxEYkYiFW;_Rf} zM*Ok=Vl*sWAsMwNF`F%B(-?;Z%wOO^eq`Uj5i&F#6ZN20fhFQNCf*#lTiMj{IUXYd zOek@=6C^Pv%5||LRk2q3;`VN{WPRbLu#pD`Hp}D8Ue{{tuq3TeIq%jeWGiMI z`o?+1AjChKG`d=$dvuG{SVErb72>uq=@G_0PZ zt4Lswft~Hgt$Q*<6CFI~zm$M`|l8EAVa85f+6yW$(5})0@6I zrgOMO1Kz*@#QQaaoRrfxdA4HB0YI}@64yf&DZ#RVJEY9xRK~HBGKO2p<#XP8E2>6J z`i!`B9D=)Ruttjq@ia#A-~9>T!v0WbpNjkWZc+-%o^{X9$prVnh@NIcs*w!N{z}5F z@v|$)kibR$7-zj3)Y0Y}K3rVfp_}7Yp1YnqY42?+zi~+3H(t*|)qRQ7zC<+SU)$9v(&DWxqI=r;BZu%ZRJ;t&QWY zGbPup8eBC`uRS))51ZduUC!ouah;w-fbZ?RpChbtIP+);{-A+Z zLo@qz*DA-55qgqVUer8=dnOeoysPx#Se%Im-f(gq&?-jOY>{!V5gQ2b4=y{pgx+(J|3R zZWE*=q@*?jp1k~Re!NLKxYDuWRv53PE{J@)G27k@apEW|I_*L3;yi7Uw7=%Qn;`=kqQRKwRa{Dp;9{W6afu>8 zgZk|4URO)v{fc=SRhEp8+oPu|IdT(U#q}VFJB3V_YuIIFWetst465c$ml}8>`a@2X z3wOeRk2&A*ria8HsW2O+pGEdlTS^H@a(1m`VumzzQ^Wh@QATsC`tChxaIU|Ln4C*^ zdb;KGVKjaPF%J5@=c(wniIDOY09kol8+}gK72@3bONZMgkLI!ll)8Uuzz<- z`Mh+zQ^1<6D1Ufrea8kvbPhl`IGxhcN z_gGiy*Df$>y$4da)qO>z+Sg>Z^ctZPZ!_ah&N~Jhp_VW`j*rhf`sYLgXu#+!7qsm$ zD;Svm!F@7K6xO=6vAhdjn|aRQyOfdC6Rw!m?52y8pJ`r0k}pr<-|L7`w0^>Y?aED_ z15fy%XKyX=ebNmH%MqL$U~6D4SFCgyN69fy)e}+X~W6G-H>qN;_2z>wloT_ zZL&g@OLW74e_)WWvW5o2bhi4*8rMspG64@-0D*iEn||f+64}ny;Y)ZB#iYqFO}r=7 z{wue`^OsGY)T_FcQ&zY_lT{zHTA_P=X*XsMhko%g_jY9qAePd>F79dBz3(uSWBnrP zKWt9M!;-unbY*wG!gnuANI}OHIuwpnS;^GjKhk49+>Z>H<(y?tdpnvq1x0#>jxCic z5^5QEs&pZm+_@%Sp-MHrOr5GssUi5@{Tmu48da0K0g#)h7O^g4FgmA(pF1qMc_A<= zpgHV~CynslZ6n`16jjZyN*4wg!KF(8$zYLxSbMP3+|+v$`pTLw2wwv5`GkGxDs8q^ zFOTL2y3b4`Wu-}(nKO!u-&?9$WbY?+w?mqkm}qN}#HK#H}w8U;ZuK@*A z>>wheZ1jhalyab&+Nr6{JMW729yafemB_^$sF~LaOEjez!QsCQn>pKgI=iT5ZgOPP zB|{F?%q#RYg{;yjN#}J8zo1Dh_r78SLIM!ZXseBD*+T*4v)8_MNw~^3T95@IR6N%O z-nIE`ZK4*dgM#AhID zZ@~FGe>607OhF(fF8!)YHU+}nsqWEobHvfs2r&uo!M7O=^@k4!u%sr*>h4oMBtBS3 zWT~wv9^|GPi(;+hoEY>%i(Z%_HlL^B4cW@H`csXLBm%!zzXaye9kk zRihU20XE{Z`?9+&&1z|wun4LSF|qSGFC!~i%0_Ni(~Q|yDpInp?dFjo35s`oY)6hj zxobDB;96EPjsJzD^9jko$*=D?+G7D8_2s}77ZdS*K3~|TW^zQWGr2j&KVnseD0*~nA( zi}@*vrGvwU)#8%llLa*QGA=ejZkliK)aXGo(-G->Uh(2tUS9>atXTJ>qs`Y=MByed zg~~o?CWbU`6C7WZuj+;qkBg`Yo^$1qQdD?#j}~j5@&f5)bWEo1M*c@CBmlh;5%sTd zT?Er*@=HAlUX5=rt;WAs(Zr4b>Syf)-UW91`!O%TbtEKP0HJ6vD(AbdXOo*|=H}+Q z#KgZr*i-^I1JnLv$T=Phlp1?2)PigxvTUFxd^p=z$-^Ik&MOAhA#v}pG-oOmFvRQ? zUNxft0+e4b(9c!4@&cuZdI=|WycE4lClN1gL{&i~8XiH&Q#~^h-rXzKUsGzUfE<1N zT;=ZEXM9icFm`?}Zm#Pa8}U~S;U>>z#i+YZr30?E<&(Gll-s%+M8mr@tSGj@yOF z>;t;lc)qX?q2Dk=uoky^BTY=1 zkvfp+$6p=~xgTuo` zwQb6OxOW<%D89eB^VTl4tUpD1dqdeH|#MX^s$dc>ol4fx+b;VUY-Q zh#p`8bKAIF+3)}bUbASucS#y0T%*2U)=pl+q}FfA4ec9j6l!_3y{dw;Ep=JofIB*9 zfllvQYDcfJmxxzxcN9tMaeG+g-6$Q#Mu5Y2`L5x7-+Ph<(~`|^%mpqI&LaM&_mebA z5HxG~bBakk<^pT-!Zj7wercqpfDo7OP?ounvq2YygF5OB#3B><*Q*8ZkQi`(FaN z6?GDg=x}JJ8eia;y6>0Q6*0o_^x4ts7YH<@N^Z zM+54djwtHy?>FAh{W@heCW4*u?9Vc%2kCON<>qGDNPG^CqhZlztae;eLK00|xveo! zprLexiphq2AEkG>LNMd4b^vMyIDa%j+RXA)j2$fsit>4hP#ji?8CXs0+f1=Apv!@z zv(K)>2s2GBshGS^Ou5$lXcAyWgqJ6F&kND_sDr=MnpPhDIc)-NngK~sDR!>j2s&_a z1Sky^8ofw#JOD)!!&m~AzQ1%yb2O}>PU(lLgWL$YdSd&&Pb4zXq&^#LX?y%7;F~Ti z=HSnJpD+xOr#(EVC;OqS2S#qTGKyzCT2@sDQ4DK-&+|;u1#+8crcB*)bB#uU+PMn- z1Swrd6lroGHO?2V<*PoL@EiDH zF$7mHuqnfr zAVY75VA!;*&&HYcu`B_vWiYaA>wm@UNIhGq@wgTFxrpf28^EE|&xi577RpoOH*%qn zb)S>9yekL*Iu-r#reo&J z>}FnvyYEv0fS7{#%xS4kCV{RT7VlJ zufKLI%G{6pAsZ&udqRI11`%^W-L5%gvYs+nmzbF(gKXKmGrwj~QaYHXyj*3zGY&}3 zfugo~>f6F6>*3JLDYC`1nNM4+g_=3fn7Ywqs@`mazKjpc94nF^P~Lqn)|33o^fo-@ zTD_(Ji>Sy3*zD`qrsHcrLu2F9v!3Queqg)YOiTi?+R@>Uh;N~&D=Twy3fw4?rxPIE z6YnQWd!rN@vI!m_kWW+*5swo!24C8-A{`^L+tGDMuT!(zaACuYFV7t~O_ym8++y;h z6X5C+o%wTdagA3SX#x9!XqBqf?&R(C9^71shGPS3k!*rFtJ%^wUK;PmOdwf#PGLGb zO&ye9{8GCQH<<(fWVU264%__RdAd&143^GK)s2``wJ&{ za~dgAHb2w(po7+cV)E12X9y=tjnQB@!kd}9==Att_kKlo%X(jBj7!1X!hkUVXdkb0 zrO9-)5{tpN6u}S^EoH^&&g*zY#HuV2t+FW$BLRY!LRESSd4f!?i3TTnY{*%1v&Be zvjA`?`*U6ky3PfW1ZzI-ji9PIpt^@%%@#!ZHJAE14EwZ&=w_4zlzee$@r-TWMx<4l z9e~Bo4+NA&a3J0ZV&AN$%{{ph@;$B4s?y#(5+FO%=|*Q`Mq*&MQq1 zrkS(;D&G%s#pkWN3Y;d@D&b7$bd5LSA1Y{yqHj?tT6UUQ0uppwmt}RJPsIfR9T?e> z8rNAVdX{pzru7W&clh~TTmMi^PuGFKaan$7dK@y$Z{>)HYQ5P_{f>?GTEtKt-ti4f z`;XxW09XcA;RFC?;x|a20bb*pHRoFn+}&Nk;o*TmXb3}J4kk2Jo`aV8@ zoQ3=OkpM7vOg#0v;8-^zfRHNI>MH>t0!0)-M}gv|2Q-PHajyN=2IGgMlU$%TERBP%F4fNl5innPNY`OA>ofTbKbnMo zU#J8#fAT~0p1MgHmanV}xvY85Qfz2TJvEf5r0R4#SB@uIpgYE}@~JiC&F1GX;_X+M z&Fg-qz+OA(eV+*3_&p{j(+fneYJ%NxzBl?g2K9LC*sIp)#?eOC?{L^cz7+UB(fey! zt?4pi=JlQgkeC}6+|~Zan>85e^wamRNT%Rlc-RUVB+Q-vMsvaeq0;^E4e4 zPB0r`&T0V~?tga-4AS2vC>rsct7#v?E1>DWPvsBFV%t}cDWnV^uxhs!Fo(rQWI@Z) zJWt(d;1IfkHBMDweR7=q(ZpchQO>b_7t{}O4|o((Z=HN8yFOg~J9(T<}n zT>K?ocRB)F_|l1Y{dX(P!ArfFnB-T^!ZTU`*x-zc+YL0On2XiY7FzHrkbVjGPZ%9j zYDFgSdy6Xy-6Lx`8wlYqc@sR6HXxMztIk|oHM?0*U8MrYr7|d~Od1gh15sb}~YC#GB zR04^8uU>mk;R|(dV;ZLzRk?}7^wxEcY$TIsAoD=Wt|f1 zGipOgDRN9}Tw-d+mLrWu>rpX+FpzY7|2X^Z9c`_o^#|ja#X*Y?4o(*2fz2G+X84*U z&bXb40R-h79KvWXbNX>@SD=(BVaRl-%6-BPXWK(7tC%#J9ngBC@QJv8_2?V`oiV^` z3=f-a_6abBp2o_oJfFX~&n`-SsrRZUqYUUJ;`8Rb9 z(b?XSAO2kBDKc1jc0@dt6VJNCn5Hgumzyp4aIqf1TG*Dy$a9}nw$?7b!@tAlR#WWR zyCmUOT`1Wqc*SV&lN3Ow7wc?HUZc-eSuwji-8(JBC$LhaX)x;!&XipqKQ6lZorR~} zlk$(G#a;D!0mlTwamP|>^;~@6-lhcRtp7l+PfU;R>uVt>RIh*Ytl+_;k+u7jxYlLT z;o0JPbgcs**FTBk9WFeHGn@WKKoR+N2v2n0kPTF|_2|8hRr0dVHWJHJwT0Y_d^kiKC@9@ z20Z8zZ`8MQ0^qFY-{0U~E$>Yia|JSDr2Xi54lb4s8;)g@*0ty^Cp>AY^f~&ISn(&6 z?t@8_RW?ndlc-T&+|Txa`YLOu5B{fA!dhp27{s$(PMUaiN=?4~;(#lco{{nLCBkPH zJ9eZ-UYk!PedY4EH6>X#i->Ag!!#sUv3aII9@XhqlfSED#aZnPhsuA#1HrmCWwO;+ zW>7V^!*BYc%DAZ=IO79E?_395RAM1N$U=<*KEl?A*KBDC&r7k6iezlq8VR&1SNHqZ zJ+6PC&N%EppVTm+;BRZ#Y@HAlg{J2EoWBq6)fuF zOq60CFNfp)E9O?6Z>?Kkad?ztlc{TQl~;k>h3bk}aW2f>f^tU2Uu$|ASnf!%2wui-O(rPlko^4Q%G2C*me1Ia-C@NBEGGpGvmf;j3lLNLJtyI0$rZ$N zx0Q}ZT|A!NvKS5HF5h zZ9fml6}dg3(iX*ljn)nf9^u9UR>~Ia1Z-Gnn7D~E(%iJH3F-UAO;#pA26H)O2c(HZ zX_>viFad6+TH51NnbI>PD*Hu9R1bh{yi8=H;}T>t0wc~YvkT4lw0ue0sTvsj9Dn_& zWLqwo|K<~#I|h`%XL>;ji|8I|)IioP^7#Cy6bMkEiDiotE`>l_|Ac7j#miNNFYJtROAL4F2L z6a23^I((s%uWrQg=UUVD_ltk&)&gfnfDC-#0DC^K{*YX)++aZgYdCEfXc;%_8!dge zcg}Qu;02%_705waVN@M(PIaMb>ccjO)wvWSh2ggAdUc<}(aH~FFOw2BIv&;{GMN+0 zkQ#gzsLL75^C~&7pl@1&`k8v*IvF)b?PLd9c;6{SCMCJOLXlwZ3wt3w!pEa?vjSq^W(w{ z$K&!nv_ca(aN-8g-gHj8c%@#HRUp-6REQy^AbXb5&F#(HoQA7k=}$_aAv{FD=9u{1JOHV6m5pM1bYi* zYQwEIYknH?PB9gF#J7iu76`_HkQG#H_ayeSo^0qv2S>*GFf8!+DDokd~CZD<(BPeAt+FEW}nSkxm%E@K2^Xa~vq@ zKTMMU6n8Ow6|?$|w5wZprlEKm|9&(>fAqT^E|Xgmg4#1EjD?U5bdE;?+q+GbV#<&o zgjG?BxsBINKP7yx8LBQk47SmL8>rKGbV|i)ggl{Qg8~)fH$``kFsJx;Yn83lAzO0R zPhzFM515gjfdDo&4m{(0R~7C^c1pn|u;3IBT=8vvbhOy*fP-kjei0H9O2o>#893we zhzTH$Ntq8jV4|nC8}#B=5Lnt4j}rbOT&i*b{YbL8O|Di`$22(R6eDfI>-`#l~mzSK&;s#!RAK5a;gr)p>TWc@5A&@88* zxXVA1I&h2FncjX2>tvnJ-Ot)kpoHEb1iJ#%pcGMW8%iCGOib)fnh1ex?TdJ;yGq?N ziYS^6nQPq9|1ntlc0EHY{3TNQ%g8~){!9mAbSvf7UbG|{|EeizKSJkxa36n|Wzk6) z;_!9zsHr~6M~qDPUcb-PX)m#Rf7#G#Z}=7J-o-(k2Z&U=qE(4+`N2Xt-lZp%{77$pOf(4O?!dEYK9TIZsk|R z0hDy)2tesP^Po|^Z~z3#Eg5LXX6y4?>wcn-IHWzjn=;*GU}lXH*!BuzS^uA0GT<_N zF_~n7^on^Rx*u)U?AK3Aaj?-K=3v>%hr3s9{o9E`C^>c2eR0q3WEYGHs(Z-ZXKtHIrSFZQHhOOt$UGnrz#)C)=8A*LnKZ zI%}QtXRYded)oKLb^Z2TDqZRiTZ5DjqDT|8l?>LRzAXXb#r3YHVepxyBe8>t_t2>g zpNI;aMMNZY|M@gU;`<8WlY1r$_vCXzRlFwAsbl{!naw_evKwsg@)F!mXZ(uBTQ%mLG6g&xtfdwT zytjvfcDEj0*IhT@T_!ZG0c7damEg-4Hv2t%l#qx04VsBZ^PaUX>g-Pv%gbxU-ZuHb zli|%6&}{&pQYzgJPj|{a`^yg;M)N(8$t;c})vQk-e7j@yFECdCoxeX|c!7M1Ye^r? zzV-ngpOelw0Hg%#>ou*q8NAwk#$iBfqn^;RC%v8HS2FFNk2)l;$D zPBhtENFSWd2La^rJ$b0w{TPAzaVMdD5y-Zm?;%(pz&b$z=W+K~weX}5pEYwmYc=q0 zSXgYTwd26IYgrV)X`MCEoi(=uXKtlXUZ&E;q;_c!1v(1+f#du+`T}TqP&WGb{%MIJ zAVXmsp;9mA26~7CAZO2XR)rH;3qL@_i>J%1Q>eF&J5`X8k)>B};ZJ7r=*H;cP#;i>N?V}Q(!;pH7D*+IMJ>}qIu5WM4pH?P-CV^i5orA{2d~LR2 z{cv-Fum!ZXPjwgIs`p+X?Jq)WsMkGx4=Pq~E<@h|rL5LunQ)=%F*S9QW_tpm%jo!F zQ)_{khlo2$XO}x9 zplGPV_n69F&GuG3UJo&QHd#=KBAk92vzX4Dnyi(8Zy#)|2@kZBY(V%$e&XQ3dxfJ) zz8pX(Q)_(!k(s3NWIXu_^7dem_xN{1DHNM z*OcTUZm`dJ((NI!$jx}rlv<}Nq<+q5!{?11geHr#?_WWL>w5))`C`kVZr1Lnpu07e z>bBo@pPYS3tqGqn^63^{0aCJjxvRYw=3e1geQyaON{@XZ=j9O${uCxeV@Bj=-_e9^ zAr7kLj!-pp`q?I)W*lZYIq*k~Dod)lF1lcIJH^L37AEt3&yl}TnX_I}bBV(#mkAJI zGT#UXB$2ktyZ%8T``1fvjr!w10*Pb2PMFsZ%mh`ciZBv`pw!PXeuPgaBJwm=-ZspBFA;66^RRTb|$+)0m zu}U30$gIvQEzxu1Zcr2N`orR&wU)iw{ctoY838TWO)usM zcx`up&L#x`#D*F+w#y~Ps-3MZv4_sDPwodV-?{5!Y`h?*0nrd>%mG8Pzo*BqUy`Q@ zP_=R#8n8i_%>HHoy(PX&cRsY@djPPy@VvnpxtQfm%(_IYEi{{CEiMsCMnRC3bjERW zA2I7{?`{KU`q4uKl&qW0tld&(3&osxS`Qbi9PQ++a`nbgfL<>^s6}mkL}kM@(`#5a z7qvmv!UVTkfTe?#Vs!(_`;PM+-WMWq+_m+i&5-IAKP@eOJpQ6n|BRLw`N*eJuF&v_ zEIL}I-eNHf$qUWFrvAmfY4@^o@^tw>FXh9Xdn61xgF96-;#@&$^LBJIWQO-Ko3rmW zIcsm$MJMO~_}^h(G7_POz^= z+W02a!6=t567$fIZXWLZ^|~TI5f*WG*TKk=?CO8t{Xjw4p>Pr(!vRFKN;MWVDpliy zrZKhqKF>nw?|y)R55RVLI(?+gt*!fI!qh-y0*qi_5ZKl0?k@0>h0bpx$@ACfE^l>Q zfY1AMKXvzztv;UA{w5t7L&CwprK|3A!iazI2n=M+r8iu9M`jDJwJ8k79o5TLOjd<9 z!U~VCdH5{L`Tot@?2FwtWBvUTc+#w-&@>``MK4`1Zvt?+_BAt}ye{aU`veSx<!2wsSQ$}Md^Zn^zb9B!L1q)Ze&X*Tx`>C&oJY7F*;7?bamtSNi zCtckW+X|iBT-~*2=a?raXc55aS|VDj zPytHc6|e&XxG$gc=!(;bHKqH(-pp9zv0@1WJUo0m$1dYIIt);qwjyL{(|HO2VZG-I z#x?j-Q&xJfw&PJAFxfE}h$*WUv`cW>7dLpg78y-#-W zx6})xu;URO1rtmoT*-6^Ow^(ksh&_2Z0N_cnE@<)*LNstcWKmbbNt>&x9Dz-Nj>>3|KFP-)Ag z1VoI0bsO5+MO6R-C{b%kJI4plSJ*FQ>L{CV0-ss4jo$^vqyC>rLI`KeJsB|@oz_z& zK-^{-cU1nA^GNJ_!tOoqS_v+~(Dt}Dy?IXbahqaqHiZN{l0W~C!)f9vcF=U)iZkc( z)b;uM7bteadiztGGlhn%0BMT#v_+id?aG8wv?t?ryZWp=uG@)4dvWdg{+|B&@KjQN zu@W&aiSY?jd~QcAiObU2tLZW@I#`k<)9Q!;g5iyIopReGKo0Hgli?L!bn9C!SBC=* z6rIVAAuxGf&j}KxUOj=Pt<6IK0)^}D^3c&*9WU?C6c2TnAS48yaiD^4tK@=xE`R~+ zb=O-&vmnKb#T1)B{fd^6IbH7zs-Ig!2mh(z(mk=B2}FWG8x8jj+$|vaj-u>_(0d)Q zvazu-ycLj%%SiOMwIcvsdd`C{ft!z^P6mUY{)6n^V#C-cTE~3C(%t5d0E|65-REau zkk55HMHj^i9S2=JynTaGdgI*Mkv_?oQ@EHOVM(0*PLiqBt2w9t=k^k&?ft@ zH<^%taHe~U1t)(P%x03*b2OzUMOt;dI2xXm(v1T-Nsol6Y%AVXKT!TFR9Cc)P!O^Z zQJ>(G`@+j>tMk*x66eKZ>A#l`-YIq$(+go|P%wcd!7u8d{>8o#76a?8|B96@lUEj= zt&jg|+?ftUg^!8^56AxWK!Ji@Ch#~s_we@7f(mQFh+j)b+~UGRz}?I8;xYBo$$rRf zY%Luc2x#mdT8w%B8p80F<~@p&AJMF@gze#j8CaFg3wP&~xgn!1%}f@fi+d;Jaoruk zTAd<|igDm#mKja6IiDeEbGzYlK(GG~_B`d~(iknkbFW)RCVP~NwzrQElippc&Uj$o zsV-&4tp0Zrs=QL{P%P4-`*DTSWFra za9zQn#VIPQY}KY@Ti?HPi5|=Z82#YgoPqxaZ`_}9WyYjINi{mm<@sk>qX>k;>-lOV zT-r*lE+a5s$zLGLrcr?BX$SAP&Hzc1rJR6}Ay`(`ijaX6-4_A9YWGC;u+Ds)HL}%) zkag3W)gM1L>~)&xBbzU2~Meeiq)ljwR5x7sGf zrX@`>c6p)6$IF#5!{RWbN2x@RhqvD>OLV^f;?eBXoT%5dF@Km@aR>Du3`UxA@5KRmdh?-4)#|cbfl@ z?Y#WP*<|y}*N;01Ugg8LtcPBb%bhxD7l`%SVl=TAKk9mY@io z<)jt}?%Tet`s;E?xz;V#R|tf|K9Uo)hCNA;!nI#i;uKzKtM6;J{HShwshb&14vmlN zR6KFANwx+fhj_KdeHz{ln?zFVClmDhg_`1G7G1-;j?;_(%K~&<#1&z2I7CXS?#*=` z7c*<@`^g=t{EOn&4EzTAcnk*iH>I|NdhbYaQYox66-#5g65BM5p8+uJ`A4~L5`W#7ulIr}_5QioR1 zBHY`dz)kO4bl=};x$MgR#vM!WXkGvKaKCGsD7oikF3nY7G}%FYzaD(y<{{eB{CIS9 z903a&gOp9cfs4MebXhYMO>!D&Pu9g}B`a&$n|oj9q{GH}(wpHBsyPcOsmbvpJDA)F z7&rk~m7itFUIS%SU55>gFFOG)Y7M`DbxYYElR5{x3j~0>-*`5{;_2)h&t#7S9)Xa^ zkbaqldu0G^Jv$E z0UhPdW>-h=Yt^FruhT4@h{`{8L$`yLJ|O&CBf4Gw%SL-O*^@>q<(w^N=a3u^G%qjN zbg5FW*Dh5C^y#iU8=9$XcccevAH!(O)xb4xk=*R_;ccDC7b97ZRusQ}bgAQODfwz6 zfBA-4kBC7Tk3>uGt(tX6MknZNUBtI|N~GSeV9!DkQm0iWV*a6k8XRZM#JS}5cRaW+ z$|w;a^XuBm`$`mHh=q!=(#i`uM|!ro7=x6Z%NwK3N#!d9e=|Y(&lQ{%#c$ENXhV#I z|Ff`seEjI>X-$-*34=ltM(XGZ*_x*F7ZA8Yfa)DuM2HfJ$d5+^%aMK+Uh(beA0(r+ zjiDjsVo2+JgLEervSy(w7!U?vwTS$=PE60&>Ek;Z&h_Lp{l!RY8Mztcx)CvBVf7&h zm(ekf-$x@>Eb{6qD+Mpj3g)S6N9Q3~Tc)y|(|MeQhNMylWDmS6)O$9#={;9{FZAgX z6ALmSeLVnkR;@Pv8x^fS{ED;Pmi4Kn2-9gj{{W2Mseu`Q-?%9o(q>Rd^At)beo_d; z6U*T83V3P%1b$%8P?NQP_AP(k(-R{t%g6g2%EQgb$HrEisrT3J3756Lt!)|B{?H@L zg-W$KY9W`4B`GZd27ml&w>RuT*_IkNPNKoc!yYp4`PY|dioA>qWPb7RD?{(=?!5Nv zSCnzg%;@!Pz3bc5@hnH%q9&I^FW$pMUG9bUjxa^rokkDb_Ps;%hE<9(W(7GNSvPAG z<|tC^NR@FG$l6oBU&CUQNF5?URXT#9*-0sgyvTX})1^ukwm5-{)?@s;=Y|3dT1CHc+(8(CL(9~+ei z&N6zExgakeh#$b`5ZK?)K{9wNzg>i}@>ViqDLhzof|XLNzq?J^Xqq-m(IB_FXJ@19 zTMdGSb)`{=a7Vx`6EFeaCX1!+Gc0Bjmm1E&dG8$V7BUa!u-JC|?7F2jW6e!oH!|Fd z@PTKkN}XHSTB|8kj1L7C5*U}V60)~p-cMG5h>jHG!v{!F2-p?(<(#pB;4lfNh2ew#d#inPOdgiN;%3&T1+)2LGQwMsxe*|9dTc)KyAW!0(8hP{Wb8}s(f;E zD20=H|D>B3Ueiu!s7}NT1-YZ5;J`wHujbxdE|WjnWhJt`Pgfel3Ut|aZ=^_xh7f+> zb1{Sis(sEP&LsluPSl4-Dw-y|Lt%3$*?H!~XF)L+N4n^`L-;HSn-vW9c{VOVmPCW0Dj!<3*K?X%#q~`<0MWi?Ldbmnem6OgO-1 zZEwtmHHna>0RxM?s-~cxomd$I_BWYc!d%yzJYk(x9WgUDThB+Q7#| zvTb}QuZ{uYCudn(oqyYbU{3dbIBGV%m>B8~?Fi*3eQQ&={s?ks>kWsVoWGD(@$kct zFbiIjf(8`|8ZiTxxPg@}+6+IT0$FRkKS)m}a#Q4`v0QF+R7m<#y5WvgL(~!?^DZPN zW_Bgnu&4lH#t=K{1fK@&&rLjQgCS_5WmBs;=H=D6M{$w!;FOtAUtAiYAMlV=7(PAW z!rAijIxG8E_Fw$?Z^tlux3(8!*NX6vLWUE+*7B8O1F1YH56e+WuY+u&lX!SIGI?7z+ zBo?4Gy?T0ryr|Zn_||Pck;#0hlWVZke0<-g-e5!^`~HlkLi3!FZrhb!|0Uq*HJ~Zg zYthSUy*g>b{CHwn;^D12YVDL7-RGU)f$J517McDlKE`gr2(e1BK_iC#)MmOkeH87Y>l5AC;2tZ~F>KJMEXs0W9C zsc63Vl4ScVX|-MdT7U*L9Shz2vz1D&NL8dL6C6mH@WsKW2}@# z?%Sn<&#qu3R2wc|f{gTqkk@-$_=~_Bm3IgfxctAa z+$Kt>1oUeG&PUM^?|lnV)EbvxNP@t%HF8N>Rlfr%)pV)2hPdEfUri*GH@$ws#7e zCPJObvg+LY0-a^=w26MJS&wIy`;{z+R2okL=fxWqi1+)K$0tZ~))(bq>M>Mhmg*-l zqygqAFdQbM+bDr}q3DA|MXlv&YJt^v*&Ews0>xFcy-RZ#jx%VRHX9I#3Jr5TaB$jK zWP)m1(PpFbmPs`^KZ>vOk&=pWn54O_VvqSMa9H-pZ-&IgCEOQ;behJc*r$Qdl4dl> zoEFpJurhz+kgBYS_!urRCi}10PG<)o`q3a65`N=XyT90J%hfI%SDy z6`1NjkEN($RM49D%@!G${tY!)FoZu0iuCT@x#amhechfPY316dY+}@}TwoAEDqq#i z_gY#`?h;hKCl;B)kq&`I{0ijD>#f}?6tf@Cj~9~$(ZH2cIR)!oGrv@#CzD{}W3iJ0 z0z$rlARPiwEv3Llp(-TItXv{OK7;k1L1*<8WA#CORsvW0^h$Dp3o}S0zsyaQU>owQbUv}E~cWW`3niu?6duYRi9DEn2kQwO%BOi zOi!uHfZ!>Ug6GkMIGvki*d{BRs{@G%O zh#KDl8?N#lm5ilUPwKI_@y6v0flYLBuANj{@~jU#OBKP6gl{y(ZmG=9sxgN^CYqnH z&)(q5j^0}{Z;bqgFQE~GL)ecITohVdFsa$xR=?Qbedoa9)mGb5T+b>lc0LhRcL>N3 zs0V9w8g8vshyuNYu}YMuESgQ{izamn8VW+fZWQXNqU_Zkka=WM`n_{lfubT*7zYf8 z`#{g8WqECZlYI-`;hk38aRjWct9Ax~pW6uGjT4 zp&`<(-IFo0G1e;}#}Pjq57qg&!Zm30-OZZr*kv>^O?kW|u)JikotY?TK&=gBzxnM*vkApRK>)8OLUlsWOdB5=KQBN zR?c)9#-NBRp{*iyUlW8$hgF3vM6H^s1v6*(ZBm1Nq>{4Wn&+3_zltlfE~$66`E@Lt zkiyf^mBwT($)R<&-bYz2|6pK_g7^GI$bn&B(l8j!`iFXKsX}J=55Ciev?@tYw~*fv zK4dPW{jK)G*D>DAiFjI`+*7GA)dZBV`2OM=l$QPIY=-0K6Rfn-&_F8a4_`i52)utM z7PKSpXDm4T1tdzUDgcBsvi;`KynUdF#qR7MKX=5ipXZ`udbkiZp27G~TI)GTO5Bp! zbi8V-_Bhiv-csdMF$ClivEyB-f6g@Whw#LM<3ORAF*X;dOa^o^MqJm@6T`G6G&|sSO)f*wR#GtOJnb zeRuvroH~1i2$K&>fa3!>IZts6HWXuo!ck66rU~Je&B%;y)Q%<57dC~O!L-!+`!KV- zj-{zucyvA#^B0IkbSZV`@Z}u|Dvvtpa?T|&otBAXr)`7fUJ0sf+X_S7=rD0C9w1#K zV--83--;#W6pqMDVo?G0H55iM0k?=`T-Rd1S95Vr-HXJqDHV}ZeMJ}%p9KtJAVA~# zlw_HtgFa+^5_r;k{WF4U73df1?e1S$*Bo$I*PU4r);I>rhT2UIX%2kuQpeo*zxcJk zgd}T9zf57kEZ6wBq#Kin6EgF}@xHzy?OV@6BNKjwhKBAm61e>ikXyO_T!1Vy!kalT zvMbtbf1knf!zL&zYGg@BV-SDgVnXHHOq1tTnUYkx8|19%dI?i7#&Vk@m5=8(bds0P zAH@?{oW#NGk2l`nXzV;DlcH7?nX4l^zK)skD^QfPC}<}VfhkBG3Kn^Aa9e^fQ2lp0 zx-X-a-DYP84}q|Erg`8MEUDKVoI~HJt#}stH>`+MI8S+ucY=F(r11U)=*+6*r&ciV zW-!${>k@@rOJV-R@DWwBW)Que$e^iXU{W88)bE6q{@eEJrHYk9t)t5C0-i};&b&{R zjIJ@;ujUOlUx)*|mfW3CC=+gG`7aFzGpsZ+)cxJsM2n=m$&0h~;fqsQ$G`ZKlf$Xg zw)4Zn)$^Uk6H(W3gwdVqbC|4<(0!4V}%IeH|9YH0{kB;KVL2cWK>NU>fzTEAs1eGZ}nf zrK46B6-ZfOB#;=hYGufkSf!z-go^zR?Pai%++DI7RJ?3tG=hOAub{NWoUFzB0Rl*g zbc`1ntA@zP?BAp7DtRi9z;nh<@^xlt$tuu!dAo3(q~O~!0WoF4t1hsXkpwvA$}cB=szj!hdL&aGOAe{RNoskqI&d>;(lZUCI0?&Nx)i~xnW*;?boWdnUni|E(LkL zU@;oTK^(mi@s&7*YB|(hu-$hKi%3F|b-nr}^77)tU*_C^f?x#{#-FV&6>GMdp)=?z z3Uw3F6DF1+P)?O)j;M$SYBAvuho0))G>?CYNfgN7h6n?RVB#J8P*FJu`IJDRw;9Z9 zst*iA)6i3q(J^049`wBx`=L>VH8opbAr5PJSZC zq5r^Il7`i^G4Ru6Qd#Io*`HY4jES3-B6h%l^yY}4$D`|up(0V!#v48a9^QU01$py! zlPq`Ox{c}4@C?`6Rwhs;KkUyQN5Y|$`^`d%&6ym@oBZF{jZsw=W{qkMT-faJR+n2+ z@uH9xT9tx{cGqZ6f|QDJ1A{D63P3u4Qt7<{Juv zrU>~`yDNkOzR!X!WzGE)N17pu^rGNLQe4gIxbGHAx=l50q?G+&s5_O=M47;^&B`-i z@d%-8;56|pDxE7T)$zb#teJll#iOA;1?-FXzh$B^;Kqvf*`mkmgooBanEII6qLq1Ys83q#7qfqjYliC|vk5=<3}(v@keDMh4}neAut_1Qtz| zb@3R~xf~5!&M#kh2wk)>x-XTuEgzVxKkCC6=UK)VXjpKvf1d47rdLY%%XbJ1yme@X z%v%b~={ME6AFBd3f$IQvS`tsF=7qO!wv?iXKbku(kP~)#J<)yczfdNIa~|>l1H2-( z$ad-eDp~|IT+CJ_ByL_wO`I-|C&m~no?g%BMS3#3vnwyX{S|Ohz?Xi}Og4+n<_D-V^bs)-;cQCKpiR&p|nwXTX969fHXzRybdH)yvK2 zFG7cUXo!#dqkqkZr;y8JGgg|>cYZnkv7MnjZ<4It_K}6w=4HUt9|TduJJjz2iNmg< zms{GiT`LudF;5QP##nL5PEQF!Pe4bfBNE?k*@E8DkOQC|09+6>V0G=P3FsSZ_p^|0 zx96I_$&|Kh5%Z`py!`rXRI!DD-|@4LFD20h1+)nUQmNFL();u0`7!k-2j~ax>v1$H zfj0jngI@qorn>HBT)Q+(U7A~68zDrFftXVpNe)#ZY<@%OP#00TpcC2SEZ_!BPX0^e zcNpjw2+XY0a{84_F~a^J^AtyId<1Fue4mVaf5JJFWQwwe8rD@Yai_zR$zg`QWKFgV z)56A?a~2*I5LgXA%h_8l9`4XoqBp2(w6;$D$wcRx)Hq{Eceu!ZKesF|y+QmJsdLl2 zG^LH*Qire%!TI&Bqw1PX*7|YZw)%(q8K}MVRsp%dUH4CHdv79{2+WPc)AQ_8U|%bTAVfr|2T#Jyqq&JHLql*RbyGRTyB)jHF=2FE@7jwZiXd1RvL<2U%tha^xcyk0mV|M9q< zWS?$yrn+=J9@NGZysyt9+*~G1=^_)b{hGtX^}crVez@%`-*9^^Z7GtlOaroIgJPgF zU%rk|pubdgHUhslk6945=`ngT;cdC{oXVee5TGuEkwZNm8wZm_psJ3{u{gybH*jtV z)r`Nwi4?-u+gscIq$emfaZ$&rRx`dH{dqb?U$ZR-=z8TgW39i6FU*ohC>vvSZH#>y z!^FROiN!1;p%FJo7#P)!p(?38y_=zwJuu7Z7@jnq5RNf;Yf$AWI33N`E+V74X@}zS zwrBUgYFV+AiLS;eQQ82y4Ww;X&JZutSN9_NKQ3w4_m>P;S66HHLqDWa84CIkBGzq& zj2LF7r}>hK+ePh6hI_G4FhP&#W;_v4cK(}c92n#yYD3Ocf^y7^m$yPIae=;DEu z)}Eo*{&2AAwtr!(t^AqnaiC^!KdF~ZM;TbD-1GQ%QJ zQBk=NiF5f3*lwfZmeYUgofNv$z+;753h}I45tc2D*AwvEMla}1fBQYZ#I9p>u=)n~ zeVAVV%zsI$PT54ZF>aiYaq*Vf?u`#Go`?zTNn#G?>^p63wSz1-XEcue@cLORbu4Zb zDUFm6Y8z=;jcVW#up)16Rs7(}X@2~*#bk|m-@JWPT;e**(oseRKKD>DWT1Z}rrm$BzD5eVU!mA|d4zg9e>?`36#s z1LvB(Fw{NsG#Nqs4AuykxEp4)^%a+}H1yt;rS>f+;R71n9Q>CrgSA_CfPG+B2QofgSPIqr80&#h7Y!;q{B5acM zCMT<2aTc$Y5Q-lr@8yIcz41#tsCdB2;G^;^MM@;5ktm!HFk4L~;quzf156kkPUpl+ zEiKI$uxeI+6e}THB&MzPp1YKd&Pv?dIc> zc#0E`zB^AWQT=8;(`H5lxUo6VX2S#r~D+FnZAvd5h{ z(bMzR36)U!id42Q@`Q`xG>CC}8~C`JDnTsd`=>-D9i%Y(W_O_GezK~zZ_nvv3k?E|G~YdAV+o^zQ2*`(yVo{RFYt{zaN{K zJyh~Ki<(7|Q`;K;Tqr_~4wFlsyLb%TMM1#M;@9&w+hQ_J_gr*q??D|Ri(8am{+kia z7&S3bLBT!v@94Ku3(I0lyKt?Dft~gmChS+Qw@_F(A`+gsFCDX(E4%@nnZFoUy^cVN zBCbb%QsQ?thyD#`00POJjzzywBQ2;{q@w4~BFff@s2v~vHbIV1U@!_2wupljC=tWT zLioCq8Se|QhTnaaXS9DHr=TO^W%$)OQW9ISIbxmO^tMyHSGxvY;}eyJE(3CmFQ z>~JO-a+HZnOc%6~=edje(S}ao*CX|VGmCxJ)Zwk+tVzpir6E&0oSe}>-KQ_dst1S_+LG35?s|i*_r}n`8Tbnr(OG~-5rjGK+$l7<7(=~}-u3(Bo zGJ5xW$5O4K;3j`#jY#3dNht~tD7;>PPB$4a#aDMDrw-}TBY_KY6E7CM`l9N5ay^w% zQ7iefJdUf{AN${4-;cAeR?_j9+IC{_FgK4~Z+-u49dP6WQt&448>7F!pxb#tmrlbO zHK;%J$8cg}1(dQl;T6&sEnc?NNHAT`e1-aGOb_VIW9&y z4s#9KLj_R6^r4jGuJT~uFX6D#&T?A&-DWLd7e7XhYYwZ^V)x6cn6h=K?5xw^eF8$K z2VSn#LUB3kr*>Cz+yngL3t_|TwNyq!A}|~X$Qh5 zirG9tB1F|&?D6`6o^x2u`MrFzCTL29=4s~F1vlnPH11A=%VvIOP0twvWHjEkE?&nN z-sUS=8v@>?g!%6cXmKq@GqsxwR^H61bQqpL&E@s$g!Kg=Yn?ub`0IWRNKuvY`i=( z0=1cY<^0`H)YWC149LUv9+@6CxKr8>yrYrJj?-pNI_zD~D;k~JEh(j=4*O-ff2QGi zo;K4__YUl?ACZ9hG-qgajLS22?KDB#eH^H*uMfKefWgT_)v2>}b+6*t;VTTYaR|U( z=hs4F$zQ!8;M}8r^_({Ia4^dgcn6wMLB+e|##?Goi;@YEpJ^Su+LqT z5P*I~2FsT$64nfrzoImA^wTfVFc2UCtMR~I&m(Amon20u$ZBB-&mH#V@54&NK1)Pd zX|!bp4kCpU1_c=r6lgp-K{zN&dc~^cjW1(BMOO?hL&Ya`&HLMSTlsQ(jkb!McsyTc^8gDIc?!;; z4A`I2#$cDu$I$S9lubkAAnqNw;=k$AB9~}lubd~6q_up`*c4D!biW-279DFp(Y(mx?ZR&<}G zH59wK>GBvdk%)i^3zIY&R%d+!uGL3In~eYrU&Q2;QQsaJgy1~L2V`XQp7XQKSx##Z zuV+)XO=V}!)vk}|=T;4os7x_AF>=5z69#viQ&P@Vu^h(a$bG^*v9MffywY?ZAZcWO z=jLpbQ&yi`3S<4liV0k$OCBDH%^BzWM^8dBJfrt!nBPdp5-Ua|@A%po z{uAWOroNMaJ6+WE2z%14kk@*iLf$Fs)2+(ME$p!lL+?-#_T`UAW4OJaJ?u35(d3}> zhifb-`AglLWQ!V*Ekw$au#%`_wF%`^sB{?+T;&xwyKGgf(<|}$T>lI+o43-)%Iz@| zXr#Dcm(4Rat^L`O%iV2H&No|rX2z%wG-t*}HrNS`i6$V&M@%G^UzL+}_NEzXn+`hp z&JmTAGEAP^M+9tqk@1vbP8NY~r5^z6j9DVEhm?lcVEt|2kl7 zy5w}^JLUvj{~#VMsptBP5Ah%gxpk8$$rk50S}_fG>zI@-E-fZdH7~EE(%Um`i257V zu&zlPkg#j*<`rzRm=Z%{r{Tlo4HZ!m_X~gxmyO*MZGgoC7_=16UFs@z4K&o1zSLaM zto9G*PeqxN#a3_Py6Wks>to2W>xBg9*^lcEFCSFBCn7a34DPibu3k*NgCz+fDJ5P9QUXBB}9)@Mb&sMX0h(vEDyL5J+R1fG)dQow^Owj{^b^E$=h@LLmd9N0m zcnATVKFf0p9*9ISspGzYrp@k?3!kugi){esBQCJ@36B20PAz0XOkwEIBr#BzP$K2BO+uGu11Ges;M0lEUY+EW!rrN3UXIXkZ@3a zZ_vK@?KVM`@de4_!ZL_GDwRl!c{^*~uYR4{*?)#D1?uygyA7Mxn){7By*dZ|A~TW+#4x4kQwXEoQyJ<%L9=F?&rUMZG@bRmCT!@BY13Q`8l8 z_O@|+Q)ug(T+Q|h9g#-HYmgY|?tDviUMD7v#+KG_pyYwTOY&+;BO+rgu{NU;QbdV= zd^7AKreLXbTe*g_TyK5FzI8W|&Klfx*%ZC#H&TiZZw+?ud^Dl>c)Im+fPvA{(0%yM z-T!VFl2%c9W_&&#rlj&)UModZXObeOvTfdi0Pweg3nNUrW4A|Dg>zQPhdHB&OT_TF zBGnzw_xG;)CT?)O?p?&zetComp0!YD(gOGRgGu*7o5Ma5-$| z>FEhLVlV)Cn3I!})zy9l{04ZyG9XYagV#;^jI4GF+-$#W9pViGLQkJHuNR0ssv7`% zRT#*q(5eHszR$WnmSBK1pA4R)9Tvc0jz{=74{EFYPOP=w<=8<^cv{uOi%UzAv35e+ zq}CCWj&k(10q6j(+lWJuX4{23U{$Ybb(PC&T2AGHYvcHiTm37Fg0i7a4M7q_X1{JjxBub)Cx4b*dAS{Cs{(CYV?NVY|w-_ou zftH|1?uxp{lLJyN;Mp#nWkJs@1rpbDT&RV^XR^uY$mduVRL#&48Y235K7;>Nd}&N8 znmrDMf;;&eoW)?mCHP`W=QREpL6eS&bEpvNOw1lf9BwT{-VJ9+X2(R8ki7W{>5NfX zR7tSES`9`$%!D{O?Z*HZuI8&u>l=J;5bYjoUK$h>%^pZB!eHe$k6^Y&g5$W))* zkkW>ExeWxfh!O08Dguv$fRvRZ*}~>x44@%J5_&c6}h!!KfbJ1ELfFl3M9>J;%e$w60)JqM}W=ZG!BNIit~?rqzr$pOL*@ zJ?YA+>x^n6aESmA?4*lSX&sF{pu#hoa{t7}`PbrdAOsZC#l`H);+t*c!!@A>4sQ6i z8|^IsQ|lQzJpJ`Ur3^4wwXPjxt}ZiY(-(g*jUQj*;-amSUh&(@f9cSedykUj%C4uO z-)|2pQPlz!25-6jK^N*q%PndwjD;jm54gR1br*$!dpBc1ijdnm(EMZPMgRr+wj((5 zvtmc?KD7>)4rA186^#(JX>5sJuAs}0G|FH=MeZog1q2K`gQm4$9`vqIGCk*60yU4v zJvC4|R~`JFRH%xoie8hOGZLYpK`O%Vo5EI{4m*`IXr#IGDIDQ?6S#}j)t=URI!V{y&gyXH8M@JPK&aJ zfNcV>cMEy77SniweJD~eH5IEQf97T#-`zJFWFVK24nhwrSb&u;lo_+)qO9K zq36O@o`fHo&EM<>lUbOVnGm$zQjSv8WSYcCm8J>HbMxuCo-Q)VrAVc_f?Ph{umL7# zJ1d(zY{F#!nP6mM%4^K&#z6)NCwuq`#uEjV2G2bRoUYvkwgFChFL7S4u919XN5a?r z$im~wsui@hyq5;E_^+;PgEt`yzqm^NpJ`(QVUk$XFBCJNkd8gua{a}NlftM+7)1ok zBI{>_bA|*Ox~eBE3)LNg!NRU*Mhtb@ZStkWVUI^TQdr!TrcS6gGK$)&lEO|TPb34V ziAkU0LSe}HAB;2V;J}ts)U3V*13UeXnm(I^`!zae(0~^>Ga`XZ(7zoqEq9^{9nXCN z(@G_f72J0Z@j$u(sDEp4*#%hNRegOu#sF}0fa|OU*g7!&t<}T`=!npNmpma+*by2P z+X!8rfxw>2j-` z>nhcfm5k&GkX0#$C-hefFQ8!I^~atY%<5YYOdaR{1_5}Ryn+@V5F8yZaGx)kh_n4o zUrz$nJu~eB!6-pnD{TE7WckbR5fi1-VZ3F1>G5&vdr_os6i88tuvtxHx0^m?23n4Q zrmCL0ioOzwLP54vfHR$Qk7x_uw{P0REe{oHt+b%~Y{)??5;ggFH}+ePH%{K` z_Gm7d^nC$<9@TVX`ua4I-u*UkgsIsS_Z(VvipV-#bUop?cMWS^roHcRIma8l+Su$~ zF`ov&Z7QO*`G2{-NbH|-)Mo=K?phS*I`CJ;ZdloGhiN&`ux2@hZ*#=@^Cy4UIzD&q zQOFY(q6uA#AMq$5<@4ugf16=M%Sx0v<~76YtLlipD@*pM&nz5-xWCxgAQ6}|pszC> zLT9N|?>V@$B)633WW}co{dl3nRJ z9e7E%9IM0{3(4;Rz0eYH$M&65L;J(N1wca6VWmIbWq-`Hzk0>+2=eJ6Vpl}RB-!Yl z3^qi5XY^~sbI$L0dXxI7V`DuXf8b&n}*Be;s0Z6g*hVMqcq2&pu8CPcFNZ*lSjxP z25Hn(_Fg0nMLKq<{9WsHBX$OV)}UHBDGr?ZT3Ja|jz5PCKWv&x2%kvDXv`k65mzsi zD_i>E8a0nnTu-fZQoV8?dEf6^EHrJR5GLuXIRXTpZdIm~v2?@Ou|`yIbFpN$#@kT| z3bk%m5-|1y*|lkC^ACH_6C2H?-kqelQ<8=Kon1KS`S0QhlY)ZQisBLnoDXa)^bN}U zIW(jk{bJ?HWOFmC%VIcyG&sDh*IcUI)}%I3ENTL-C1b1iXy-SXgi~_2IQa2_d`vkC2HgzJzsi!NlFp3{y}f+J^ZjF;hFcI?}XO;l5TR z8J`#oLQ>K!QJzP`Bb_8bd=|_%I=O%KdO`TrQW;Z4t7zNWFhLIi20lyGX)T_NlsDAG z(RCmbo^JqPb`?ocVNt3!{#39H5`>if*~PI7;_$-ddWXG$2}M;&&WddE4xx^&2|e!k z_vZCMyikJ?H-Q8GNy|lCuF}uHw?LpOm>-%@odo_H)jqDhRI($>dWsxc-^-DF35|A? zQcNK5WbV{LOV`!rILIQ``?122y?K%suv-s@zv#SA@h(+6o)|j@s3FBY#H> zJSyz2k|=O^>3F|Fk0Q-meLHe`uFJg#4Zz~?sv9Vr;I?>pEtoFyf#{fhcHHaUnB=8W zfpJ?_XJVOiX%+JtbDaWY5?wmOySa3meIV$W7j=~6*oLOEJh#KkW3(i&n!{!&@*_D6 zbG{VZv;`gSDn7c(?bamGxaDv?hbCHF#Cb45i_jtB+vCLlH20N3aYW0)gy6vvG!Q~a zaEBnl9fG@Sf-mkaf#Ae&*T4dc1eRSqB)9~3TRh0(4vT!lz4!gNRj=NAKkko@Dr#zK zXL|bd>C@e(Pm6@PB@$t}+mtUivN~<-V!@j^PfkRR@!^r_&7;n|SsFsH!=9nTc7*e* zsCKsz^e*qF)v&M`ZLAYDi?uY|=eU|t?NUIJOoShJYfPeW0eSNValFDM!|+^Zxh)>Z zP>K}+d+c@M;bjlTBa=#Ah!6*x1n5Xf<#*?4^`3lw{6>#CL3eGF=S!)pfK5{WFJ4xh zVG$#lyEnTGK1UjU-mgot!a5#GfxHsG7i({u6}qJdP1ry+%}o7zsr;79texe6e36UG zi5PPYeDB5jo+^lA%%-R%LJBMT9eY5DCNk=`$;(n0UNHAB5{IX)T@s zWc{SKQBBM*!0*3n74{IFg_F&xi3l1~KL2dTyJ;@1t8(qNU^+EFh=9fI&_wW>s*;8` zE4a5*HbS8WH0J;O_6C}IEq}^?VRnEemXjzaUG+wm;xIj74Z(ljy;no`K$a+|Jy1CXzz>I(QJKxMct|ZN%z`rwLLrUHfG9jHGH@$S$5dc3oyaMtcT7qfo zNlJbKVd_o9wLgPzor{|c5sNoR`p~b0ICwU$S+sa!t47Qxz6@^SNGI%mXio{VfM~QX zewPxr^_6jqlP}HhcMjQJ`pdCx&d_SWJ2P2j)O9L2Xa#VSQlSZBiR@c^iY@9_?k}c3 z6k@OAZg0(aVm=sM<)@do-^8`#0!S178(WI>aE-9UBhjMd^+{~BJBzHU&1(gq^$Z&) zeQ9Q*{*HwAM{U0b&W{DSJaB?qn_A3giyGbi}kZH+QD zCXAE*fMelZbb- z>2-k&K>BmF+G;?Ht5&_aQ%;2=;mBgdrkzclt z)cT9V{ghl}r1G=hV32tmetF3M@-N{ej@OHkkIBRv=W)g445sr;2<`g7&J7@;aJt@` z0SLQpzPbvC#c9DGv9|);oomT6rH-41-IIwVl$fYe%1mcT(zWM@&HyG@7+7>MPrQX` zIZQOowqc1|nOS|6?bx#_sn+QUN4yC06Cp0qYm8vCp7A-v;mCXedMdePwtRoP&-{YY z$1^^ECi+4x#ig#@cfE}he_GbPv5JeD28@tKUuTRAB~V2t@`aPbR(y@58A!MjqmV$( zFWMMf!;~4ob$H1j=bZTDRDE|f84h$2q37h8X(!6dE{m<13}a7<2Fg=>u=uizXUfIDl&-?zW9@mH)Whngp@BHg+6>V8Q}RO6BrPMKMc7~KO)WrF_LqID zY5l-H=?`O~zM9Q+C{U3wQrpD4YyEn(&;9nGYtaB|{ZKGI`>j^h8$K$6Wd4br=~l1| zs-}S>o30i_lAL)~J-f3b++LOj^lhHi>9APmEoB9U$5z7Up6pP^d zi|sV(H}S@dszo6nTEJHm_L?EE)q(ocZah$IUm_gX&LrvSSU~HXrmk0B=H#9;1>YQ- zb2D??ACVgBVpfHmNC@oC?(s^INdj^a>*UZ(U?6Xa_{(G}4Pgcq=bi-b%B)*_z4tS& z+lJ2*%0MBYHg%wPn+oSVs! zEz%uefX<>A52OPzBa7cAP0ex}`6GDFNO^#)oOq=%5{s{u&*-S0k|5op*!MjJ2nc`! zhIFYQl*ioo(`%pK*wYIJ?4KA(fI}MV8^WP&KOl7pMJ5UQ<|Ht#n(zqP`6n|sIJEP< zwVgi}NKr1lI;LRsiNMbEq^0v$Awfi2DkQZ5riY%ClzKEsK^st;JLg;<^CwGNXJWEG zE9Av7vyT{xvRZRwlg|imC~4U9!&PAdvm5a36IGQ%gv<`oaD~z^g(q`PqGt(+dfq)3 z?EwB|Cl6n-n_l2TQaHaCmyPZ!TmTW77)ujG<#Y6sEi0UHM7sJoTls}u%wlMm5znJ# zhDX0EwXqKtnRvVshbM0C8rtnLlbdZ}Eym_#|k z&tz}>tqPFJ96-RSmSye9r!i@w7=qJS;!mH{Ws8pPofx_rNx9hZXMMOgR$c6qjx%rd z%+yjRh4>q)jqqwY0}`I4hDw1QEZ38)(`T=Q{|Jk-76wh=R6!d+fLCaRNf`5ae0{pW6739S=5_1C zcUR9|E3&2Cs(xSf8rAUZvD^rov<~U7A+&Zadb~dC?Rc@*{jk>`Mv5L6-eP^zydl_x zy%eiZ`lcX;tp8}mM^&MKz4T*F$RB8b7oq)jcipe_(}y6`;V8+_(Kw{Fu%@`Nzr(J@ zxK8^)#oxedgZEW$y6|aS-frliq0y))%1Y_<*3JEFbu=fhx?T|?d?q37tSOah;~jLH z-|ctB)e1gPiBXsum|AK-CK4M&$>2GU*DsBL*Jy9}L4p0T_K zEJWb%ajwL+i}15@V_1?GB!!h=_Thm|h}M%AoF;kZS-*&wXvE;x`7U#7#)U zs4297038RfiefSUsUXUeRF^D8cN5XO%NvMnL`0pt$X;!lR;kJ79+f8NmtPixB!GyF zY*u!T7Z$@?m+LnY86Nc30$s?|RVAOBo&I^{fhHn-^90T<{qk|1N4w*qqocPUpu%@-S!^EQUHFSXzxl?!n0^_!vBbZ)A1H3VH-S7(1+5K` zSVNmaxa9RY4SSks(DD=&HmSM9APW1L^WV{m|h<=#NCofQ(FwJ9_!8|DqHMkv{Xw!t|_N}_=449;Ql)|I@%lKH;&pcIg7yY8KQda#5b>UGP%19N)#Q9*?tHYU5E$iIDiqpg>T27+Z z<@;!2;y3TaP+bz9l?B(fL3p0Ga1*wN0cRtqkRZd?cPJuZPd-z!WHK0nsJ$fpM=2aP zyIil%h^gUf!gOry-TAPGOG|-v!%S*A={D+*4Kz@+<>cZd>g2%mT%d6$}HfN`Ao;<}?}f5SDmd{Lx?>eRsd67?{d2Z$7Tl#`FLsUY!U zHDogQXR{iekvrVL+{vtprdKSPl*Guzr_vT&X>>FjVASAN@t5=REw#6!uGI2VU48u! z)M7-khKK9`H%PkgJ)D{Oh2-JL?l{zAYTxPHN-gDRh}7ijw{_au-EP4-MTXb@!`Qgb zBOZ6C*t2KzT&c*pxr6ZD;!iFTvkeDu%1AnOA|lu)yPI&P?5zOs1M?DiNGgc&sM@fC zcfvjXCp=_-hDFg>+@QF1=+eC9t~W|)=uL51*|Bo=(}s7)lM*n{F;;R?O3%8xY3;K=5wxWEn`~x)Aj3_V|2tuyJ)h>2G{Ux}v7|gtSzpf=z<$4>t z>3v8g?4CtmYVF&hep_kM|D%gK;6kAJ&%`#R^<;jcuF_CtO8N@OWjV z(}J~g2b;!(kgN#f>DQ*F!a;>NfpW7_>h5{1Dms6}kDm|u01i{+hgLloO(@nDnqg(- z)bg_~DtC6SncF!!KGV~X%xjY|GV{LaZ7Idp)$K2wa};2q=iW?^pcb=;mM5Yt2r9*f zon}@g=eVhWhe%5b3bYuZir$&3IfO)){(g{%s$BGk=wwus-!Ia<-KV+4Vdpu<&iddp z+|=gE{_I(hKqhRZ!VmVGMonqccjhol$U4JDExlMqVbt>lhpx~ic->vMw|h>Z`kT`@ zKb80Hi11lx9+ zTl#9&6!qiLMDYR!@fifAMXi~UKF3K-EpAoeCL6>~3yku=Iogvl6LrD8CZCJJ+sF#@ z?X$-OP3+PeXSmxY%FU*3d?B!P-fy;=$RB#CErHT1A`Bh1kW|#M<%mIM#BKNWI@Yolzp;)YT{znfg_08WG5-YO4?mwMyIDD4SG|8^+;!zlCZ z!|2jy@`^eA{PHl6u5eM;Y~h{62h9b`ac+7+DCuNdF`cO>>2o{hlScrDCGPGKdZ}!) zG%c8ccXfoajT)@-Z*OK&?EMFjP>R$)hEQI z*pE+w9GZWDlPJ$gHDw z!;X4Z2K8a+MkeQ6=nim49Y48Fh7j_FGOxrY36yt8#b`o;uv0q-#=^@Q>4SrYM)lUy zF)J9g(Ym$VedpQR`w4G|bn}~iBHza9L`{BgQwX5`sk$b!VBgkg&oijXz| zx;+Y}$b9VZ|MD` zMYJX~+NZqZYYEcfqT62IEzAkRX1&pf9VDlius4pMq=FH26Ba)TiSD=S7J-DB1|WLtJ`sP(^m?_^fjID$VKW*z4> zRUYL@zj`6pUy-%pT<0)OAVWIWs0#HDT_amVCZF3vhITEz#=^BvuSKdHfKzuRk>Hc2 zMIk^z$LUuM$1h5hDYgbncB^HTcoh^Z-(a!S%Z&+n`K`mljm|G?ghPJ4ht5spdyMj% zJ|ag8Mb2FIeiC13HV+Bf6>~DGQvynIy|V~)=+H8!2F-Dr#h5m-35qDMZ}e@~R*d-x z8NKm=OTT)_Z@E(9An*pIMYQGO(pqNipugp4uUOHaora&-7G}Ktt;v?JA6wDSI$}^a z$zgX=^~$|lzsBGXZ~C7_x#a$JE!|}8b+Y8L^73;2$qHxZMi0j^qyD4e4=q>jyJf;I z-Ftf)se3KHoe$8Ks6-?hM!tSqO-fDd3`J8FlUsd-Yg>?<1_R$u_uWU|{ zlTe`YuyrO#jpVKx)*Bq(>!@d;5xpYp>CAh6lV4gFxc{(d_yA|Yy<2QMZG2UMD*#^q z7HB4-7ua3d+=h-u1tXWDlR(u#Qn@Ctea!Fn?kWVD8qYQ>ME3kN_CC|*;%q@wk;zH( zsn8RbBpbdCOuzF4_{ilkvrkl%{P~va1h(zl{aer?qx}7J zLXHu-S471q0el&$+Xwzv-xfjhUmuHRaK7Rc)_VyPB0t+%nxuCuJ0OP+=W z6lV4;yL&sMw4gbGYRlg=%PK1;E2{*Mx4o7pE5?t;3x`7c&CWixO0cP`b4in;4VSN5 z48#^IeJsJZON8g>0WejoK?*~ncA4W#iY&5b<*{rrVPT;R33yj8?g zr#AUPtSKoosBBW(uuA8$ftSO)R+5lN-SE#j$k%;dmp1R5IU{cJ15A!uon;Y4wCuZyY_dTa^jXBrTVa$SP-wYnrrqm#?s`VB?P{93w`K22fQqlCZeg$$i>D z**LTGipBZk(B};SkqA_7YRoY02*e`+5qRZy>LR`P<=Sq*n9x{lR)pOcJ&3^aAd;cs!IS~Itchl4kH91<*jyy$mR(7%JI);(Y3(G$p2 zi|vwKknkBH*#ZrGM}jg#1HUS^Kye z=Pij;MmG8-^z0xw*4cl?vn`!Vj%OkyEp1Ep3H*Ki?dQL<2>8BeMX8J=v zA2KL{J!-r*e?3=iM2X9FclC~qDdT1*)Y@)%i=AFO00u_M--fKrLK)QgLEj@ZJ9yyr zwb%9A3=_`Ogaj05W&CN4k<+~pArCpoIdZR=@LAGO98EKJPJPE)s{93!@y5EbTmV(q zDySedV~F>~ZXh&__=Y9H|4g%0svUL_ey-yVuQ!9IhY0J_4g@K5%0;jWd!!b}Q+FVm z@XUNySX5h`YT!Xo<+<~{(cAV?;hf~s>oD|e)ES9b=0q8qd5ByszHU_7+4cggsSzl; zCcxULxG<$&w=-4Xw{O?m*1*cfQE_~xVgogbl(Sp@Y(FrQ3p#b|n#CNj2Ld);>1QE3r`G z7IeK#)7AZYW9+k?@to|rbx*y?e($(AJecQlVD{jE=42^}|IP=No!^ibKb=H6>zu$% z-Rh3;^TWi`rqjbPA*1FcF*5SCseS=5QPByP1MUa~dHy&aBh(CUXBSgn|D3|zO^1qg zcHelRx_F@g&n`bIxpk|f`Xr9?_=IT5|(^Dm&<#;wuI3uMm5f5NwT|GU! z)!Cub%*8u7Awwqq)w7YScIe%(Wk{eJ;%D$SiO(T>i=U8T29JGG@ZBHfjMFvf(b=i? z;zbHRBhE-Jo3MbIplViucKXvk@npk|n7Ba*vjms`qR)(6!=;f(D5j2#A9fOCgfbmM zTUxEMQvxrzDz4XL$Z}XpQk^n5$RiZsBhhXo89N=K*k|K`fZvl4yffAx5~MWLd<~~( zLFp7f5%JNU^FHkB?;SmodqlcetvnDH+gXrRp2#YyotLGZuCz0%%Ck%2DJ*E*Pr;!9 zM5_=9qe((t|Em0iyC4o07E6F7ZOVZj&Sy7GNh4lwZUZ$8!a^{+(3%HvZ>T0Gr0S&)e zMWJQp+H@@-6EnHQ*c6Y>4z&%e99k|W!}e{k0+&YaMssNc405@Qzb_5D-Y?M#^A_m@ zK8sCevCGYe{;Qp~o_8+6V6ctYcRj%*GCjJccLQl{FV{w}Qa zn|^M5Ik-J!EjWCfqak-i#A_sa&G(F*6S?q3-vQ8w_d8`8wHbXEg&l@o5UkW;NZkLZ zA(iaOWbE%QpGH|<`;Ei*Smguve=ocqIZFM~v z%`d?o$;p1w3ZsNuu6^P(A_Fjy}v_`9_h=CPQ%Rs@`b*t?0^w*cC!NM0sK(AvtSviNcq%9E1SPl(3@KwG@ z;^gmuh1a->zy{=GWwJ7Pa2;RAgjR6nPE%o#WDrlf3LZY*NWG%=I3U%qFo1XLQ~*qu zA6M}E&C1-HUK{=7rS&C0et(HaC+OtH_d{LT1O7n%zmvT+2J@7pz#B^nX_cMH@Xwz| zOkW2VEekF%n%|*4&3Y2YNR@Af5N~ul@m0+dmO+UB#K!|3A2yKDNq9L|JCTI4*)GQQ zVP&S*VN8r^`M^k#4>U*}GaaMV1A^nj?+0K)*AHtx`l0u&(c`N9=;#cHiaJhm<1}%Vr!hc|sSNgGp9bC?wwXQnDaPZz~0PlqsQxzmw@n%k-BI)qHL~Hq4B~6ChmX2y^>u3gl zZu#gi20t>(AOmk zc%+}*Yn5OwnU{bf81KnsDznXfz-!EokAF|QENPvDXV0Hgi5@X_c6bd_u%&{IuO^gc znv?+*o>dtK=>oo~ViI;mKvnt9(pghhhwmSRoqrk@UK(yf{mG-z3oXzuCVuR zWjN~0Zm!sC_HJMb(1K;!>D^wH%%qNuTr?I(Y-r0!#pQLs?l;Vvy2Pg)y&{>Bre`f@J|c&dT1S@8TY z$fK;JL<87L0v;PRq!U)PeVPhyCI?{J)yz1#`<#DmAA>SV0*=91R;_QEP7{qzi8JMZM2z?t2?^N0P553jsf5H7fga0SmX#ZF2|Ks5QTdMpI*#CD#{~xgb-@*UybNcV#|Mxll zH|+oO;Qz^r|3Q}d|A-^&)qv{TA-p(fzZ_noDRvF6c23|wyk93vpylX)ET(@)_TNbT h?~f5Ei@oaHoga>D5oY{DQyw<{{Sc7bv*z8 From 9d9d360bdbcfdfed2d18a12f07076f593abea57e Mon Sep 17 00:00:00 2001 From: Dan Lee <71398022+dandhlee@users.noreply.github.com> Date: Thu, 2 Sep 2021 12:15:35 -0400 Subject: [PATCH 26/39] chore: migrate to main branch (#706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: migrate to main branch * 🦉 Updates from OwlBot See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * use latest post processor image * remove obsolete replacements in owlbot.py * update post processor image Co-authored-by: Owl Bot Co-authored-by: Anthonios Partheniou --- .github/.OwlBot.lock.yaml | 4 ++-- .github/workflows/django_tests_against_emulator0.yml | 2 +- .github/workflows/django_tests_against_emulator1.yml | 2 +- .github/workflows/django_tests_against_emulator2.yml | 2 +- .github/workflows/django_tests_against_emulator3.yml | 2 +- .github/workflows/django_tests_against_emulator4.yml | 2 +- .github/workflows/django_tests_against_emulator5.yml | 2 +- .github/workflows/django_tests_against_emulator6.yml | 2 +- .github/workflows/django_tests_against_emulator7.yml | 2 +- .github/workflows/django_tests_against_emulator8.yml | 2 +- .github/workflows/django_tests_against_emulator9.yml | 2 +- .github/workflows/integration-tests-against-emulator.yml | 2 +- .kokoro/test-samples-impl.sh | 2 +- README.rst | 8 ++++---- 14 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml index 9ce8396fe0..cd8bc8740c 100644 --- a/.github/.OwlBot.lock.yaml +++ b/.github/.OwlBot.lock.yaml @@ -5,5 +5,5 @@ # https://developers.google.com/open-source/licenses/bsd docker: - image: gcr.io/cloud-devrel-public-resources/owlbot-python:latest - digest: sha256:82d046d572a11b84d64d4b9af93ad55a1b6a4854917504ba7557acdfce907dde + image: gcr.io/repo-automation-bots/owlbot-python:latest + digest: sha256:0ffe3bdd6c7159692df5f7744da74e5ef19966288a6bf76023e8e04e0c424d7d diff --git a/.github/workflows/django_tests_against_emulator0.yml b/.github/workflows/django_tests_against_emulator0.yml index 0f7f7c99db..2b31049e72 100644 --- a/.github/workflows/django_tests_against_emulator0.yml +++ b/.github/workflows/django_tests_against_emulator0.yml @@ -1,7 +1,7 @@ on: push: branches: - - master + - main pull_request: name: django-tests0 jobs: diff --git a/.github/workflows/django_tests_against_emulator1.yml b/.github/workflows/django_tests_against_emulator1.yml index 921a9a5848..722a593af1 100644 --- a/.github/workflows/django_tests_against_emulator1.yml +++ b/.github/workflows/django_tests_against_emulator1.yml @@ -1,7 +1,7 @@ on: push: branches: - - master + - main pull_request: name: django-tests1 jobs: diff --git a/.github/workflows/django_tests_against_emulator2.yml b/.github/workflows/django_tests_against_emulator2.yml index 682de38ed6..364803ab3a 100644 --- a/.github/workflows/django_tests_against_emulator2.yml +++ b/.github/workflows/django_tests_against_emulator2.yml @@ -1,7 +1,7 @@ on: push: branches: - - master + - main pull_request: name: django-tests2 jobs: diff --git a/.github/workflows/django_tests_against_emulator3.yml b/.github/workflows/django_tests_against_emulator3.yml index ea83185a82..9e2df8ae94 100644 --- a/.github/workflows/django_tests_against_emulator3.yml +++ b/.github/workflows/django_tests_against_emulator3.yml @@ -1,7 +1,7 @@ on: push: branches: - - master + - main pull_request: name: django-tests3 jobs: diff --git a/.github/workflows/django_tests_against_emulator4.yml b/.github/workflows/django_tests_against_emulator4.yml index 6a59ff7725..361dccfbc7 100644 --- a/.github/workflows/django_tests_against_emulator4.yml +++ b/.github/workflows/django_tests_against_emulator4.yml @@ -1,7 +1,7 @@ on: push: branches: - - master + - main pull_request: name: django-tests4 jobs: diff --git a/.github/workflows/django_tests_against_emulator5.yml b/.github/workflows/django_tests_against_emulator5.yml index f07609fb2f..817b0c4295 100644 --- a/.github/workflows/django_tests_against_emulator5.yml +++ b/.github/workflows/django_tests_against_emulator5.yml @@ -1,7 +1,7 @@ on: push: branches: - - master + - main pull_request: name: django-tests5 jobs: diff --git a/.github/workflows/django_tests_against_emulator6.yml b/.github/workflows/django_tests_against_emulator6.yml index 3984852de2..ab9d817c98 100644 --- a/.github/workflows/django_tests_against_emulator6.yml +++ b/.github/workflows/django_tests_against_emulator6.yml @@ -1,7 +1,7 @@ on: push: branches: - - master + - main pull_request: name: django-tests6 jobs: diff --git a/.github/workflows/django_tests_against_emulator7.yml b/.github/workflows/django_tests_against_emulator7.yml index 0932197f86..4ee19f9373 100644 --- a/.github/workflows/django_tests_against_emulator7.yml +++ b/.github/workflows/django_tests_against_emulator7.yml @@ -1,7 +1,7 @@ on: push: branches: - - master + - main pull_request: name: django-tests7 jobs: diff --git a/.github/workflows/django_tests_against_emulator8.yml b/.github/workflows/django_tests_against_emulator8.yml index 98d4b5f1d8..5c148ddd23 100644 --- a/.github/workflows/django_tests_against_emulator8.yml +++ b/.github/workflows/django_tests_against_emulator8.yml @@ -1,7 +1,7 @@ on: push: branches: - - master + - main pull_request: name: django-tests8 jobs: diff --git a/.github/workflows/django_tests_against_emulator9.yml b/.github/workflows/django_tests_against_emulator9.yml index 0ba4baefce..4388c3cfa6 100644 --- a/.github/workflows/django_tests_against_emulator9.yml +++ b/.github/workflows/django_tests_against_emulator9.yml @@ -1,7 +1,7 @@ on: push: branches: - - master + - main pull_request: name: django-tests9 jobs: diff --git a/.github/workflows/integration-tests-against-emulator.yml b/.github/workflows/integration-tests-against-emulator.yml index efd2c4e5bb..5767e966bb 100644 --- a/.github/workflows/integration-tests-against-emulator.yml +++ b/.github/workflows/integration-tests-against-emulator.yml @@ -1,7 +1,7 @@ on: push: branches: - - master + - main pull_request: name: Run Django Spanner integration tests against emulator jobs: diff --git a/.kokoro/test-samples-impl.sh b/.kokoro/test-samples-impl.sh index ac70148661..595dca8b0d 100755 --- a/.kokoro/test-samples-impl.sh +++ b/.kokoro/test-samples-impl.sh @@ -72,7 +72,7 @@ for file in samples/**/requirements.txt; do EXIT=$? # If this is a periodic build, send the test log to the FlakyBot. - # See https://github.com/googleapis/repo-automation-bots/tree/master/packages/flakybot. + # See https://github.com/googleapis/repo-automation-bots/tree/main/packages/flakybot. if [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"periodic"* ]]; then chmod +x $KOKORO_GFILE_DIR/linux_amd64/flakybot $KOKORO_GFILE_DIR/linux_amd64/flakybot diff --git a/README.rst b/README.rst index 637ffe6c7e..62739a3182 100644 --- a/README.rst +++ b/README.rst @@ -220,13 +220,13 @@ How it works Overall design ~~~~~~~~~~~~~~ -.. figure:: https://raw.githubusercontent.com/googleapis/python-spanner-django/master/assets/overview.png +.. figure:: https://raw.githubusercontent.com/googleapis/python-spanner-django/main/assets/overview.png :alt: "Overall Design" Internals ~~~~~~~~~ -.. figure:: https://raw.githubusercontent.com/googleapis/python-spanner-django/master/assets/internals.png +.. figure:: https://raw.githubusercontent.com/googleapis/python-spanner-django/main/assets/internals.png :alt: "Internals" @@ -247,11 +247,11 @@ HOW TO CONTRIBUTE Contributions to this library are always welcome and highly encouraged. -See `CONTRIBUTING `_ for more information on how to get started. +See `CONTRIBUTING `_ for more information on how to get started. Please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms. See the `Code -of Conduct `_ for more information. +of Conduct `_ for more information. Current limitations ------------------- From 578f5bc8bc2b95db557106cd18879339f3096114 Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Mon, 13 Sep 2021 20:07:59 +0530 Subject: [PATCH 27/39] fix: added fixes for latest feature changes in django 3.2 --- README.rst | 124 +---------- django_spanner/__init__.py | 15 +- django_spanner/base.py | 7 + django_spanner/features.py | 234 ++++++++++++++++---- django_spanner/operations.py | 35 ++- django_spanner/schema.py | 34 ++- docs/index.rst | 8 + docs/limitations.rst | 165 ++++++++++++++ noxfile.py | 2 +- tests/system/django_spanner/test_decimal.py | 11 +- 10 files changed, 451 insertions(+), 184 deletions(-) create mode 100644 docs/limitations.rst diff --git a/README.rst b/README.rst index e79e23cd91..895bde8708 100644 --- a/README.rst +++ b/README.rst @@ -253,125 +253,11 @@ Please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms. See the `Code of Conduct `_ for more information. -Current limitations -------------------- -``AutoField`` generates random IDs -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Spanner doesn't have support for auto-generating primary key values. -Therefore, ``django-google-spanner`` monkey-patches ``AutoField`` to generate a -random UUID4. It generates a default using ``Field``'s ``default`` option which -means ``AutoField``\ s will have a value when a model instance is created. For -example: - -:: - - >>> ExampleModel() - >>> ExampleModel.pk - 4229421414948291880 - -To avoid -`hotspotting `__, -these IDs are not monotonically increasing. This means that sorting -models by ID isn't guaranteed to return them in the order in which they -were created. - -``ForeignKey`` constraints aren't created (`#313 `__) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Spanner does not support ``ON DELETE CASCADE`` when creating foreign-key -constraints, so this is not supported in ``django-google-spanner``. - -Check constraints aren't supported -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Spanner does not support ``CHECK`` constraints so one isn't created for -`PositiveIntegerField -`__ -and `CheckConstraint -`__ -can't be used. - -No native support for ``DecimalField`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Spanner's support for `Decimal `__ -types is limited to -`NUMERIC `__ -precision. Higher-precision values can be stored as strings instead. - -``Meta.order_with_respect_to`` model option isn't supported -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -This feature uses a column name that starts with an underscore -(``_order``) which Spanner doesn't allow. - -Random ``QuerySet`` ordering isn't supported -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Spanner does not support it and will throw an exception. For example: - -:: - - >>> ExampleModel.objects.order_by('?') - ... - django.db.utils.ProgrammingError: 400 Function not found: RANDOM ... FROM - example_model ORDER BY RANDOM() ASC - -Schema migrations -~~~~~~~~~~~~~~~~~ - -There are some limitations on schema changes to consider: - -- No support for renaming tables and columns; -- A column's type can't be changed; -- A table's primary key can't be altered. - -``DurationField`` arithmetic doesn't work with ``DateField`` values (`#253 `__) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Spanner requires using different functions for arithmetic depending on -the column type: - -- ``TIMESTAMP`` columns (``DateTimeField``) require ``TIMESTAMP_ADD`` - or ``TIMESTAMP_SUB`` -- ``DATE`` columns (``DateField``) require ``DATE_ADD`` or ``DATE_SUB`` - -Django does not provide ways to determine which database function to -use. ``DatabaseOperations.combine_duration_expression()`` arbitrarily uses -``TIMESTAMP_ADD`` and ``TIMESTAMP_SUB``. Therefore, if you use a -``DateField`` in a ``DurationField`` expression, you'll likely see an error -such as: - -:: - - "No matching signature for function TIMESTAMP\_ADD for argument types: - DATE, INTERVAL INT64 DATE\_TIME\_PART." - -Computations that yield FLOAT64 values cannot be assigned to INT64 columns -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Spanner does not support this (`#331 -`__) and will -throw an error: - -:: - - >>> ExampleModel.objects.update(integer=F('integer') / 2) - ... - django.db.utils.ProgrammingError: 400 Value of type FLOAT64 cannot be - assigned to integer, which has type INT64 [at 1:46]\nUPDATE - example_model SET integer = (example_model.integer /... - -Addition with null values crash -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Additions cannot include ``None`` values. For example: +LIMITATIONS +----------------- -:: +Spanner has certain limitations of it's own and a full set of limitations are documented over `here `_ +It is recommended that you go through that list. - >>> Book.objects.annotate(adjusted_rating=F('rating') + None) - ... - google.api_core.exceptions.InvalidArgument: 400 Operands of + cannot be literal - NULL ... +Django spanner has a set of limitations as well, please go through the list `here . diff --git a/django_spanner/__init__.py b/django_spanner/__init__.py index 861e3abb94..90243795ef 100644 --- a/django_spanner/__init__.py +++ b/django_spanner/__init__.py @@ -11,7 +11,12 @@ from uuid import uuid4 import pkg_resources -from django.db.models.fields import AutoField, Field +from django.db.models.fields import ( + AutoField, + SmallAutoField, + BigAutoField, + Field, +) # Monkey-patch google.DatetimeWithNanoseconds's __eq__ compare against # datetime.datetime. @@ -42,6 +47,14 @@ def autofield_init(self, *args, **kwargs): AutoField.__init__ = autofield_init +SmallAutoField.__init__ = autofield_init +BigAutoField.__init__ = autofield_init +AutoField.db_returning = False +SmallAutoField.db_returning = False +BigAutoField.db_returning = False +AutoField.validators = [] +SmallAutoField.validators = [] +BigAutoField.validators = [] old_datetimewithnanoseconds_eq = getattr( DatetimeWithNanoseconds, "__eq__", None diff --git a/django_spanner/base.py b/django_spanner/base.py index 4a4b86ff7d..00033bf223 100644 --- a/django_spanner/base.py +++ b/django_spanner/base.py @@ -45,6 +45,7 @@ class DatabaseWrapper(BaseDatabaseWrapper): "GenericIPAddressField": "STRING(39)", "NullBooleanField": "BOOL", "OneToOneField": "INT64", + "PositiveBigIntegerField": "INT64", "PositiveIntegerField": "INT64", "PositiveSmallIntegerField": "INT64", "SlugField": "STRING(%(max_length)s)", @@ -96,6 +97,12 @@ class DatabaseWrapper(BaseDatabaseWrapper): "iendswith": "", } + data_type_check_constraints = { + "PositiveBigIntegerField": "%(column)s >= 0", + "PositiveIntegerField": "%(column)s >= 0", + "PositiveSmallIntegerField": "%(column)s >= 0", + } + Database = spanner_dbapi SchemaEditorClass = DatabaseSchemaEditor creation_class = DatabaseCreation diff --git a/django_spanner/features.py b/django_spanner/features.py index af7e4c1131..22409c834d 100644 --- a/django_spanner/features.py +++ b/django_spanner/features.py @@ -30,19 +30,88 @@ class DatabaseFeatures(BaseDatabaseFeatures): supports_sequence_reset = False supports_timezones = False supports_transactions = False - supports_column_check_constraints = False + supports_column_check_constraints = True supports_table_check_constraints = False + supports_order_by_nulls_modifier = False + # Spanner does not support json + supports_json_field = False + supports_primitives_in_json_field = 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 + # Spanner does not support expression indexes + # example: CREATE INDEX index_name ON table (LOWER(column_name)) + supports_expression_indexes = False # Django tests that aren't supported by Spanner. skip_tests = ( + # Spanner autofield is replaced with uuid4 so validation is disabled + "model_fields.test_autofield.AutoFieldTests.test_backend_range_validation", + "model_fields.test_autofield.AutoFieldTests.test_redundant_backend_range_validators", + "model_fields.test_autofield.AutoFieldTests.test_redundant_backend_range_validators", + "model_fields.test_autofield.BigAutoFieldTests.test_redundant_backend_range_validators", + "model_fields.test_autofield.BigAutoFieldTests.test_redundant_backend_range_validators", + "model_fields.test_autofield.SmallAutoFieldTests.test_backend_range_validation", + "model_fields.test_autofield.SmallAutoFieldTests.test_redundant_backend_range_validators", + "model_fields.test_autofield.SmallAutoFieldTests.test_redundant_backend_range_validators", + # Spanner does not support deferred unique constraints + "migrations.test_operations.OperationTests.test_create_model_with_deferred_unique_constraint", + # Spanner does not support JSON objects + "db_functions.comparison.test_json_object.JSONObjectTests.test_empty", + "db_functions.comparison.test_json_object.JSONObjectTests.test_basic", + "db_functions.comparison.test_json_object.JSONObjectTests.test_expressions", + "db_functions.comparison.test_json_object.JSONObjectTests.test_nested_empty_json_object", + "db_functions.comparison.test_json_object.JSONObjectTests.test_nested_json_object", + "db_functions.comparison.test_json_object.JSONObjectTests.test_textfield", + # Spanner does not support iso_week_day but week_day is supported. + "db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_func", + "db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_iso_weekday_func", + "db_functions.datetime.test_extract_trunc.DateFunctionWithTimeZoneTests.test_extract_func", + "db_functions.datetime.test_extract_trunc.DateFunctionWithTimeZoneTests.test_extract_func_with_timezone", + "db_functions.datetime.test_extract_trunc.DateFunctionWithTimeZoneTests.test_extract_func_with_timezone", + "db_functions.datetime.test_extract_trunc.DateFunctionWithTimeZoneTests.test_extract_iso_weekday_func", + # Spanner gived SHA encryption output in bytes, django expects it in hex string format. + "db_functions.text.test_sha512.SHA512Tests.test_basic", + "db_functions.text.test_sha512.SHA512Tests.test_transform", + "db_functions.text.test_md5.MD5Tests.test_basic", + "db_functions.text.test_md5.MD5Tests.test_transform", + "db_functions.text.test_sha1.SHA1Tests.test_basic", + "db_functions.text.test_sha1.SHA1Tests.test_transform", + "db_functions.text.test_sha224.SHA224Tests.test_basic", + "db_functions.text.test_sha224.SHA224Tests.test_transform", + "db_functions.text.test_sha256.SHA256Tests.test_basic", + "db_functions.text.test_sha256.SHA256Tests.test_transform", + "db_functions.text.test_sha384.SHA384Tests.test_basic", + "db_functions.text.test_sha384.SHA384Tests.test_transform", + # Spanner does not support RANDOM number generation function + "db_functions.math.test_random.RandomTests.test", + # Spanner supports order by id, but it's does not work the same way as + # an auto increment field. + "ordering.tests.OrderingTests.test_order_by_self_referential_fk", + "fixtures.tests.ForwardReferenceTests.test_forward_reference_m2m_natural_key", + "fixtures.tests.ForwardReferenceTests.test_forward_reference_fk_natural_key", + # Spanner does not support empty list of DML statement. + "backends.tests.BackendTestCase.test_cursor_executemany_with_empty_params_list", + # Spanner does not support SELECTing an arbitrary expression that also + # appears in the GROUP BY clause. + "annotations.tests.NonAggregateAnnotationTestCase.test_grouping_by_q_expression_annotation", # No foreign key constraints in Spanner. "backends.tests.FkConstraintsTests.test_check_constraints", "fixtures_regress.tests.TestFixtures.test_loaddata_raises_error_when_fixture_has_invalid_foreign_key", # No Django transaction management in Spanner. "basic.tests.SelectOnSaveTests.test_select_on_save_lying_update", # django_spanner monkey patches AutoField to have a default value. + # Tests that expect it to be empty untill saved in db. + "test_utils.test_testcase.TestDataTests.test_class_attribute_identity", + "model_fields.test_jsonfield.TestSerialization.test_dumping", + "model_fields.test_jsonfield.TestSerialization.test_dumping", + "model_fields.test_jsonfield.TestSerialization.test_dumping", + "model_fields.test_jsonfield.TestSerialization.test_xml_serialization", + "model_fields.test_jsonfield.TestSerialization.test_xml_serialization", + "model_fields.test_jsonfield.TestSerialization.test_xml_serialization", "basic.tests.ModelTest.test_hash", + "bulk_create.tests.BulkCreateTests.test_unsaved_parent", "custom_managers.tests.CustomManagerTests.test_slow_removal_through_specified_fk_related_manager", "custom_managers.tests.CustomManagerTests.test_slow_removal_through_default_fk_related_manager", "generic_relations.test_forms.GenericInlineFormsetTests.test_options", @@ -149,12 +218,12 @@ class DatabaseFeatures(BaseDatabaseFeatures): "prefetch_related.test_prefetch_related_objects.PrefetchRelatedObjectsTests.test_m2m_then_m2m", "prefetch_related.tests.CustomPrefetchTests.test_custom_qs", "prefetch_related.tests.CustomPrefetchTests.test_nested_prefetch_related_are_not_overwritten", - "prefetch_related.tests.DirectPrefechedObjectCacheReuseTests.test_detect_is_fetched", - "prefetch_related.tests.DirectPrefechedObjectCacheReuseTests.test_detect_is_fetched_with_to_attr", + "prefetch_related.tests.DirectPrefetchedObjectCacheReuseTests.test_detect_is_fetched", + "prefetch_related.tests.DirectPrefetchedObjectCacheReuseTests.test_detect_is_fetched_with_to_attr", "prefetch_related.tests.ForeignKeyToFieldTest.test_m2m", "queries.test_bulk_update.BulkUpdateNoteTests.test_multiple_fields", "queries.test_bulk_update.BulkUpdateTests.test_inherited_fields", - "queries.tests.Queries1Tests.test_ticket9411", + # "queries.tests.Queries1Tests.test_ticket9411", "queries.tests.Queries4Tests.test_ticket15316_exclude_true", "queries.tests.Queries5Tests.test_ticket7256", "queries.tests.SubqueryTests.test_related_sliced_subquery", @@ -233,17 +302,13 @@ class DatabaseFeatures(BaseDatabaseFeatures): "queries.test_bulk_update.BulkUpdateTests.test_large_batch", # Spanner doesn't support random ordering. "ordering.tests.OrderingTests.test_random_ordering", + "aggregation.tests.AggregateTestCase.test_aggregation_random_ordering", # casting DateField to DateTimeField adds an unexpected hour: # https://github.com/googleapis/python-spanner-django/issues/260 "db_functions.comparison.test_cast.CastTests.test_cast_from_db_date_to_datetime", # Tests that fail during tear down on databases that don't support # transactions: https://github.com/googleapis/python-spanner-django/issues/271 - "admin_views.test_multidb.MultiDatabaseTests.test_add_view", - "admin_views.test_multidb.MultiDatabaseTests.test_change_view", - "admin_views.test_multidb.MultiDatabaseTests.test_delete_view", - "auth_tests.test_admin_multidb.MultiDatabaseTests.test_add_view", - "auth_tests.test_remote_user_deprecation.RemoteUserCustomTest.test_configure_user_deprecation_warning", - "contenttypes_tests.test_models.ContentTypesMultidbTests.test_multidb", + # "contenttypes_tests.test_models.ContentTypesMultidbTests.test_multidb", # Tests that by-pass using django_spanner and generate # invalid DDL: https://github.com/googleapis/python-spanner-django/issues/298 "cache.tests.CreateCacheTableForDBCacheTests", @@ -259,6 +324,10 @@ class DatabaseFeatures(BaseDatabaseFeatures): "transaction_hooks.tests.TestConnectionOnCommit.test_hooks_cleared_on_reconnect", "transaction_hooks.tests.TestConnectionOnCommit.test_no_hooks_run_from_failed_transaction", "transaction_hooks.tests.TestConnectionOnCommit.test_no_savepoints_atomic_merged_with_outer", + "test_utils.tests.CaptureOnCommitCallbacksTests.test_execute", + "test_utils.tests.CaptureOnCommitCallbacksTests.test_no_arguments", + "test_utils.tests.CaptureOnCommitCallbacksTests.test_pre_callback", + "test_utils.tests.CaptureOnCommitCallbacksTests.test_using", # Tests that require savepoints. "get_or_create.tests.UpdateOrCreateTests.test_integrity", "get_or_create.tests.UpdateOrCreateTests.test_manual_primary_key_test", @@ -270,6 +339,11 @@ class DatabaseFeatures(BaseDatabaseFeatures): # Spanner doesn't support views. "inspectdb.tests.InspectDBTransactionalTests.test_include_views", "introspection.tests.IntrospectionTests.test_table_names_with_views", + # Fields: JSON, GenericIPAddressField are mapped to String in Spanner + "inspectdb.tests.InspectDBTestCase.test_field_types", + "inspectdb.tests.InspectDBTestCase.test_json_field", + # BigIntegerField is mapped to IntegerField in Spanner + "inspectdb.tests.InspectDBTestCase.test_number_field_types", # No sequence for AutoField in Spanner. "introspection.tests.IntrospectionTests.test_sequence_list", # DatabaseIntrospection.get_key_columns() is only required if this @@ -293,6 +367,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): "schema.tests.SchemaTests.test_alter_text_field_to_datetime_field", "schema.tests.SchemaTests.test_alter_text_field_to_time_field", # Spanner limitation: Cannot rename tables and columns. + "migrations.test_operations.OperationTests.test_rename_field_case", "contenttypes_tests.test_operations.ContentTypeOperationsTests", "migrations.test_operations.OperationTests.test_alter_fk_non_fk", "migrations.test_operations.OperationTests.test_alter_model_table", @@ -372,6 +447,8 @@ class DatabaseFeatures(BaseDatabaseFeatures): "view_tests.tests.test_csrf.CsrfViewTests.test_no_cookies", "view_tests.tests.test_csrf.CsrfViewTests.test_no_referer", "view_tests.tests.test_i18n.SetLanguageTests.test_lang_from_translated_i18n_pattern", + # Tests that fail but are not related to spanner. + "test_utils.test_testcase.TestDataTests.test_undeepcopyable_warning", ) if os.environ.get("SPANNER_EMULATOR_HOST", None): @@ -379,6 +456,18 @@ class DatabaseFeatures(BaseDatabaseFeatures): skip_tests += ( # Untyped parameters are not supported: # https://github.com/GoogleCloudPlatform/cloud-spanner-emulator#features-and-limitations + "admin_views.test_multidb.MultiDatabaseTests.test_delete_view", # noqa + "auth_tests.test_admin_multidb.MultiDatabaseTests.test_add_view", # noqa + "admin_docs.test_middleware.XViewMiddlewareTest.test_no_auth_middleware", # noqa + "admin_changelist.test_date_hierarchy.DateHierarchyTests.test_bounded_params_with_dst_time_zone", # noqa + "admin_changelist.tests.ChangeListTests.test_changelist_search_form_validation", # noqa + "admin_changelist.tests.ChangeListTests.test_clear_all_filters_link", # noqa + "admin_changelist.tests.ChangeListTests.test_clear_all_filters_link_callable_filter", # noqa + "admin_changelist.tests.ChangeListTests.test_no_clear_all_filters_link", # noqa + "admin_changelist.tests.ChangeListTests.test_total_ordering_optimization_meta_constraints", # noqa + "admin_inlines.tests.TestInline.test_custom_form_tabular_inline_extra_field_label", # noqa + "admin_inlines.tests.TestInline.test_inlines_singular_heading_one_to_one", # noqa + "admin_inlines.tests.TestInline.test_non_editable_custom_form_tabular_inline_extra_field_label", # noqa "admin_changelist.test_date_hierarchy.DateHierarchyTests.test_bounded_params", # noqa "admin_changelist.test_date_hierarchy.DateHierarchyTests.test_bounded_params_with_time_zone", # noqa "admin_changelist.test_date_hierarchy.DateHierarchyTests.test_invalid_params", # noqa @@ -390,14 +479,14 @@ class DatabaseFeatures(BaseDatabaseFeatures): "admin_changelist.tests.ChangeListTests.test_custom_paginator", # noqa "admin_changelist.tests.ChangeListTests.test_deterministic_order_for_model_ordered_by_its_manager", # noqa "admin_changelist.tests.ChangeListTests.test_deterministic_order_for_unordered_model", # noqa - "admin_changelist.tests.ChangeListTests.test_distinct_for_inherited_m2m_in_list_filter", # noqa - "admin_changelist.tests.ChangeListTests.test_distinct_for_m2m_in_list_filter", # noqa - "admin_changelist.tests.ChangeListTests.test_distinct_for_m2m_to_inherited_in_list_filter", # noqa - "admin_changelist.tests.ChangeListTests.test_distinct_for_many_to_many_at_second_level_in_search_fields", # noqa - "admin_changelist.tests.ChangeListTests.test_distinct_for_non_unique_related_object_in_list_filter", # noqa - "admin_changelist.tests.ChangeListTests.test_distinct_for_non_unique_related_object_in_search_fields", # noqa - "admin_changelist.tests.ChangeListTests.test_distinct_for_through_m2m_at_second_level_in_list_filter", # noqa - "admin_changelist.tests.ChangeListTests.test_distinct_for_through_m2m_in_list_filter", # noqa + "admin_changelist.tests.ChangeListTests.test_no_duplicates_for_inherited_m2m_in_list_filter", # noqa + "admin_changelist.tests.ChangeListTests.test_no_duplicates_for_m2m_in_list_filter", # noqa + "admin_changelist.tests.ChangeListTests.test_no_duplicates_for_m2m_to_inherited_in_list_filter", # noqa + "admin_changelist.tests.ChangeListTests.test_no_duplicates_for_many_to_many_at_second_level_in_search_fields", # noqa + "admin_changelist.tests.ChangeListTests.test_no_duplicates_for_non_unique_related_object_in_list_filter", # noqa + "admin_changelist.tests.ChangeListTests.test_no_duplicates_for_non_unique_related_object_in_search_fields", # noqa + "admin_changelist.tests.ChangeListTests.test_no_duplicates_for_through_m2m_at_second_level_in_list_filter", # noqa + "admin_changelist.tests.ChangeListTests.test_no_duplicates_for_through_m2m_in_list_filter", # noqa "admin_changelist.tests.ChangeListTests.test_dynamic_list_display", # noqa "admin_changelist.tests.ChangeListTests.test_dynamic_list_display_links", # noqa "admin_changelist.tests.ChangeListTests.test_dynamic_list_filter", # noqa @@ -407,7 +496,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): "admin_changelist.tests.ChangeListTests.test_get_list_editable_queryset_with_regex_chars_in_prefix", # noqa "admin_changelist.tests.ChangeListTests.test_get_select_related_custom_method", # noqa "admin_changelist.tests.ChangeListTests.test_multiuser_edit", # noqa - "admin_changelist.tests.ChangeListTests.test_no_distinct_for_m2m_in_list_filter_without_params", # noqa + "admin_changelist.tests.ChangeListTests.test_no_exists_for_m2m_in_list_filter_without_params", # noqa "admin_changelist.tests.ChangeListTests.test_no_list_display_links", # noqa "admin_changelist.tests.ChangeListTests.test_object_tools_displayed_no_add_permission", # noqa "admin_changelist.tests.ChangeListTests.test_pagination", # noqa @@ -570,7 +659,6 @@ class DatabaseFeatures(BaseDatabaseFeatures): "aggregation.tests.AggregateTestCase.test_filter_aggregate", # noqa "aggregation.tests.AggregateTestCase.test_fkey_aggregate", # noqa "aggregation.tests.AggregateTestCase.test_grouped_annotation_in_group_by", # noqa - "aggregation.tests.AggregateTestCase.test_missing_output_field_raises_error", # noqa "aggregation.tests.AggregateTestCase.test_more_aggregation", # noqa "aggregation.tests.AggregateTestCase.test_multi_arg_aggregate", # noqa "aggregation.tests.AggregateTestCase.test_multiple_aggregates", # noqa @@ -649,10 +737,10 @@ class DatabaseFeatures(BaseDatabaseFeatures): "auth_tests.test_context_processors.AuthContextProcessorTests.test_session_is_accessed", # noqa "auth_tests.test_context_processors.AuthContextProcessorTests.test_session_not_accessed", # noqa "auth_tests.test_context_processors.AuthContextProcessorTests.test_user_attrs", # noqa - "auth_tests.test_decorators.LoginRequiredTestCase.testCallable", # noqa - "auth_tests.test_decorators.LoginRequiredTestCase.testLoginRequired", # noqa - "auth_tests.test_decorators.LoginRequiredTestCase.testLoginRequiredNextUrl", # noqa - "auth_tests.test_decorators.LoginRequiredTestCase.testView", # noqa + "auth_tests.test_decorators.LoginRequiredTestCase.test_callable", # noqa + "auth_tests.test_decorators.LoginRequiredTestCase.test_login_required", # noqa + "auth_tests.test_decorators.LoginRequiredTestCase.test_login_required_next_url", # noqa + "auth_tests.test_decorators.LoginRequiredTestCase.test_view", # noqa "auth_tests.test_decorators.PermissionsRequiredDecoratorTest.test_many_permissions_in_set_pass", # noqa "auth_tests.test_decorators.PermissionsRequiredDecoratorTest.test_many_permissions_pass", # noqa "auth_tests.test_decorators.PermissionsRequiredDecoratorTest.test_permissioned_denied_exception_raised", # noqa @@ -824,20 +912,19 @@ class DatabaseFeatures(BaseDatabaseFeatures): "auth_tests.test_remote_user.RemoteUserTest.test_last_login", # noqa "auth_tests.test_remote_user.RemoteUserTest.test_unknown_user", # noqa "auth_tests.test_remote_user.RemoteUserTest.test_user_switch_forces_new_login", # noqa - "auth_tests.test_remote_user_deprecation.RemoteUserCustomTest.test_configure_user_deprecation_warning", # noqa "auth_tests.test_signals.SignalTestCase.test_failed_login_without_request", # noqa "auth_tests.test_signals.SignalTestCase.test_login", # noqa "auth_tests.test_signals.SignalTestCase.test_login_with_custom_user_without_last_login_field", # noqa "auth_tests.test_signals.SignalTestCase.test_logout", # noqa "auth_tests.test_signals.SignalTestCase.test_logout_anonymous", # noqa "auth_tests.test_signals.SignalTestCase.test_update_last_login", # noqa - "auth_tests.test_templates.AuthTemplateTests.test_PasswordChangeDoneView", # noqa - "auth_tests.test_templates.AuthTemplateTests.test_PasswordResetChangeView", # noqa - "auth_tests.test_templates.AuthTemplateTests.test_PasswordResetCompleteView", # noqa - "auth_tests.test_templates.AuthTemplateTests.test_PasswordResetConfirmView_invalid_token", # noqa - "auth_tests.test_templates.AuthTemplateTests.test_PasswordResetConfirmView_valid_token", # noqa - "auth_tests.test_templates.AuthTemplateTests.test_PasswordResetDoneView", # noqa - "auth_tests.test_templates.AuthTemplateTests.test_PasswordResetView", # noqa + "auth_tests.test_templates.AuthTemplateTests.test_password_change_done_view", # noqa + "auth_tests.test_templates.AuthTemplateTests.test_password_reset_change_view", # noqa + "auth_tests.test_templates.AuthTemplateTests.test_password_reset_complete_view", # noqa + "auth_tests.test_templates.AuthTemplateTests.test_password_reset_confirm_view_invalid_token", # noqa + "auth_tests.test_templates.AuthTemplateTests.test_password_reset_confirm_view_valid_token", # noqa + "auth_tests.test_templates.AuthTemplateTests.test_password_reset_done_view", # noqa + "auth_tests.test_templates.AuthTemplateTests.test_password_reset_view", # noqa "auth_tests.test_tokens.TokenGeneratorTest.test_10265", # noqa "auth_tests.test_tokens.TokenGeneratorTest.test_check_token_with_nonexistent_token_and_user", # noqa "auth_tests.test_tokens.TokenGeneratorTest.test_make_token", # noqa @@ -1170,7 +1257,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): "generic_inline_admin.tests.GenericAdminViewTest.test_basic_add_POST", # noqa "generic_inline_admin.tests.GenericAdminViewTest.test_basic_edit_GET", # noqa "generic_inline_admin.tests.GenericAdminViewTest.test_basic_edit_POST", # noqa - "generic_inline_admin.tests.GenericInlineAdminParametersTest.testMaxNumParam", # noqa + "generic_inline_admin.tests.GenericInlineAdminParametersTest.test_max_num_param", # noqa "generic_inline_admin.tests.GenericInlineAdminParametersTest.test_get_extra", # noqa "generic_inline_admin.tests.GenericInlineAdminParametersTest.test_extra_param", # noqa "generic_inline_admin.tests.GenericInlineAdminParametersTest.test_get_max_num", # noqa @@ -1286,7 +1373,6 @@ class DatabaseFeatures(BaseDatabaseFeatures): "migrations.test_operations.OperationTests.test_alter_model_table_noop", # noqa "migrations.test_operations.OperationTests.test_alter_unique_together", # noqa "migrations.test_operations.OperationTests.test_alter_unique_together_remove", # noqa - "migrations.test_operations.OperationTests.test_autofield_foreignfield_growth", # noqa "migrations.test_operations.OperationTests.test_column_name_quoting", # noqa "migrations.test_operations.OperationTests.test_create_model", # noqa "migrations.test_operations.OperationTests.test_create_model_inheritance", # noqa @@ -1393,7 +1479,6 @@ class DatabaseFeatures(BaseDatabaseFeatures): "null_queries.tests.NullQueriesTests.test_reverse_relations", # noqa "ordering.tests.OrderingTests.test_default_ordering", # noqa "ordering.tests.OrderingTests.test_default_ordering_override", # noqa - "ordering.tests.OrderingTests.test_deprecated_values_annotate", # noqa "ordering.tests.OrderingTests.test_extra_ordering", # noqa "ordering.tests.OrderingTests.test_extra_ordering_quoting", # noqa "ordering.tests.OrderingTests.test_extra_ordering_with_table_name", # noqa @@ -1469,7 +1554,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): "queries.tests.Queries1Tests.test_ticket2306", # noqa "queries.tests.Queries1Tests.test_ticket2400", # noqa "queries.tests.Queries1Tests.test_ticket2496", # noqa - "queries.tests.Queries1Tests.test_ticket2902", # noqa + # "queries.tests.Queries1Tests.test_ticket2902", # noqa "queries.tests.Queries1Tests.test_ticket3037", # noqa "queries.tests.Queries1Tests.test_ticket3141", # noqa "queries.tests.Queries1Tests.test_ticket4358", # noqa @@ -1598,7 +1683,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): "schema.tests.SchemaTests.test_alter_auto_field_to_integer_field", # noqa "schema.tests.SchemaTests.test_alter_charfield_to_null", # noqa "schema.tests.SchemaTests.test_alter_field_add_index_to_integerfield", # noqa - "schema.tests.SchemaTests.test_alter_field_default_doesnt_perfom_queries", # noqa + "schema.tests.SchemaTests.test_alter_field_default_doesnt_perform_queries", # noqa "schema.tests.SchemaTests.test_alter_field_default_dropped", # noqa "schema.tests.SchemaTests.test_alter_field_fk_keeps_index", # noqa "schema.tests.SchemaTests.test_alter_field_fk_to_o2o", # noqa @@ -1693,7 +1778,11 @@ class DatabaseFeatures(BaseDatabaseFeatures): "sitemaps_tests.test_http.HTTPSitemapTests.test_paged_sitemap", # noqa "sitemaps_tests.test_http.HTTPSitemapTests.test_requestsite_sitemap", # noqa "sitemaps_tests.test_http.HTTPSitemapTests.test_simple_custom_sitemap", # noqa - "sitemaps_tests.test_http.HTTPSitemapTests.test_simple_i18nsitemap_index", # noqa + # "sitemaps_tests.test_http.HTTPSitemapTests.test_simple_i18nsitemap_index", # noqa + "sitemaps_tests.test_http.HTTPSitemapTests.test_alternate_i18n_sitemap_index", # noqa + "sitemaps_tests.test_http.HTTPSitemapTests.test_alternate_i18n_sitemap_limited", # noqa + "sitemaps_tests.test_http.HTTPSitemapTests.test_alternate_i18n_sitemap_xdefault", # noqa + "sitemaps_tests.test_http.HTTPSitemapTests.test_simple_i18n_sitemap_index", # noqa "sitemaps_tests.test_http.HTTPSitemapTests.test_simple_sitemap", # noqa "sitemaps_tests.test_http.HTTPSitemapTests.test_simple_sitemap_custom_index", # noqa "sitemaps_tests.test_http.HTTPSitemapTests.test_simple_sitemap_index", # noqa @@ -1742,9 +1831,8 @@ class DatabaseFeatures(BaseDatabaseFeatures): "fixtures.tests.FixtureLoadingTests.test_loading_and_dumping", # noqa "fixtures.tests.FixtureLoadingTests.test_loading_stdin", # noqa "fixtures.tests.FixtureLoadingTests.test_output_formats", # noqa - "fixtures.tests.TestCaseFixtureLoadingTests.testClassFixtures", # noqa + "fixtures.tests.TestCaseFixtureLoadingTests.test_class_fixtures", # noqa "fixtures_model_package.tests.FixtureTestCase.test_loaddata", # noqa - "fixtures_model_package.tests.SampleTestCase.testClassFixtures", # noqa "get_or_create.tests.UpdateOrCreateTests.test_create_twice", # noqa "get_or_create.tests.UpdateOrCreateTests.test_defaults_exact", # noqa "get_or_create.tests.UpdateOrCreateTests.test_update", # noqa @@ -1756,6 +1844,10 @@ class DatabaseFeatures(BaseDatabaseFeatures): "model_inheritance.tests.ModelInheritanceTests.test_update_parent_filtering", # noqa "model_inheritance.tests.ModelInheritanceDataTests.test_update_query_counts", # noqa "model_inheritance.tests.ModelInheritanceDataTests.test_update_inherited_model", # noqa + "test_client.tests.ClientTest.test_exc_info", # noqa + "test_client.tests.ClientTest.test_exc_info_none", # noqa + "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 "test_client.tests.ClientTest.test_empty_post", # noqa "test_client.tests.ClientTest.test_exception_following_nested_client_request", # noqa "test_client.tests.ClientTest.test_external_redirect", # noqa @@ -1876,4 +1968,66 @@ class DatabaseFeatures(BaseDatabaseFeatures): "validation.tests.GenericIPAddressFieldTests.test_empty_generic_ip_passes", # noqa "validation.tests.GenericIPAddressFieldTests.test_v4_unpack_uniqueness_detection", # noqa "validation.tests.GenericIPAddressFieldTests.test_v6_uniqueness_detection", # noqa + "auth_tests.test_auth_backends.AuthenticateTests.test_authenticate_sensitive_variables", # noqa + "auth_tests.test_auth_backends.AuthenticateTests.test_clean_credentials_sensitive_variables", # noqa + "auth_tests.test_auth_backends.AuthenticateTests.test_skips_backends_with_decorated_method", # noqa + "auth_tests.test_auth_backends.BaseBackendTest.test_get_all_permissions", # noqa + "auth_tests.test_auth_backends.BaseBackendTest.test_get_group_permissions", # noqa + "auth_tests.test_auth_backends.BaseBackendTest.test_get_user_permissions", # noqa + "auth_tests.test_auth_backends.BaseBackendTest.test_has_perm", # noqa + "auth_tests.test_auth_backends.CustomPermissionsUserModelBackendTest.test_authentication_without_credentials", # noqa + "auth_tests.test_auth_backends.ExtensionUserModelBackendTest.test_authentication_without_credentials", # noqa + "auth_tests.test_auth_backends.ModelBackendTest.test_authentication_without_credentials", # noqa + "auth_tests.test_basic.BasicTestCase.test_superuser_no_email_or_password", # noqa + "auth_tests.test_basic.BasicTestCase.test_superuser_no_email_or_password", # noqa + "auth_tests.test_basic.BasicTestCase.test_superuser_no_email_or_password", # noqa + "auth_tests.test_basic.BasicTestCase.test_superuser_no_email_or_password", # noqa + "auth_tests.test_forms.AdminPasswordChangeFormTest.test_html_autocomplete_attributes", # noqa + "auth_tests.test_forms.AuthenticationFormTest.test_html_autocomplete_attributes", # noqa + "auth_tests.test_forms.AuthenticationFormTest.test_username_field_autocapitalize_none", # noqa + "auth_tests.test_forms.PasswordChangeFormTest.test_html_autocomplete_attributes", # noqa + "auth_tests.test_forms.PasswordResetFormTest.test_html_autocomplete_attributes", # noqa + "auth_tests.test_forms.SetPasswordFormTest.test_html_autocomplete_attributes", # noqa + "auth_tests.test_forms.UserChangeFormTest.test_username_field_autocapitalize_none", # 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_management.CreatesuperuserManagementCommandTestCase.test_environment_variable_non_interactive", # noqa + "auth_tests.test_management.CreatesuperuserManagementCommandTestCase.test_fields_with_m2m", # noqa + "auth_tests.test_management.CreatesuperuserManagementCommandTestCase.test_fields_with_m2m_interactive", # noqa + "auth_tests.test_management.CreatesuperuserManagementCommandTestCase.test_fields_with_m2m_interactive_blank", # noqa + "auth_tests.test_management.CreatesuperuserManagementCommandTestCase.test_ignore_environment_variable_interactive", # noqa + "auth_tests.test_management.CreatesuperuserManagementCommandTestCase.test_ignore_environment_variable_non_interactive", # noqa + "auth_tests.test_management.GetDefaultUsernameTestCase.test_with_database", # noqa + "auth_tests.test_management.MultiDBCreatesuperuserTestCase.test_createsuperuser_command_suggested_username_with_database_option", # noqa + "auth_tests.test_middleware.TestAuthenticationMiddleware.test_no_password_change_does_not_invalidate_legacy_session", # noqa + "auth_tests.test_middleware.TestAuthenticationMiddleware.test_no_session", # noqa + "auth_tests.test_middleware.TestAuthenticationMiddleware.test_session_default_hashing_algorithm", # noqa + "auth_tests.test_models.UserWithPermTestCase.test_backend_without_with_perm", # noqa + "auth_tests.test_password_reset_timeout_days.DeprecationTests.test_timeout", # noqa + "auth_tests.test_remote_user.AllowAllUsersRemoteUserBackendTest.test_csrf_validation_passes_after_process_request_login", # noqa + "auth_tests.test_remote_user.CustomHeaderRemoteUserTest.test_csrf_validation_passes_after_process_request_login", # noqa + "auth_tests.test_remote_user.PersistentRemoteUserTest.test_csrf_validation_passes_after_process_request_login", # noqa + "auth_tests.test_remote_user.RemoteUserCustomTest.test_csrf_validation_passes_after_process_request_login", # noqa + "auth_tests.test_remote_user.RemoteUserTest.test_csrf_validation_passes_after_process_request_login", # noqa + "auth_tests.test_templates.AuthTemplateTests.test_password_reset_confirm_view_custom_username_hint", # 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_tokens.TokenGeneratorTest.test_token_with_different_email", # noqa + "auth_tests.test_tokens.TokenGeneratorTest.test_token_with_different_email", # noqa + "auth_tests.test_tokens.TokenGeneratorTest.test_token_with_different_email", # noqa + "auth_tests.test_views.LoginTest.test_legacy_session_key_flushed_on_login", # noqa + "auth_tests.test_views.PasswordResetTest.test_confirm_custom_reset_url_token", # noqa + "auth_tests.test_models.UserWithPermTestCase.test_basic", # noqa + "auth_tests.test_models.UserWithPermTestCase.test_custom_backend", # noqa + "auth_tests.test_models.UserWithPermTestCase.test_custom_backend_pass_obj", # noqa + "auth_tests.test_models.UserWithPermTestCase.test_invalid_backend_type", # noqa + "auth_tests.test_models.UserWithPermTestCase.test_invalid_permission_name", # noqa + "auth_tests.test_models.UserWithPermTestCase.test_invalid_permission_type", # noqa + "auth_tests.test_models.UserWithPermTestCase.test_multiple_backends", # noqa + "auth_tests.test_models.UserWithPermTestCase.test_nonexistent_backend", # noqa + "auth_tests.test_models.UserWithPermTestCase.test_nonexistent_permission", # noqa + "auth_tests.test_models.UserManagerTestCase.test_runpython_manager_methods", # noqa + "datetimes.tests.DateTimesTests.test_datetimes_ambiguous_and_invalid_times", # noqa + "db_functions.comparison.test_cast.CastTests.test_cast_to_duration", # noqa ) diff --git a/django_spanner/operations.py b/django_spanner/operations.py index 48a3e3cef3..5cc78b161c 100644 --- a/django_spanner/operations.py +++ b/django_spanner/operations.py @@ -377,7 +377,7 @@ def datetime_extract_sql(self, lookup_type, field_name, tzname): :rtype: str :returns: A SQL statement for extracting. """ - tzname = tzname if settings.USE_TZ else "UTC" + 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, @@ -403,7 +403,7 @@ def time_extract_sql(self, lookup_type, field_name): field_name, ) - def date_trunc_sql(self, lookup_type, field_name): + def date_trunc_sql(self, lookup_type, field_name, tzname=None): """Truncate date in the lookup. :type lookup_type: str @@ -412,6 +412,10 @@ def date_trunc_sql(self, lookup_type, field_name): :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. """ @@ -429,7 +433,7 @@ def date_trunc_sql(self, lookup_type, field_name): sql = "DATE_ADD(CAST(" + sql + " AS DATE), INTERVAL 1 DAY)" return sql - def datetime_trunc_sql(self, lookup_type, field_name, tzname): + def datetime_trunc_sql(self, lookup_type, field_name, tzname="UTC"): """Truncate datetime in the lookup. :type lookup_type: str @@ -438,11 +442,14 @@ def datetime_trunc_sql(self, lookup_type, field_name, tzname): :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 else "UTC" + 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 @@ -458,7 +465,7 @@ def datetime_trunc_sql(self, lookup_type, field_name, tzname): sql = "TIMESTAMP_ADD(" + sql + ", INTERVAL 1 DAY)" return sql - def time_trunc_sql(self, lookup_type, field_name): + def time_trunc_sql(self, lookup_type, field_name, tzname="UTC"): """Truncate time in the lookup. :type lookup_type: str @@ -467,11 +474,19 @@ def time_trunc_sql(self, lookup_type, field_name): :type field_name: str :param field_name: The name of the field. + :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 - return 'TIMESTAMP_TRUNC(%s, %s, "UTC")' % (field_name, lookup_type) + tzname = tzname if settings.USE_TZ and tzname else "UTC" + return 'TIMESTAMP_TRUNC(%s, %s, "%s")' % ( + field_name, + lookup_type, + tzname, + ) def datetime_cast_date_sql(self, field_name, tzname): """Cast date in the lookup. @@ -487,7 +502,7 @@ def datetime_cast_date_sql(self, field_name, tzname): :returns: A SQL statement for casting. """ # https://cloud.google.com/spanner/docs/functions-and-operators#date - tzname = tzname if settings.USE_TZ else "UTC" + tzname = tzname if settings.USE_TZ and tzname else "UTC" return 'DATE(%s, "%s")' % (field_name, tzname) def datetime_cast_time_sql(self, field_name, tzname): @@ -503,7 +518,7 @@ def datetime_cast_time_sql(self, field_name, tzname): :rtype: str :returns: A SQL statement for casting. """ - tzname = tzname if settings.USE_TZ else "UTC" + 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 ( @@ -549,6 +564,10 @@ def combine_expression(self, connector, sub_expressions): return "MOD(%s)" % ", ".join(sub_expressions) elif connector == "^": return "POWER(%s)" % ", ".join(sub_expressions) + elif connector == "#": + # Connector '#' represents Bit Xor in django. + # Spanner represents the same fuction with '^' symbol. + return super().combine_expression("^", sub_expressions) elif connector == ">>": lhs, rhs = sub_expressions # Use an alternate computation because Cloud Sapnner's '>>' diff --git a/django_spanner/schema.py b/django_spanner/schema.py index d28dcc4f6e..58a6a36575 100644 --- a/django_spanner/schema.py +++ b/django_spanner/schema.py @@ -4,6 +4,7 @@ # license that can be found in the LICENSE file or at # https://developers.google.com/open-source/licenses/bsd +import uuid from django.db import NotSupportedError from django.db.backends.base.schema import BaseDatabaseSchemaEditor from django_spanner._opentelemetry_tracing import trace_call @@ -59,7 +60,15 @@ def create_model(self, model): # Check constraints can go on the column SQL here db_params = field.db_parameters(connection=self.connection) if db_params["check"]: - definition += " " + self.sql_check_constraint % db_params + definition += ( + ", CONSTRAINT constraint_%s_%s_%s " + % ( + model._meta.db_table, + self.quote_name(field.name), + uuid.uuid4().hex[:6].lower(), + ) + + self.sql_check_constraint % db_params + ) # Autoincrement SQL (for backends with inline variant) col_type_suffix = field.db_type_suffix(connection=self.connection) if col_type_suffix: @@ -123,6 +132,7 @@ def create_model(self, model): trace_attributes = { "model_name": self.quote_name(model._meta.db_table) } + with trace_call( "CloudSpannerDjango.create_model", self.connection, @@ -471,14 +481,24 @@ def _alter_column_type_sql(self, model, old_field, new_field, new_type): [], ) - def _check_sql(self, name, check): - # Spanner doesn't support CHECK constraints. - return None - - def _unique_sql(self, model, fields, name, condition=None): + def _unique_sql( + self, + model, + fields, + name, + condition=None, + deferrable=None, # Spanner does not require this parameter + include=None, + opclasses=None, + ): # Inline constraints aren't supported, so create the index separately. sql = self._create_unique_sql( - model, fields, name=name, condition=condition + model, + fields, + name=name, + condition=condition, + include=include, + opclasses=opclasses, ) if sql: self.deferred_sql.append(sql) diff --git a/docs/index.rst b/docs/index.rst index ec9b23a055..950bb65f28 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -25,3 +25,11 @@ For a list of all ``google-cloud-spanner-django`` releases: :maxdepth: 2 changelog + +Limitations +--------- + +.. toctree:: + :maxdepth: 1 + + limitations \ No newline at end of file diff --git a/docs/limitations.rst b/docs/limitations.rst new file mode 100644 index 0000000000..2909743922 --- /dev/null +++ b/docs/limitations.rst @@ -0,0 +1,165 @@ +Current limitations +------------------- + +``AutoField`` generates random IDs +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Spanner doesn't have support for auto-generating primary key values. +Therefore, ``django-google-spanner`` monkey-patches ``AutoField`` to generate a +random UUID4. It generates a default using ``Field``'s ``default`` option which +means ``AutoField``\ s will have a value when a model instance is created. For +example: + +:: + + >>> ExampleModel() + >>> ExampleModel.pk + 4229421414948291880 + +To avoid +`hotspotting `__, +these IDs are not monotonically increasing. This means that sorting +models by ID isn't guaranteed to return them in the order in which they +were created. + +``ForeignKey`` constraints aren't created (`#313 `__) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Spanner does not support ``ON DELETE CASCADE`` when creating foreign-key +constraints, so this is not supported in ``django-google-spanner``. + + +No native support for ``DecimalField`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Spanner's support for `Decimal `__ +types is limited to +`NUMERIC `__ +precision. Higher-precision values can be stored as strings instead. + + +``Meta.order_with_respect_to`` model option isn't supported +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This feature uses a column name that starts with an underscore +(``_order``) which Spanner doesn't allow. + +Random ``QuerySet`` ordering isn't supported +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Spanner does not support it and will throw an exception. For example: + +:: + + >>> ExampleModel.objects.order_by('?') + ... + django.db.utils.ProgrammingError: 400 Function not found: RANDOM ... FROM + example_model ORDER BY RANDOM() ASC + +Schema migrations +~~~~~~~~~~~~~~~~~ + +There are some limitations on schema changes to consider: + +- No support for renaming tables and columns; +- A column's type can't be changed; +- A table's primary key can't be altered. + +``DurationField`` arithmetic doesn't work with ``DateField`` values (`#253 `__) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Spanner requires using different functions for arithmetic depending on +the column type: + +- ``TIMESTAMP`` columns (``DateTimeField``) require ``TIMESTAMP_ADD`` + or ``TIMESTAMP_SUB`` +- ``DATE`` columns (``DateField``) require ``DATE_ADD`` or ``DATE_SUB`` + +Django does not provide ways to determine which database function to +use. ``DatabaseOperations.combine_duration_expression()`` arbitrarily uses +``TIMESTAMP_ADD`` and ``TIMESTAMP_SUB``. Therefore, if you use a +``DateField`` in a ``DurationField`` expression, you'll likely see an error +such as: + +:: + + "No matching signature for function TIMESTAMP\_ADD for argument types: + DATE, INTERVAL INT64 DATE\_TIME\_PART." + +Computations that yield FLOAT64 values cannot be assigned to INT64 columns +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Spanner does not support this (`#331 +`__) and will +throw an error: + +:: + + >>> ExampleModel.objects.update(integer=F('integer') / 2) + ... + django.db.utils.ProgrammingError: 400 Value of type FLOAT64 cannot be + assigned to integer, which has type INT64 [at 1:46]\nUPDATE + example_model SET integer = (example_model.integer /... + +Addition with null values crash +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Additions cannot include ``None`` values. For example: + +:: + + >>> Book.objects.annotate(adjusted_rating=F('rating') + None) + ... + google.api_core.exceptions.InvalidArgument: 400 Operands of + cannot be literal + NULL ... + +stddev() and variance() function call with sample population only +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Spanner supports `stddev()` and `variance()` functions (`link `__). + +Django’s Variance and StdDev database functions have 2 modes. +One with full population `STDDEV_POP` and another with sample population `STDDEV_SAMP` and `VAR_SAMP`. +Currently spanner only supports these functions with samples and not the full population `STDDEV_POP`, + + +Interleaving is not supported currently +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Interleaving is a feature that is supported by spanner database `link `_. +But currently django spanner does not support this feature, more details on this is discussed in this `github issue `_. + +Update object by passing primary key +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +In django3.1 a new feature was introduced, `._state.adding`, +this allowed spanner to resolve `this bug `_. + +But introduced a new issue with spanner django. Calling `instance.save()` an object after setting it's primary key to an existing primary key value, +will cause a `IntegrityError` as follows: `django.db.utils.IntegrityError: (1062, "Duplicate entry ....` + +The workaround for this is to update `._state.adding` to `False`. +Example: +.. code:: python + + >>> # This test case passes. + >>> def test_update_primary_with_default(self): + >>> obj = PrimaryKeyWithDefault() + >>> obj.save() + >>> obj_2 = PrimaryKeyWithDefault(uuid=obj.uuid) + >>> obj_2._state.adding = False + >>> obj_2.save() + + >>> # This test case fails with `IntegrityError`. + >>> def test_update_primary_with_default(self): + >>> obj = PrimaryKeyWithDefault() + >>> obj.save() + >>> obj_2 = PrimaryKeyWithDefault(uuid=obj.uuid) + >>> obj_2.save() + +More details about this issue can be tracked `here `_. + +Support for Json field is currently not there +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This library currently does not support json. For more info on json support support follow this `github issue `_. + diff --git a/noxfile.py b/noxfile.py index bcfe118ca3..dfa030e25c 100644 --- a/noxfile.py +++ b/noxfile.py @@ -43,7 +43,7 @@ def lint(session): session.run("flake8", "django_spanner", "tests") -@nox.session(python="3.6") +@nox.session(python=DEFAULT_PYTHON_VERSION) def blacken(session): """Run black. diff --git a/tests/system/django_spanner/test_decimal.py b/tests/system/django_spanner/test_decimal.py index 73df7e796b..4155599af1 100644 --- a/tests/system/django_spanner/test_decimal.py +++ b/tests/system/django_spanner/test_decimal.py @@ -6,14 +6,13 @@ from .models import Author, Number from django.test import TransactionTestCase -from django.db import connection, ProgrammingError +from django.db import connection from decimal import Decimal from tests.system.django_spanner.utils import ( setup_instance, teardown_instance, setup_database, teardown_database, - USE_EMULATOR, ) @@ -87,12 +86,8 @@ def test_decimal_precision_limit(self): Tests decimal object precission limit. """ num_val = Number(num=Decimal(1) / Decimal(3)) - if USE_EMULATOR: - with self.assertRaises(ValueError): - num_val.save() - else: - with self.assertRaises(ProgrammingError): - num_val.save() + with self.assertRaises(ValueError): + num_val.save() def test_decimal_update(self): """ From c9c10196ef1447f230f385175a4c54606eb6231c Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Thu, 16 Sep 2021 14:14:45 +0530 Subject: [PATCH 28/39] fix: fixes for running tests for django3.2 --- django_spanner/features.py | 22 +++++++++++++++++++++- django_spanner/lookups.py | 16 ++++++++++++---- django_spanner/operations.py | 2 -- django_spanner/schema.py | 12 +++++++++++- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/django_spanner/features.py b/django_spanner/features.py index 22409c834d..903a0c75d9 100644 --- a/django_spanner/features.py +++ b/django_spanner/features.py @@ -31,7 +31,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): supports_timezones = False supports_transactions = False supports_column_check_constraints = True - supports_table_check_constraints = False + supports_table_check_constraints = True supports_order_by_nulls_modifier = False # Spanner does not support json supports_json_field = False @@ -46,10 +46,14 @@ class DatabaseFeatures(BaseDatabaseFeatures): # Django tests that aren't supported by Spanner. skip_tests = ( + # Insert sql with param variables using %(name)s parameter style is failing + # https://github.com/googleapis/python-spanner/issues/542 + "backends.tests.LastExecutedQueryTest.test_last_executed_query_dict", # Spanner autofield is replaced with uuid4 so validation is disabled "model_fields.test_autofield.AutoFieldTests.test_backend_range_validation", "model_fields.test_autofield.AutoFieldTests.test_redundant_backend_range_validators", "model_fields.test_autofield.AutoFieldTests.test_redundant_backend_range_validators", + "model_fields.test_autofield.BigAutoFieldTests.test_backend_range_validation", "model_fields.test_autofield.BigAutoFieldTests.test_redundant_backend_range_validators", "model_fields.test_autofield.BigAutoFieldTests.test_redundant_backend_range_validators", "model_fields.test_autofield.SmallAutoFieldTests.test_backend_range_validation", @@ -65,6 +69,9 @@ class DatabaseFeatures(BaseDatabaseFeatures): "db_functions.comparison.test_json_object.JSONObjectTests.test_nested_json_object", "db_functions.comparison.test_json_object.JSONObjectTests.test_textfield", # Spanner does not support iso_week_day but week_day is supported. + "timezones.tests.LegacyDatabaseTests.test_query_datetime_lookups", + "timezones.tests.NewDatabaseTests.test_query_datetime_lookups", + "timezones.tests.NewDatabaseTests.test_query_datetime_lookups_in_other_timezone", "db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_func", "db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_iso_weekday_func", "db_functions.datetime.test_extract_trunc.DateFunctionWithTimeZoneTests.test_extract_func", @@ -88,6 +95,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): "db_functions.math.test_random.RandomTests.test", # Spanner supports order by id, but it's does not work the same way as # an auto increment field. + "queries.test_qs_combinators.QuerySetSetOperationTests.test_union_with_values_list_and_order", "ordering.tests.OrderingTests.test_order_by_self_referential_fk", "fixtures.tests.ForwardReferenceTests.test_forward_reference_m2m_natural_key", "fixtures.tests.ForwardReferenceTests.test_forward_reference_fk_natural_key", @@ -97,9 +105,12 @@ class DatabaseFeatures(BaseDatabaseFeatures): # appears in the GROUP BY clause. "annotations.tests.NonAggregateAnnotationTestCase.test_grouping_by_q_expression_annotation", # No foreign key constraints in Spanner. + "backends.tests.FkConstraintsTests.test_check_constraints_sql_keywords", "backends.tests.FkConstraintsTests.test_check_constraints", "fixtures_regress.tests.TestFixtures.test_loaddata_raises_error_when_fixture_has_invalid_foreign_key", # No Django transaction management in Spanner. + "transactions.tests.DisableDurabiltityCheckTests.test_nested_both_durable", + "transactions.tests.DisableDurabiltityCheckTests.test_nested_inner_durable", "basic.tests.SelectOnSaveTests.test_select_on_save_lying_update", # django_spanner monkey patches AutoField to have a default value. # Tests that expect it to be empty untill saved in db. @@ -223,6 +234,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): "prefetch_related.tests.ForeignKeyToFieldTest.test_m2m", "queries.test_bulk_update.BulkUpdateNoteTests.test_multiple_fields", "queries.test_bulk_update.BulkUpdateTests.test_inherited_fields", + "lookup.tests.LookupTests.test_exact_query_rhs_with_selected_columns", # "queries.tests.Queries1Tests.test_ticket9411", "queries.tests.Queries4Tests.test_ticket15316_exclude_true", "queries.tests.Queries5Tests.test_ticket7256", @@ -238,6 +250,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): "syndication_tests.tests.SyndicationFeedTest.test_template_feed", # datetimes retrieved from the database with the wrong hour when # USE_TZ = True: https://github.com/googleapis/python-spanner-django/issues/193 + "timezones.tests.NewDatabaseTests.test_query_convert_timezones", "datetimes.tests.DateTimesTests.test_21432", "db_functions.datetime.test_extract_trunc.DateFunctionWithTimeZoneTests.test_trunc_func_with_timezone", "db_functions.datetime.test_extract_trunc.DateFunctionWithTimeZoneTests.test_trunc_timezone_applied_before_truncation", # noqa @@ -456,6 +469,13 @@ class DatabaseFeatures(BaseDatabaseFeatures): skip_tests += ( # Untyped parameters are not supported: # https://github.com/GoogleCloudPlatform/cloud-spanner-emulator#features-and-limitations + "queries.tests.Queries1Tests.test_excluded_intermediary_m2m_table_joined", # noqa + "queries.tests.Queries4Tests.test_combine_or_filter_reuse", # noqa + "queries.tests.Queries1Tests.test_negate_field", # noqa + "queries.tests.Queries1Tests.test_field_with_filterable", # noqa + "queries.tests.Queries1Tests.test_order_by_raw_column_alias_warning", # noqa + "queries.tests.Queries1Tests.test_order_by_rawsql", # noqa + "queries.tests.Queries4Tests.test_filter_reverse_non_integer_pk", # noqa "admin_views.test_multidb.MultiDatabaseTests.test_delete_view", # noqa "auth_tests.test_admin_multidb.MultiDatabaseTests.test_add_view", # noqa "admin_docs.test_middleware.XViewMiddlewareTest.test_no_auth_middleware", # noqa diff --git a/django_spanner/lookups.py b/django_spanner/lookups.py index d9a54982f2..8f837733c5 100644 --- a/django_spanner/lookups.py +++ b/django_spanner/lookups.py @@ -100,10 +100,18 @@ def iexact(self, compiler, connection): # lhs_sql is the expression/column to use as the regular expression. # Use concat to make the value case-insensitive. lhs_sql = "CONCAT('^(?i)', " + lhs_sql + ", '$')" - if not self.rhs_is_direct_value() and not params: - # If rhs is not a direct value and parameter is not present we want - # to have only 1 formatable argument in rhs_sql else we need 2. - rhs_sql = rhs_sql.replace("%%s", "%s") + if not self.rhs_is_direct_value(): + # If rhs is not a direct value + if not params: + # if params is not present, then we have only 1 formatable + # argument in rhs_sql. + rhs_sql = rhs_sql.replace("%%s", "%s") + else: + # If params is present and rhs_sql is to be replaced as well. + # Example: model_fields.test_uuid.TestQuerying.test_iexact. + rhs_sql = rhs_sql.replace("%%s", "__PLACEHOLDER_FOR_LHS_SQL__") + rhs_sql = rhs_sql.replace("%s", "%%s") + rhs_sql = rhs_sql.replace("__PLACEHOLDER_FOR_LHS_SQL__", "%s") # rhs_sql is REGEXP_CONTAINS(%s, %%s), and lhs_sql is the column name. return rhs_sql % lhs_sql, params diff --git a/django_spanner/operations.py b/django_spanner/operations.py index 5cc78b161c..a57c794973 100644 --- a/django_spanner/operations.py +++ b/django_spanner/operations.py @@ -645,8 +645,6 @@ def prep_for_like_query(self, x): """ return re.escape(str(x)) - prep_for_iexact_query = prep_for_like_query - def no_limit_value(self): """The largest INT64: (2**63) - 1 diff --git a/django_spanner/schema.py b/django_spanner/schema.py index 58a6a36575..c50bcbada9 100644 --- a/django_spanner/schema.py +++ b/django_spanner/schema.py @@ -215,7 +215,15 @@ def add_field(self, model, field): # Check constraints can go on the column SQL here db_params = field.db_parameters(connection=self.connection) if db_params["check"]: - definition += " " + self.sql_check_constraint % db_params + definition += ( + ", CONSTRAINT constraint_%s_%s_%s " + % ( + model._meta.db_table, + self.quote_name(field.name), + uuid.uuid4().hex[:6].lower(), + ) + + self.sql_check_constraint % db_params + ) # Build the SQL and run it sql = self.sql_create_column % { "table": self.quote_name(model._meta.db_table), @@ -387,6 +395,8 @@ def add_index(self, model, index): def quote_value(self, value): # A more complete implementation isn't currently required. + if isinstance(value, str): + return "'%s'" % value.replace("'", "''") return str(value) def _alter_field( From a2ae17a8fa8f80cfa37e9e5025a45ab8437605d0 Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Thu, 16 Sep 2021 22:53:59 +0530 Subject: [PATCH 29/39] fix: change django repo path --- django_test_suite.sh | 2 +- setup.py | 2 +- version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/django_test_suite.sh b/django_test_suite.sh index 85bc2a1e9b..1acc1028ff 100755 --- a/django_test_suite.sh +++ b/django_test_suite.sh @@ -18,7 +18,7 @@ mkdir -p $DJANGO_TESTS_DIR if [ $SPANNER_EMULATOR_HOST != 0 ] then pip3 install . - git clone --depth 1 --single-branch --branch "spanner/stable/3.2.x" https://github.com/c24t/django.git $DJANGO_TESTS_DIR/django + git clone --depth 1 --single-branch --branch "stable/spanner/3.2.x" https://github.com/vi3k6i5/django.git $DJANGO_TESTS_DIR/django fi # Install dependencies for Django tests. diff --git a/setup.py b/setup.py index a3143566bb..25e19457b3 100644 --- a/setup.py +++ b/setup.py @@ -65,7 +65,7 @@ "Programming Language :: Python :: 3.9", "Topic :: Utilities", "Framework :: Django", - "Framework :: Django :: 2.2", + "Framework :: Django :: 3.2", ], extras_require=extras, python_requires=">=3.6", diff --git a/version.py b/version.py index 3c579da8db..715a618af0 100644 --- a/version.py +++ b/version.py @@ -4,4 +4,4 @@ # license that can be found in the LICENSE file or at # https://developers.google.com/open-source/licenses/bsd -__version__ = "3.2.5" +__version__ = "3.2.1" From 97c7ea00e51b9a53d2210a730742440912ef4472 Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Fri, 17 Sep 2021 11:00:17 +0530 Subject: [PATCH 30/39] test: test fixes for order by nulls first and last --- django_spanner/schema.py | 5 +---- tests/unit/django_spanner/test_expressions.py | 6 ++++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/django_spanner/schema.py b/django_spanner/schema.py index e2e3dcbbc2..c50bcbada9 100644 --- a/django_spanner/schema.py +++ b/django_spanner/schema.py @@ -8,7 +8,6 @@ from django.db import NotSupportedError from django.db.backends.base.schema import BaseDatabaseSchemaEditor from django_spanner._opentelemetry_tracing import trace_call -from django_spanner import USE_EMULATOR class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): @@ -390,9 +389,7 @@ def add_index(self, model, index): "index": "|".join(index.fields), } with trace_call( - "CloudSpannerDjango.add_index", - self.connection, - trace_attributes, + "CloudSpannerDjango.add_index", self.connection, trace_attributes, ): super().add_index(model, index) diff --git a/tests/unit/django_spanner/test_expressions.py b/tests/unit/django_spanner/test_expressions.py index ae5b53f4f8..aeeebb5075 100644 --- a/tests/unit/django_spanner/test_expressions.py +++ b/tests/unit/django_spanner/test_expressions.py @@ -20,7 +20,8 @@ def test_order_by_sql_query_with_order_by_null_last(self): self.assertEqual( sql_compiled, "SELECT tests_report.name FROM tests_report ORDER BY " - + "tests_report.name IS NULL, tests_report.name DESC NULLS LAST", + + "tests_report.name IS NULL, tests_report.name IS NULL, " + + "tests_report.name DESC", ) def test_order_by_sql_query_with_order_by_null_first(self): @@ -32,7 +33,8 @@ def test_order_by_sql_query_with_order_by_null_first(self): self.assertEqual( sql_compiled, "SELECT tests_report.name FROM tests_report ORDER BY " - + "tests_report.name IS NOT NULL, tests_report.name DESC NULLS FIRST", + + "tests_report.name IS NOT NULL, tests_report.name " + + "IS NOT NULL, tests_report.name DESC", ) def test_order_by_sql_query_with_order_by_name(self): From d5b567c46d16be5954a1fe2573539c881196a9db Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Fri, 17 Sep 2021 11:11:01 +0530 Subject: [PATCH 31/39] docs: fix readme link target --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index c1a93bd598..4f0d9ca886 100644 --- a/README.rst +++ b/README.rst @@ -260,4 +260,4 @@ LIMITATIONS Spanner has certain limitations of it's own and a full set of limitations are documented over `here `_ It is recommended that you go through that list. -Django spanner has a set of limitations as well, please go through the list `here . +Django spanner has a set of limitations as well, please go through the `list `_. From 22c41315ea49eb4ec184ac71b4e308c869bd9d7a Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Mon, 20 Sep 2021 12:09:10 +0530 Subject: [PATCH 32/39] test: set default auto field type in test settings --- django_test_suite.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/django_test_suite.sh b/django_test_suite.sh index 1acc1028ff..b70ec99d17 100755 --- a/django_test_suite.sh +++ b/django_test_suite.sh @@ -60,6 +60,7 @@ SECRET_KEY = 'spanner_tests_secret_key' PASSWORD_HASHERS = [ 'django.contrib.auth.hashers.MD5PasswordHasher', ] +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' ! } From 69c3c08c567d17c6ea6fb28be11f32c4b0cd3b66 Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Mon, 20 Sep 2021 15:11:33 +0530 Subject: [PATCH 33/39] fix: update features to skip tests that are not support by spanner --- django_spanner/features.py | 14 +++++++++++++- django_spanner/schema.py | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/django_spanner/features.py b/django_spanner/features.py index aab8a5fa2d..2417b0b95f 100644 --- a/django_spanner/features.py +++ b/django_spanner/features.py @@ -23,6 +23,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): # https://cloud.google.com/spanner/quotas#query_limits max_query_params = 900 supports_foreign_keys = False + can_create_inline_fk = False supports_ignore_conflicts = False supports_partial_indexes = False supports_regex_backreferencing = False @@ -46,6 +47,11 @@ class DatabaseFeatures(BaseDatabaseFeatures): # Django tests that aren't supported by Spanner. skip_tests = ( + # Spanner does not support setting a default value on columns. + "schema.tests.SchemaTests.test_alter_text_field_to_not_null_with_default_value", + # Direct SQL query test that do not follow spanner syntax. + "schema.tests.SchemaTests.test_alter_auto_field_quoted_db_column", + "schema.tests.SchemaTests.test_alter_primary_key_quoted_db_table", # Insert sql with param variables using %(name)s parameter style is failing # https://github.com/googleapis/python-spanner/issues/542 "backends.tests.LastExecutedQueryTest.test_last_executed_query_dict", @@ -95,6 +101,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): "db_functions.math.test_random.RandomTests.test", # Spanner supports order by id, but it's does not work the same way as # an auto increment field. + "model_forms.test_modelchoicefield.ModelChoiceFieldTests.test_choice_iterator_passes_model_to_widget", "queries.test_qs_combinators.QuerySetSetOperationTests.test_union_with_values_list_and_order", "ordering.tests.OrderingTests.test_order_by_self_referential_fk", "fixtures.tests.ForwardReferenceTests.test_forward_reference_m2m_natural_key", @@ -323,7 +330,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): "db_functions.comparison.test_cast.CastTests.test_cast_from_db_date_to_datetime", # Tests that fail during tear down on databases that don't support # transactions: https://github.com/googleapis/python-spanner-django/issues/271 - # "contenttypes_tests.test_models.ContentTypesMultidbTests.test_multidb", + "contenttypes_tests.test_models.ContentTypesMultidbTests.test_multidb", # Tests that by-pass using django_spanner and generate # invalid DDL: https://github.com/googleapis/python-spanner-django/issues/298 "cache.tests.CreateCacheTableForDBCacheTests", @@ -376,11 +383,13 @@ class DatabaseFeatures(BaseDatabaseFeatures): "migrations.test_commands.MigrateTests.test_migrate_initial_false", "migrations.test_executor.ExecutorTests.test_soft_apply", # Spanner limitation: Cannot change type of column. + "schema.tests.SchemaTests.test_char_field_pk_to_auto_field", "migrations.test_executor.ExecutorTests.test_alter_id_type_with_fk", "schema.tests.SchemaTests.test_alter_auto_field_to_char_field", "schema.tests.SchemaTests.test_alter_text_field_to_date_field", "schema.tests.SchemaTests.test_alter_text_field_to_datetime_field", "schema.tests.SchemaTests.test_alter_text_field_to_time_field", + "schema.tests.SchemaTests.test_ci_cs_db_collation", # Spanner limitation: Cannot rename tables and columns. "migrations.test_operations.OperationTests.test_rename_field_case", "contenttypes_tests.test_operations.ContentTypeOperationsTests", @@ -455,6 +464,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): # os.chmod() doesn't work on Kokoro? "file_uploads.tests.DirectoryCreationTests.test_readonly_root", # Tests that sometimes fail on Kokoro for unknown reasons. + "migrations.test_operations.OperationTests.test_add_constraint_combinable", "contenttypes_tests.test_models.ContentTypesTests.test_cache_not_shared_between_managers", "migration_test_data_persistence.tests.MigrationDataNormalPersistenceTestCase.test_persistence", "servers.test_liveserverthread.LiveServerThreadTest.test_closes_connections", @@ -471,6 +481,8 @@ class DatabaseFeatures(BaseDatabaseFeatures): skip_tests += ( # Untyped parameters are not supported: # https://github.com/GoogleCloudPlatform/cloud-spanner-emulator#features-and-limitations + "auth_tests.test_views.PasswordResetTest.test_confirm_custom_reset_url_token_link_redirects_to_set_password_page", # noqa + "admin_docs.test_views.AdminDocViewDefaultEngineOnly.test_template_detail_path_traversal", # noqa "queries.tests.Queries1Tests.test_excluded_intermediary_m2m_table_joined", # noqa "queries.tests.Queries4Tests.test_combine_or_filter_reuse", # noqa "queries.tests.Queries1Tests.test_negate_field", # noqa diff --git a/django_spanner/schema.py b/django_spanner/schema.py index c50bcbada9..1004302f08 100644 --- a/django_spanner/schema.py +++ b/django_spanner/schema.py @@ -469,7 +469,7 @@ def _alter_field( self.connection, trace_attributes, ): - self.execute(self._create_index_sql(model, [new_field])) + self.execute(self._create_index_sql(model, fields=[new_field])) def _alter_column_type_sql(self, model, old_field, new_field, new_type): # Spanner needs to use sql_alter_column_not_null if the field is From 9d53a06ede43393ee6f92c8e4acb6a91a88a970a Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Mon, 20 Sep 2021 15:52:04 +0530 Subject: [PATCH 34/39] fix: remove choices module from django3.2 as it has been removed from django 3.2 --- .github/workflows/django_tests_against_emulator0.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/django_tests_against_emulator0.yml b/.github/workflows/django_tests_against_emulator0.yml index 2b31049e72..54d44caef6 100644 --- a/.github/workflows/django_tests_against_emulator0.yml +++ b/.github/workflows/django_tests_against_emulator0.yml @@ -29,4 +29,4 @@ jobs: 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 choices 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 + 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 From 8d677b4d22a886f65738fcceb5c342e563a2010a Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Fri, 1 Oct 2021 00:00:22 +0530 Subject: [PATCH 35/39] feat: add json support --- django_spanner/__init__.py | 13 +++++ django_spanner/base.py | 1 + django_spanner/features.py | 26 ++++------ django_spanner/introspection.py | 19 +++++++- tests/system/django_spanner/models.py | 4 ++ .../system/django_spanner/test_json_field.py | 48 +++++++++++++++++++ 6 files changed, 94 insertions(+), 17 deletions(-) create mode 100644 tests/system/django_spanner/test_json_field.py diff --git a/django_spanner/__init__.py b/django_spanner/__init__.py index 0d88ffac91..373bc929ed 100644 --- a/django_spanner/__init__.py +++ b/django_spanner/__init__.py @@ -12,12 +12,14 @@ from uuid import uuid4 import pkg_resources +from google.cloud.spanner_v1.types.data_types import JsonObject from django.db.models.fields import ( AutoField, SmallAutoField, BigAutoField, Field, ) +from django.db.models import JSONField # Monkey-patch google.DatetimeWithNanoseconds's __eq__ compare against # datetime.datetime. @@ -59,6 +61,17 @@ def autofield_init(self, *args, **kwargs): SmallAutoField.validators = [] BigAutoField.validators = [] + +def get_prep_value(self, value): + # Json encoding and decoding for spanner is done in python-spanner. + if not isinstance(value, JsonObject) and isinstance(value, dict): + return JsonObject(value) + + return value + + +JSONField.get_prep_value = get_prep_value + old_datetimewithnanoseconds_eq = getattr( DatetimeWithNanoseconds, "__eq__", None ) diff --git a/django_spanner/base.py b/django_spanner/base.py index 00033bf223..25c42416a5 100644 --- a/django_spanner/base.py +++ b/django_spanner/base.py @@ -34,6 +34,7 @@ class DatabaseWrapper(BaseDatabaseWrapper): "DateField": "DATE", "DateTimeField": "TIMESTAMP", "DecimalField": "NUMERIC", + "JSONField": "JSON", "DurationField": "INT64", "EmailField": "STRING(%(max_length)s)", "FileField": "STRING(%(max_length)s)", diff --git a/django_spanner/features.py b/django_spanner/features.py index 2417b0b95f..1312a11a04 100644 --- a/django_spanner/features.py +++ b/django_spanner/features.py @@ -8,6 +8,7 @@ from django.db.backends.base.features import BaseDatabaseFeatures from django.db.utils import InterfaceError +from django_spanner import USE_EMULATOR class DatabaseFeatures(BaseDatabaseFeatures): @@ -34,8 +35,11 @@ class DatabaseFeatures(BaseDatabaseFeatures): supports_column_check_constraints = True supports_table_check_constraints = True supports_order_by_nulls_modifier = False - # Spanner does not support json - supports_json_field = False + if USE_EMULATOR: + # Emulator does not support json. + supports_json_field = False + else: + supports_json_field = True supports_primitives_in_json_field = False # Spanner does not support SELECTing an arbitrary expression that also # appears in the GROUP BY clause. @@ -67,7 +71,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): "model_fields.test_autofield.SmallAutoFieldTests.test_redundant_backend_range_validators", # Spanner does not support deferred unique constraints "migrations.test_operations.OperationTests.test_create_model_with_deferred_unique_constraint", - # Spanner does not support JSON objects + # Spanner does not support JSON object query on fields. "db_functions.comparison.test_json_object.JSONObjectTests.test_empty", "db_functions.comparison.test_json_object.JSONObjectTests.test_basic", "db_functions.comparison.test_json_object.JSONObjectTests.test_expressions", @@ -268,17 +272,11 @@ class DatabaseFeatures(BaseDatabaseFeatures): "timezones.tests.NewDatabaseTests.test_query_datetimes", # using NULL with + crashes: https://github.com/googleapis/python-spanner-django/issues/201 "annotations.tests.NonAggregateAnnotationTestCase.test_combined_annotation_commutative", - # Spanner loses DecimalField precision due to conversion to float: - # https://github.com/googleapis/python-spanner-django/pull/133#pullrequestreview-328482925 - "aggregation.tests.AggregateTestCase.test_decimal_max_digits_has_no_effect", - "aggregation.tests.AggregateTestCase.test_related_aggregate", + # Spanner does not support custom precision on DecimalField "db_functions.comparison.test_cast.CastTests.test_cast_to_decimal_field", "model_fields.test_decimalfield.DecimalFieldTests.test_fetch_from_db_without_float_rounding", "model_fields.test_decimalfield.DecimalFieldTests.test_roundtrip_with_trailing_zeros", - # Spanner does not support unsigned integer field. - "model_fields.test_integerfield.PositiveIntegerFieldTests.test_negative_values", - # Spanner doesn't support the variance the standard deviation database - # functions: + # Spanner doesn't support the variance the standard deviation database functions on full population. "aggregation.test_filter_argument.FilteredAggregateTests.test_filtered_numerical_aggregates", "aggregation_regress.tests.AggregationTests.test_stddev", # SELECT list expression references which is neither grouped @@ -358,12 +356,8 @@ class DatabaseFeatures(BaseDatabaseFeatures): "transaction_hooks.tests.TestConnectionOnCommit.test_discards_hooks_from_rolled_back_savepoint", "transaction_hooks.tests.TestConnectionOnCommit.test_inner_savepoint_rolled_back_with_outer", "transaction_hooks.tests.TestConnectionOnCommit.test_inner_savepoint_does_not_affect_outer", - # Spanner doesn't support views. - "inspectdb.tests.InspectDBTransactionalTests.test_include_views", - "introspection.tests.IntrospectionTests.test_table_names_with_views", - # Fields: JSON, GenericIPAddressField are mapped to String in Spanner + # Field: GenericIPAddressField is mapped to String in Spanner "inspectdb.tests.InspectDBTestCase.test_field_types", - "inspectdb.tests.InspectDBTestCase.test_json_field", # BigIntegerField is mapped to IntegerField in Spanner "inspectdb.tests.InspectDBTestCase.test_number_field_types", # No sequence for AutoField in Spanner. diff --git a/django_spanner/introspection.py b/django_spanner/introspection.py index 95db6723d5..4884322fa0 100644 --- a/django_spanner/introspection.py +++ b/django_spanner/introspection.py @@ -25,6 +25,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): TypeCode.STRING: "CharField", TypeCode.TIMESTAMP: "DateTimeField", TypeCode.NUMERIC: "DecimalField", + TypeCode.JSON: "JSONField", } def get_field_type(self, data_type, description): @@ -53,8 +54,24 @@ def get_table_list(self, cursor): :rtype: list :returns: A list of table and view names in the current database. """ + results = cursor.run_sql_in_snapshot( + """ + SELECT + t.table_name, t.table_type + FROM + information_schema.tables AS t + WHERE + t.table_catalog = '' and t.table_schema = '' + """ + ) + tables = [] # The second TableInfo field is 't' for table or 'v' for view. - return [TableInfo(row[0], "t") for row in cursor.list_tables()] + for row in results: + table_type = "t" + if row[1] == "VIEW": + table_type = "v" + tables.append(TableInfo(row[0], table_type)) + return tables def get_table_description(self, cursor, table_name): """Return a description of the table with the DB-API cursor.description diff --git a/tests/system/django_spanner/models.py b/tests/system/django_spanner/models.py index f7153ba994..edf0a807e6 100644 --- a/tests/system/django_spanner/models.py +++ b/tests/system/django_spanner/models.py @@ -34,3 +34,7 @@ class Meta: name="check_start_date", ), ] + + +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 new file mode 100644 index 0000000000..ab19ef6f63 --- /dev/null +++ b/tests/system/django_spanner/test_json_field.py @@ -0,0 +1,48 @@ +# Copyright 2021 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd + +from .models import Detail +import unittest +from django.test import TransactionTestCase +from django.db import connection +from django_spanner import USE_EMULATOR +from tests.system.django_spanner.utils import ( + setup_instance, + teardown_instance, + setup_database, + teardown_database, +) + + +@unittest.skipIf(USE_EMULATOR, "Jsonfield is not implemented in emulator.") +class TestJsonField(TransactionTestCase): + @classmethod + def setUpClass(cls): + setup_instance() + setup_database() + with connection.schema_editor() as editor: + # Create the tables + editor.create_model(Detail) + + @classmethod + def tearDownClass(cls): + with connection.schema_editor() as editor: + # delete the table + editor.delete_model(Detail) + teardown_database() + teardown_instance() + + def test_insert_and_fetch_value(self): + """ + Tests model object creation with Detail model. + Inserting json data into the model and retrieving it. + """ + json_data = Detail(value={"name": "Jakob", "age": "26"}) + json_data.save() + qs1 = Detail.objects.all() + self.assertEqual(qs1[0].value, {"name": "Jakob", "age": "26"}) + # Delete data from Detail table. + Detail.objects.all().delete() From 8500b397cfe6288a02d3e9547ee729c173d7e4b2 Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Tue, 5 Oct 2021 18:03:53 +0530 Subject: [PATCH 36/39] fix: correct JsonObject import path --- django_spanner/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_spanner/__init__.py b/django_spanner/__init__.py index 373bc929ed..cdbfa9a3ef 100644 --- a/django_spanner/__init__.py +++ b/django_spanner/__init__.py @@ -12,7 +12,7 @@ from uuid import uuid4 import pkg_resources -from google.cloud.spanner_v1.types.data_types import JsonObject +from google.cloud.spanner_v1 import JsonObject from django.db.models.fields import ( AutoField, SmallAutoField, From 81fa32f593b818568cbcb77a371201e08113a802 Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Tue, 5 Oct 2021 21:37:23 +0530 Subject: [PATCH 37/39] fix: table_type is not supported in emulator --- django_spanner/introspection.py | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/django_spanner/introspection.py b/django_spanner/introspection.py index 4884322fa0..a9ff28b0d7 100644 --- a/django_spanner/introspection.py +++ b/django_spanner/introspection.py @@ -11,6 +11,7 @@ ) from django.db.models import Index from google.cloud.spanner_v1 import TypeCode +from django_spanner import USE_EMULATOR class DatabaseIntrospection(BaseDatabaseIntrospection): @@ -27,6 +28,26 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): TypeCode.NUMERIC: "DecimalField", TypeCode.JSON: "JSONField", } + if USE_EMULATOR: + # Emulator does not support table_type yet. + # https://github.com/GoogleCloudPlatform/cloud-spanner-emulator/issues/43 + LIST_TABLE_SQL = """ + SELECT + t.table_name, t.table_name + FROM + information_schema.tables AS t + WHERE + t.table_catalog = '' and t.table_schema = '' + """ + else: + LIST_TABLE_SQL = """ + SELECT + t.table_name, t.table_type + FROM + information_schema.tables AS t + WHERE + t.table_catalog = '' and t.table_schema = '' + """ def get_field_type(self, data_type, description): """A hook for a Spanner database to use the cursor description to @@ -54,16 +75,7 @@ def get_table_list(self, cursor): :rtype: list :returns: A list of table and view names in the current database. """ - results = cursor.run_sql_in_snapshot( - """ - SELECT - t.table_name, t.table_type - FROM - information_schema.tables AS t - WHERE - t.table_catalog = '' and t.table_schema = '' - """ - ) + results = cursor.run_sql_in_snapshot(self.LIST_TABLE_SQL) tables = [] # The second TableInfo field is 't' for table or 'v' for view. for row in results: From d050879a6d5d573d7d519728953c9f28c0421378 Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Tue, 5 Oct 2021 22:10:24 +0530 Subject: [PATCH 38/39] !fix: update dependency for json support --- setup.py | 2 +- testing/constraints-3.6.txt | 2 +- tests/unit/django_spanner/test_introspection.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index 25e19457b3..ed485ec086 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ # 'Development Status :: 4 - Beta' # 'Development Status :: 5 - Production/Stable' release_status = "Development Status :: 4 - Beta" -dependencies = ["sqlparse >= 0.3.0", "google-cloud-spanner >= 3.0.0"] +dependencies = ["sqlparse >= 0.3.0", "google-cloud-spanner >= 3.11.1"] extras = { "tracing": [ "opentelemetry-api >= 1.1.0", diff --git a/testing/constraints-3.6.txt b/testing/constraints-3.6.txt index 7573802344..8bb73e0b1b 100644 --- a/testing/constraints-3.6.txt +++ b/testing/constraints-3.6.txt @@ -6,7 +6,7 @@ # e.g., if setup.py has "foo >= 1.14.0, < 2.0.0dev", # Then this file should have foo==1.14.0 sqlparse==0.3.0 -google-cloud-spanner==3.0.0 +google-cloud-spanner==3.11.1 opentelemetry-api==1.1.0 opentelemetry-sdk==1.1.0 opentelemetry-instrumentation==0.20b0 diff --git a/tests/unit/django_spanner/test_introspection.py b/tests/unit/django_spanner/test_introspection.py index 03b5b67ca9..a86c65cdbb 100644 --- a/tests/unit/django_spanner/test_introspection.py +++ b/tests/unit/django_spanner/test_introspection.py @@ -49,9 +49,9 @@ def test_get_table_list(self): cursor = mock.MagicMock() def list_tables(*args, **kwargs): - return [["Table_1"], ["Table_2"]] + return [["Table_1", "t"], ["Table_2", "t"]] - cursor.list_tables = list_tables + cursor.run_sql_in_snapshot = list_tables table_list = db_introspection.get_table_list(cursor=cursor) self.assertEqual( table_list, From c6beb1394000c06bc05ccb7587ac9d6ad7d41950 Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Tue, 5 Oct 2021 23:32:54 +0530 Subject: [PATCH 39/39] fix: views are not supported by spanner --- django_spanner/features.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/django_spanner/features.py b/django_spanner/features.py index 1312a11a04..86120329e8 100644 --- a/django_spanner/features.py +++ b/django_spanner/features.py @@ -473,6 +473,9 @@ class DatabaseFeatures(BaseDatabaseFeatures): if os.environ.get("SPANNER_EMULATOR_HOST", None): # Some code isn't yet supported by the Spanner emulator. skip_tests += ( + # Views are not supported by emulator + "inspectdb.tests.InspectDBTransactionalTests.test_include_views", # noqa + "introspection.tests.IntrospectionTests.test_table_names_with_views", # noqa # Untyped parameters are not supported: # https://github.com/GoogleCloudPlatform/cloud-spanner-emulator#features-and-limitations "auth_tests.test_views.PasswordResetTest.test_confirm_custom_reset_url_token_link_redirects_to_set_password_page", # noqa @@ -1582,7 +1585,6 @@ class DatabaseFeatures(BaseDatabaseFeatures): "queries.tests.Queries1Tests.test_ticket2306", # noqa "queries.tests.Queries1Tests.test_ticket2400", # noqa "queries.tests.Queries1Tests.test_ticket2496", # noqa - # "queries.tests.Queries1Tests.test_ticket2902", # noqa "queries.tests.Queries1Tests.test_ticket3037", # noqa "queries.tests.Queries1Tests.test_ticket3141", # noqa "queries.tests.Queries1Tests.test_ticket4358", # noqa @@ -1806,7 +1808,6 @@ class DatabaseFeatures(BaseDatabaseFeatures): "sitemaps_tests.test_http.HTTPSitemapTests.test_paged_sitemap", # noqa "sitemaps_tests.test_http.HTTPSitemapTests.test_requestsite_sitemap", # noqa "sitemaps_tests.test_http.HTTPSitemapTests.test_simple_custom_sitemap", # noqa - # "sitemaps_tests.test_http.HTTPSitemapTests.test_simple_i18nsitemap_index", # noqa "sitemaps_tests.test_http.HTTPSitemapTests.test_alternate_i18n_sitemap_index", # noqa "sitemaps_tests.test_http.HTTPSitemapTests.test_alternate_i18n_sitemap_limited", # noqa "sitemaps_tests.test_http.HTTPSitemapTests.test_alternate_i18n_sitemap_xdefault", # noqa