Repository: googleapis/python-spanner-django

Issue:
django: modulo operator doesn't work
e.g. `Number.objects.filter(pk=self.n.pk).update(integer=F('integer') % 20)`

Traceback:
```
======================================================================
ERROR: test_lefthand_modulo (expressions.tests.ExpressionOperatorTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/tim/.virtualenvs/django37/lib/python3.7/site-packages/google/api_core/grpc_helpers.py", line 57, in error_remapped_callable
    return callable_(*args, **kwargs)
  File "/home/tim/.virtualenvs/django37/lib/python3.7/site-packages/grpc/_channel.py", line 690, in __call__
    return _end_unary_response_blocking(state, call, False, None)
  File "/home/tim/.virtualenvs/django37/lib/python3.7/site-packages/grpc/_channel.py", line 592, in _end_unary_response_blocking
    raise _Rendezvous(state, None, None, deadline)
grpc._channel._Rendezvous: <_Rendezvous of RPC that terminated with:
	status = StatusCode.INVALID_ARGUMENT
	details = "Syntax error: Illegal input character \"%\" [at 1:77]\n...SET the_integer = (expressions_number.the_integer %% @a0) WHERE expression...\n                                                     ^"
	debug_error_string = "{"created":"@1584199346.140812150","description":"Error received from peer ipv4:172.217.12.138:443","file":"src/core/lib/surface/call.cc","file_line":1055,"grpc_message":"Syntax error: Illegal input character \"%\" [at 1:77]\n...SET the_integer = (expressions_number.the_integer %% @a0) WHERE expression...\n                                                     ^","grpc_status":3}"
>

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/tim/code/spanner-django/spanner/dbapi/autocommit_on_cursor.py", line 95, in execute
    self.__handle_update(sql, args or None)
  File "/home/tim/code/spanner-django/spanner/dbapi/autocommit_on_cursor.py", line 106, in __handle_update
    sql, params,
  File "/home/tim/code/spanner-django/spanner/dbapi/autocommit_on_connection.py", line 74, in in_transaction
    return self.__dbhandle.run_in_transaction(fn, *args, **kwargs)
  File "/home/tim/.virtualenvs/django37/lib/python3.7/site-packages/google/cloud/spanner_v1/database.py", line 443, in run_in_transaction
    return session.run_in_transaction(func, *args, **kw)
  File "/home/tim/.virtualenvs/django37/lib/python3.7/site-packages/google/cloud/spanner_v1/session.py", line 299, in run_in_transaction
    return_value = func(txn, *args, **kw)
  File "/home/tim/code/spanner-django/spanner/dbapi/autocommit_on_cursor.py", line 113, in __do_execute_update
    res = transaction.execute_update(sql, params=params, param_types=get_param_types(params))
  File "/home/tim/.virtualenvs/django37/lib/python3.7/site-packages/google/cloud/spanner_v1/transaction.py", line 202, in execute_update
    metadata=metadata,
  File "/home/tim/.virtualenvs/django37/lib/python3.7/site-packages/google/cloud/spanner_v1/gapic/spanner_client.py", line 812, in execute_sql
    request, retry=retry, timeout=timeout, metadata=metadata
  File "/home/tim/.virtualenvs/django37/lib/python3.7/site-packages/google/api_core/gapic_v1/method.py", line 143, in __call__
    return wrapped_func(*args, **kwargs)
  File "/home/tim/.virtualenvs/django37/lib/python3.7/site-packages/google/api_core/retry.py", line 277, in retry_wrapped_func
    on_error=on_error,
  File "/home/tim/.virtualenvs/django37/lib/python3.7/site-packages/google/api_core/retry.py", line 182, in retry_target
    return target()
  File "/home/tim/.virtualenvs/django37/lib/python3.7/site-packages/google/api_core/timeout.py", line 214, in func_with_timeout
    return func(*args, **kwargs)
  File "/home/tim/.virtualenvs/django37/lib/python3.7/site-packages/google/api_core/grpc_helpers.py", line 59, in error_remapped_callable
    six.raise_from(exceptions.from_grpc_error(exc), exc)
  File "<string>", line 3, in raise_from
google.api_core.exceptions.InvalidArgument: 400 Syntax error: Illegal input character \"%\" [at 1:77]\n...SET the_integer = (expressions_number.the_integer %% @a0) WHERE expression...\n                                                     ^

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/tim/code/django/django/db/backends/utils.py", line 84, in _execute
    return self.cursor.execute(sql, params)
  File "/home/tim/code/spanner-django/spanner/dbapi/autocommit_on_cursor.py", line 99, in execute
    raise ProgrammingError(e.details if hasattr(e, 'details') else e)
spanner.dbapi.exceptions.ProgrammingError: 400 Syntax error: Illegal input character \"%\" [at 1:77]\n...SET the_integer = (expressions_number.the_integer %% @a0) WHERE expression...\n                                                     ^

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/tim/code/django/tests/expressions/tests.py", line 980, in test_lefthand_modulo
    Number.objects.filter(pk=self.n.pk).update(integer=F('integer') % 20)
  File "/home/tim/code/django/django/db/models/query.py", line 741, in update
    rows = query.get_compiler(self.db).execute_sql(CURSOR)
  File "/home/tim/code/django/django/db/models/sql/compiler.py", line 1469, in execute_sql
    cursor = super().execute_sql(result_type)
  File "/home/tim/code/django/django/db/models/sql/compiler.py", line 1140, in execute_sql
    cursor.execute(sql, params)
  File "/home/tim/code/django/django/db/backends/utils.py", line 67, in execute
    return self._execute_with_wrappers(sql, params, many=False, executor=self._execute)
  File "/home/tim/code/django/django/db/backends/utils.py", line 76, in _execute_with_wrappers
    return executor(sql, params, many, context)
  File "/home/tim/code/django/django/db/backends/utils.py", line 84, in _execute
    return self.cursor.execute(sql, params)
  File "/home/tim/code/django/django/db/utils.py", line 89, in __exit__
    raise dj_exc_value.with_traceback(traceback) from exc_value
  File "/home/tim/code/django/django/db/backends/utils.py", line 84, in _execute
    return self.cursor.execute(sql, params)
  File "/home/tim/code/spanner-django/spanner/dbapi/autocommit_on_cursor.py", line 99, in execute
    raise ProgrammingError(e.details if hasattr(e, 'details') else e)
django.db.utils.ProgrammingError: 400 Syntax error: Illegal input character \"%\" [at 1:77]\n...SET the_integer = (expressions_number.the_integer %% @a0) WHERE expression...\n                                                     ^
````


Discussion:

Resolving patch:
diff --git a/django_spanner/features.py b/django_spanner/features.py
--- a/django_spanner/features.py
+++ b/django_spanner/features.py
@@ -198,10 +198,6 @@ class DatabaseFeatures(BaseDatabaseFeatures):
         # https://github.com/orijtech/django-spanner/issues/331
         'expressions.tests.ExpressionOperatorTests.test_lefthand_division',
         'expressions.tests.ExpressionOperatorTests.test_right_hand_division',
-        # modulo operator doesn't work:
-        # https://github.com/orijtech/django-spanner/issues/332
-        'expressions.tests.ExpressionOperatorTests.test_lefthand_modulo',
-        'expressions.tests.ExpressionOperatorTests.test_right_hand_modulo',
         # power operator doesn't work:
         # https://github.com/orijtech/django-spanner/issues/333
         'expressions.tests.ExpressionOperatorTests.test_lefthand_power',
diff --git a/django_spanner/operations.py b/django_spanner/operations.py
--- a/django_spanner/operations.py
+++ b/django_spanner/operations.py
@@ -211,6 +211,11 @@ def date_interval_sql(self, timedelta):
     def format_for_duration_arithmetic(self, sql):
         return 'INTERVAL %s MICROSECOND' % sql
 
+    def combine_expression(self, connector, sub_expressions):
+        if connector == '%%':
+            return 'MOD(%s)' % ', '.join(sub_expressions)
+        return super().combine_expression(connector, sub_expressions)
+
     def combine_duration_expression(self, connector, sub_expressions):
         if connector == '+':
             return 'TIMESTAMP_ADD(' + ', '.join(sub_expressions) + ')'


Test patch: