Repository: googleapis/python-spanner-django

Issue:
django-test-suite: admin_changelist test_pk_in_search_fields fails because REGEXP_CONTAINS is given int arguments
- [ ] test_pk_in_search_fields

currently fails with
```shell
======================================================================
ERROR: test_pk_in_search_fields (admin_changelist.tests.ChangeListTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/google/api_core/grpc_helpers.py", line 79, in next
    return six.next(self._wrapped)
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/grpc/_channel.py", line 364, in __next__
    return self._next()
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/grpc/_channel.py", line 358, in _next
    raise self
grpc._channel._Rendezvous: <_Rendezvous of RPC that terminated with:
	status = StatusCode.INVALID_ARGUMENT
	details = "No matching signature for function REGEXP_CONTAINS for argument types: INT64, INT64. Supported signatures: REGEXP_CONTAINS(STRING, STRING); REGEXP_CONTAINS(BYTES, BYTES) [at 1:64]\n...AS __count FROM admin_changelist_concert WHERE REGEXP_CONTAINS(admin_chang...\n                                                  ^"
	debug_error_string = "{"created":"@1575714945.927449000","description":"Error received from peer ipv6:[2607:f8b0:4005:804::200a]:443","file":"src/core/lib/surface/call.cc","file_line":1046,"grpc_message":"No matching signature for function REGEXP_CONTAINS for argument types: INT64, INT64. Supported signatures: REGEXP_CONTAINS(STRING, STRING); REGEXP_CONTAINS(BYTES, BYTES) [at 1:64]\n...AS __count FROM admin_changelist_concert WHERE REGEXP_CONTAINS(admin_chang...\n                                                  ^","grpc_status":3}"
>

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/emmanuelodeke/Desktop/spanner-orm/django_tests/django/tests/admin_changelist/tests.py", line 455, in test_pk_in_search_fields
    cl = m.get_changelist_instance(request)
  File "/Users/emmanuelodeke/Desktop/spanner-orm/django_tests/django/django/contrib/admin/options.py", line 744, in get_changelist_instance
    sortable_by,
  File "/Users/emmanuelodeke/Desktop/spanner-orm/django_tests/django/django/contrib/admin/views/main.py", line 82, in __init__
    self.get_results(request)
  File "/Users/emmanuelodeke/Desktop/spanner-orm/django_tests/django/django/contrib/admin/views/main.py", line 210, in get_results
    result_count = paginator.count
  File "/Users/emmanuelodeke/Desktop/spanner-orm/django_tests/django/django/utils/functional.py", line 80, in __get__
    res = instance.__dict__[self.name] = self.func(instance)
  File "/Users/emmanuelodeke/Desktop/spanner-orm/django_tests/django/django/core/paginator.py", line 91, in count
    return c()
  File "/Users/emmanuelodeke/Desktop/spanner-orm/django_tests/django/django/db/models/query.py", line 392, in count
    return self.query.get_count(using=self.db)
  File "/Users/emmanuelodeke/Desktop/spanner-orm/django_tests/django/django/db/models/sql/query.py", line 504, in get_count
    number = obj.get_aggregation(using, ['__count'])['__count']
  File "/Users/emmanuelodeke/Desktop/spanner-orm/django_tests/django/django/db/models/sql/query.py", line 489, in get_aggregation
    result = compiler.execute_sql(SINGLE)
  File "/Users/emmanuelodeke/Desktop/spanner-orm/django_tests/django/django/db/models/sql/compiler.py", line 1144, in execute_sql
    val = cursor.fetchone()
  File "/Users/emmanuelodeke/Desktop/spanner-orm/django_tests/django/django/db/utils.py", line 96, in inner
    return func(*args, **kwargs)
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/spanner/dbapi/cursor.py", line 196, in fetchone
    return next(self)
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/spanner/dbapi/cursor.py", line 187, in __next__
    return next(self.__itr)
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/google/cloud/spanner_v1/streamed.py", line 143, in __iter__
    self._consume_next()
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/google/cloud/spanner_v1/streamed.py", line 116, in _consume_next
    response = six.next(self._response_iterator)
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/google/cloud/spanner_v1/snapshot.py", line 45, in _restart_on_unavailable
    for item in iterator:
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/google/api_core/grpc_helpers.py", line 81, in next
    six.raise_from(exceptions.from_grpc_error(exc), exc)
  File "<string>", line 3, in raise_from
google.api_core.exceptions.InvalidArgument: 400 No matching signature for function REGEXP_CONTAINS for argument types: INT64, INT64. Supported signatures: REGEXP_CONTAINS(STRING, STRING); REGEXP_CONTAINS(BYTES, BYTES) [at 1:64]\n...AS __count FROM admin_changelist_concert WHERE REGEXP_CONTAINS(admin_chang...\n  
```


Discussion:
And in regards to the table, here is the DDL for it

```sql
CREATE TABLE admin_changelist_concert (
    id INT64 NOT NULL,
    group_id INT64 NOT NULL,
    name STRING(30) NOT NULL,
) PRIMARY KEY (id)
```

and the failing SQL
```python
SQL: SELECT COUNT(*) AS __count FROM admin_changelist_concert
     WHERE REGEXP_CONTAINS(admin_changelist_concert.group_id, @a0)
Params: {'a0': '(?i)2414547065983792308'}
Param_Types: {}
```

and the corresponding code that caused it in https://github.com/timgraham/django/blob/e3b3534cb984431c878d0dc59c621a9c25630493/tests/admin_changelist/tests.py#L446-L461
```python
    def test_pk_in_search_fields(self):
        band = Group.objects.create(name='The Hype')
        Concert.objects.create(name='Woodstock', group=band)

        m = ConcertAdmin(Concert, custom_site)
        m.search_fields = ['group__pk']

        request = self.factory.get('/concert/', data={SEARCH_VAR: band.pk})
        request.user = self.superuser
        cl = m.get_changelist_instance(request)
        self.assertEqual(cl.queryset.count(), 1)

        request = self.factory.get('/concert/', data={SEARCH_VAR: band.pk + 5})
        request.user = self.superuser
        cl = m.get_changelist_instance(request)
        self.assertEqual(cl.queryset.count(), 0)
```
The equivalent PostgreSQL backend produces
```sql
SELECT COUNT(*) AS "__count" FROM "admin_changelist_concert" WHERE 
UPPER("admin_changelist_concert"."group_id"::text) LIKE UPPER('%6%')
```
After doing some research on Cloud Spanner, seem like we can achieve this with casting as per https://cloud.google.com/spanner/docs/functions-and-operators#casting by detecting for example if the value on the right hand side being comparing REGEXP_CONTAINS is a non-string and then making the query
```sql
SELECT * FROM admin_changelist_concert WHERE 
REGEXP_CONTAINS(CAST(admin_changelist_concert.group_id as STRING), '(i?)3915209862240653606')
```

and this results in a positive result
![Screen Shot 2019-12-07 at 11 47 33 PM](https://user-images.githubusercontent.com/4898263/70386220-f745a680-194b-11ea-8b64-7a997dd7bb4d.png)

Resolving patch:
diff --git a/spanner/django/features.py b/spanner/django/features.py
--- a/spanner/django/features.py
+++ b/spanner/django/features.py
@@ -37,4 +37,6 @@ class DatabaseFeatures(BaseDatabaseFeatures):
         'syndication_tests.tests.SyndicationFeedTest.test_latest_post_date',
         'syndication_tests.tests.SyndicationFeedTest.test_rss091_feed',
         'syndication_tests.tests.SyndicationFeedTest.test_template_feed',
+        # To be investigated: https://github.com/orijtech/spanner-orm/issues/135
+        'admin_changelist.tests.ChangeListTests.test_multiuser_edit',
     )
diff --git a/spanner/django/lookups.py b/spanner/django/lookups.py
--- a/spanner/django/lookups.py
+++ b/spanner/django/lookups.py
@@ -51,6 +51,8 @@ def regex(self, compiler, connection):
     rhs_sql = self.get_rhs_op(connection, rhs_sql)
     if self.lookup_name.startswith('i'):
         params[0] = '(?i)%s' % params[0]
+    else:
+        params[0] = str(params[0])
     # rhs_sql is REGEXP_CONTAINS(%s, %%s), and lhs_sql is the column name.
     return rhs_sql % lhs_sql, params
 
diff --git a/spanner/django/operations.py b/spanner/django/operations.py
--- a/spanner/django/operations.py
+++ b/spanner/django/operations.py
@@ -117,3 +117,10 @@ def datetime_trunc_sql(self, lookup_type, field_name, tzname):
         # https://cloud.google.com/spanner/docs/functions-and-operators#timestamp_trunc
         tzname = self.connection.timezone if settings.USE_TZ else 'UTC'
         return 'TIMESTAMP_TRUNC(%s, %s, "%s")' % (field_name, lookup_type, tzname)
+
+    def lookup_cast(self, lookup_type, internal_type=None):
+        # Cast text lookups to string to allow things like filter(x__contains=4)
+        if lookup_type in ('contains', 'icontains', 'startswith', 'istartswith',
+                           'endswith', 'iendswith', 'regex', 'iregex'):
+            return 'CAST(%s AS STRING)'
+        return '%s'


Test patch: