Repository: googleapis/google-cloud-python

Issue:
extract_table does not check that the job config is an ExtractJobConfig
See: https://github.com/GoogleCloudPlatform/python-docs-samples/issues/1281

    bigquery_client = bigquery.Client()
    dataset_ref = bigquery_client.dataset(dataset_id)
    table_ref = dataset_ref.table(table_id)

    job_config = bigquery.LoadJobConfig()
    job_config.source_format = 'NEWLINE_DELIMITED_JSON'

    job = bigquery_client.extract_table(table_ref, destination, job_config=job_config)

    job.result()  # Waits for job to complete

    print('Exported {}:{} to {}'.format(
        dataset_id, table_id, destination))

Fails with an error

```
google.api_core.exceptions.BadRequest: 400 Operation cannot be performed on a nested schema
```

Whereas it should fail because the `job_config` argument is a `LoadJobConfig`, not a `ExtractJobConfig`.


Discussion:
Note, this issue likely applies to the other methods on `Client` that have a `job_config` argument.

/cc @alixhami 
My thought on this: we should change how we convert to/from the [Load/Query/Copy/Extract]JobConfig classes' API representation.

The current way is to return the `job.configuration.[load|query|copy|extract]` dictionary 

```
{ 'writeDispostion': 'WRITE_TRUNCATE', ... }
```

Currently the Job class add the [load|query|copy|extract] key (plus moves up shared options such as `dryRun` to the `configuration` dictionary. I propose we return the whole configuration dictionary.

```
{ 'load': { 'writeDispostion': 'WRITE_TRUNCATE', ... }}
```

Then the BigQuery backend can complain when it gets two job types in the same resource. Also, it will make it easier to implement new properties that are shared among all job configuration types such as 'labels'.
Can we make that change without breaking the interface? If not, we should do that ASAP
There's gonna be some slight breakage that I know will bubble up to `pandas-gbq`. That's why it's on my mind today.

Yep, let's do it ASAP. Starting on it now.

Resolving patch:
diff --git a/bigquery/google/cloud/bigquery/_helpers.py b/bigquery/google/cloud/bigquery/_helpers.py
--- a/bigquery/google/cloud/bigquery/_helpers.py
+++ b/bigquery/google/cloud/bigquery/_helpers.py
@@ -339,7 +339,7 @@ def __get__(self, instance, owner):
 
     def _validate(self, value):
         """Subclasses override to impose validation policy."""
-        pass
+        raise NotImplementedError("Abstract")
 
     def __set__(self, instance, value):
         """Descriptor protocol:  mutator"""
@@ -417,17 +417,6 @@ def _validate(self, value):
                 'Required type: list of %s' % (self.property_type,))
 
 
-class _EnumApiResourceProperty(_ApiResourceProperty):
-    """Pseudo-enumeration class.
-
-    :type name: str
-    :param name:  name of the property.
-
-    :type resource_name: str
-    :param resource_name:  name of the property in the resource dictionary
-    """
-
-
 def _item_to_row(iterator, resource):
     """Convert a JSON row to the native object.
 
@@ -486,6 +475,94 @@ def _should_retry(exc):
     return reason == 'backendError' or reason == 'rateLimitExceeded'
 
 
+def get_sub_prop(container, keys, default=None):
+    """Get a nested value from a dictionary.
+
+    This method works like ``dict.get(key)``, but for nested values.
+
+    Arguments:
+        container (dict):
+            A dictionary which may contain other dictionaries as values.
+        keys (iterable):
+            A sequence of keys to attempt to get the value for. Each item in
+            the sequence represents a deeper nesting. The first key is for
+            the top level. If there is a dictionary there, the second key
+            attempts to get the value within that, and so on.
+        default (object):
+            (Optional) Value to returned if any of the keys are not found.
+            Defaults to ``None``.
+
+    Examples:
+        Get a top-level value (equivalent to ``container.get('key')``).
+
+        >>> get_sub_prop({'key': 'value'}, ['key'])
+        'value'
+
+        Get a top-level value, providing a default (equivalent to
+        ``container.get('key', default='default')``).
+
+        >>> get_sub_prop({'nothere': 123}, ['key'], default='not found')
+        'not found'
+
+        Get a nested value.
+
+        >>> get_sub_prop({'key': {'subkey': 'value'}}, ['key', 'subkey'])
+        'value'
+
+    Returns:
+        object: The value if present or the default.
+    """
+    sub_val = container
+    for key in keys:
+        if key not in sub_val:
+            return default
+        sub_val = sub_val[key]
+    return sub_val
+
+
+def set_sub_prop(container, keys, value):
+    """Set a nested value in a dictionary.
+
+    Arguments:
+        container (dict):
+            A dictionary which may contain other dictionaries as values.
+        keys (iterable):
+            A sequence of keys to attempt to set the value for. Each item in
+            the sequence represents a deeper nesting. The first key is for
+            the top level. If there is a dictionary there, the second key
+            attempts to get the value within that, and so on.
+        value (object): Value to set within the container.
+
+    Examples:
+        Set a top-level value (equivalent to ``container['key'] = 'value'``).
+
+        >>> container = {}
+        >>> set_sub_prop(container, ['key'], 'value')
+        >>> container
+        {'key': 'value'}
+
+        Set a nested value.
+
+        >>> container = {}
+        >>> set_sub_prop(container, ['key', 'subkey'], 'value')
+        >>> container
+        {'key': {'subkey': 'value'}}
+
+        Replace a nested value.
+
+        >>> container = {'key': {'subkey': 'prev'}}
+        >>> set_sub_prop(container, ['key', 'subkey'], 'new')
+        >>> container
+        {'key': {'subkey': 'new'}}
+    """
+    sub_val = container
+    for key in keys[:-1]:
+        if key not in sub_val:
+            sub_val[key] = {}
+        sub_val = sub_val[key]
+    sub_val[keys[-1]] = value
+
+
 DEFAULT_RETRY = retry.Retry(predicate=_should_retry)
 """The default retry object.
 
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
@@ -17,7 +17,6 @@
 import copy
 import threading
 
-import six
 from six.moves import http_client
 
 import google.api_core.future.polling
@@ -34,10 +33,7 @@
 from google.cloud.bigquery.schema import SchemaField
 from google.cloud.bigquery.table import EncryptionConfiguration
 from google.cloud.bigquery.table import TableReference
-from google.cloud.bigquery.table import _build_schema_resource
-from google.cloud.bigquery.table import _parse_schema_resource
-from google.cloud.bigquery._helpers import _EnumApiResourceProperty
-from google.cloud.bigquery._helpers import _TypedApiResourceProperty
+from google.cloud.bigquery import _helpers
 from google.cloud.bigquery._helpers import DEFAULT_RETRY
 from google.cloud.bigquery._helpers import _int_or_none
 
@@ -89,16 +85,20 @@ def _error_result_to_exception(error_result):
         status_code, error_result.get('message', ''), errors=[error_result])
 
 
-class Compression(_EnumApiResourceProperty):
+class Compression(object):
     """The compression type to use for exported files.
 
-    Possible values include `GZIP` and `NONE`. The default value is `NONE`.
+    Possible values include `GZIP`, `DEFLATE`, `SNAPPY`, and `NONE`. The
+    default value is `NONE`. `DEFLATE` and `SNAPPY` are only supported for
+    Avro.
     """
     GZIP = 'GZIP'
+    DEFLATE = 'DEFLATE'
+    SNAPPY = 'SNAPPY'
     NONE = 'NONE'
 
 
-class CreateDisposition(_EnumApiResourceProperty):
+class CreateDisposition(object):
     """Specifies whether the job is allowed to create new tables.
 
     The following values are supported:
@@ -115,7 +115,7 @@ class CreateDisposition(_EnumApiResourceProperty):
     CREATE_NEVER = 'CREATE_NEVER'
 
 
-class DestinationFormat(_EnumApiResourceProperty):
+class DestinationFormat(object):
     """The exported file format.
 
     Possible values include `CSV`, `NEWLINE_DELIMITED_JSON` and `AVRO`.
@@ -127,7 +127,7 @@ class DestinationFormat(_EnumApiResourceProperty):
     AVRO = 'AVRO'
 
 
-class Encoding(_EnumApiResourceProperty):
+class Encoding(object):
     """The character encoding of the data. The supported values
     are `UTF_8` corresponding to `'UTF-8'` or `ISO_8859_1` corresponding to
     `'ISO-8559-1'`. The default value is `UTF_8`.
@@ -139,7 +139,7 @@ class Encoding(_EnumApiResourceProperty):
     ISO_8559_1 = 'ISO-8559-1'
 
 
-class QueryPriority(_EnumApiResourceProperty):
+class QueryPriority(object):
     """Specifies a priority for the query.
 
     Possible values include `INTERACTIVE` and `BATCH`. The default value
@@ -149,7 +149,7 @@ class QueryPriority(_EnumApiResourceProperty):
     BATCH = 'BATCH'
 
 
-class SourceFormat(_EnumApiResourceProperty):
+class SourceFormat(object):
     """The format of the data files.
 
     For CSV files, specify `CSV`. For datastore backups, specify
@@ -164,7 +164,7 @@ class SourceFormat(_EnumApiResourceProperty):
     PARQUET = 'PARQUET'
 
 
-class WriteDisposition(_EnumApiResourceProperty):
+class WriteDisposition(object):
     """Specifies the action that occurs if destination table already exists.
 
     The following values are supported:
@@ -185,17 +185,6 @@ class WriteDisposition(_EnumApiResourceProperty):
     WRITE_EMPTY = 'WRITE_EMPTY'
 
 
-class AutoDetectSchema(_TypedApiResourceProperty):
-    """Property for ``autodetect`` properties.
-
-    :raises ValueError: on ``set`` operation if ``instance.schema``
-                        is already defined.
-    """
-    def __set__(self, instance, value):
-        self._validate(value)
-        instance._properties[self.resource_name] = value
-
-
 class _AsyncJob(google.api_core.future.polling.PollingFuture):
     """Base class for asynchronous jobs.
 
@@ -386,8 +375,7 @@ def _set_properties(self, api_response):
 
         self._properties.clear()
         self._properties.update(cleaned)
-        configuration = cleaned['configuration'][self._JOB_TYPE]
-        self._copy_configuration_properties(configuration)
+        self._copy_configuration_properties(cleaned['configuration'])
 
         # For Future interface
         self._set_future_result()
@@ -414,8 +402,7 @@ def _get_resource_config(cls, resource):
                 cls._JOB_TYPE not in resource['configuration']):
             raise KeyError('Resource lacks required configuration: '
                            '["configuration"]["%s"]' % cls._JOB_TYPE)
-        config = resource['configuration'][cls._JOB_TYPE]
-        return job_id, config
+        return job_id, resource['configuration']
 
     def _begin(self, client=None, retry=DEFAULT_RETRY):
         """API call:  begin the job via a POST request
@@ -590,7 +577,93 @@ def cancelled(self):
                 and self.error_result.get('reason') == _STOPPED_REASON)
 
 
-class LoadJobConfig(object):
+class _JobConfig(object):
+    """Abstract base class for job configuration objects.
+
+    Arguments:
+        job_type (str): The key to use for the job configuration.
+    """
+
+    def __init__(self, job_type):
+        self._job_type = job_type
+        self._properties = {job_type: {}}
+
+    def _get_sub_prop(self, key, default=None):
+        """Get a value in the ``self._properties[self._job_type]`` dictionary.
+
+        Most job properties are inside the dictionary related to the job type
+        (e.g. 'copy', 'extract', 'load', 'query'). Use this method to access
+        those properties::
+
+            self._get_sub_prop('destinationTable')
+
+        This is equivalent to using the ``_helper.get_sub_prop`` function::
+
+            _helper.get_sub_prop(
+                self._properties, ['query', 'destinationTable'])
+
+        Arguments:
+            key (str):
+                 Key for the value to get in the
+                 ``self._properties[self._job_type]`` dictionary.
+            default (object):
+                (Optional) Default value to return if the key is not found.
+                Defaults to ``None``.
+
+        Returns:
+            object: The value if present or the default.
+        """
+        return _helpers.get_sub_prop(
+            self._properties, [self._job_type, key], default=default)
+
+    def _set_sub_prop(self, key, value):
+        """Set a value in the ``self._properties[self._job_type]`` dictionary.
+
+        Most job properties are inside the dictionary related to the job type
+        (e.g. 'copy', 'extract', 'load', 'query'). Use this method to set
+        those properties::
+
+            self._set_sub_prop('useLegacySql', False)
+
+        This is equivalent to using the ``_helper.set_sub_prop`` function::
+
+            _helper.set_sub_prop(
+                self._properties, ['query', 'useLegacySql'], False)
+
+        Arguments:
+            key (str):
+                 Key to set in the ``self._properties[self._job_type]``
+                 dictionary.
+            value (object): Value to set.
+        """
+        _helpers.set_sub_prop(self._properties, [self._job_type, key], value)
+
+    def to_api_repr(self):
+        """Build an API representation of the job config.
+
+        :rtype: dict
+        :returns: A dictionary in the format used by the BigQuery API.
+        """
+        return copy.deepcopy(self._properties)
+
+    @classmethod
+    def from_api_repr(cls, resource):
+        """Factory: construct a job configuration given its API representation
+
+        :type resource: dict
+        :param resource:
+            An extract job configuration in the same representation as is
+            returned from the API.
+
+        :rtype: :class:`google.cloud.bigquery.job._JobConfig`
+        :returns: Configuration parsed from ``resource``.
+        """
+        config = cls()
+        config._properties = copy.deepcopy(resource)
+        return config
+
+
+class LoadJobConfig(_JobConfig):
     """Configuration options for load jobs.
 
     All properties in this class are optional. Values which are ``None`` ->
@@ -598,96 +671,202 @@ class LoadJobConfig(object):
     """
 
     def __init__(self):
-        self._properties = {}
-        self._schema = ()
+        super(LoadJobConfig, self).__init__('load')
 
-    allow_jagged_rows = _TypedApiResourceProperty(
-        'allow_jagged_rows', 'allowJaggedRows', bool)
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.allowJaggedRows
-    """
+    @property
+    def allow_jagged_rows(self):
+        """bool: Allow missing trailing optional columns (CSV only).
 
-    allow_quoted_newlines = _TypedApiResourceProperty(
-        'allow_quoted_newlines', 'allowQuotedNewlines', bool)
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.allowQuotedNewlines
-    """
+        See
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.allowJaggedRows
+        """
+        return self._get_sub_prop('allowJaggedRows')
 
-    autodetect = AutoDetectSchema('autodetect', 'autodetect', bool)
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.autodetect
-    """
+    @allow_jagged_rows.setter
+    def allow_jagged_rows(self, value):
+        self._set_sub_prop('allowJaggedRows', value)
 
-    create_disposition = CreateDisposition('create_disposition',
-                                           'createDisposition')
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.createDisposition
-    """
+    @property
+    def allow_quoted_newlines(self):
+        """bool: Allow quoted data containing newline characters (CSV only).
 
-    encoding = Encoding('encoding', 'encoding')
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.encoding
-    """
+        See
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.allowQuotedNewlines
+        """
+        return self._get_sub_prop('allowQuotedNewlines')
 
-    field_delimiter = _TypedApiResourceProperty(
-        'field_delimiter', 'fieldDelimiter', six.string_types)
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.fieldDelimiter
-    """
+    @allow_quoted_newlines.setter
+    def allow_quoted_newlines(self, value):
+        self._set_sub_prop('allowQuotedNewlines', value)
 
-    ignore_unknown_values = _TypedApiResourceProperty(
-        'ignore_unknown_values', 'ignoreUnknownValues', bool)
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.ignoreUnknownValues
-    """
+    @property
+    def autodetect(self):
+        """bool: Automatically infer the schema from a sample of the data.
 
-    max_bad_records = _TypedApiResourceProperty(
-        'max_bad_records', 'maxBadRecords', six.integer_types)
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.maxBadRecords
-    """
+        See
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.autodetect
+        """
+        return self._get_sub_prop('autodetect')
 
-    null_marker = _TypedApiResourceProperty(
-        'null_marker', 'nullMarker', six.string_types)
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.nullMarker
-    """
+    @autodetect.setter
+    def autodetect(self, value):
+        self._set_sub_prop('autodetect', value)
 
-    quote_character = _TypedApiResourceProperty(
-        'quote_character', 'quote', six.string_types)
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.quote
-    """
+    @property
+    def create_disposition(self):
+        """google.cloud.bigquery.job.CreateDisposition: Specifies behavior
+        for creating tables.
 
-    skip_leading_rows = _TypedApiResourceProperty(
-        'skip_leading_rows', 'skipLeadingRows', six.integer_types)
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.skipLeadingRows
-    """
+        See
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.createDisposition
+        """
+        return self._get_sub_prop('createDisposition')
 
-    source_format = SourceFormat('source_format', 'sourceFormat')
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.sourceFormat
-    """
+    @create_disposition.setter
+    def create_disposition(self, value):
+        self._set_sub_prop('createDisposition', value)
 
-    write_disposition = WriteDisposition('write_disposition',
-                                         'writeDisposition')
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.writeDisposition
-    """
+    @property
+    def encoding(self):
+        """google.cloud.bigquery.job.Encoding: The character encoding of the
+        data.
+
+        See
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.encoding
+        """
+        return self._get_sub_prop('encoding')
+
+    @encoding.setter
+    def encoding(self, value):
+        self._set_sub_prop('encoding', value)
+
+    @property
+    def field_delimiter(self):
+        """str: The separator for fields in a CSV file.
+
+        See
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.fieldDelimiter
+        """
+        return self._get_sub_prop('fieldDelimiter')
+
+    @field_delimiter.setter
+    def field_delimiter(self, value):
+        self._set_sub_prop('fieldDelimiter', value)
+
+    @property
+    def ignore_unknown_values(self):
+        """bool: Ignore extra values not represented in the table schema.
+
+        See
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.ignoreUnknownValues
+        """
+        return self._get_sub_prop('ignoreUnknownValues')
+
+    @ignore_unknown_values.setter
+    def ignore_unknown_values(self, value):
+        self._set_sub_prop('ignoreUnknownValues', value)
+
+    @property
+    def max_bad_records(self):
+        """int: Number of invalid rows to ignore.
+
+        See
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.maxBadRecords
+        """
+        return self._get_sub_prop('maxBadRecords')
+
+    @max_bad_records.setter
+    def max_bad_records(self, value):
+        self._set_sub_prop('maxBadRecords', value)
+
+    @property
+    def null_marker(self):
+        """str: Represents a null value (CSV only).
+
+        See
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.nullMarker
+        """
+        return self._get_sub_prop('nullMarker')
+
+    @null_marker.setter
+    def null_marker(self, value):
+        self._set_sub_prop('nullMarker', value)
+
+    @property
+    def quote_character(self):
+        """str: Character used to quote data sections (CSV only).
+
+        See
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.quote
+        """
+        return self._get_sub_prop('quote')
+
+    @quote_character.setter
+    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 _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):
-        """See
+        """List[google.cloud.bigquery.schema.SchemaField]: Schema of the
+        destination table.
+
+        See
         https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.schema
         """
-        return list(self._schema)
+        schema = _helpers.get_sub_prop(
+            self._properties, ['load', 'schema', 'fields'])
+        if schema is None:
+            return
+        return [SchemaField.from_api_repr(field) for field in schema]
 
     @schema.setter
     def schema(self, value):
-        if not all(isinstance(field, SchemaField) for field in value):
+        if not all(hasattr(field, 'to_api_repr') for field in value):
             raise ValueError('Schema items must be fields')
-        self._schema = tuple(value)
+        _helpers.set_sub_prop(
+            self._properties,
+            ['load', 'schema', 'fields'],
+            [field.to_api_repr() for field in value])
 
     @property
     def destination_encryption_configuration(self):
@@ -700,7 +879,7 @@ def destination_encryption_configuration(self):
         See
         https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.load.destinationEncryptionConfiguration
         """
-        prop = self._properties.get('destinationEncryptionConfiguration')
+        prop = self._get_sub_prop('destinationEncryptionConfiguration')
         if prop is not None:
             prop = EncryptionConfiguration.from_api_repr(prop)
         return prop
@@ -710,45 +889,7 @@ def destination_encryption_configuration(self, value):
         api_repr = value
         if value is not None:
             api_repr = value.to_api_repr()
-        self._properties['destinationEncryptionConfiguration'] = api_repr
-
-    def to_api_repr(self):
-        """Build an API representation of the load job config.
-
-        :rtype: dict
-        :returns: A dictionary in the format used by the BigQuery API.
-        """
-        config = copy.deepcopy(self._properties)
-        if len(self.schema) > 0:
-            config['schema'] = {'fields': _build_schema_resource(self.schema)}
-        # skipLeadingRows is a string because it's defined as an int64, which
-        # can't be represented as a JSON number.
-        slr = config.get('skipLeadingRows')
-        if slr is not None:
-            config['skipLeadingRows'] = str(slr)
-        return config
-
-    @classmethod
-    def from_api_repr(cls, resource):
-        """Factory: construct a job configuration given its API representation
-
-        :type resource: dict
-        :param resource:
-            An extract job configuration in the same representation as is
-            returned from the API.
-
-        :rtype: :class:`google.cloud.bigquery.job.LoadJobConfig`
-        :returns: Configuration parsed from ``resource``.
-        """
-        schema = resource.pop('schema', {'fields': ()})
-        slr = resource.pop('skipLeadingRows', None)
-        config = cls()
-        config._properties = copy.deepcopy(resource)
-        config.schema = _parse_schema_resource(schema)
-        config.skip_leading_rows = _int_or_none(slr)
-        if config.skip_leading_rows is None:
-            del config.skip_leading_rows
-        return config
+        self._set_sub_prop('destinationEncryptionConfiguration', api_repr)
 
 
 class LoadJob(_AsyncJob):
@@ -946,17 +1087,19 @@ def _build_resource(self):
         """Generate a resource for :meth:`begin`."""
         configuration = self._configuration.to_api_repr()
         if self.source_uris is not None:
-            configuration['sourceUris'] = self.source_uris
-        configuration['destinationTable'] = self.destination.to_api_repr()
+            _helpers.set_sub_prop(
+                configuration, ['load', 'sourceUris'], self.source_uris)
+        _helpers.set_sub_prop(
+            configuration,
+            ['load', 'destinationTable'],
+            self.destination.to_api_repr())
 
         return {
             'jobReference': {
                 'projectId': self.project,
                 'jobId': self.job_id,
             },
-            'configuration': {
-                self._JOB_TYPE: configuration,
-            },
+            'configuration': configuration,
         }
 
     def _copy_configuration_properties(self, configuration):
@@ -984,18 +1127,20 @@ def from_api_repr(cls, resource, client):
         """
         job_id, config_resource = cls._get_resource_config(resource)
         config = LoadJobConfig.from_api_repr(config_resource)
-        dest_config = config_resource['destinationTable']
+        dest_config = _helpers.get_sub_prop(
+            config_resource, ['load', 'destinationTable'])
         ds_ref = DatasetReference(dest_config['projectId'],
                                   dest_config['datasetId'],)
         destination = TableReference(ds_ref, dest_config['tableId'])
         # sourceUris will be absent if this is a file upload.
-        source_uris = config_resource.get('sourceUris')
+        source_uris = _helpers.get_sub_prop(
+            config_resource, ['load', 'sourceUris'])
         job = cls(job_id, source_uris, destination, client, config)
         job._set_properties(resource)
         return job
 
 
-class CopyJobConfig(object):
+class CopyJobConfig(_JobConfig):
     """Configuration options for copy jobs.
 
     All properties in this class are optional. Values which are ``None`` ->
@@ -1003,19 +1148,35 @@ class CopyJobConfig(object):
     """
 
     def __init__(self):
-        self._properties = {}
+        super(CopyJobConfig, self).__init__('copy')
 
-    create_disposition = CreateDisposition('create_disposition',
-                                           'createDisposition')
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.copy.createDisposition
-    """
+    @property
+    def create_disposition(self):
+        """google.cloud.bigquery.job.CreateDisposition: Specifies behavior
+        for creating tables.
 
-    write_disposition = WriteDisposition('write_disposition',
-                                         'writeDisposition')
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.copy.writeDisposition
-    """
+        See
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.copy.createDisposition
+        """
+        return self._get_sub_prop('createDisposition')
+
+    @create_disposition.setter
+    def create_disposition(self, value):
+        self._set_sub_prop('createDisposition', 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.copy.writeDisposition
+        """
+        return self._get_sub_prop('writeDisposition')
+
+    @write_disposition.setter
+    def write_disposition(self, value):
+        self._set_sub_prop('writeDisposition', value)
 
     @property
     def destination_encryption_configuration(self):
@@ -1028,7 +1189,7 @@ def destination_encryption_configuration(self):
         See
         https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.copy.destinationEncryptionConfiguration
         """
-        prop = self._properties.get('destinationEncryptionConfiguration')
+        prop = self._get_sub_prop('destinationEncryptionConfiguration')
         if prop is not None:
             prop = EncryptionConfiguration.from_api_repr(prop)
         return prop
@@ -1038,31 +1199,7 @@ def destination_encryption_configuration(self, value):
         api_repr = value
         if value is not None:
             api_repr = value.to_api_repr()
-        self._properties['destinationEncryptionConfiguration'] = api_repr
-
-    def to_api_repr(self):
-        """Build an API representation of the copy job config.
-
-        :rtype: dict
-        :returns: A dictionary in the format used by the BigQuery API.
-        """
-        return copy.deepcopy(self._properties)
-
-    @classmethod
-    def from_api_repr(cls, resource):
-        """Factory: construct a job configuration given its API representation
-
-        :type resource: dict
-        :param resource:
-            An extract job configuration in the same representation as is
-            returned from the API.
-
-        :rtype: :class:`google.cloud.bigquery.job.CopyJobConfig`
-        :returns: Configuration parsed from ``resource``.
-        """
-        config = cls()
-        config._properties = copy.deepcopy(resource)
-        return config
+        self._set_sub_prop('destinationEncryptionConfiguration', api_repr)
 
 
 class CopyJob(_AsyncJob):
@@ -1134,21 +1271,23 @@ def _build_resource(self):
         } for table in self.sources]
 
         configuration = self._configuration.to_api_repr()
-        configuration['sourceTables'] = source_refs
-        configuration['destinationTable'] = {
-            'projectId': self.destination.project,
-            'datasetId': self.destination.dataset_id,
-            'tableId': self.destination.table_id,
-        }
+        _helpers.set_sub_prop(
+            configuration, ['copy', 'sourceTables'], source_refs)
+        _helpers.set_sub_prop(
+            configuration,
+            ['copy', 'destinationTable'],
+            {
+                'projectId': self.destination.project,
+                'datasetId': self.destination.dataset_id,
+                'tableId': self.destination.table_id,
+            })
 
         return {
             'jobReference': {
                 'projectId': self.project,
                 'jobId': self.job_id,
             },
-            'configuration': {
-                self._JOB_TYPE: configuration,
-            },
+            'configuration': configuration,
         }
 
     def _copy_configuration_properties(self, configuration):
@@ -1176,12 +1315,14 @@ def from_api_repr(cls, resource, client):
         """
         job_id, config_resource = cls._get_resource_config(resource)
         config = CopyJobConfig.from_api_repr(config_resource)
+        # Copy required fields to the job.
+        copy_resource = config_resource['copy']
         destination = TableReference.from_api_repr(
-            config_resource['destinationTable'])
+            copy_resource['destinationTable'])
         sources = []
-        source_configs = config_resource.get('sourceTables')
+        source_configs = copy_resource.get('sourceTables')
         if source_configs is None:
-            single = config_resource.get('sourceTable')
+            single = copy_resource.get('sourceTable')
             if single is None:
                 raise KeyError(
                     "Resource missing 'sourceTables' / 'sourceTable'")
@@ -1195,7 +1336,7 @@ def from_api_repr(cls, resource, client):
         return job
 
 
-class ExtractJobConfig(object):
+class ExtractJobConfig(_JobConfig):
     """Configuration options for extract jobs.
 
     All properties in this class are optional. Values which are ``None`` ->
@@ -1203,54 +1344,60 @@ class ExtractJobConfig(object):
     """
 
     def __init__(self):
-        self._properties = {}
+        super(ExtractJobConfig, self).__init__('extract')
 
-    compression = Compression('compression', 'compression')
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.extract.compression
-    """
+    @property
+    def compression(self):
+        """google.cloud.bigquery.job.Compression: Compression type to use for
+        exported files.
 
-    destination_format = DestinationFormat(
-        'destination_format', 'destinationFormat')
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.extract.destinationFormat
-    """
+        See
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.extract.compression
+        """
+        return self._get_sub_prop('compression')
 
-    field_delimiter = _TypedApiResourceProperty(
-        'field_delimiter', 'fieldDelimiter', six.string_types)
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.extract.fieldDelimiter
-    """
+    @compression.setter
+    def compression(self, value):
+        self._set_sub_prop('compression', value)
 
-    print_header = _TypedApiResourceProperty(
-        'print_header', 'printHeader', bool)
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.extract.printHeader
-    """
+    @property
+    def destination_format(self):
+        """google.cloud.bigquery.job.DestinationFormat: Exported file format.
 
-    def to_api_repr(self):
-        """Build an API representation of the extract job config.
+        See
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.extract.destinationFormat
+        """
+        return self._get_sub_prop('destinationFormat')
 
-        :rtype: dict
-        :returns: A dictionary in the format used by the BigQuery API.
+    @destination_format.setter
+    def destination_format(self, value):
+        self._set_sub_prop('destinationFormat', value)
+
+    @property
+    def field_delimiter(self):
+        """str: Delimiter to use between fields in the exported data.
+
+        See
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.extract.fieldDelimiter
         """
-        return copy.deepcopy(self._properties)
+        return self._get_sub_prop('fieldDelimiter')
 
-    @classmethod
-    def from_api_repr(cls, resource):
-        """Factory: construct a job configuration given its API representation
+    @field_delimiter.setter
+    def field_delimiter(self, value):
+        self._set_sub_prop('fieldDelimiter', value)
 
-        :type resource: dict
-        :param resource:
-            An extract job configuration in the same representation as is
-            returned from the API.
+    @property
+    def print_header(self):
+        """bool: Print a header row in the exported data.
 
-        :rtype: :class:`google.cloud.bigquery.job.ExtractJobConfig`
-        :returns: Configuration parsed from ``resource``.
+        See
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.extract.printHeader
         """
-        config = cls()
-        config._properties = copy.deepcopy(resource)
-        return config
+        return self._get_sub_prop('printHeader')
+
+    @print_header.setter
+    def print_header(self, value):
+        self._set_sub_prop('printHeader', value)
 
 
 class ExtractJob(_AsyncJob):
@@ -1342,17 +1489,19 @@ def _build_resource(self):
         }
 
         configuration = self._configuration.to_api_repr()
-        configuration['sourceTable'] = source_ref
-        configuration['destinationUris'] = self.destination_uris
+        _helpers.set_sub_prop(
+            configuration, ['extract', 'sourceTable'], source_ref)
+        _helpers.set_sub_prop(
+            configuration,
+            ['extract', 'destinationUris'],
+            self.destination_uris)
 
         resource = {
             'jobReference': {
                 'projectId': self.project,
                 'jobId': self.job_id,
             },
-            'configuration': {
-                self._JOB_TYPE: configuration,
-            },
+            'configuration': configuration,
         }
 
         return resource
@@ -1382,11 +1531,13 @@ def from_api_repr(cls, resource, client):
         """
         job_id, config_resource = cls._get_resource_config(resource)
         config = ExtractJobConfig.from_api_repr(config_resource)
-        source_config = config_resource['sourceTable']
+        source_config = _helpers.get_sub_prop(
+            config_resource, ['extract', 'sourceTable'])
         dataset = DatasetReference(
             source_config['projectId'], source_config['datasetId'])
         source = dataset.table(source_config['tableId'])
-        destination_uris = config_resource['destinationUris']
+        destination_uris = _helpers.get_sub_prop(
+            config_resource, ['extract', 'destinationUris'])
 
         job = cls(
             job_id, source, destination_uris, client=client, job_config=config)
@@ -1431,7 +1582,7 @@ def _to_api_repr_table_defs(value):
     return {k: ExternalConfig.to_api_repr(v) for k, v in value.items()}
 
 
-class QueryJobConfig(object):
+class QueryJobConfig(_JobConfig):
     """Configuration options for query jobs.
 
     All properties in this class are optional. Values which are ``None`` ->
@@ -1439,7 +1590,7 @@ class QueryJobConfig(object):
     """
 
     def __init__(self):
-        self._properties = {}
+        super(QueryJobConfig, self).__init__('query')
 
     @property
     def destination_encryption_configuration(self):
@@ -1452,7 +1603,7 @@ def destination_encryption_configuration(self):
         See
         https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.query.destinationEncryptionConfiguration
         """
-        prop = self._properties.get('destinationEncryptionConfiguration')
+        prop = self._get_sub_prop('destinationEncryptionConfiguration')
         if prop is not None:
             prop = EncryptionConfiguration.from_api_repr(prop)
         return prop
@@ -1462,58 +1613,34 @@ def destination_encryption_configuration(self, value):
         api_repr = value
         if value is not None:
             api_repr = value.to_api_repr()
-        self._properties['destinationEncryptionConfiguration'] = api_repr
+        self._set_sub_prop('destinationEncryptionConfiguration', api_repr)
 
-    def to_api_repr(self):
-        """Build an API representation of the query job config.
+    @property
+    def allow_large_results(self):
+        """bool: Allow large query results tables (legacy SQL, only)
 
-        Returns:
-            dict: A dictionary in the format used by the BigQuery API.
+        See
+        https://g.co/cloud/bigquery/docs/reference/rest/v2/jobs#configuration.query.allowLargeResults
         """
-        resource = copy.deepcopy(self._properties)
-
-        # Query parameters have an addition property associated with them
-        # to indicate if the query is using named or positional parameters.
-        query_parameters = resource.get('queryParameters')
-        if query_parameters:
-            if query_parameters[0].get('name') is None:
-                resource['parameterMode'] = 'POSITIONAL'
-            else:
-                resource['parameterMode'] = 'NAMED'
-
-        return resource
+        return self._get_sub_prop('allowLargeResults')
 
-    @classmethod
-    def from_api_repr(cls, resource):
-        """Factory: construct a job configuration given its API representation
+    @allow_large_results.setter
+    def allow_large_results(self, value):
+        self._set_sub_prop('allowLargeResults', value)
 
-        Args:
-            resource (dict):
-                A query job configuration in the same representation as is
-                returned from the API.
+    @property
+    def create_disposition(self):
+        """google.cloud.bigquery.job.CreateDisposition: Specifies behavior
+        for creating tables.
 
-        Returns:
-            ~google.cloud.bigquery.job.QueryJobConfig:
-                Configuration parsed from ``resource``.
+        See
+        https://g.co/cloud/bigquery/docs/reference/rest/v2/jobs#configuration.query.createDisposition
         """
-        config = cls()
-        config._properties = copy.deepcopy(resource)
-
-        return config
+        return self._get_sub_prop('createDisposition')
 
-    allow_large_results = _TypedApiResourceProperty(
-        'allow_large_results', 'allowLargeResults', bool)
-    """bool: Allow large query results tables (legacy SQL, only)
-
-    See
-    https://g.co/cloud/bigquery/docs/reference/rest/v2/jobs#configuration.query.allowLargeResults
-    """
-
-    create_disposition = CreateDisposition(
-        'create_disposition', 'createDisposition')
-    """See
-    https://g.co/cloud/bigquery/docs/reference/rest/v2/jobs#configuration.query.createDisposition
-    """
+    @create_disposition.setter
+    def create_disposition(self, value):
+        self._set_sub_prop('createDisposition', value)
 
     @property
     def default_dataset(self):
@@ -1523,14 +1650,17 @@ def default_dataset(self):
         See
         https://g.co/cloud/bigquery/docs/reference/v2/jobs#configuration.query.defaultDataset
         """
-        prop = self._properties.get('defaultDataset')
+        prop = self._get_sub_prop('defaultDataset')
         if prop is not None:
             prop = DatasetReference.from_api_repr(prop)
         return prop
 
     @default_dataset.setter
     def default_dataset(self, value):
-        self._properties['defaultDataset'] = value.to_api_repr()
+        resource = None
+        if value is not None:
+            resource = value.to_api_repr()
+        self._set_sub_prop('defaultDataset', resource)
 
     @property
     def destination(self):
@@ -1540,34 +1670,57 @@ def destination(self):
         See
         https://g.co/cloud/bigquery/docs/reference/rest/v2/jobs#configuration.query.destinationTable
         """
-        prop = self._properties.get('destinationTable')
+        prop = self._get_sub_prop('destinationTable')
         if prop is not None:
             prop = TableReference.from_api_repr(prop)
         return prop
 
     @destination.setter
     def destination(self, value):
-        self._properties['destinationTable'] = value.to_api_repr()
+        resource = None
+        if value is not None:
+            resource = value.to_api_repr()
+        self._set_sub_prop('destinationTable', resource)
 
-    dry_run = _TypedApiResourceProperty('dry_run', 'dryRun', bool)
-    """
-    bool: ``True`` if this query should be a dry run to estimate costs.
+    @property
+    def dry_run(self):
+        """bool: ``True`` if this query should be a dry run to estimate costs.
 
-    See
-    https://g.co/cloud/bigquery/docs/reference/v2/jobs#configuration.dryRun
-    """
+        See
+        https://g.co/cloud/bigquery/docs/reference/v2/jobs#configuration.dryRun
+        """
+        return self._properties.get('dryRun')
 
-    flatten_results = _TypedApiResourceProperty(
-        'flatten_results', 'flattenResults', bool)
-    """See
-    https://g.co/cloud/bigquery/docs/reference/rest/v2/jobs#configuration.query.flattenResults
-    """
+    @dry_run.setter
+    def dry_run(self, value):
+        self._properties['dryRun'] = value
 
-    maximum_billing_tier = _TypedApiResourceProperty(
-        'maximum_billing_tier', 'maximumBillingTier', int)
-    """See
-    https://g.co/cloud/bigquery/docs/reference/rest/v2/jobs#configuration.query.maximumBillingTier
-    """
+    @property
+    def flatten_results(self):
+        """bool: Flatten nested/repeated fields in results. (Legacy SQL only)
+
+        See
+        https://g.co/cloud/bigquery/docs/reference/rest/v2/jobs#configuration.query.flattenResults
+        """
+        return self._get_sub_prop('flattenResults')
+
+    @flatten_results.setter
+    def flatten_results(self, value):
+        self._set_sub_prop('flattenResults', value)
+
+    @property
+    def maximum_billing_tier(self):
+        """int: Deprecated. Changes the billing tier to allow high-compute
+        queries.
+
+        See
+        https://g.co/cloud/bigquery/docs/reference/rest/v2/jobs#configuration.query.maximumBillingTier
+        """
+        return self._get_sub_prop('maximumBillingTier')
+
+    @maximum_billing_tier.setter
+    def maximum_billing_tier(self, value):
+        self._set_sub_prop('maximumBillingTier', value)
 
     @property
     def maximum_bytes_billed(self):
@@ -1576,19 +1729,24 @@ def maximum_bytes_billed(self):
         See
         https://g.co/cloud/bigquery/docs/reference/rest/v2/jobs#configuration.query.maximumBytesBilled
         """
-        prop = self._properties.get('maximumBytesBilled')
-        if prop is not None:
-            prop = int(prop)
-        return prop
+        return _int_or_none(self._get_sub_prop('maximumBytesBilled'))
 
     @maximum_bytes_billed.setter
     def maximum_bytes_billed(self, value):
-        self._properties['maximumBytesBilled'] = str(value)
+        self._set_sub_prop('maximumBytesBilled', str(value))
 
-    priority = QueryPriority('priority', 'priority')
-    """See
-    https://g.co/cloud/bigquery/docs/reference/rest/v2/jobs#configuration.query.priority
-    """
+    @property
+    def priority(self):
+        """google.cloud.bigquery.job.QueryPriority: Priority of the query.
+
+        See
+        https://g.co/cloud/bigquery/docs/reference/rest/v2/jobs#configuration.query.priority
+        """
+        return self._get_sub_prop('priority')
+
+    @priority.setter
+    def priority(self, value):
+        self._set_sub_prop('priority', value)
 
     @property
     def query_parameters(self):
@@ -1600,13 +1758,13 @@ def query_parameters(self):
         See:
         https://g.co/cloud/bigquery/docs/reference/rest/v2/jobs#configuration.query.queryParameters
         """
-        prop = self._properties.get('queryParameters', [])
+        prop = self._get_sub_prop('queryParameters', default=[])
         return _from_api_repr_query_parameters(prop)
 
     @query_parameters.setter
     def query_parameters(self, values):
-        self._properties['queryParameters'] = _to_api_repr_query_parameters(
-            values)
+        self._set_sub_prop(
+            'queryParameters', _to_api_repr_query_parameters(values))
 
     @property
     def udf_resources(self):
@@ -1616,31 +1774,54 @@ def udf_resources(self):
         See:
         https://g.co/cloud/bigquery/docs/reference/rest/v2/jobs#configuration.query.userDefinedFunctionResources
         """
-        prop = self._properties.get('userDefinedFunctionResources', [])
+        prop = self._get_sub_prop('userDefinedFunctionResources', default=[])
         return _from_api_repr_udf_resources(prop)
 
     @udf_resources.setter
     def udf_resources(self, values):
-        self._properties['userDefinedFunctionResources'] = (
+        self._set_sub_prop(
+            'userDefinedFunctionResources',
             _to_api_repr_udf_resources(values))
 
-    use_legacy_sql = _TypedApiResourceProperty(
-        'use_legacy_sql', 'useLegacySql', bool)
-    """See
-    https://g.co/cloud/bigquery/docs/reference/v2/jobs#configuration.query.useLegacySql
-    """
+    @property
+    def use_legacy_sql(self):
+        """bool: Use legacy SQL syntax.
 
-    use_query_cache = _TypedApiResourceProperty(
-        'use_query_cache', 'useQueryCache', bool)
-    """See
-    https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.query.useQueryCache
-    """
+        See
+        https://g.co/cloud/bigquery/docs/reference/v2/jobs#configuration.query.useLegacySql
+        """
+        return self._get_sub_prop('useLegacySql')
 
-    write_disposition = WriteDisposition(
-        'write_disposition', 'writeDisposition')
-    """See
-    https://g.co/cloud/bigquery/docs/reference/rest/v2/jobs#configuration.query.writeDisposition
-    """
+    @use_legacy_sql.setter
+    def use_legacy_sql(self, value):
+        self._set_sub_prop('useLegacySql', value)
+
+    @property
+    def use_query_cache(self):
+        """bool: Look for the query result in the cache.
+
+        See
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.query.useQueryCache
+        """
+        return self._get_sub_prop('useQueryCache')
+
+    @use_query_cache.setter
+    def use_query_cache(self, value):
+        self._set_sub_prop('useQueryCache', 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.query.writeDisposition
+        """
+        return self._get_sub_prop('writeDisposition')
+
+    @write_disposition.setter
+    def write_disposition(self, value):
+        self._set_sub_prop('writeDisposition', value)
 
     @property
     def table_definitions(self):
@@ -1650,17 +1831,34 @@ def table_definitions(self):
         See
         https://g.co/cloud/bigquery/docs/reference/rest/v2/jobs#configuration.query.tableDefinitions
         """
-        prop = self._properties.get('tableDefinitions')
+        prop = self._get_sub_prop('tableDefinitions')
         if prop is not None:
             prop = _from_api_repr_table_defs(prop)
         return prop
 
     @table_definitions.setter
     def table_definitions(self, values):
-        self._properties['tableDefinitions'] = _to_api_repr_table_defs(values)
+        self._set_sub_prop(
+            'tableDefinitions',  _to_api_repr_table_defs(values))
+
+    def to_api_repr(self):
+        """Build an API representation of the query job config.
+
+        Returns:
+            dict: A dictionary in the format used by the BigQuery API.
+        """
+        resource = copy.deepcopy(self._properties)
 
-    _maximum_billing_tier = None
-    _maximum_bytes_billed = None
+        # Query parameters have an addition property associated with them
+        # to indicate if the query is using named or positional parameters.
+        query_parameters = resource['query'].get('queryParameters')
+        if query_parameters:
+            if query_parameters[0].get('name') is None:
+                resource['query']['parameterMode'] = 'POSITIONAL'
+            else:
+                resource['query']['parameterMode'] = 'NAMED'
+
+        return resource
 
 
 class QueryJob(_AsyncJob):
@@ -1823,46 +2021,16 @@ def _build_resource(self):
                 'projectId': self.project,
                 'jobId': self.job_id,
             },
-            'configuration': {
-                self._JOB_TYPE: configuration,
-            },
+            'configuration': configuration,
         }
-
-        # The dryRun property only applies to query jobs, but it is defined at
-        # a level higher up. We need to remove it from the query config.
-        if 'dryRun' in configuration:
-            dry_run = configuration['dryRun']
-            del configuration['dryRun']
-            resource['configuration']['dryRun'] = dry_run
-
-        configuration['query'] = self.query
+        configuration['query']['query'] = self.query
 
         return resource
 
-    def _scrub_local_properties(self, cleaned):
-        """Helper:  handle subclass properties in cleaned.
-
-        .. note:
-
-           This method assumes that the project found in the resource matches
-           the client's project.
-        """
-        configuration = cleaned['configuration']['query']
-        self.query = configuration['query']
-
-        # The dryRun property only applies to query jobs, but it is defined at
-        # a level higher up. We need to copy it to the query config.
-        self._configuration.dry_run = cleaned['configuration'].get('dryRun')
-
     def _copy_configuration_properties(self, configuration):
         """Helper:  assign subclass configuration properties in cleaned."""
-        # The dryRun property only applies to query jobs, but it is defined at
-        # a level higher up. We need to copy it to the query config.
-        # It should already be correctly set by the _scrub_local_properties()
-        # method.
-        dry_run = self.dry_run
-        self._configuration = QueryJobConfig.from_api_repr(configuration)
-        self._configuration.dry_run = dry_run
+        self._configuration._properties = copy.deepcopy(configuration)
+        self.query = _helpers.get_sub_prop(configuration, ['query', 'query'])
 
     @classmethod
     def from_api_repr(cls, resource, client):
@@ -1879,7 +2047,7 @@ def from_api_repr(cls, resource, client):
         :returns: Job parsed from ``resource``.
         """
         job_id, config = cls._get_resource_config(resource)
-        query = config['query']
+        query = config['query']['query']
         job = cls(job_id, query, client=client)
         job._set_properties(resource)
         return job
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
@@ -116,9 +116,10 @@ def to_api_repr(self):
         """
         # Put together the basic representation. See http://bit.ly/2hOAT5u.
         answer = {
-            'mode': self.mode.lower(),
+            'mode': self.mode.upper(),
             'name': self.name,
-            'type': self.field_type.lower(),
+            'type': self.field_type.upper(),
+            'description': self.description,
         }
 
         # If this is a RECORD type, then sub-fields are also included,
@@ -140,8 +141,8 @@ def _key(self):
         """
         return (
             self._name,
-            self._field_type.lower(),
-            self._mode,
+            self._field_type.upper(),
+            self._mode.upper(),
             self._description,
             self._fields,
         )
@@ -198,14 +199,4 @@ def _build_schema_resource(fields):
     :rtype: mapping
     :returns: a mapping describing the schema of the supplied fields.
     """
-    infos = []
-    for field in fields:
-        info = {'name': field.name,
-                'type': field.field_type,
-                'mode': field.mode}
-        if field.description is not None:
-            info['description'] = field.description
-        if field.fields:
-            info['fields'] = _build_schema_resource(field.fields)
-        infos.append(info)
-    return infos
+    return [field.to_api_repr() for field in fields]


Test patch:
diff --git a/bigquery/tests/system.py b/bigquery/tests/system.py
--- a/bigquery/tests/system.py
+++ b/bigquery/tests/system.py
@@ -892,6 +892,18 @@ def test_query_w_failed_query(self):
         with self.assertRaises(BadRequest):
             Config.CLIENT.query('invalid syntax;').result()
 
+    def test_query_w_wrong_config(self):
+        from google.cloud.bigquery.job import LoadJobConfig
+
+        good_query = 'SELECT 1;'
+        rows = list(Config.CLIENT.query('SELECT 1;').result())
+        assert rows[0][0] == 1
+
+        bad_config = LoadJobConfig()
+        bad_config.destination = Config.CLIENT.dataset('dset').table('tbl')
+        with self.assertRaises(Exception):
+            Config.CLIENT.query(good_query, job_config=bad_config).result()
+
     def test_query_w_timeout(self):
         query_job = Config.CLIENT.query(
             'SELECT * FROM `bigquery-public-data.github_repos.commits`;',
diff --git a/bigquery/tests/unit/test_client.py b/bigquery/tests/unit/test_client.py
--- a/bigquery/tests/unit/test_client.py
+++ b/bigquery/tests/unit/test_client.py
@@ -554,9 +554,21 @@ def test_create_table_w_schema_and_query(self):
                 'datasetId': self.DS_ID,
                 'tableId': self.TABLE_ID
             },
-            'schema': {'fields': [
-                {'name': 'full_name', 'type': 'STRING', 'mode': 'REQUIRED'},
-                {'name': 'age', 'type': 'INTEGER', 'mode': 'REQUIRED'}]
+            'schema': {
+                'fields': [
+                    {
+                        'name': 'full_name',
+                        'type': 'STRING',
+                        'mode': 'REQUIRED',
+                        'description': None
+                    },
+                    {
+                        'name': 'age',
+                        'type': 'INTEGER',
+                        'mode': 'REQUIRED',
+                        'description': None
+                    },
+                ],
             },
             'view': {'query': query},
         }
@@ -585,11 +597,13 @@ def test_create_table_w_schema_and_query(self):
                             'name': 'full_name',
                             'type': 'STRING',
                             'mode': 'REQUIRED',
+                            'description': None,
                         },
                         {
                             'name': 'age',
                             'type': 'INTEGER',
                             'mode': 'REQUIRED',
+                            'description': None,
                         },
                     ],
                 },
@@ -757,9 +771,21 @@ def test_update_table(self):
                 'datasetId': self.DS_ID,
                 'tableId': self.TABLE_ID
             },
-            'schema': {'fields': [
-                {'name': 'full_name', 'type': 'STRING', 'mode': 'REQUIRED'},
-                {'name': 'age', 'type': 'INTEGER', 'mode': 'REQUIRED'}]
+            'schema': {
+                'fields': [
+                    {
+                        'name': 'full_name',
+                        'type': 'STRING',
+                        'mode': 'REQUIRED',
+                        'description': None
+                    },
+                    {
+                        'name': 'age',
+                        'type': 'INTEGER',
+                        'mode': 'REQUIRED',
+                        'description': None
+                    },
+                ],
             },
             'etag': 'etag',
             'description': description,
@@ -787,9 +813,22 @@ def test_update_table(self):
                 'datasetId': self.DS_ID,
                 'tableId': self.TABLE_ID
             },
-            'schema': {'fields': [
-                {'name': 'full_name', 'type': 'STRING', 'mode': 'REQUIRED'},
-                {'name': 'age', 'type': 'INTEGER', 'mode': 'REQUIRED'}]},
+            'schema': {
+                'fields': [
+                    {
+                        'name': 'full_name',
+                        'type': 'STRING',
+                        'mode': 'REQUIRED',
+                        'description': None
+                    },
+                    {
+                        'name': 'age',
+                        'type': 'INTEGER',
+                        'mode': 'REQUIRED',
+                        'description': None
+                    },
+                ],
+            },
             'description': description,
             'friendlyName': title,
             'labels': {'x': 'y'},
@@ -858,9 +897,22 @@ def test_update_table_w_query(self):
         query = 'select fullname, age from person_ages'
         location = 'EU'
         exp_time = datetime.datetime(2015, 8, 1, 23, 59, 59, tzinfo=UTC)
-        schema_resource = {'fields': [
-            {'name': 'full_name', 'type': 'STRING', 'mode': 'REQUIRED'},
-            {'name': 'age', 'type': 'INTEGER', 'mode': 'REQUIRED'}]}
+        schema_resource = {
+                'fields': [
+                    {
+                        'name': 'full_name',
+                        'type': 'STRING',
+                        'mode': 'REQUIRED',
+                        'description': None
+                    },
+                    {
+                        'name': 'age',
+                        'type': 'INTEGER',
+                        'mode': 'REQUIRED',
+                        'description': None
+                    },
+                ],
+        }
         schema = [
             SchemaField('full_name', 'STRING', mode='REQUIRED'),
             SchemaField('age', 'INTEGER', mode='REQUIRED')
diff --git a/bigquery/tests/unit/test_external_config.py b/bigquery/tests/unit/test_external_config.py
--- a/bigquery/tests/unit/test_external_config.py
+++ b/bigquery/tests/unit/test_external_config.py
@@ -51,6 +51,7 @@ def test_api_repr_base(self):
                         'name': 'full_name',
                         'type': 'STRING',
                         'mode': 'REQUIRED',
+                        'description': None,
                     },
                 ],
             },
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
@@ -262,8 +262,10 @@ def test_to_api_repr_with_encryption(self):
         self.assertEqual(
             resource,
             {
-                'destinationEncryptionConfiguration': {
-                    'kmsKeyName': self.KMS_KEY_NAME,
+                'load': {
+                    'destinationEncryptionConfiguration': {
+                        'kmsKeyName': self.KMS_KEY_NAME,
+                    },
                 },
             })
 
@@ -274,7 +276,9 @@ def test_to_api_repr_with_encryption_none(self):
         self.assertEqual(
             resource,
             {
-                'destinationEncryptionConfiguration': None,
+                'load': {
+                    'destinationEncryptionConfiguration': None,
+                },
             })
 
 
@@ -418,7 +422,6 @@ def test_ctor(self):
         self.assertEqual(
             job.path,
             '/projects/%s/jobs/%s' % (self.PROJECT, self.JOB_ID))
-        self.assertEqual(job.schema, [])
 
         self._verifyInitialReadonlyProperties(job)
 
@@ -429,6 +432,7 @@ def test_ctor(self):
         self.assertIsNone(job.output_rows)
 
         # set/read from resource['configuration']['load']
+        self.assertIsNone(job.schema)
         self.assertIsNone(job.allow_jagged_rows)
         self.assertIsNone(job.allow_quoted_newlines)
         self.assertIsNone(job.autodetect)
@@ -771,8 +775,18 @@ def test_begin_w_alternate_client(self):
             'sourceFormat': 'CSV',
             'writeDisposition': WriteDisposition.WRITE_TRUNCATE,
             'schema': {'fields': [
-                {'name': 'full_name', 'type': 'STRING', 'mode': 'REQUIRED'},
-                {'name': 'age', 'type': 'INTEGER', 'mode': 'REQUIRED'},
+                {
+                    'name': 'full_name',
+                    'type': 'STRING',
+                    'mode': 'REQUIRED',
+                    'description': None,
+                },
+                {
+                    'name': 'age',
+                    'type': 'INTEGER',
+                    'mode': 'REQUIRED',
+                    'description': None,
+                },
             ]}
         }
         RESOURCE['configuration']['load'] = LOAD_CONFIGURATION
@@ -935,9 +949,11 @@ def test_to_api_repr_with_encryption(self):
         self.assertEqual(
             resource,
             {
-                'destinationEncryptionConfiguration': {
-                    'kmsKeyName': self.KMS_KEY_NAME,
-                }
+                'copy': {
+                    'destinationEncryptionConfiguration': {
+                        'kmsKeyName': self.KMS_KEY_NAME,
+                    },
+                },
             })
 
     def test_to_api_repr_with_encryption_none(self):
@@ -947,7 +963,9 @@ def test_to_api_repr_with_encryption_none(self):
         self.assertEqual(
             resource,
             {
-                'destinationEncryptionConfiguration': None,
+                'copy': {
+                    'destinationEncryptionConfiguration': None,
+                },
             })
 
 
@@ -1343,6 +1361,55 @@ def test_reload_w_alternate_client(self):
         self._verifyResourceProperties(job, RESOURCE)
 
 
+class TestExtractJobConfig(unittest.TestCase, _Base):
+    JOB_TYPE = 'extract'
+
+    @staticmethod
+    def _get_target_class():
+        from google.cloud.bigquery.job import ExtractJobConfig
+        return ExtractJobConfig
+
+    def test_to_api_repr(self):
+        from google.cloud.bigquery import job
+        config = self._make_one()
+        config.compression = job.Compression.SNAPPY
+        config.destination_format = job.DestinationFormat.AVRO
+        config.field_delimiter = 'ignored for avro'
+        config.print_header = False
+        config._properties['extract']['someNewField'] = 'some-value'
+        resource = config.to_api_repr()
+        self.assertEqual(
+            resource,
+            {
+                'extract': {
+                    'compression': 'SNAPPY',
+                    'destinationFormat': 'AVRO',
+                    'fieldDelimiter': 'ignored for avro',
+                    'printHeader': False,
+                    'someNewField': 'some-value',
+                },
+            })
+
+    def test_from_api_repr(self):
+        cls = self._get_target_class()
+        config = cls.from_api_repr(
+            {
+                'extract': {
+                    'compression': 'NONE',
+                    'destinationFormat': 'CSV',
+                    'fieldDelimiter': '\t',
+                    'printHeader': True,
+                    'someNewField': 'some-value',
+                },
+            })
+        self.assertEqual(config.compression, 'NONE')
+        self.assertEqual(config.destination_format, 'CSV')
+        self.assertEqual(config.field_delimiter, '\t')
+        self.assertEqual(config.print_header, True)
+        self.assertEqual(
+            config._properties['extract']['someNewField'], 'some-value')
+
+
 class TestExtractJob(unittest.TestCase, _Base):
     JOB_TYPE = 'extract'
     SOURCE_TABLE = 'source_table'
@@ -1665,7 +1732,14 @@ def _make_one(self, *args, **kw):
 
     def test_ctor(self):
         config = self._make_one()
-        self.assertEqual(config._properties, {})
+        self.assertEqual(config._properties, {'query': {}})
+
+    def test_ctor_w_none(self):
+        config = self._make_one()
+        config.default_dataset = None
+        config.destination = None
+        self.assertIsNone(config.default_dataset)
+        self.assertIsNone(config.destination)
 
     def test_from_api_repr_empty(self):
         klass = self._get_target_class()
@@ -1678,13 +1752,16 @@ def test_from_api_repr_empty(self):
 
     def test_from_api_repr_normal(self):
         resource = {
-            'useLegacySql': True,
-            'query': 'no property for me',
-            'defaultDataset': {
-                'projectId': 'someproject',
-                'datasetId': 'somedataset',
+            'query': {
+                'useLegacySql': True,
+                'query': 'no property for me',
+                'defaultDataset': {
+                    'projectId': 'someproject',
+                    'datasetId': 'somedataset',
+                },
+                'someNewProperty': 'I should be saved, too.',
             },
-            'someNewProperty': 'I should be saved, too.',
+            'dryRun': True,
         }
         klass = self._get_target_class()
 
@@ -1694,28 +1771,33 @@ def test_from_api_repr_normal(self):
         self.assertEqual(
             config.default_dataset,
             DatasetReference('someproject', 'somedataset'))
+        self.assertTrue(config.dry_run)
         # Make sure unknown properties propagate.
-        self.assertEqual(config._properties['query'], 'no property for me')
         self.assertEqual(
-            config._properties['someNewProperty'], 'I should be saved, too.')
+            config._properties['query']['query'], 'no property for me')
+        self.assertEqual(
+            config._properties['query']['someNewProperty'],
+            'I should be saved, too.')
 
     def test_to_api_repr_normal(self):
         config = self._make_one()
         config.use_legacy_sql = True
         config.default_dataset = DatasetReference(
             'someproject', 'somedataset')
+        config.dry_run = False
         config._properties['someNewProperty'] = 'Woohoo, alpha stuff.'
 
         resource = config.to_api_repr()
 
-        self.assertTrue(resource['useLegacySql'])
+        self.assertFalse(resource['dryRun'])
+        self.assertTrue(resource['query']['useLegacySql'])
         self.assertEqual(
-            resource['defaultDataset']['projectId'], 'someproject')
+            resource['query']['defaultDataset']['projectId'], 'someproject')
         self.assertEqual(
-            resource['defaultDataset']['datasetId'], 'somedataset')
+            resource['query']['defaultDataset']['datasetId'], 'somedataset')
         # Make sure unknown properties propagate.
         self.assertEqual(
-            config._properties['someNewProperty'], 'Woohoo, alpha stuff.')
+            resource['someNewProperty'], 'Woohoo, alpha stuff.')
 
     def test_to_api_repr_with_encryption(self):
         config = self._make_one()
@@ -1724,8 +1806,10 @@ def test_to_api_repr_with_encryption(self):
         resource = config.to_api_repr()
         self.assertEqual(
             resource, {
-                'destinationEncryptionConfiguration': {
-                    'kmsKeyName': self.KMS_KEY_NAME,
+                'query': {
+                    'destinationEncryptionConfiguration': {
+                        'kmsKeyName': self.KMS_KEY_NAME,
+                    },
                 },
             })
 
@@ -1736,14 +1820,18 @@ def test_to_api_repr_with_encryption_none(self):
         self.assertEqual(
             resource,
             {
-                'destinationEncryptionConfiguration': None,
+                'query': {
+                    'destinationEncryptionConfiguration': None,
+                },
             })
 
     def test_from_api_repr_with_encryption(self):
         resource = {
-            'destinationEncryptionConfiguration': {
-                'kmsKeyName': self.KMS_KEY_NAME
-            }
+            'query': {
+                'destinationEncryptionConfiguration': {
+                    'kmsKeyName': self.KMS_KEY_NAME,
+                },
+            },
         }
         klass = self._get_target_class()
         config = klass.from_api_repr(resource)
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
@@ -64,9 +64,10 @@ def test_constructor_subfields(self):
     def test_to_api_repr(self):
         field = self._make_one('foo', 'INTEGER', 'NULLABLE')
         self.assertEqual(field.to_api_repr(), {
-            'mode': 'nullable',
+            'mode': 'NULLABLE',
             'name': 'foo',
-            'type': 'integer',
+            'type': 'INTEGER',
+            'description': None,
         })
 
     def test_to_api_repr_with_subfield(self):
@@ -74,13 +75,15 @@ def test_to_api_repr_with_subfield(self):
         field = self._make_one('foo', 'RECORD', 'REQUIRED', fields=(subfield,))
         self.assertEqual(field.to_api_repr(), {
             'fields': [{
-                'mode': 'nullable',
+                'mode': 'NULLABLE',
                 'name': 'bar',
-                'type': 'integer',
+                'type': 'INTEGER',
+                'description': None,
             }],
-            'mode': 'required',
+            'mode': 'REQUIRED',
             'name': 'foo',
-            'type': 'record',
+            'type': 'RECORD',
+            'description': None,
         })
 
     def test_from_api_repr(self):
@@ -244,7 +247,7 @@ def test___hash__not_equals(self):
 
     def test___repr__(self):
         field1 = self._make_one('field1', 'STRING')
-        expected = "SchemaField('field1', 'string', 'NULLABLE', None, ())"
+        expected = "SchemaField('field1', 'STRING', 'NULLABLE', None, ())"
         self.assertEqual(repr(field1), expected)
 
 
@@ -326,11 +329,13 @@ def test_defaults(self):
         self.assertEqual(resource[0],
                          {'name': 'full_name',
                           'type': 'STRING',
-                          'mode': 'REQUIRED'})
+                          'mode': 'REQUIRED',
+                          'description': None})
         self.assertEqual(resource[1],
                          {'name': 'age',
                           'type': 'INTEGER',
-                          'mode': 'REQUIRED'})
+                          'mode': 'REQUIRED',
+                          'description': None})
 
     def test_w_description(self):
         from google.cloud.bigquery.schema import SchemaField
@@ -349,7 +354,8 @@ def test_w_description(self):
         self.assertEqual(resource[1],
                          {'name': 'age',
                           'type': 'INTEGER',
-                          'mode': 'REQUIRED'})
+                          'mode': 'REQUIRED',
+                          'description': None})
 
     def test_w_subfields(self):
         from google.cloud.bigquery.schema import SchemaField
@@ -364,14 +370,18 @@ def test_w_subfields(self):
         self.assertEqual(resource[0],
                          {'name': 'full_name',
                           'type': 'STRING',
-                          'mode': 'REQUIRED'})
+                          'mode': 'REQUIRED',
+                          'description': None})
         self.assertEqual(resource[1],
                          {'name': 'phone',
                           'type': 'RECORD',
                           'mode': 'REPEATED',
+                          'description': None,
                           'fields': [{'name': 'type',
                                       'type': 'STRING',
-                                      'mode': 'REQUIRED'},
+                                      'mode': 'REQUIRED',
+                                      'description': None},
                                      {'name': 'number',
                                       'type': 'STRING',
-                                      'mode': 'REQUIRED'}]})
+                                      'mode': 'REQUIRED',
+                                      'description': None}]})