Repository: googleapis/google-cloud-python

Issue:
BigQuery: add destinationTableProperties to LoadJobConfig
To catch up with https://issuetracker.google.com/issues/70117381



Discussion:

Resolving patch:
diff --git a/bigquery/google/cloud/bigquery/job.py b/bigquery/google/cloud/bigquery/job.py
--- a/bigquery/google/cloud/bigquery/job.py
+++ b/bigquery/google/cloud/bigquery/job.py
@@ -920,6 +920,34 @@ def autodetect(self):
     def autodetect(self, value):
         self._set_sub_prop('autodetect', value)
 
+    @property
+    def clustering_fields(self):
+        """Union[List[str], None]: Fields defining clustering for the table
+
+        (Defaults to :data:`None`).
+
+        Clustering fields are immutable after table creation.
+
+        .. note::
+
+           As of 2018-06-29, clustering fields cannot be set on a table
+           which does not also have time partioning defined.
+        """
+        prop = self._get_sub_prop('clustering')
+        if prop is not None:
+            return list(prop.get('fields', ()))
+
+    @clustering_fields.setter
+    def clustering_fields(self, value):
+        """Union[List[str], None]: Fields defining clustering for the table
+
+        (Defaults to :data:`None`).
+        """
+        if value is not None:
+            self._set_sub_prop('clustering', {'fields': value})
+        else:
+            self._del_sub_prop('clustering')
+
     @property
     def create_disposition(self):
         """google.cloud.bigquery.job.CreateDisposition: Specifies behavior
@@ -934,6 +962,69 @@ def create_disposition(self):
     def create_disposition(self, value):
         self._set_sub_prop('createDisposition', value)
 
+    @property
+    def destination_encryption_configuration(self):
+        """google.cloud.bigquery.table.EncryptionConfiguration: Custom
+        encryption configuration for the destination table.
+
+        Custom encryption configuration (e.g., Cloud KMS keys) or ``None``
+        if using default encryption.
+
+        See
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.destinationEncryptionConfiguration
+        """
+        prop = self._get_sub_prop('destinationEncryptionConfiguration')
+        if prop is not None:
+            prop = EncryptionConfiguration.from_api_repr(prop)
+        return prop
+
+    @destination_encryption_configuration.setter
+    def destination_encryption_configuration(self, value):
+        api_repr = value
+        if value is not None:
+            api_repr = value.to_api_repr()
+            self._set_sub_prop('destinationEncryptionConfiguration', api_repr)
+        else:
+            self._del_sub_prop('destinationEncryptionConfiguration')
+
+    @property
+    def destination_table_description(self):
+        """Union[str, None] name given to destination table.
+
+        See:
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.destinationTableProperties.description
+        """
+        prop = self._get_sub_prop('destinationTableProperties')
+        if prop is not None:
+            return prop['description']
+
+    @destination_table_description.setter
+    def destination_table_description(self, value):
+        keys = [self._job_type, 'destinationTableProperties', 'description']
+        if value is not None:
+            _helpers._set_sub_prop(self._properties, keys, value)
+        else:
+            _helpers._del_sub_prop(self._properties, keys)
+
+    @property
+    def destination_table_friendly_name(self):
+        """Union[str, None] name given to destination table.
+
+        See:
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.destinationTableProperties.friendlyName
+        """
+        prop = self._get_sub_prop('destinationTableProperties')
+        if prop is not None:
+            return prop['friendlyName']
+
+    @destination_table_friendly_name.setter
+    def destination_table_friendly_name(self, value):
+        keys = [self._job_type, 'destinationTableProperties', 'friendlyName']
+        if value is not None:
+            _helpers._set_sub_prop(self._properties, keys, value)
+        else:
+            _helpers._del_sub_prop(self._properties, keys)
+
     @property
     def encoding(self):
         """google.cloud.bigquery.job.Encoding: The character encoding of the
@@ -981,7 +1072,7 @@ def max_bad_records(self):
         See
         https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.maxBadRecords
         """
-        return self._get_sub_prop('maxBadRecords')
+        return _helpers._int_or_none(self._get_sub_prop('maxBadRecords'))
 
     @max_bad_records.setter
     def max_bad_records(self, value):
@@ -1013,46 +1104,6 @@ def quote_character(self):
     def quote_character(self, value):
         self._set_sub_prop('quote', value)
 
-    @property
-    def skip_leading_rows(self):
-        """int: Number of rows to skip when reading data (CSV only).
-
-        See
-        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.skipLeadingRows
-        """
-        return _helpers._int_or_none(self._get_sub_prop('skipLeadingRows'))
-
-    @skip_leading_rows.setter
-    def skip_leading_rows(self, value):
-        self._set_sub_prop('skipLeadingRows', str(value))
-
-    @property
-    def source_format(self):
-        """google.cloud.bigquery.job.SourceFormat: File format of the data.
-
-        See
-        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.sourceFormat
-        """
-        return self._get_sub_prop('sourceFormat')
-
-    @source_format.setter
-    def source_format(self, value):
-        self._set_sub_prop('sourceFormat', value)
-
-    @property
-    def write_disposition(self):
-        """google.cloud.bigquery.job.WriteDisposition: Action that occurs if
-        the destination table already exists.
-
-        See
-        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.writeDisposition
-        """
-        return self._get_sub_prop('writeDisposition')
-
-    @write_disposition.setter
-    def write_disposition(self, value):
-        self._set_sub_prop('writeDisposition', value)
-
     @property
     def schema(self):
         """List[google.cloud.bigquery.schema.SchemaField]: Schema of the
@@ -1077,27 +1128,42 @@ def schema(self, value):
             [field.to_api_repr() for field in value])
 
     @property
-    def destination_encryption_configuration(self):
-        """google.cloud.bigquery.table.EncryptionConfiguration: Custom
-        encryption configuration for the destination table.
+    def schema_update_options(self):
+        """List[google.cloud.bigquery.job.SchemaUpdateOption]: Specifies
+        updates to the destination table schema to allow as a side effect of
+        the load job.
+        """
+        return self._get_sub_prop('schemaUpdateOptions')
 
-        Custom encryption configuration (e.g., Cloud KMS keys) or ``None``
-        if using default encryption.
+    @schema_update_options.setter
+    def schema_update_options(self, values):
+        self._set_sub_prop('schemaUpdateOptions', values)
+
+    @property
+    def skip_leading_rows(self):
+        """int: Number of rows to skip when reading data (CSV only).
 
         See
-        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.destinationEncryptionConfiguration
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.skipLeadingRows
         """
-        prop = self._get_sub_prop('destinationEncryptionConfiguration')
-        if prop is not None:
-            prop = EncryptionConfiguration.from_api_repr(prop)
-        return prop
+        return _helpers._int_or_none(self._get_sub_prop('skipLeadingRows'))
 
-    @destination_encryption_configuration.setter
-    def destination_encryption_configuration(self, value):
-        api_repr = value
-        if value is not None:
-            api_repr = value.to_api_repr()
-        self._set_sub_prop('destinationEncryptionConfiguration', api_repr)
+    @skip_leading_rows.setter
+    def skip_leading_rows(self, value):
+        self._set_sub_prop('skipLeadingRows', str(value))
+
+    @property
+    def source_format(self):
+        """google.cloud.bigquery.job.SourceFormat: File format of the data.
+
+        See
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.sourceFormat
+        """
+        return self._get_sub_prop('sourceFormat')
+
+    @source_format.setter
+    def source_format(self, value):
+        self._set_sub_prop('sourceFormat', value)
 
     @property
     def time_partitioning(self):
@@ -1114,47 +1180,23 @@ def time_partitioning(self, value):
         api_repr = value
         if value is not None:
             api_repr = value.to_api_repr()
-        self._set_sub_prop('timePartitioning', api_repr)
-
-    @property
-    def clustering_fields(self):
-        """Union[List[str], None]: Fields defining clustering for the table
-
-        (Defaults to :data:`None`).
-
-        Clustering fields are immutable after table creation.
-
-        .. note::
-
-           As of 2018-06-29, clustering fields cannot be set on a table
-           which does not also have time partioning defined.
-        """
-        prop = self._get_sub_prop('clustering')
-        if prop is not None:
-            return list(prop.get('fields', ()))
-
-    @clustering_fields.setter
-    def clustering_fields(self, value):
-        """Union[List[str], None]: Fields defining clustering for the table
-
-        (Defaults to :data:`None`).
-        """
-        if value is not None:
-            self._set_sub_prop('clustering', {'fields': value})
+            self._set_sub_prop('timePartitioning', api_repr)
         else:
-            self._del_sub_prop('clustering')
+            self._del_sub_prop('timePartitioning')
 
     @property
-    def schema_update_options(self):
-        """List[google.cloud.bigquery.job.SchemaUpdateOption]: Specifies
-        updates to the destination table schema to allow as a side effect of
-        the load job.
+    def write_disposition(self):
+        """google.cloud.bigquery.job.WriteDisposition: Action that occurs if
+        the destination table already exists.
+
+        See
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.writeDisposition
         """
-        return self._get_sub_prop('schemaUpdateOptions')
+        return self._get_sub_prop('writeDisposition')
 
-    @schema_update_options.setter
-    def schema_update_options(self, values):
-        self._set_sub_prop('schemaUpdateOptions', values)
+    @write_disposition.setter
+    def write_disposition(self, value):
+        self._set_sub_prop('writeDisposition', value)
 
 
 class LoadJob(_AsyncJob):
diff --git a/bigquery/google/cloud/bigquery/table.py b/bigquery/google/cloud/bigquery/table.py
--- a/bigquery/google/cloud/bigquery/table.py
+++ b/bigquery/google/cloud/bigquery/table.py
@@ -132,6 +132,20 @@ def to_api_repr(self):
         """
         return copy.deepcopy(self._properties)
 
+    def __eq__(self, other):
+        if not isinstance(other, EncryptionConfiguration):
+            return NotImplemented
+        return self.kms_key_name == other.kms_key_name
+
+    def __ne__(self, other):
+        return not self == other
+
+    def __hash__(self):
+        return hash(self.kms_key_name)
+
+    def __repr__(self):
+        return 'EncryptionConfiguration({})'.format(self.kms_key_name)
+
 
 class TableReference(object):
     """TableReferences are pointers to tables.
@@ -1342,6 +1356,24 @@ def to_api_repr(self):
         """
         return self._properties
 
+    def _key(self):
+        return tuple(sorted(self._properties.items()))
+
+    def __eq__(self, other):
+        if not isinstance(other, TimePartitioning):
+            return NotImplemented
+        return self._key() == other._key()
+
+    def __ne__(self, other):
+        return not self == other
+
+    def __hash__(self):
+        return hash(self._key())
+
+    def __repr__(self):
+        key_vals = ['{}={}'.format(key, val) for key, val in self._key()]
+        return 'TimePartitioning({})'.format(','.join(key_vals))
+
 
 def _item_to_row(iterator, resource):
     """Convert a JSON row to the native object.


Test patch:
diff --git a/bigquery/tests/unit/test_job.py b/bigquery/tests/unit/test_job.py
--- a/bigquery/tests/unit/test_job.py
+++ b/bigquery/tests/unit/test_job.py
@@ -1147,94 +1147,558 @@ def _verifyReadonlyResourceProperties(self, job, resource):
 class TestLoadJobConfig(unittest.TestCase, _Base):
     JOB_TYPE = 'load'
 
-    def _make_resource(self, started=False, ended=False):
-        resource = super(TestLoadJobConfig, self)._make_resource(
-            started, ended)
-        config = resource['configuration']['load']
-        config['sourceUris'] = [self.SOURCE1]
-        config['destinationTable'] = {
-            'projectId': self.PROJECT,
-            'datasetId': self.DS_ID,
-            'tableId': self.TABLE_ID,
-        }
-        config['destinationEncryptionConfiguration'] = {
-            'kmsKeyName': self.KMS_KEY_NAME}
-
-        return resource
-
     @staticmethod
     def _get_target_class():
         from google.cloud.bigquery.job import LoadJobConfig
         return LoadJobConfig
 
-    def test_schema(self):
-        from google.cloud.bigquery.schema import SchemaField
+    def test_allow_jagged_rows_missing(self):
         config = self._get_target_class()()
-        full_name = SchemaField('full_name', 'STRING', mode='REQUIRED')
-        age = SchemaField('age', 'INTEGER', mode='REQUIRED')
-        config.schema = [full_name, age]
-        self.assertEqual(config.schema, [full_name, age])
+        self.assertIsNone(config.allow_jagged_rows)
 
-    def test_time_partitioning(self):
-        from google.cloud.bigquery import table
+    def test_allow_jagged_rows_hit(self):
+        config = self._get_target_class()()
+        config._properties['load']['allowJaggedRows'] = True
+        self.assertTrue(config.allow_jagged_rows)
 
-        time_partitioning = table.TimePartitioning(
-            type_=table.TimePartitioningType.DAY, field='name')
+    def test_allow_jagged_rows_setter(self):
         config = self._get_target_class()()
-        config.time_partitioning = time_partitioning
-        # TimePartitioning should be configurable after assigning
-        time_partitioning.expiration_ms = 10000
-        self.assertEqual(
-            config.time_partitioning.type_,
-            table.TimePartitioningType.DAY)
-        self.assertEqual(config.time_partitioning.field, 'name')
-        self.assertEqual(config.time_partitioning.expiration_ms, 10000)
+        config.allow_jagged_rows = True
+        self.assertTrue(config._properties['load']['allowJaggedRows'])
 
-        config.time_partitioning = None
-        self.assertIsNone(config.time_partitioning)
+    def test_allow_quoted_newlines_missing(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.allow_quoted_newlines)
 
-    def test_clustering_fields(self):
+    def test_allow_quoted_newlines_hit(self):
+        config = self._get_target_class()()
+        config._properties['load']['allowQuotedNewlines'] = True
+        self.assertTrue(config.allow_quoted_newlines)
+
+    def test_allow_quoted_newlines_setter(self):
+        config = self._get_target_class()()
+        config.allow_quoted_newlines = True
+        self.assertTrue(config._properties['load']['allowQuotedNewlines'])
+
+    def test_autodetect_missing(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.autodetect)
+
+    def test_autodetect_hit(self):
+        config = self._get_target_class()()
+        config._properties['load']['autodetect'] = True
+        self.assertTrue(config.autodetect)
+
+    def test_autodetect_setter(self):
+        config = self._get_target_class()()
+        config.autodetect = True
+        self.assertTrue(config._properties['load']['autodetect'])
+
+    def test_clustering_fields_miss(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.clustering_fields)
+
+    def test_clustering_fields_hit(self):
+        config = self._get_target_class()()
+        fields = ['email', 'postal_code']
+        config._properties['load']['clustering'] = {
+            'fields': fields,
+        }
+        self.assertEqual(config.clustering_fields, fields)
+
+    def test_clustering_fields_setter(self):
         fields = ['email', 'postal_code']
         config = self._get_target_class()()
         config.clustering_fields = fields
-        self.assertEqual(config.clustering_fields, fields)
+        self.assertEqual(
+            config._properties['load']['clustering'], {'fields': fields})
 
+    def test_clustering_fields_setter_w_none(self):
+        config = self._get_target_class()()
+        fields = ['email', 'postal_code']
+        config._properties['load']['clustering'] = {
+            'fields': fields,
+        }
         config.clustering_fields = None
         self.assertIsNone(config.clustering_fields)
+        self.assertNotIn('clustering', config._properties['load'])
 
-    def test_api_repr(self):
-        resource = self._make_resource()
-        config = self._get_target_class().from_api_repr(resource)
-        self.assertEqual(config.to_api_repr(), resource)
+    def test_create_disposition_missing(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.create_disposition)
 
-    def test_to_api_repr_with_encryption(self):
+    def test_create_disposition_hit(self):
+        from google.cloud.bigquery.job import CreateDisposition
+
+        disposition = CreateDisposition.CREATE_IF_NEEDED
+        config = self._get_target_class()()
+        config._properties['load']['createDisposition'] = disposition
+        self.assertEqual(config.create_disposition, disposition)
+
+    def test_create_disposition_setter(self):
+        from google.cloud.bigquery.job import CreateDisposition
+
+        disposition = CreateDisposition.CREATE_IF_NEEDED
+        config = self._get_target_class()()
+        config.create_disposition = disposition
+        self.assertEqual(
+            config._properties['load']['createDisposition'], disposition)
+
+    def test_destination_encryption_configuration_missing(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.destination_encryption_configuration)
+
+    def test_destination_encryption_configuration_hit(self):
         from google.cloud.bigquery.table import EncryptionConfiguration
 
-        config = self._make_one()
-        config.destination_encryption_configuration = EncryptionConfiguration(
-            kms_key_name=self.KMS_KEY_NAME)
-        resource = config.to_api_repr()
+        kms_key_name = 'kms-key-name'
+        encryption_configuration = EncryptionConfiguration(kms_key_name)
+        config = self._get_target_class()()
+        config._properties['load']['destinationEncryptionConfiguration'] = {
+            'kmsKeyName': kms_key_name,
+        }
         self.assertEqual(
-            resource,
-            {
-                'load': {
-                    'destinationEncryptionConfiguration': {
-                        'kmsKeyName': self.KMS_KEY_NAME,
-                    },
-                },
-            })
+            config.destination_encryption_configuration,
+            encryption_configuration)
 
-    def test_to_api_repr_with_encryption_none(self):
-        config = self._make_one()
+    def test_destination_encryption_configuration_setter(self):
+        from google.cloud.bigquery.table import EncryptionConfiguration
+
+        kms_key_name = 'kms-key-name'
+        encryption_configuration = EncryptionConfiguration(kms_key_name)
+        config = self._get_target_class()()
+        config.destination_encryption_configuration = encryption_configuration
+        expected = {
+            'kmsKeyName': kms_key_name,
+        }
+        self.assertEqual(
+            config._properties['load']['destinationEncryptionConfiguration'],
+            expected)
+
+    def test_destination_encryption_configuration_setter_w_none(self):
+        kms_key_name = 'kms-key-name'
+        config = self._get_target_class()()
+        config._properties['load']['destinationEncryptionConfiguration'] = {
+            'kmsKeyName': kms_key_name,
+        }
         config.destination_encryption_configuration = None
-        resource = config.to_api_repr()
+        self.assertIsNone(config.destination_encryption_configuration)
+        self.assertNotIn(
+            'destinationEncryptionConfiguration', config._properties['load'])
+
+    def test_destination_table_description_missing(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.destination_table_description)
+
+    def test_destination_table_description_hit(self):
+        description = 'Description'
+        config = self._get_target_class()()
+        config._properties['load']['destinationTableProperties'] = {
+            'description': description,
+        }
         self.assertEqual(
-            resource,
-            {
-                'load': {
-                    'destinationEncryptionConfiguration': None,
-                },
-            })
+            config.destination_table_description, description)
+
+    def test_destination_table_description_setter(self):
+        description = 'Description'
+        config = self._get_target_class()()
+        config.destination_table_description = description
+        expected = {
+            'description': description,
+        }
+        self.assertEqual(
+            config._properties['load']['destinationTableProperties'], expected)
+
+    def test_destination_table_description_setter_w_fn_already(self):
+        description = 'Description'
+        friendly_name = 'Friendly Name'
+        config = self._get_target_class()()
+        config._properties['load']['destinationTableProperties'] = {
+            'friendlyName': friendly_name,
+        }
+        config.destination_table_description = description
+        expected = {
+            'friendlyName': friendly_name,
+            'description': description,
+        }
+        self.assertEqual(
+            config._properties['load']['destinationTableProperties'], expected)
+
+    def test_destination_table_description_w_none(self):
+        description = 'Description'
+        friendly_name = 'Friendly Name'
+        config = self._get_target_class()()
+        config._properties['load']['destinationTableProperties'] = {
+            'description': description,
+            'friendlyName': friendly_name,
+        }
+        config.destination_table_description = None
+        expected = {
+            'friendlyName': friendly_name,
+        }
+        self.assertEqual(
+            config._properties['load']['destinationTableProperties'], expected)
+
+    def test_destination_table_friendly_name_missing(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.destination_table_friendly_name)
+
+    def test_destination_table_friendly_name_hit(self):
+        friendly_name = 'Friendly Name'
+        config = self._get_target_class()()
+        config._properties['load']['destinationTableProperties'] = {
+            'friendlyName': friendly_name,
+        }
+        self.assertEqual(
+            config.destination_table_friendly_name, friendly_name)
+
+    def test_destination_table_friendly_name_setter(self):
+        friendly_name = 'Friendly Name'
+        config = self._get_target_class()()
+        config.destination_table_friendly_name = friendly_name
+        expected = {
+            'friendlyName': friendly_name,
+        }
+        self.assertEqual(
+            config._properties['load']['destinationTableProperties'], expected)
+
+    def test_destination_table_friendly_name_setter_w_descr_already(self):
+        friendly_name = 'Friendly Name'
+        description = 'Description'
+        config = self._get_target_class()()
+        config._properties['load']['destinationTableProperties'] = {
+            'description': description,
+        }
+        config.destination_table_friendly_name = friendly_name
+        expected = {
+            'friendlyName': friendly_name,
+            'description': description,
+        }
+        self.assertEqual(
+            config._properties['load']['destinationTableProperties'], expected)
+
+    def test_destination_table_friendly_name_w_none(self):
+        friendly_name = 'Friendly Name'
+        description = 'Description'
+        config = self._get_target_class()()
+        config._properties['load']['destinationTableProperties'] = {
+            'description': description,
+            'friendlyName': friendly_name,
+        }
+        config.destination_table_friendly_name = None
+        expected = {
+            'description': description,
+        }
+        self.assertEqual(
+            config._properties['load']['destinationTableProperties'], expected)
+
+    def test_encoding_missing(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.encoding)
+
+    def test_encoding_hit(self):
+        from google.cloud.bigquery.job import Encoding
+
+        encoding = Encoding.UTF_8
+        config = self._get_target_class()()
+        config._properties['load']['encoding'] = encoding
+        self.assertEqual(config.encoding, encoding)
+
+    def test_encoding_setter(self):
+        from google.cloud.bigquery.job import Encoding
+
+        encoding = Encoding.UTF_8
+        config = self._get_target_class()()
+        config.encoding = encoding
+        self.assertEqual(
+            config._properties['load']['encoding'], encoding)
+
+    def test_field_delimiter_missing(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.field_delimiter)
+
+    def test_field_delimiter_hit(self):
+        field_delimiter = '|'
+        config = self._get_target_class()()
+        config._properties['load']['fieldDelimiter'] = field_delimiter
+        self.assertEqual(config.field_delimiter, field_delimiter)
+
+    def test_field_delimiter_setter(self):
+        field_delimiter = '|'
+        config = self._get_target_class()()
+        config.field_delimiter = field_delimiter
+        self.assertEqual(
+            config._properties['load']['fieldDelimiter'], field_delimiter)
+
+    def test_ignore_unknown_values_missing(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.ignore_unknown_values)
+
+    def test_ignore_unknown_values_hit(self):
+        config = self._get_target_class()()
+        config._properties['load']['ignoreUnknownValues'] = True
+        self.assertTrue(config.ignore_unknown_values)
+
+    def test_ignore_unknown_values_setter(self):
+        config = self._get_target_class()()
+        config.ignore_unknown_values = True
+        self.assertTrue(config._properties['load']['ignoreUnknownValues'])
+
+    def test_max_bad_records_missing(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.max_bad_records)
+
+    def test_max_bad_records_hit(self):
+        max_bad_records = 13
+        config = self._get_target_class()()
+        config._properties['load']['maxBadRecords'] = max_bad_records
+        self.assertEqual(config.max_bad_records, max_bad_records)
+
+    def test_max_bad_records_setter(self):
+        max_bad_records = 13
+        config = self._get_target_class()()
+        config.max_bad_records = max_bad_records
+        self.assertEqual(
+            config._properties['load']['maxBadRecords'], max_bad_records)
+
+    def test_null_marker_missing(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.null_marker)
+
+    def test_null_marker_hit(self):
+        null_marker = 'XXX'
+        config = self._get_target_class()()
+        config._properties['load']['nullMarker'] = null_marker
+        self.assertEqual(config.null_marker, null_marker)
+
+    def test_null_marker_setter(self):
+        null_marker = 'XXX'
+        config = self._get_target_class()()
+        config.null_marker = null_marker
+        self.assertEqual(
+            config._properties['load']['nullMarker'], null_marker)
+
+    def test_quote_character_missing(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.quote_character)
+
+    def test_quote_character_hit(self):
+        quote_character = "'"
+        config = self._get_target_class()()
+        config._properties['load']['quote'] = quote_character
+        self.assertEqual(config.quote_character, quote_character)
+
+    def test_quote_character_setter(self):
+        quote_character = "'"
+        config = self._get_target_class()()
+        config.quote_character = quote_character
+        self.assertEqual(
+            config._properties['load']['quote'], quote_character)
+
+    def test_schema_missing(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.schema)
+
+    def test_schema_hit(self):
+        from google.cloud.bigquery.schema import SchemaField
+
+        config = self._get_target_class()()
+        all_props_repr = {
+            'mode': 'REQUIRED',
+            'name': 'foo',
+            'type': 'INTEGER',
+            'description':  'Foo',
+        }
+        minimal_repr = {
+            'name': 'bar',
+            'type': 'STRING',
+        }
+        config._properties['load']['schema'] = {
+            'fields': [all_props_repr, minimal_repr],
+        }
+        all_props, minimal = config.schema
+        self.assertEqual(all_props, SchemaField.from_api_repr(all_props_repr))
+        self.assertEqual(minimal, SchemaField.from_api_repr(minimal_repr))
+
+    def test_schema_setter(self):
+        from google.cloud.bigquery.schema import SchemaField
+
+        config = self._get_target_class()()
+        full_name = SchemaField('full_name', 'STRING', mode='REQUIRED')
+        age = SchemaField('age', 'INTEGER', mode='REQUIRED')
+        config.schema = [full_name, age]
+        full_name_repr = {
+            'name': 'full_name',
+            'type': 'STRING',
+            'mode': 'REQUIRED',
+            'description': None,
+        }
+        age_repr = {
+            'name': 'age',
+            'type': 'INTEGER',
+            'mode': 'REQUIRED',
+            'description': None,
+        }
+        self.assertEqual(
+            config._properties['load']['schema'],
+            {'fields': [full_name_repr, age_repr]})
+
+    def test_schema_update_options_missing(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.schema_update_options)
+
+    def test_schema_update_options_hit(self):
+        from google.cloud.bigquery.job import SchemaUpdateOption
+
+        options = [
+            SchemaUpdateOption.ALLOW_FIELD_ADDITION,
+            SchemaUpdateOption.ALLOW_FIELD_RELAXATION,
+        ]
+        config = self._get_target_class()()
+        config._properties['load']['schemaUpdateOptions'] = options
+        self.assertEqual(config.schema_update_options, options)
+
+    def test_schema_update_options_setter(self):
+        from google.cloud.bigquery.job import SchemaUpdateOption
+
+        options = [
+            SchemaUpdateOption.ALLOW_FIELD_ADDITION,
+            SchemaUpdateOption.ALLOW_FIELD_RELAXATION,
+        ]
+        config = self._get_target_class()()
+        config.schema_update_options = options
+        self.assertEqual(
+            config._properties['load']['schemaUpdateOptions'], options)
+
+    def test_skip_leading_rows_missing(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.skip_leading_rows)
+
+    def test_skip_leading_rows_hit_w_str(self):
+        skip_leading_rows = 1
+        config = self._get_target_class()()
+        config._properties['load']['skipLeadingRows'] = str(skip_leading_rows)
+        self.assertEqual(config.skip_leading_rows, skip_leading_rows)
+
+    def test_skip_leading_rows_hit_w_integer(self):
+        skip_leading_rows = 1
+        config = self._get_target_class()()
+        config._properties['load']['skipLeadingRows'] = skip_leading_rows
+        self.assertEqual(config.skip_leading_rows, skip_leading_rows)
+
+    def test_skip_leading_rows_setter(self):
+        skip_leading_rows = 1
+        config = self._get_target_class()()
+        config.skip_leading_rows = skip_leading_rows
+        self.assertEqual(
+            config._properties['load']['skipLeadingRows'],
+            str(skip_leading_rows))
+
+    def test_source_format_missing(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.source_format)
+
+    def test_source_format_hit(self):
+        from google.cloud.bigquery.job import SourceFormat
+
+        source_format = SourceFormat.CSV
+        config = self._get_target_class()()
+        config._properties['load']['sourceFormat'] = source_format
+        self.assertEqual(config.source_format, source_format)
+
+    def test_source_format_setter(self):
+        from google.cloud.bigquery.job import SourceFormat
+
+        source_format = SourceFormat.CSV
+        config = self._get_target_class()()
+        config.source_format = source_format
+        self.assertEqual(
+            config._properties['load']['sourceFormat'], source_format)
+
+    def test_time_partitioning_miss(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.time_partitioning)
+
+    def test_time_partitioning_hit(self):
+        from google.cloud.bigquery.table import TimePartitioning
+        from google.cloud.bigquery.table import TimePartitioningType
+
+        field = 'creation_date'
+        year_ms = 86400 * 1000 * 365
+        config = self._get_target_class()()
+        config._properties['load']['timePartitioning'] = {
+            'type': TimePartitioningType.DAY,
+            'field': field,
+            'expirationMs': str(year_ms),
+            'requirePartitionFilter': False,
+        }
+        expected = TimePartitioning(
+            type_=TimePartitioningType.DAY,
+            field=field,
+            expiration_ms=year_ms,
+            require_partition_filter=False,
+        )
+        self.assertEqual(config.time_partitioning, expected)
+
+    def test_time_partitioning_setter(self):
+        from google.cloud.bigquery.table import TimePartitioning
+        from google.cloud.bigquery.table import TimePartitioningType
+
+        field = 'creation_date'
+        year_ms = 86400 * 1000 * 365
+        time_partitioning = TimePartitioning(
+            type_=TimePartitioningType.DAY,
+            field=field,
+            expiration_ms=year_ms,
+            require_partition_filter=False,
+        )
+        config = self._get_target_class()()
+        config.time_partitioning = time_partitioning
+        expected = {
+            'type': TimePartitioningType.DAY,
+            'field': field,
+            'expirationMs': str(year_ms),
+            'requirePartitionFilter': False,
+        }
+        self.assertEqual(
+            config._properties['load']['timePartitioning'], expected)
+
+    def test_time_partitioning_setter_w_none(self):
+        from google.cloud.bigquery.table import TimePartitioningType
+
+        field = 'creation_date'
+        year_ms = 86400 * 1000 * 365
+        config = self._get_target_class()()
+        config._properties['load']['timePartitioning'] = {
+            'type': TimePartitioningType.DAY,
+            'field': field,
+            'expirationMs': str(year_ms),
+            'requirePartitionFilter': False,
+        }
+        config.time_partitioning = None
+        self.assertIsNone(config.time_partitioning)
+        self.assertNotIn('timePartitioning', config._properties['load'])
+
+    def test_write_disposition_missing(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.write_disposition)
+
+    def test_write_disposition_hit(self):
+        from google.cloud.bigquery.job import WriteDisposition
+
+        write_disposition = WriteDisposition.WRITE_TRUNCATE
+        config = self._get_target_class()()
+        config._properties['load']['writeDisposition'] = write_disposition
+        self.assertEqual(config.write_disposition, write_disposition)
+
+    def test_write_disposition_setter(self):
+        from google.cloud.bigquery.job import WriteDisposition
+
+        write_disposition = WriteDisposition.WRITE_TRUNCATE
+        config = self._get_target_class()()
+        config.write_disposition = write_disposition
+        self.assertEqual(
+            config._properties['load']['writeDisposition'], write_disposition)
 
 
 class TestLoadJob(unittest.TestCase, _Base):
diff --git a/bigquery/tests/unit/test_table.py b/bigquery/tests/unit/test_table.py
--- a/bigquery/tests/unit/test_table.py
+++ b/bigquery/tests/unit/test_table.py
@@ -84,6 +84,59 @@ def test_to_api_repr(self):
                 'kmsKeyName': self.KMS_KEY_NAME,
             })
 
+    def test___eq___wrong_type(self):
+        encryption_config = self._make_one()
+        other = object()
+        self.assertNotEqual(encryption_config, other)
+        self.assertEqual(encryption_config, mock.ANY)
+
+    def test___eq___kms_key_name_mismatch(self):
+        encryption_config = self._make_one()
+        other = self._make_one(self.KMS_KEY_NAME)
+        self.assertNotEqual(encryption_config, other)
+
+    def test___eq___hit(self):
+        encryption_config = self._make_one(self.KMS_KEY_NAME)
+        other = self._make_one(self.KMS_KEY_NAME)
+        self.assertEqual(encryption_config, other)
+
+    def test___ne___wrong_type(self):
+        encryption_config = self._make_one()
+        other = object()
+        self.assertNotEqual(encryption_config, other)
+        self.assertEqual(encryption_config, mock.ANY)
+
+    def test___ne___same_value(self):
+        encryption_config1 = self._make_one(self.KMS_KEY_NAME)
+        encryption_config2 = self._make_one(self.KMS_KEY_NAME)
+        # unittest ``assertEqual`` uses ``==`` not ``!=``.
+        comparison_val = (encryption_config1 != encryption_config2)
+        self.assertFalse(comparison_val)
+
+    def test___ne___different_values(self):
+        encryption_config1 = self._make_one()
+        encryption_config2 = self._make_one(self.KMS_KEY_NAME)
+        self.assertNotEqual(encryption_config1, encryption_config2)
+
+    def test___hash__set_equality(self):
+        encryption_config1 = self._make_one(self.KMS_KEY_NAME)
+        encryption_config2 = self._make_one(self.KMS_KEY_NAME)
+        set_one = {encryption_config1, encryption_config2}
+        set_two = {encryption_config1, encryption_config2}
+        self.assertEqual(set_one, set_two)
+
+    def test___hash__not_equals(self):
+        encryption_config1 = self._make_one()
+        encryption_config2 = self._make_one(self.KMS_KEY_NAME)
+        set_one = {encryption_config1}
+        set_two = {encryption_config2}
+        self.assertNotEqual(set_one, set_two)
+
+    def test___repr__(self):
+        encryption_config = self._make_one(self.KMS_KEY_NAME)
+        expected = "EncryptionConfiguration({})".format(self.KMS_KEY_NAME)
+        self.assertEqual(repr(encryption_config), expected)
+
 
 class TestTableReference(unittest.TestCase):
 
@@ -1440,33 +1493,26 @@ def test_to_dataframe_error_if_pandas_is_none(self):
 
 class TestTimePartitioning(unittest.TestCase):
 
-    def test_constructor_defaults(self):
+    def _get_target_class(self):
         from google.cloud.bigquery.table import TimePartitioning
 
-        time_partitioning = TimePartitioning()
+        return TimePartitioning
+
+    def _make_one(self, *args, **kw):
+        return self._get_target_class()(*args, **kw)
+
+    def test_constructor_defaults(self):
+        time_partitioning = self._make_one()
 
         self.assertEqual(time_partitioning.type_, 'DAY')
         self.assertIsNone(time_partitioning.field)
         self.assertIsNone(time_partitioning.expiration_ms)
         self.assertIsNone(time_partitioning.require_partition_filter)
 
-        api_repr = time_partitioning.to_api_repr()
-
-        exp_api_repr = {'type': 'DAY'}
-        self.assertEqual(api_repr, exp_api_repr)
-
-        tp_from_api_repr = TimePartitioning.from_api_repr(api_repr)
-
-        self.assertEqual(tp_from_api_repr.type_, 'DAY')
-        self.assertIsNone(tp_from_api_repr.field)
-        self.assertIsNone(tp_from_api_repr.expiration_ms)
-        self.assertIsNone(tp_from_api_repr.require_partition_filter)
-
-    def test_constructor_properties(self):
-        from google.cloud.bigquery.table import TimePartitioning
+    def test_constructor_explicit(self):
         from google.cloud.bigquery.table import TimePartitioningType
 
-        time_partitioning = TimePartitioning(
+        time_partitioning = self._make_one(
             type_=TimePartitioningType.DAY,
             field='name',
             expiration_ms=10000,
@@ -1478,19 +1524,143 @@ def test_constructor_properties(self):
         self.assertEqual(time_partitioning.expiration_ms, 10000)
         self.assertTrue(time_partitioning.require_partition_filter)
 
-        api_repr = time_partitioning.to_api_repr()
+    def test_from_api_repr_minimal(self):
+        from google.cloud.bigquery.table import TimePartitioningType
+
+        klass = self._get_target_class()
+        api_repr = {'type': 'DAY'}
+        time_partitioning = klass.from_api_repr(api_repr)
+
+        self.assertEqual(time_partitioning.type_, TimePartitioningType.DAY)
+        self.assertIsNone(time_partitioning.field)
+        self.assertIsNone(time_partitioning.expiration_ms)
+        self.assertIsNone(time_partitioning.require_partition_filter)
 
-        exp_api_repr = {
+    def test_from_api_repr_explicit(self):
+        from google.cloud.bigquery.table import TimePartitioningType
+
+        klass = self._get_target_class()
+        api_repr = {
             'type': 'DAY',
             'field': 'name',
             'expirationMs': '10000',
             'requirePartitionFilter': True,
         }
-        self.assertEqual(api_repr, exp_api_repr)
+        time_partitioning = klass.from_api_repr(api_repr)
 
-        tp_from_api_repr = TimePartitioning.from_api_repr(api_repr)
-
-        self.assertEqual(tp_from_api_repr.type_, 'DAY')
-        self.assertEqual(tp_from_api_repr.field, 'name')
-        self.assertEqual(tp_from_api_repr.expiration_ms, 10000)
+        self.assertEqual(time_partitioning.type_, TimePartitioningType.DAY)
+        self.assertEqual(time_partitioning.field, 'name')
+        self.assertEqual(time_partitioning.expiration_ms, 10000)
         self.assertTrue(time_partitioning.require_partition_filter)
+
+    def test_to_api_repr_defaults(self):
+        time_partitioning = self._make_one()
+        expected = {'type': 'DAY'}
+        self.assertEqual(time_partitioning.to_api_repr(), expected)
+
+    def test_to_api_repr_explicit(self):
+        from google.cloud.bigquery.table import TimePartitioningType
+
+        time_partitioning = self._make_one(
+            type_=TimePartitioningType.DAY,
+            field='name',
+            expiration_ms=10000,
+            require_partition_filter=True
+        )
+
+        expected = {
+            'type': 'DAY',
+            'field': 'name',
+            'expirationMs': '10000',
+            'requirePartitionFilter': True,
+        }
+        self.assertEqual(time_partitioning.to_api_repr(), expected)
+
+    def test___eq___wrong_type(self):
+        time_partitioning = self._make_one()
+        other = object()
+        self.assertNotEqual(time_partitioning, other)
+        self.assertEqual(time_partitioning, mock.ANY)
+
+    def test___eq___type__mismatch(self):
+        time_partitioning = self._make_one()
+        other = self._make_one(type_='HOUR')
+        self.assertNotEqual(time_partitioning, other)
+
+    def test___eq___field_mismatch(self):
+        time_partitioning = self._make_one(field='foo')
+        other = self._make_one(field='bar')
+        self.assertNotEqual(time_partitioning, other)
+
+    def test___eq___expiration_ms_mismatch(self):
+        time_partitioning = self._make_one(field='foo', expiration_ms=100000)
+        other = self._make_one(field='foo', expiration_ms=200000)
+        self.assertNotEqual(time_partitioning, other)
+
+    def test___eq___require_partition_filter_mismatch(self):
+        time_partitioning = self._make_one(
+            field='foo', expiration_ms=100000, require_partition_filter=True)
+        other = self._make_one(
+            field='foo', expiration_ms=100000, require_partition_filter=False)
+        self.assertNotEqual(time_partitioning, other)
+
+    def test___eq___hit(self):
+        time_partitioning = self._make_one(
+            field='foo', expiration_ms=100000, require_partition_filter=True)
+        other = self._make_one(
+            field='foo', expiration_ms=100000, require_partition_filter=True)
+        self.assertEqual(time_partitioning, other)
+
+    def test___ne___wrong_type(self):
+        time_partitioning = self._make_one()
+        other = object()
+        self.assertNotEqual(time_partitioning, other)
+        self.assertEqual(time_partitioning, mock.ANY)
+
+    def test___ne___same_value(self):
+        time_partitioning1 = self._make_one()
+        time_partitioning2 = self._make_one()
+        # unittest ``assertEqual`` uses ``==`` not ``!=``.
+        comparison_val = (time_partitioning1 != time_partitioning2)
+        self.assertFalse(comparison_val)
+
+    def test___ne___different_values(self):
+        time_partitioning1 = self._make_one()
+        time_partitioning2 = self._make_one(type_='HOUR')
+        self.assertNotEqual(time_partitioning1, time_partitioning2)
+
+    def test___hash__set_equality(self):
+        time_partitioning1 = self._make_one(field='foo')
+        time_partitioning2 = self._make_one(field='foo')
+        set_one = {time_partitioning1, time_partitioning2}
+        set_two = {time_partitioning1, time_partitioning2}
+        self.assertEqual(set_one, set_two)
+
+    def test___hash__not_equals(self):
+        time_partitioning1 = self._make_one(field='foo')
+        time_partitioning2 = self._make_one(field='bar')
+        set_one = {time_partitioning1}
+        set_two = {time_partitioning2}
+        self.assertNotEqual(set_one, set_two)
+
+    def test___repr___minimal(self):
+        time_partitioning = self._make_one()
+        expected = "TimePartitioning(type=DAY)"
+        self.assertEqual(repr(time_partitioning), expected)
+
+    def test___repr___explicit(self):
+        from google.cloud.bigquery.table import TimePartitioningType
+
+        time_partitioning = self._make_one(
+            type_=TimePartitioningType.DAY,
+            field='name',
+            expiration_ms=10000,
+            require_partition_filter=True
+        )
+        expected = (
+            "TimePartitioning("
+            "expirationMs=10000,"
+            "field=name,"
+            "requirePartitionFilter=True,"
+            "type=DAY)")
+        self.assertEqual(repr(time_partitioning), expected)