Repository: googleapis/google-cloud-python

Issue:
BigQuery: expose public helper method to convert a list of schema fields to/from a list of schema dictionaries (JSON)
I've received some feedback asking how to take a schema from the client library and save it to a JSON file. One reason to do this is the [`bq` command-line tool](https://cloud.google.com/bigquery/bq-command-line-tool#creatingtablefromfile) accepts a schema file, formatted like

```
[
  {"name": "name", "type": "string", "mode": "required"},
  {"name": "gender", "type": "string", "mode": "nullable"},
  {"name": "count", "type": "integer", "mode": "required"}
]
```

Note: this format is the same as the API representation.

It would be great if our client libraries could read/write in this format.




Discussion:
We already have non-public helper methods which [render](https://github.com/GoogleCloudPlatform/google-cloud-python/blob/3e8d53c02ec658faa0b72667cbd2cff492ee3278/bigquery/google/cloud/bigquery/table.py#L1188) / [parse](https://github.com/GoogleCloudPlatform/google-cloud-python/blob/3e8d53c02ec658faa0b72667cbd2cff492ee3278/bigquery/google/cloud/bigquery/table.py#L1188) schemas to / from a format like:

```json
{"fields": [
  {"name": "name", "type": "string", "mode": "required"},
  {"name": "gender", "type": "string", "mode": "nullable"},
  {"name": "count", "type": "integer", "mode": "required"}
]}
```

It would be pretty trivial to adapt them to skip / overlook the missing `fields` key.  How would you envision this for the library API surface?  Methods on the `Table` class to load / store the schema to a file?  What about schema for e.g. `query` jobs?

There are two main use cases that I've heard about

- at table creation/load time, being able to load schema from a file.
- after a query so that they can serialize the schema from the stats to then use to create other table.
Since this is something that would act on lists, it doesn't really correspond to an existing resource representation that we have. Do we put static helper functions in the top-level `google.cloud.apiname` package, generally?
Going through the issues I see that this is a duplicate of https://github.com/GoogleCloudPlatform/google-cloud-python/issues/3419.

Closing this issue in favor of that one.

Resolving patch:
diff --git a/bigquery/google/cloud/bigquery/schema.py b/bigquery/google/cloud/bigquery/schema.py
--- a/bigquery/google/cloud/bigquery/schema.py
+++ b/bigquery/google/cloud/bigquery/schema.py
@@ -43,6 +43,25 @@ def __init__(self, name, field_type, mode='NULLABLE',
         self._description = description
         self._fields = tuple(fields)
 
+    @classmethod
+    def from_api_repr(cls, api_repr):
+        """Return a ``SchemaField`` object deserialized from a dictionary.
+
+        Args:
+            api_repr (Mapping[str, str]): The serialized representation
+                of the SchemaField, such as what is output by
+                :meth:`to_api_repr`.
+
+        Returns:
+            SchemaField: The ``SchemaField`` object.
+        """
+        return cls(
+            field_type=api_repr['type'].upper(),
+            fields=[cls.from_api_repr(f) for f in api_repr.get('fields', ())],
+            mode=api_repr['mode'].upper(),
+            name=api_repr['name'],
+        )
+
     @property
     def name(self):
         """str: The name of the field."""
@@ -84,6 +103,28 @@ def fields(self):
         """
         return self._fields
 
+    def to_api_repr(self):
+        """Return a dictionary representing this schema field.
+
+        Returns:
+            dict: A dictionary representing the SchemaField in a serialized
+                form.
+        """
+        # Put together the basic representation. See http://bit.ly/2hOAT5u.
+        answer = {
+            'mode': self.mode.lower(),
+            'name': self.name,
+            'type': self.field_type.lower(),
+        }
+
+        # If this is a RECORD type, then sub-fields are also included,
+        # add this to the serialized representation.
+        if self.field_type.upper() == 'RECORD':
+            answer['fields'] = [f.to_api_repr() for f in self.fields]
+
+        # Done; return the serialized dictionary.
+        return answer
+
     def _key(self):
         """A tuple key that unique-ly describes this field.
 


Test patch:
diff --git a/bigquery/tests/unit/test_schema.py b/bigquery/tests/unit/test_schema.py
--- a/bigquery/tests/unit/test_schema.py
+++ b/bigquery/tests/unit/test_schema.py
@@ -61,6 +61,47 @@ def test_constructor_subfields(self):
         self.assertIs(field._fields[0], sub_field1)
         self.assertIs(field._fields[1], sub_field2)
 
+    def test_to_api_repr(self):
+        field = self._make_one('foo', 'INTEGER', 'NULLABLE')
+        self.assertEqual(field.to_api_repr(), {
+            'mode': 'nullable',
+            'name': 'foo',
+            'type': 'integer',
+        })
+
+    def test_to_api_repr_with_subfield(self):
+        subfield = self._make_one('bar', 'INTEGER', 'NULLABLE')
+        field = self._make_one('foo', 'RECORD', 'REQUIRED', fields=(subfield,))
+        self.assertEqual(field.to_api_repr(), {
+            'fields': [{
+                'mode': 'nullable',
+                'name': 'bar',
+                'type': 'integer',
+            }],
+            'mode': 'required',
+            'name': 'foo',
+            'type': 'record',
+        })
+
+    def test_from_api_repr(self):
+        field = self._get_target_class().from_api_repr({
+            'fields': [{
+                'mode': 'nullable',
+                'name': 'bar',
+                'type': 'integer',
+            }],
+            'mode': 'required',
+            'name': 'foo',
+            'type': 'record',
+        })
+        self.assertEqual(field.name, 'foo')
+        self.assertEqual(field.field_type, 'RECORD')
+        self.assertEqual(field.mode, 'REQUIRED')
+        self.assertEqual(len(field.fields), 1)
+        self.assertEqual(field.fields[0].name, 'bar')
+        self.assertEqual(field.fields[0].field_type, 'INTEGER')
+        self.assertEqual(field.fields[0].mode, 'NULLABLE')
+
     def test_name_property(self):
         name = 'lemon-ness'
         schema_field = self._make_one(name, 'INTEGER')