Repository: googleapis/python-spanner-django

Issue:
django: fix Concat database function with null inputs
Spanner's CONCAT database function returns null if any of its arguments are null. This causes a couple of test failures:
```
======================================================================
FAIL: test_basic (db_functions.text.test_concat.ConcatTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/tim/code/django/tests/db_functions/text/test_concat.py", line 31, in test_basic
    lambda a: a.joined
  File "/home/tim/code/django/django/test/testcases.py", line 1056, in assertQuerysetEqual
    return self.assertEqual(list(items), values, msg=msg)
AssertionError: Lists differ: [None, 'smithjJohn', None, None] != ['', 'smithjJohn', 'Maggie', 'adnohR']

First differing element 0:
None
''

- [None, 'smithjJohn', None, None]
+ ['', 'smithjJohn', 'Maggie', 'adnohR']

======================================================================
FAIL: test_many (db_functions.text.test_concat.ConcatTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/tim/code/django/tests/db_functions/text/test_concat.py", line 53, in test_many
    lambda a: a.joined
  File "/home/tim/code/django/django/test/testcases.py", line 1056, in assertQuerysetEqual
    return self.assertEqual(list(items), values, msg=msg)
AssertionError: Lists differ: [None, 'John Smith (John)', 'Margaret (Maggie)', None] != ['Jayden ()', 'John Smith (John)', 'Margaret (Maggie)', 'Rhonda ()']

First differing element 0:
None
'Jayden ()'

- [None, 'John Smith (John)', 'Margaret (Maggie)', None]
?  ^^ ^                                            ^  ^

+ ['Jayden ()', 'John Smith (John)', 'Margaret (Maggie)', 'Rhonda ()']
?  ^^^^^^ ^^^^                                            ^^^  ^^^^^^
```


Discussion:
So Cloud Spanner has an IFNULL operator https://cloud.google.com/spanner/docs/functions-and-operators#conditional_expressions which perhaps we can use with every argument passed into CONCAT so perhaps
```sql
SELECT CONCAT(IFNULL("T.P", ""), " ", "FOO", IFNULL(NULL, "")) as author
```
which returns

author
-- |
T.P FOO

Resolving patch:
diff --git a/django_spanner/features.py b/django_spanner/features.py
--- a/django_spanner/features.py
+++ b/django_spanner/features.py
@@ -226,10 +226,6 @@ class DatabaseFeatures(BaseDatabaseFeatures):
         # FLOAT64. Supported signatures: MOD(INT64, INT64)
         'db_functions.math.test_mod.ModTests.test_decimal',
         'db_functions.math.test_mod.ModTests.test_float',
-        # Spanner's CAST function returns null if any of its arguments are
-        # null: https://github.com/orijtech/spanner-orm/issues/242
-        'db_functions.text.test_concat.ConcatTests.test_basic',
-        'db_functions.text.test_concat.ConcatTests.test_many',
         # casting DateField to DateTimeField adds an unexpected hour:
         # https://github.com/orijtech/spanner-orm/issues/260
         'db_functions.comparison.test_cast.CastTests.test_cast_from_db_date_to_datetime',
diff --git a/django_spanner/functions.py b/django_spanner/functions.py
--- a/django_spanner/functions.py
+++ b/django_spanner/functions.py
@@ -6,12 +6,18 @@
 
 import math
 
+from django.db.models.expressions import Func, Value
 from django.db.models.functions import (
-    Cast, Chr, Cot, Degrees, Left, Log, Ord, Pi, Radians, Right, StrIndex,
-    Substr,
+    Cast, Chr, ConcatPair, Cot, Degrees, Left, Log, Ord, Pi, Radians, Right,
+    StrIndex, Substr,
 )
 
 
+class IfNull(Func):
+    function = 'IFNULL'
+    arity = 2
+
+
 def cast(self, compiler, connection, **extra_context):
     # Account for a field's max_length using SUBSTR.
     max_length = getattr(self.output_field, 'max_length', None)
@@ -26,6 +32,14 @@ def chr_(self, compiler, connection, **extra_context):
     return self.as_sql(compiler, connection, template='CODE_POINTS_TO_STRING([%(expressions)s])', **extra_context)
 
 
+def concatpair(self, compiler, connection, **extra_context):
+    # 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()
+    clone.set_source_expressions(IfNull(e, Value('')) for e in self.get_source_expressions())
+    return clone.as_sql(compiler, connection, **extra_context)
+
+
 def cot(self, compiler, connection, **extra_context):
     return self.as_sql(compiler, connection, template='(1 / TAN(%(expressions)s))', **extra_context)
 
@@ -77,6 +91,7 @@ def substr(self, compiler, connection, **extra_context):
 def register_functions():
     Cast.as_spanner = cast
     Chr.as_spanner = chr_
+    ConcatPair.as_spanner = concatpair
     Cot.as_spanner = cot
     Degrees.as_spanner = degrees
     Left.as_spanner = left_and_right


Test patch: