diff --git a/django_spanner/base.py b/django_spanner/base.py index 044f8ddd75..b6620b7dfb 100644 --- a/django_spanner/base.py +++ b/django_spanner/base.py @@ -103,6 +103,11 @@ class DatabaseWrapper(BaseDatabaseWrapper): @property def instance(self): + """Reference to a Cloud Spanner Instance containing the Database. + + :rtype: :class:`~google.cloud.spanner_v1.instance.Instance` + :returns: A new instance owned by the existing Spanner Client. + """ return spanner.Client().instance(self.settings_dict["INSTANCE"]) @property @@ -112,6 +117,12 @@ def _nodb_connection(self): ) def get_connection_params(self): + """Retrieve the connection parameters. + + :rtype: dict + :returns: A dictionary containing the Spanner connection parameters + in Django Spanner format. + """ return { "project": self.settings_dict["PROJECT"], "instance_id": self.settings_dict["INSTANCE"], @@ -120,20 +131,52 @@ def get_connection_params(self): **self.settings_dict["OPTIONS"], } - def get_new_connection(self, conn_params): + def get_new_connection(self, **conn_params): + """Create a new connection with corresponding connection parameters. + + :type conn_params: list + :param conn_params: A List of the connection parameters for + :class:`~google.cloud.spanner_dbapi.connection.Connection` + + :rtype: :class:`google.cloud.spanner_dbapi.connection.Connection` + :returns: A new Spanner DB API Connection object associated with the + given Google Cloud Spanner resource. + + :raises: :class:`ValueError` in case the given instance/database + doesn't exist. + """ return Database.connect(**conn_params) def init_connection_state(self): + """Initialize the state of the existing connection.""" pass def create_cursor(self, name=None): + """Create a new Database cursor. + + :type name: str + :param name: Currently not used. + + :rtype: :class:`~google.cloud.spanner_dbapi.cursor.Cursor` + :returns: The Cursor for this connection. + """ return self.connection.cursor() def _set_autocommit(self, autocommit): + """Set the Spanner transaction autocommit flag. + + :type autocommit: bool + :param autocommit: The new value of the autocommit flag. + """ with self.wrap_database_errors: self.connection.autocommit = autocommit def is_usable(self): + """Check whether the connection is valid. + + :rtype: bool + :returns: True if the connection is open, otherwise False. + """ if self.connection is None: return False try: diff --git a/django_spanner/client.py b/django_spanner/client.py index 3623632b03..3dcb326310 100644 --- a/django_spanner/client.py +++ b/django_spanner/client.py @@ -9,5 +9,7 @@ class DatabaseClient(BaseDatabaseClient): + """Wrap the Django base class.""" + def runshell(self, parameters): raise NotSupportedError("This method is not supported.") diff --git a/django_spanner/compiler.py b/django_spanner/compiler.py index 61d980a6f3..4cd7dfb877 100644 --- a/django_spanner/compiler.py +++ b/django_spanner/compiler.py @@ -17,11 +17,27 @@ class SQLCompiler(BaseSQLCompiler): + """ + A variation of the Django SQL compiler, adjusted for Spanner-specific + functionality. + """ + def get_combinator_sql(self, combinator, all): - """ + """Override the native Django method. + Copied from the base class except for: combinator_sql += ' ALL' if all else ' DISTINCT' Cloud Spanner requires ALL or DISTINCT. + + :type combinator: str + :param combinator: A type of the combinator for the operation. + + :type all: bool + :param all: Bool option for the SQL statement. + + :rtype: tuple + :returns: A tuple containing SQL statement(s) with some additional + parameters. """ features = self.connection.features compilers = [ @@ -97,16 +113,20 @@ def get_combinator_sql(self, combinator, all): class SQLInsertCompiler(BaseSQLInsertCompiler, SQLCompiler): + """A wrapper class for compatibility with Django specifications.""" pass class SQLDeleteCompiler(BaseSQLDeleteCompiler, SQLCompiler): + """A wrapper class for compatibility with Django specifications.""" pass class SQLUpdateCompiler(BaseSQLUpdateCompiler, SQLCompiler): + """A wrapper class for compatibility with Django specifications.""" pass class SQLAggregateCompiler(BaseSQLAggregateCompiler, SQLCompiler): + """A wrapper class for compatibility with Django specifications.""" pass diff --git a/django_spanner/creation.py b/django_spanner/creation.py index 66bd531170..ee73decc0a 100644 --- a/django_spanner/creation.py +++ b/django_spanner/creation.py @@ -14,6 +14,11 @@ class DatabaseCreation(BaseDatabaseCreation): + """ + Spanner-specific wrapper for Django class encapsulating methods for + creation and destruction of the underlying test database. + """ + def mark_skips(self): """Skip tests that don't work on Spanner.""" for test_name in self.connection.features.skip_tests: @@ -30,6 +35,11 @@ def mark_skips(self): ) def create_test_db(self, *args, **kwargs): + """Create a test database. + + :rtype: str + :returns: The name of the newly created test Database. + """ # This environment variable is set by the Travis build script or # by a developer running the tests locally. if os.environ.get("RUNNING_SPANNER_BACKEND_TESTS") == "1": @@ -37,6 +47,11 @@ def create_test_db(self, *args, **kwargs): super().create_test_db(*args, **kwargs) def _create_test_db(self, verbosity, autoclobber, keepdb=False): + """ + Create dummy test tables. This method is mostly copied from the + base class but removes usage of `_nodb_connection` since Spanner doesn't + have or need one. + """ # Mostly copied from the base class but removes usage of # _nodb_connection since Spanner doesn't have or need one. test_database_name = self._get_test_db_name() diff --git a/django_spanner/expressions.py b/django_spanner/expressions.py index bb0992c0af..526b6a7362 100644 --- a/django_spanner/expressions.py +++ b/django_spanner/expressions.py @@ -8,8 +8,16 @@ def order_by(self, compiler, connection, **extra_context): - # In Django 3.1, this can be replaced with - # DatabaseFeatures.supports_order_by_nulls_modifier = False. + """ + Order expressions in the SQL query and generate a new query using + Spanner-specific templates. + + :rtype: str + :returns: A SQL query. + """ + # TODO: In Django 3.1, this can be replaced with + # DatabaseFeatures.supports_order_by_nulls_modifier = False. + # Also, consider making this a class method. template = None if self.nulls_last: template = "%(expression)s IS NULL, %(expression)s %(ordering)s" @@ -21,4 +29,5 @@ def order_by(self, compiler, connection, **extra_context): def register_expressions(): + """Add Spanner-specific attribute to the Django OrderBy class.""" OrderBy.as_spanner = order_by diff --git a/django_spanner/features.py b/django_spanner/features.py index 771dd0b939..98e3bc3daf 100644 --- a/django_spanner/features.py +++ b/django_spanner/features.py @@ -11,6 +11,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): + """An extension to Django database feature descriptor.""" can_introspect_big_integer_field = False can_introspect_duration_field = False can_introspect_foreign_keys = False diff --git a/django_spanner/functions.py b/django_spanner/functions.py index e21258a3ec..bc02d0b5d8 100644 --- a/django_spanner/functions.py +++ b/django_spanner/functions.py @@ -4,6 +4,8 @@ # license that can be found in the LICENSE file or at # https://developers.google.com/open-source/licenses/bsd +"""Various math helper functions.""" + import math from django.db.models.expressions import Func, Value @@ -25,11 +27,33 @@ class IfNull(Func): + """Represent SQL `IFNULL` function.""" function = "IFNULL" arity = 2 def cast(self, compiler, connection, **extra_context): + """ + A method to extend Django Cast class. Cast SQL query for given + parameters. + + :type self: :class:`~django.db.models.functions.comparison.Cast` + :param self: the instance of the class that owns this method. + + :type compiler: :class:`~django_spanner.compiler.SQLCompilerst` + :param compiler: The query compiler responsible for generating the query. + Must have a compile method, returning a (sql, [params]) + tuple. Calling compiler(value) will return a quoted + `value`. + + :type connection: :class:`~google.cloud.spanner_dbapi.connection.Connection` + :param connection: The Spanner database connection used for the current + query. + + :rtype: tuple(str, list) + :returns: A tuple where `str` is a string containing ordered SQL parameters + to be replaced with the elements of the `list`. + """ # Account for a field's max_length using SUBSTR. max_length = getattr(self.output_field, "max_length", None) if max_length is not None: @@ -42,6 +66,27 @@ def cast(self, compiler, connection, **extra_context): def chr_(self, compiler, connection, **extra_context): + """ + A method to extend Django Chr class. Returns a SQL query where the code + points are displayed as a string. + + :type self: :class:`~django.db.models.functions.text.Chr` + :param self: the instance of the class that owns this method. + + :type compiler: :class:`~django_spanner.compiler.SQLCompilerst` + :param compiler: The query compiler responsible for generating the query. + Must have a compile method, returning a (sql, [params]) + tuple. Calling compiler(value) will return a quoted + `value`. + + :type connection: :class:`~google.cloud.spanner_dbapi.connection.Connection` + :param connection: The Spanner database connection used for the current + query. + + :rtype: tuple(str, list) + :returns: A tuple where `str` is a string containing ordered SQL parameters + to be replaced with the elements of the `list`. + """ return self.as_sql( compiler, connection, @@ -51,6 +96,27 @@ def chr_(self, compiler, connection, **extra_context): def concatpair(self, compiler, connection, **extra_context): + """ + A method to extend Django ConcatPair class. Concatenates a SQL query + into the sequence of :class:`IfNull` objects. + + :type self: :class:`~django.db.models.functions.text.ConcatPair` + :param self: the instance of the class that owns this method. + + :type compiler: :class:`~django_spanner.compiler.SQLCompilerst` + :param compiler: The query compiler responsible for generating the query. + Must have a compile method, returning a (sql, [params]) + tuple. Calling compiler(value) will return a quoted + `value`. + + :type connection: :class:`~google.cloud.spanner_dbapi.connection.Connection` + :param connection: The Spanner database connection used for the current + query. + + :rtype: tuple(str, list) + :returns: A tuple where `str` is a string containing ordered SQL parameters + to be replaced with the elements of the `list`. + """ # Spanner's CONCAT function returns null if any of its arguments are null. # Prevent that by converting null arguments to an empty string. clone = self.copy() @@ -61,6 +127,27 @@ def concatpair(self, compiler, connection, **extra_context): def cot(self, compiler, connection, **extra_context): + """ + A method to extend Django Cot class. Returns a SQL query of calculated + trigonometric cotangent function. + + :type self: :class:`~django.db.models.functions.math.Cot` + :param self: the instance of the class that owns this method. + + :type compiler: :class:`~django_spanner.compiler.SQLCompilerst` + :param compiler: The query compiler responsible for generating the query. + Must have a compile method, returning a (sql, [params]) + tuple. Calling compiler(value) will return a quoted + `value`. + + :type connection: :class:`~google.cloud.spanner_dbapi.connection.Connection` + :param connection: The Spanner database connection used for the current + query. + + :rtype: tuple(str, list) + :returns: A tuple where `str` is a string containing ordered SQL parameters + to be replaced with the elements of the `list`. + """ return self.as_sql( compiler, connection, @@ -70,6 +157,27 @@ def cot(self, compiler, connection, **extra_context): def degrees(self, compiler, connection, **extra_context): + """ + A method to extend Django Degress class. Returns a SQL query of the + angle converted to degrees. + + :type self: :class:`~django.db.models.functions.math.Degrees` + :param self: the instance of the class that owns this method. + + :type compiler: :class:`~django_spanner.compiler.SQLCompilerst` + :param compiler: The query compiler responsible for generating the query. + Must have a compile method, returning a (sql, [params]) + tuple. Calling compiler(value) will return a quoted + `value`. + + :type connection: :class:`~google.cloud.spanner_dbapi.connection.Connection` + :param connection: The Spanner database connection used for the current + query. + + :rtype: tuple(str, list) + :returns: A tuple where `str` is a string containing ordered SQL parameters + to be replaced with the elements of the `list`. + """ return self.as_sql( compiler, connection, @@ -79,10 +187,51 @@ def degrees(self, compiler, connection, **extra_context): def left_and_right(self, compiler, connection, **extra_context): + """A method to extend Django Left and Right classes. + + :type self: :class:`~django.db.models.functions.text.Left` or + :class:`~django.db.models.functions.text.Right` + :param self: the instance of the class that owns this method. + + :type compiler: :class:`~django_spanner.compiler.SQLCompilerst` + :param compiler: The query compiler responsible for generating the query. + Must have a compile method, returning a (sql, [params]) + tuple. Calling compiler(value) will return a quoted + `value`. + + :type connection: :class:`~google.cloud.spanner_dbapi.connection.Connection` + :param connection: The Spanner database connection used for the current + query. + + :rtype: tuple(str, list) + :returns: A tuple where `str` is a string containing ordered SQL parameters + to be replaced with the elements of the `list`. + """ return self.get_substr().as_spanner(compiler, connection, **extra_context) def log(self, compiler, connection, **extra_context): + """ + A method to extend Django Log class. Returns a SQL query of calculated + logarithm. + + :type self: :class:`~django.db.models.functions.math.Log` + :param self: the instance of the class that owns this method. + + :type compiler: :class:`~django_spanner.compiler.SQLCompilerst` + :param compiler: The query compiler responsible for generating the query. + Must have a compile method, returning a (sql, [params]) + tuple. Calling compiler(value) will return a quoted + `value`. + + :type connection: :class:`~google.cloud.spanner_dbapi.connection.Connection` + :param connection: The Spanner database connection used for the current + query. + + :rtype: tuple(str, list) + :returns: A tuple where `str` is a string containing ordered SQL parameters + to be replaced with the elements of the `list`. + """ # This function is usually Log(b, x) returning the logarithm of x to the # base b, but on Spanner it's Log(x, b). clone = self.copy() @@ -91,6 +240,27 @@ def log(self, compiler, connection, **extra_context): def ord_(self, compiler, connection, **extra_context): + """ + A method to extend Django Ord class. Returns a SQL query of the + expression converted to ord. + + :type self: :class:`~django.db.models.functions.text.Ord` + :param self: the instance of the class that owns this method. + + :type compiler: :class:`~django_spanner.compiler.SQLCompilerst` + :param compiler: The query compiler responsible for generating the query. + Must have a compile method, returning a (sql, [params]) + tuple. Calling compiler(value) will return a quoted + `value`. + + :type connection: :class:`~google.cloud.spanner_dbapi.connection.Connection` + :param connection: The Spanner database connection used for the current + query. + + :rtype: tuple(str, list) + :returns: A tuple where `str` is a string containing ordered SQL parameters + to be replaced with the elements of the `list`. + """ return self.as_sql( compiler, connection, @@ -100,12 +270,54 @@ def ord_(self, compiler, connection, **extra_context): def pi(self, compiler, connection, **extra_context): + """ + A method to extend Django Pi class. Returns a SQL query of the Pi + constant. + + :type self: :class:`~django.db.models.functions.math.Pi` + :param self: the instance of the class that owns this method. + + :type compiler: :class:`~django_spanner.compiler.SQLCompilerst` + :param compiler: The query compiler responsible for generating the query. + Must have a compile method, returning a (sql, [params]) + tuple. Calling compiler(value) will return a quoted + `value`. + + :type connection: :class:`~google.cloud.spanner_dbapi.connection.Connection` + :param connection: The Spanner database connection used for the current + query. + + :rtype: tuple(str, list) + :returns: A tuple where `str` is a string containing ordered SQL parameters + to be replaced with the elements of the `list`. + """ return self.as_sql( compiler, connection, template=str(math.pi), **extra_context ) def radians(self, compiler, connection, **extra_context): + """ + A method to extend Django Radians class. Returns a SQL query of the + angle converted to radians. + + :type self: :class:`~django.db.models.functions.math.Radians` + :param self: the instance of the class that owns this method. + + :type compiler: :class:`~django_spanner.compiler.SQLCompilerst` + :param compiler: The query compiler responsible for generating the query. + Must have a compile method, returning a (sql, [params]) + tuple. Calling compiler(value) will return a quoted + `value`. + + :type connection: :class:`~google.cloud.spanner_dbapi.connection.Connection` + :param connection: The Spanner database connection used for the current + query. + + :rtype: tuple(str, list) + :returns: A tuple where `str` is a string containing ordered SQL parameters + to be replaced with the elements of the `list`. + """ return self.as_sql( compiler, connection, @@ -115,18 +327,61 @@ def radians(self, compiler, connection, **extra_context): def strindex(self, compiler, connection, **extra_context): + """ + A method to extend Django StrIndex class. Returns a SQL query of the + string position. + + :type self: :class:`~django.db.models.functions.text.StrIndex` + :param self: the instance of the class that owns this method. + + :type compiler: :class:`~django_spanner.compiler.SQLCompilerst` + :param compiler: The query compiler responsible for generating the query. + Must have a compile method, returning a (sql, [params]) + tuple. Calling compiler(value) will return a quoted + `value`. + + :type connection: :class:`~google.cloud.spanner_dbapi.connection.Connection` + :param connection: The Spanner database connection used for the current + query. + + :rtype: tuple(str, list) + :returns: A tuple where `str` is a string containing ordered SQL parameters + to be replaced with the elements of the `list`. + """ return self.as_sql( compiler, connection, function="STRPOS", **extra_context ) def substr(self, compiler, connection, **extra_context): + """ + A method to extend Django Substr class. Returns a SQL query of a + substring. + + :type self: :class:`~django.db.models.functions.text.Substr` + :param self: the instance of the class that owns this method. + + :type compiler: :class:`~django_spanner.compiler.SQLCompilerst` + :param compiler: The query compiler responsible for generating the query. + Must have a compile method, returning a (sql, [params]) + tuple. Calling compiler(value) will return a quoted + `value`. + + :type connection: :class:`~google.cloud.spanner_dbapi.connection.Connection` + :param connection: The Spanner database connection used for the current + query. + + :rtype: tuple(str, list) + :returns: A tuple where `str` is a string containing ordered SQL parameters + to be replaced with the elements of the `list`. + """ return self.as_sql( compiler, connection, function="SUBSTR", **extra_context ) def register_functions(): + """Register the above methods with the corersponding Django classes.""" Cast.as_spanner = cast Chr.as_spanner = chr_ ConcatPair.as_spanner = concatpair diff --git a/django_spanner/introspection.py b/django_spanner/introspection.py index 2928c84798..2dd7341972 100644 --- a/django_spanner/introspection.py +++ b/django_spanner/introspection.py @@ -14,6 +14,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): + """A Spanner-specific version of Django introspection utilities.""" data_types_reverse = { TypeCode.BOOL: "BooleanField", TypeCode.BYTES: "BinaryField", @@ -25,19 +26,47 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): } def get_field_type(self, data_type, description): + """A hook for a Spanner database to use the cursor description to + match a Django field type to the database column. + + :type data_type: int + :param data_type: One of Spanner's standard data types. + + :type description: :class:`~google.cloud.spanner_dbapi._helpers.ColumnInfo` + :param description: A description of Spanner column data type. + + :rtype: str + :returns: The corresponding type of Django field. + """ if data_type == TypeCode.STRING and description.internal_size == "MAX": return "TextField" return super().get_field_type(data_type, description) def get_table_list(self, cursor): - """Return a list of table and view names in the current database.""" + """Return a list of table and view names in the current database. + + :type cursor: :class:`~google.cloud.spanner_dbapi.cursor.Cursor` + :param cursor: A reference to a Spanner Database cursor. + + :rtype: list + :returns: A list of table and view names in the current database. + """ # The second TableInfo field is 't' for table or 'v' for view. return [TableInfo(row[0], "t") for row in cursor.list_tables()] def get_table_description(self, cursor, table_name): - """ - Return a description of the table with the DB-API cursor.description + """Return a description of the table with the DB-API cursor.description interface. + + :type cursor: :class:`~google.cloud.spanner_dbapi.cursor.Cursor` + :param cursor: A reference to a Spanner Database cursor. + + :type table_name: str + :param table_name: The name of the table. + + :rtype: list + :returns: A description of the table with the DB-API + cursor.description interface. """ cursor.execute( "SELECT * FROM %s LIMIT 1" @@ -71,13 +100,22 @@ def get_table_description(self, cursor, table_name): return descriptions def get_relations(self, cursor, table_name): - # TODO: PLEASE DO NOT USE THIS METHOD UNTIL - # https://github.com/orijtech/django-spanner/issues/313 - # is resolved so that foreign keys can be supported, as documented in: - # https://github.com/orijtech/django-spanner/issues/311 - """ - Return a dictionary of {field_name: (field_name_other_table, other_table)} - representing all relationships in the table. + """Return a dictionary of {field_name: (field_name_other_table, other_table)} + representing all the relationships in the table. + + TODO: DO NOT USE THIS METHOD UNTIL + https://github.com/orijtech/django-spanner/issues/313 + is resolved so that foreign keys can be supported, as documented in: + https://github.com/orijtech/django-spanner/issues/311 + + :type cursor: :class:`~google.cloud.spanner_dbapi.cursor.Cursor` + :param cursor: A reference to a Spanner Database cursor. + + :type table_name: str + :param table_name: The name of the Cloud Spanner Database. + + :rtype: dict + :returns: A dictionary representing column relationships to other tables. """ results = cursor.run_sql_in_snapshot( ''' @@ -103,6 +141,17 @@ def get_relations(self, cursor, table_name): } def get_primary_key_column(self, cursor, table_name): + """Return Primary Key column. + + :type cursor: :class:`~google.cloud.spanner_dbapi.cursor.Cursor` + :param cursor: A reference to a Spanner Database cursor. + + :type table_name: str + :param table_name: The name of the table. + + :rtype: str + :returns: The name of the PK column. + """ results = cursor.run_sql_in_snapshot( """ SELECT @@ -121,6 +170,17 @@ def get_primary_key_column(self, cursor, table_name): return results[0][0] if results else None def get_constraints(self, cursor, table_name): + """Retrieve the Spanner Table column constraints. + + :type cursor: :class:`~google.cloud.spanner_dbapi.cursor.Cursor` + :param cursor: The cursor in the linked database. + + :type table_name: str + :param table_name: The name of the table. + + :rtype: dict + :returns: A dictionary with constraints. + """ constraints = {} quoted_table_name = self.connection.ops.quote_name(table_name) diff --git a/django_spanner/lookups.py b/django_spanner/lookups.py index 726a8f8611..cad536c914 100644 --- a/django_spanner/lookups.py +++ b/django_spanner/lookups.py @@ -24,7 +24,25 @@ def contains(self, compiler, connection): - """contains and icontains""" + """A method to extend Django Contains and IContains classes. + + :type self: :class:`~django.db.models.lookups.Contains` or + :class:`~django.db.models.lookups.IContains` + :param self: the instance of the class that owns this method. + + :type compiler: :class:`~django_spanner.compiler.SQLCompilerst` + :param compiler: The query compiler responsible for generating the query. + Must have a compile method, returning a (sql, [params]) + tuple. Calling compiler(value) will return a quoted + `value`. + + :type connection: :class:`~google.cloud.spanner_dbapi.connection.Connection` + :param connection: The Spanner database connection used for the current + query. + + :rtype: tuple[str, str] + :returns: A tuple of the SQL request and parameters. + """ lhs_sql, params = self.process_lhs(compiler, connection) rhs_sql, rhs_params = self.process_rhs(compiler, connection) params.extend(rhs_params) @@ -52,6 +70,26 @@ def contains(self, compiler, connection): def iexact(self, compiler, connection): + """A method to extend Django IExact class. Case-insensitive exact match. + If the value provided for comparison is None, it will be interpreted as + an SQL NULL. + + :type self: :class:`~django.db.models.lookups.IExact` + :param self: the instance of the class that owns this method. + + :type compiler: :class:`~django_spanner.compiler.SQLCompilerst` + :param compiler: The query compiler responsible for generating the query. + Must have a compile method, returning a (sql, [params]) + tuple. Calling compiler(value) will return a quoted + `value`. + + :type connection: :class:`~google.cloud.spanner_dbapi.connection.Connection` + :param connection: The Spanner database connection used for the current + query. + + :rtype: tuple[str, str] + :returns: A tuple of the SQL request and parameters. + """ lhs_sql, params = self.process_lhs(compiler, connection) rhs_sql, rhs_params = self.process_rhs(compiler, connection) params.extend(rhs_params) @@ -69,7 +107,25 @@ def iexact(self, compiler, connection): def regex(self, compiler, connection): - """regex and iregex""" + """A method to extend Django Regex and IRegex classes. + + :type self: :class:`~django.db.models.lookups.Regex` or + :class:`~django.db.models.lookups.IRegex` + :param self: the instance of the class that owns this method. + + :type compiler: :class:`~django_spanner.compiler.SQLCompilerst` + :param compiler: The query compiler responsible for generating the query. + Must have a compile method, returning a (sql, [params]) + tuple. Calling compiler(value) will return a quoted + `value`. + + :type connection: :class:`~google.cloud.spanner_dbapi.connection.Connection` + :param connection: The Spanner database connection used for the current + query. + + :rtype: tuple[str, str] + :returns: A tuple of the SQL request and parameters. + """ lhs_sql, params = self.process_lhs(compiler, connection) rhs_sql, rhs_params = self.process_rhs(compiler, connection) params.extend(rhs_params) @@ -91,7 +147,28 @@ def regex(self, compiler, connection): def startswith_endswith(self, compiler, connection): - """startswith, endswith, istartswith, and iendswith lookups.""" + """A method to extend Django StartsWith, IStartsWith, EndsWith, and + IEndsWith classes. + + :type self: :class:`~django.db.models.lookups.StartsWith` or + :class:`~django.db.models.lookups.IStartsWith` or + :class:`~django.db.models.lookups.EndsWith` or + :class:`~django.db.models.lookups.IEndsWith` + :param self: the instance of the class that owns this method. + + :type compiler: :class:`~django_spanner.compiler.SQLCompilerst` + :param compiler: The query compiler responsible for generating the query. + Must have a compile method, returning a (sql, [params]) + tuple. Calling compiler(value) will return a quoted + `value`. + + :type connection: :class:`~google.cloud.spanner_dbapi.connection.Connection` + :param connection: The Spanner database connection used for the current + query. + + :rtype: tuple[str, str] + :returns: A tuple of the SQL request and parameters. + """ lhs_sql, params = self.process_lhs(compiler, connection) rhs_sql, rhs_params = self.process_rhs(compiler, connection) params.extend(rhs_params) @@ -131,6 +208,29 @@ def startswith_endswith(self, compiler, connection): def cast_param_to_float(self, compiler, connection): + """A method to extend Django Exact, GreaterThan, GreaterThanOrEqual, + LessThan, and LessThanOrEqual classes. + + :type self: :class:`~django.db.models.lookups.Exact` or + :class:`~django.db.models.lookups.GreaterThan` or + :class:`~django.db.models.lookups.GreaterThanOrEqual` or + :class:`~django.db.models.lookups.LessThan` or + :class:`~django.db.models.lookups.LessThanOrEqual` + :param self: the instance of the class that owns this method. + + :type compiler: :class:`~django_spanner.compiler.SQLCompilerst` + :param compiler: The query compiler responsible for generating the query. + Must have a compile method, returning a (sql, [params]) + tuple. Calling compiler(value) will return a quoted + `value`. + + :type connection: :class:`~google.cloud.spanner_dbapi.connection.Connection` + :param connection: The Spanner database connection used for the current + query. + + :rtype: tuple[str, str] + :returns: A tuple of the SQL request and float parameters. + """ sql, params = self.as_sql(compiler, connection) if params: # Cast to DecimaField lookup values to float because @@ -151,6 +251,7 @@ def cast_param_to_float(self, compiler, connection): def register_lookups(): + """Registers the above methods with the corersponding Django classes.""" Contains.as_spanner = contains IContains.as_spanner = contains IExact.as_spanner = iexact diff --git a/django_spanner/operations.py b/django_spanner/operations.py index be8cd04e67..6ce0260c81 100644 --- a/django_spanner/operations.py +++ b/django_spanner/operations.py @@ -24,9 +24,11 @@ class DatabaseOperations(BaseDatabaseOperations): + """A Spanner-specific version of Django database operations.""" cast_data_types = {"CharField": "STRING", "TextField": "STRING"} cast_char_field_without_max_length = "STRING" compiler_module = "django_spanner.compiler" + # Django's lookup names that require a different name in Spanner's # EXTRACT() function. # https://cloud.google.com/spanner/docs/functions-and-operators#extract @@ -36,30 +38,99 @@ class DatabaseOperations(BaseDatabaseOperations): "week_day": "dayofweek", } + # TODO: Consider changing the hardcoded output to a linked value. def max_name_length(self): - # https://cloud.google.com/spanner/quotas#tables + """Get the maximum length of Spanner table and column names. + + See also: https://cloud.google.com/spanner/quotas#tables + + :rtype: int + :returns: Maximum length of the name of the table. + """ return 128 def quote_name(self, name): - # Spanner says "column name not valid" if spaces or hyphens are present - # (although according the docs, any character should be allowed in - # quoted identifiers). Replace problematic characters when running the - # Django tests to prevent crashes. (Don't modify names in normal - # operation to prevent the possibility of colliding with another - # column.) https://github.com/orijtech/spanner-orm/issues/204 + """ + Return a quoted version of the given table or column name. Also, + applies backticks to the name that either contain '-' or ' ', or is a + Cloud Spanner's reserved keyword. + + Spanner says "column name not valid" if spaces or hyphens are present + (although according to the documantation, any character should be + allowed in quoted identifiers). Replace problematic characters when + running the Django tests to prevent crashes. (Don't modify names in + normal operation to prevent the possibility of colliding with another + column.) + + See: https://github.com/googleapis/python-spanner-django/issues/204 + + :type name: str + :param name: The Quota name. + + :rtype: :class:`str` + :returns: Name escaped if it has to be escaped. + """ if os.environ.get("RUNNING_SPANNER_BACKEND_TESTS") == "1": name = name.replace(" ", "_").replace("-", "_") return escape_name(name) def bulk_batch_size(self, fields, objs): + """ + Override the base class method. Returns the maximum number of the + query parameters. + + :type fields: list + :param fields: Currently not used. + + :type objs: list + :param objs: Currently not used. + + :rtype: int + :returns: The maximum number of query parameters (constant). + """ return self.connection.features.max_query_params def bulk_insert_sql(self, fields, placeholder_rows): + """ + A helper method that stitches multiple values into a single SQL + record. + + :type fields: list + :param fields: Currently not used. + + :type placeholder_rows: list + :param placeholder_rows: Data "rows" containing values to combine. + + :rtype: str + :returns: A SQL statement (a `VALUES` command). + """ placeholder_rows_sql = (", ".join(row) for row in placeholder_rows) values_sql = ", ".join("(%s)" % sql for sql in placeholder_rows_sql) return "VALUES " + values_sql - def sql_flush(self, style, tables, sequences, allow_cascade=False): + def sql_flush(self, style, tables, reset_sequences=False, allow_cascade=False): + """ + Override the base class method. Returns a list of SQL statements + required to remove all data from the given database tables (without + actually removing the tables themselves). + + :type style: :class:`~django.core.management.color.Style` + :param style: (Currently not used) An object as returned by either + color_style() or no_style(). + + :type tables: list + :param tables: A collection of Cloud Spanner Tables + + :type reset_sequences: bool + :param reset_sequences: (Optional) Currently not used. + + :type allow_cascade: bool + :param allow_cascade: (Optional) Currently not used. + + :rtype: list + :returns: A list of SQL statements required to remove all data from + the given database tables. + """ # Cloud Spanner doesn't support TRUNCATE so DELETE instead. # A dummy WHERE clause is required. if tables: @@ -75,11 +146,27 @@ def sql_flush(self, style, tables, sequences, allow_cascade=False): return [] def adapt_datefield_value(self, value): + """Cast date argument into Spanner DB API DateStr format. + + :type value: object + :param value: A date argument. + + :rtype: :class:`~google.cloud.spanner_dbapi.types.DateStr` + :returns: Formatted Date. + """ if value is None: return None return DateStr(str(value)) def adapt_datetimefield_value(self, value): + """Reformat time argument into Cloud Spanner. + + :type value: object + :param value: A time argument. + + :rtype: :class:`~google.cloud.spanner_dbapi.types.TimestampStr` + :returns: Formatted Time. + """ if value is None: return None # Expression values are adapted by the database. @@ -102,12 +189,35 @@ def adapt_decimalfield_value( """ Convert value from decimal.Decimal into float, for a direct mapping and correct serialization with RPCs to Cloud Spanner. + + :type value: :class:`~google.cloud.spanner_v1.types.Numeric` + :param value: A decimal field value. + + :type max_digits: int + :param max_digits: (Optional) A maximum number of digits. + + :type decimal_places: int + :param decimal_places: (Optional) The number of decimal places to store + with the number. + + :rtype: float + :returns: Formatted value. """ if value is None: return None return float(value) def adapt_timefield_value(self, value): + """ + Transform a time value to an object compatible with what is expected + by the backend driver for time columns. + + :type value: `datetime.datetime` + :param value: A time field value. + + :rtype: :class:`~google.cloud.spanner_dbapi.types.TimestampStr` + :returns: Formatted Time. + """ if value is None: return None # Expression values are adapted by the database. @@ -119,6 +229,14 @@ def adapt_timefield_value(self, value): ) def get_db_converters(self, expression): + """Get a list of functions needed to convert field data. + + :type expression: :class:`django.db.models.expressions.BaseExpression` + :param expression: A query expression to convert. + + :rtype: list + :returns: Converter functions to apply to Spanner field values. + """ converters = super().get_db_converters(expression) internal_type = expression.output_field.get_internal_type() if internal_type == "DateTimeField": @@ -134,12 +252,40 @@ def get_db_converters(self, expression): return converters def convert_binaryfield_value(self, value, expression, connection): + """Convert Spanner BinaryField value for Django. + + :type value: bytes + :param value: A base64-encoded binary field value. + + :type expression: :class:`django.db.models.expressions.BaseExpression` + :param expression: A query expression. + + :type connection: :class:`~google.cloud.cpanner_dbapi.connection.Connection` + :param connection: Reference to a Spanner database connection. + + :rtype: bytes + :returns: A base64 encoded bytes. + """ if value is None: return value # Cloud Spanner stores bytes base64 encoded. return b64decode(value) def convert_datetimefield_value(self, value, expression, connection): + """Convert Spanner DateTimeField value for Django. + + :type value: `DatetimeWithNanoseconds` + :param value: A datetime field value. + + :type expression: :class:`django.db.models.expressions.BaseExpression` + :param expression: A query expression. + + :type connection: :class:`~google.cloud.cpanner_dbapi.connection.Connection` + :param connection: Reference to a Spanner database connection. + + :rtype: datetime + :returns: A TZ-aware datetime. + """ if value is None: return value # Cloud Spanner returns the @@ -163,27 +309,95 @@ def convert_datetimefield_value(self, value, expression, connection): ) def convert_decimalfield_value(self, value, expression, connection): + """Convert Spanner DecimalField value for Django. + + :type value: float + :param value: A decimal field. + + :type expression: :class:`django.db.models.expressions.BaseExpression` + :param expression: A query expression. + + :type connection: :class:`~google.cloud.cpanner_dbapi.connection.Connection` + :param connection: Reference to a Spanner database connection. + + :rtype: :class:`Decimal` + :returns: A converted decimal field. + """ if value is None: return value # Cloud Spanner returns a float. return Decimal(str(value)) def convert_timefield_value(self, value, expression, connection): + """Convert Spanner TimeField value for Django. + + :type value: :class:`~google.api_core.datetime_helpers.DatetimeWithNanoseconds` + :param value: A datetime/time field. + + :type expression: :class:`django.db.models.expressions.BaseExpression` + :param expression: A query expression. + + :type connection: :class:`~google.cloud.cpanner_dbapi.connection.Connection` + :param connection: Reference to a Spanner database connection. + + :rtype: :class:`datetime.time` + :returns: A converted datetime. + """ if value is None: return value # Convert DatetimeWithNanoseconds to time. return time(value.hour, value.minute, value.second, value.microsecond) def convert_uuidfield_value(self, value, expression, connection): + """Convert a UUID field to Cloud Spanner. + + :type value: str + :param value: A UUID-valued str. + + :type expression: :class:`django.db.models.expressions.BaseExpression` + :param expression: A query expression. + + :type connection: :class:`~google.cloud.cpanner_dbapi.connection.Connection` + :param connection: Reference to a Spanner database connection. + + :rtype: :class:`uuid.UUID` + :returns: A converted UUID. + """ if value is not None: value = UUID(value) return value def date_extract_sql(self, lookup_type, field_name): + """Extract date from the lookup. + + :type lookup_type: str + :param lookup_type: A type of the lookup. + + :type field_name: str + :param field_name: The name of the field. + + :rtype: str + :returns: A SQL statement for extracting. + """ lookup_type = self.extract_names.get(lookup_type, lookup_type) return "EXTRACT(%s FROM %s)" % (lookup_type, field_name) def datetime_extract_sql(self, lookup_type, field_name, tzname): + """Extract datetime from the lookup. + + :type lookup_type: str + :param lookup_type: A type of the lookup. + + :type field_name: str + :param field_name: The name of the field. + + :type tzname: str + :param tzname: The time zone name. If using of time zone is not + allowed in settings default will be UTC. + + :rtype: str + :returns: A SQL statement for extracting. + """ tzname = tzname if settings.USE_TZ else "UTC" lookup_type = self.extract_names.get(lookup_type, lookup_type) return 'EXTRACT(%s FROM %s AT TIME ZONE "%s")' % ( @@ -193,6 +407,17 @@ def datetime_extract_sql(self, lookup_type, field_name, tzname): ) def time_extract_sql(self, lookup_type, field_name): + """Extract time from the lookup. + + :type lookup_type: str + :param lookup_type: A type of the lookup. + + :type field_name: str + :param field_name: The name of the field. + + :rtype: str + :returns: A SQL statement for extracting. + """ # Time is stored as TIMESTAMP with UTC time zone. return 'EXTRACT(%s FROM %s AT TIME ZONE "UTC")' % ( lookup_type, @@ -200,6 +425,17 @@ def time_extract_sql(self, lookup_type, field_name): ) def date_trunc_sql(self, lookup_type, field_name): + """Truncate date in the lookup. + + :type lookup_type: str + :param lookup_type: A type of the lookup. + + :type field_name: str + :param field_name: The name of the field. + + :rtype: str + :returns: A SQL statement for truncating. + """ # https://cloud.google.com/spanner/docs/functions-and-operators#date_trunc if lookup_type == "week": # Spanner truncates to Sunday but Django expects Monday. First, @@ -215,6 +451,17 @@ def date_trunc_sql(self, lookup_type, field_name): return sql def datetime_trunc_sql(self, lookup_type, field_name, tzname): + """Truncate datetime in the lookup. + + :type lookup_type: str + :param lookup_type: A type of the lookup. + + :type field_name: str + :param field_name: The name of the field. + + :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" if lookup_type == "week": @@ -233,15 +480,50 @@ def datetime_trunc_sql(self, lookup_type, field_name, tzname): return sql def time_trunc_sql(self, lookup_type, field_name): + """Truncate time in the lookup. + + :type lookup_type: str + :param lookup_type: A type of the lookup. + + :type field_name: str + :param field_name: The name of the field. + + :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) def datetime_cast_date_sql(self, field_name, tzname): + """Cast date in the lookup. + + :type field_name: str + :param field_name: The name of the field. + + :type tzname: str + :param tzname: The time zone name. If using of time zone is not + allowed in settings default will be UTC. + + :rtype: str + :returns: A SQL statement for casting. + """ # https://cloud.google.com/spanner/docs/functions-and-operators#date tzname = tzname if settings.USE_TZ else "UTC" return 'DATE(%s, "%s")' % (field_name, tzname) def datetime_cast_time_sql(self, field_name, tzname): + """Cast time in the lookup. + + :type field_name: str + :param field_name: The name of the field. + + :type tzname: str + :param tzname: The time zone name. If using of time zone is not + allowed in settings default will be UTC. + + :rtype: str + :returns: A SQL statement for casting. + """ tzname = tzname if settings.USE_TZ else "UTC" # Cloud Spanner doesn't have a function for converting # TIMESTAMP to another time zone. @@ -251,12 +533,39 @@ def datetime_cast_time_sql(self, field_name, tzname): ) def date_interval_sql(self, timedelta): + """Get a date interval in microseconds. + + :type timedelta: datetime + :param timedelta: A time delta for the interval. + + :rtype: str + :returns: A SQL statement. + """ return "INTERVAL %s MICROSECOND" % duration_microseconds(timedelta) def format_for_duration_arithmetic(self, sql): + """Do nothing since formatting is handled in the custom function. + + :type sql: str + :param sql: A SQL statement. + + :rtype: str + :return: A SQL statement. + """ return "INTERVAL %s MICROSECOND" % sql def combine_expression(self, connector, sub_expressions): + """Recurrently combine expressions into single one using connector. + + :type connector: str + :param connector: A type of connector operation. + + :type sub_expressions: list + :param sub_expressions: A list of expressions to combine. + + :rtype: str + :return: A SQL statement for combining. + """ if connector == "%%": return "MOD(%s)" % ", ".join(sub_expressions) elif connector == "^": @@ -276,6 +585,19 @@ def combine_expression(self, connector, sub_expressions): return super().combine_expression(connector, sub_expressions) def combine_duration_expression(self, connector, sub_expressions): + """Combine duration expressions into single one using connector. + + :type connector: str + :param connector: A type of connector operation. + + :type sub_expressions: list + :param sub_expressions: A list of expressions to combine. + + :raises: :class:`~django.db.utils.DatabaseError` + + :rtype: str + :return: A SQL statement for combining. + """ if connector == "+": return "TIMESTAMP_ADD(" + ", ".join(sub_expressions) + ")" elif connector == "-": @@ -286,6 +608,18 @@ def combine_duration_expression(self, connector, sub_expressions): ) def lookup_cast(self, lookup_type, internal_type=None): + """ + Cast text lookups to string to allow things like filter(x__contains=4). + + :type lookup_type: str + :param lookup_type: A type of the lookup. + + :type internal_type: str + :param internal_type: (Optional) + + :rtype: str + :return: A SQL statement. + """ # Cast text lookups to string to allow things like # filter(x__contains=4) if lookup_type in ( @@ -303,13 +637,24 @@ def lookup_cast(self, lookup_type, internal_type=None): return "%s" def prep_for_like_query(self, x): - """Lookups that use this method use REGEXP_CONTAINS instead of LIKE.""" + """Lookups that use this method use REGEXP_CONTAINS instead of LIKE. + + :type x: str + :param x: A query to prepare. + + :rtype: str + :returns: A prepared query. + """ return re.escape(str(x)) prep_for_iexact_query = prep_for_like_query def no_limit_value(self): - """The largest INT64: (2**63) - 1""" + """The largest INT64: (2**63) - 1 + + :rtype: int + :returns: The largest INT64. + """ return 9223372036854775807 def _get_limit_offset_params(self, low_mark, high_mark): diff --git a/django_spanner/schema.py b/django_spanner/schema.py index c3cafcd0eb..b6c859c466 100644 --- a/django_spanner/schema.py +++ b/django_spanner/schema.py @@ -9,6 +9,10 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): + """ + The database abstraction layer that turns things like “create a model” or + “delete a field” into SQL. + """ sql_create_table = ( "CREATE TABLE %(table)s (%(definition)s) PRIMARY KEY(%(primary_key)s)" ) @@ -38,6 +42,9 @@ def create_model(self, model): """ Create a table and any accompanying indexes or unique constraints for the given `model`. + + :type model: :class:`~django.db.migrations.operations.models.ModelOperation` + :param model: A model for creating a table. """ # Create column SQL, add FK deferreds if needed column_sqls = [] @@ -123,6 +130,13 @@ def create_model(self, model): self.create_model(field.remote_field.through) def delete_model(self, model): + """ + Drop the model's table in the database along with any unique constraints + or indexes it has. + + :type model: :class:`~django.db.migrations.operations.models.ModelOperation` + :param model: A model for creating a table. + """ # Spanner requires dropping all of a table's indexes before dropping # the table. index_names = self._constraint_names( @@ -133,6 +147,21 @@ def delete_model(self, model): super().delete_model(model) def add_field(self, model, field): + """ + Add a column (or sometimes multiple) to the model's table to + represent the field. This will also add indexes or a unique constraint + if the field has db_index=True or unique=True. If the field is a + ManyToManyField without a value for through, instead of creating a + column, it will make a table to represent the relationship. If through + is provided, it is a no-op. If the field is a ForeignKey, this will + also add the foreign key constraint to the column. + + :type model: :class:`~django.db.migrations.operations.models.ModelOperation` + :param model: A model for creating a table. + + :type field: :class:`~django.db.migrations.operations.models.fields.FieldOperation` + :param field: The field of the table. + """ # Special-case implicit M2M tables if ( field.many_to_many @@ -203,6 +232,19 @@ def add_field(self, model, field): ) def remove_field(self, model, field): + """ + Remove the column(s) representing the field from the model's table, + along with any unique constraints, foreign key constraints, or indexes + caused by that field. If the field is a ManyToManyField without a + value for through, it will remove the table created to track the + relationship. If through is provided, it is a no-op. + + :type model: :class:`~django.db.migrations.operations.models.ModelOperation` + :param model: A model for creating a table. + + :type field: :class:`~django.db.migrations.operations.models.fields.FieldOperation` + :param field: The field of the table. + """ # Spanner requires dropping a column's indexes before dropping the # column. index_names = self._constraint_names(model, [field.column], index=True) @@ -216,6 +258,18 @@ def column_sql( """ Take a field and return its column definition. The field must already have had set_attributes_from_name() called. + + :type model: :class:`~django.db.migrations.operations.models.ModelOperation` + :param model: A model for creating a table. + + :type field: :class:`~django.db.migrations.operations.models.fields.FieldOperation` + :param field: The field of the table. + + :type include_default: bool + :param include_default: (Optional) Flag for including default fields. + + :type exclude_not_null: bool + :param exclude_not_null: (Optional) Flag for excluding not null fields. """ # Get the column's type and use that as the basis of the SQL db_params = field.db_parameters(connection=self.connection) @@ -250,6 +304,14 @@ def column_sql( return sql, params def add_index(self, model, index): + """Add index to model's table. + + :type model: :class:`~django.db.migrations.operations.models.ModelOperation` + :param model: A model for creating a table. + + :type index: :class:`~django.db.migrations.operations.models.Index` + :param index: An index to add. + """ # Work around a bug in Django where a space isn't inserting before # DESC: https://code.djangoproject.com/ticket/30961 # This method can be removed in Django 3.1. diff --git a/django_spanner/utils.py b/django_spanner/utils.py index 444afe053d..6fb40db812 100644 --- a/django_spanner/utils.py +++ b/django_spanner/utils.py @@ -31,6 +31,12 @@ def add_dummy_where(sql): """ Cloud Spanner requires a WHERE clause on UPDATE and DELETE statements. Add a dummy WHERE clause if necessary. + + :type sql: str + :param sql: A SQL statement. + + :rtype: str + :returns: A SQL statement with dummy WHERE clause. """ if any( isinstance(token, sqlparse.sql.Where) diff --git a/django_spanner/validation.py b/django_spanner/validation.py index f22dbb3338..99f270d3d6 100644 --- a/django_spanner/validation.py +++ b/django_spanner/validation.py @@ -7,6 +7,17 @@ class DatabaseValidation(BaseDatabaseValidation): def check_field_type(self, field, field_type): + """Check field type and collect errors. + + :type field: :class:`~django.db.migrations.operations.models.fields.FieldOperation` + :param field: The field of the table. + + :type field_type: str + :param field_type: The type of the field. + + :rtype: list + :return: A list of errors. + """ errors = [] # Disable the error when running the Django test suite. if os.environ.get(