SEBA-198 Allow clients to determine if a field is set to None

Change-Id: I9c8eca7cbb52fd6fffd1a2a1ff59f441d1fa1240
diff --git a/xos/xos_client/xosapi/fake_stub.py b/xos/xos_client/xosapi/fake_stub.py
index 0970b39..ac35805 100644
--- a/xos/xos_client/xosapi/fake_stub.py
+++ b/xos/xos_client/xosapi/fake_stub.py
@@ -53,6 +53,25 @@
         self.is_set[name] = True
         super(FakeObj, self).__setattr__(name, value)
 
+    def HasField(self, name):
+        """ Return True if the field is set in the protobuf. """
+
+        # gRPC throws a valueerror if the field doesn't exist in the schema
+        if name not in self.fields:
+            raise ValueError("Field %s does not exist in schema" % name)
+
+        # Fields that are always set
+        if name in ["leaf_model_name"]:
+            return True
+
+        field = self.DESCRIPTOR.fields_by_name[name].field_decl
+
+        # Reverse foreign keys lists are never None, they are an empty list
+        if field.get("fk_reverse", None):
+            return True
+
+        return self.is_set.get(name, False)
+
     @property
     def self_content_type_id(self):
         return "xos.%s" % self.__class__.__name__.lower()
@@ -79,6 +98,8 @@
     def __init__(self, field):
         extensions = {}
 
+        self.field_decl = field
+
         fk_model = field.get("fk_model", None)
         if fk_model:
             reverseFieldName = field.get("fk_reverseFieldName", None)
@@ -268,6 +289,16 @@
 
     DESCRIPTOR = FakeDescriptor("Tag")
 
+class TestModel(FakeObj):
+    FIELDS = ( {"name": "id", "default": 0},
+               {"name": "intfield", "default": 0} )
+
+    def __init__(self, **kwargs):
+        return super(TestModel, self).__init__(self.FIELDS, **kwargs)
+
+    DESCRIPTOR = FakeDescriptor("TestModel")
+
+
 class ID(FakeObj):
     pass
 
@@ -300,7 +331,9 @@
     def __init__(self):
         self.id_counter = 1
         self.objs = {}
-        for name in ["Controller", "Deployment", "Slice", "Site", "Tag", "Service", "ServiceInstance", "ONOSService", "User", "Network", "NetworkTemplate", "ControllerNetwork", "NetworkSlice"]:
+        for name in ["Controller", "Deployment", "Slice", "Site", "Tag", "Service", "ServiceInstance", "ONOSService",
+                     "User", "Network", "NetworkTemplate", "ControllerNetwork", "NetworkSlice",
+                     "TestModel"]:
             setattr(self, "Get%s" % name, functools.partial(self.get, name))
             setattr(self, "List%s" % name, functools.partial(self.list, name))
             setattr(self, "Create%s" % name, functools.partial(self.create, name))
@@ -371,14 +404,18 @@
 
 class FakeProtos(object):
     def __init__(self):
-        for name in ["Controller", "Deployment", "Slice", "Site", "ID", "Tag", "Service", "ServiceInstance", "ONOSService", "User", "Network", "NetworkTemplate", "ControllerNetwork", "NetworkSlice"]:
+        for name in ["Controller", "Deployment", "Slice", "Site", "ID", "Tag", "Service", "ServiceInstance",
+                     "ONOSService", "User", "Network", "NetworkTemplate", "ControllerNetwork", "NetworkSlice",
+                     "TestModel"]:
             setattr(self, name, globals()[name])
             self.common__pb2 = FakeCommonProtos()
 
 class FakeSymDb(object):
     def __init__(self):
         self._classes = {}
-        for name in ["Controller", "Deployment", "Slice", "Site", "ID", "Tag", "Service", "ServiceInstance", "ONOSService", "User", "Network", "NetworkTemplate", "ControllerNetwork", "NetworkSlice"]:
+        for name in ["Controller", "Deployment", "Slice", "Site", "ID", "Tag", "Service", "ServiceInstance",
+                     "ONOSService", "User", "Network", "NetworkTemplate", "ControllerNetwork", "NetworkSlice",
+                     "TestModel"]:
             self._classes["xos.%s" % name] = globals()[name]
 
 
diff --git a/xos/xos_client/xosapi/orm.py b/xos/xos_client/xosapi/orm.py
index d0a2a31..7d6d263 100644
--- a/xos/xos_client/xosapi/orm.py
+++ b/xos/xos_client/xosapi/orm.py
@@ -232,6 +232,16 @@
         if name in self._reverse_fkmap.keys():
             return self.reverse_fk_resolve(name)
 
+        try:
+            # When sending a reply, XOS will leave the field unset if it is None in the data model. If
+            # HasField(<fieldname>)==False for an existing object, then the caller can infer that field was set to
+            # None.
+            if (not self.is_new) and (not self._wrapped_class.HasField(name)):
+                return None
+        except ValueError:
+            # ValueError is thrown if the field does not exist. We will handle that case in the getattr() below.
+            pass
+
         return getattr(self._wrapped_class, name, *args, **kwargs)
 
     def __setattr__(self, name, value):
@@ -239,6 +249,11 @@
             self.fk_set(name, value)
         elif name in self.__dict__:
             super(ORMWrapper,self).__setattr__(name, value)
+        elif value is None:
+            # When handling requests, XOS interprets gRPC HasField(<fieldname>)==False to indicate that the caller
+            # has not set the field and wants it to continue to use its existing (or default) value. That leaves us
+            # with no easy way to support setting a field to None.
+            raise ValueError("Setting a non-foreignkey field to None is not supported")
         else:
             setattr(self._wrapped_class, name, value)
 
diff --git a/xos/xos_client/xosapi/test_orm.py b/xos/xos_client/xosapi/test_orm.py
index 6c2661c..6006a52 100644
--- a/xos/xos_client/xosapi/test_orm.py
+++ b/xos/xos_client/xosapi/test_orm.py
@@ -416,6 +416,44 @@
         self.assertEqual(onos_service_cast.leaf_model_name, "ONOSService")
         self.assertEqual(onos_service_cast.id, onos_service.id)
 
+    def test_field_null(self):
+        """ In a saved object, if a nullable field is left set to None, make sure the ORM returns None """
+
+        orm = self.make_coreapi()
+        tm = orm.TestModel()
+        tm.save()
+
+        tm = orm.TestModel.objects.all()[0]
+        self.assertFalse(tm._wrapped_class.HasField("intfield"))
+        self.assertEqual(tm.intfield, None)
+
+    def test_field_null_new(self):
+        """ For models that haven't been saved yet, reading the field should return the gRPC default """
+
+        orm = self.make_coreapi()
+        tm = orm.TestModel()
+
+        self.assertEqual(tm.intfield, 0)
+
+    def test_field_non_null(self):
+        """ In a saved object, if a nullable field is set to a value, then make sure the ORM returns the value """
+
+        orm = self.make_coreapi()
+        tm = orm.TestModel(intfield=3)
+        tm.save()
+
+        tm = orm.TestModel.objects.all()[0]
+        self.assertEqual(tm.intfield, 3)
+
+    def test_field_set_null(self):
+        """ Setting a field to None is not allowed """
+
+        orm = self.make_coreapi()
+        tm = orm.TestModel()
+        with self.assertRaises(Exception) as e:
+            tm.intfile = None
+        self.assertEqual(e.exception.message, "Setting a non-foreignkey field to None is not supported")
+
 def main():
     global USE_FAKE_STUB
     global xos_grpc_client