Repository: googleapis/python-bigquery

Issue:
Support arrays of record/structs as query parameters
Thanks for stopping by to let us know something could be better!

**PLEASE READ**: If you have a support contract with Google, please create an issue in the [support console](https://cloud.google.com/support/) instead of filing on GitHub. This will ensure a timely response.

 **Is your feature request related to a problem? Please describe.**
 Setting nested repeated fields via the python-bigquery API works (despite not being documented by [ArrayQueryParameter](https://googleapis.dev/python/bigquery/latest/generated/google.cloud.bigquery.query.ArrayQueryParameter.html#google.cloud.bigquery.query.ArrayQueryParameter)).
[ArrayQueryParamter.to_api_repr](https://googleapis.dev/python/bigquery/latest/_modules/google/cloud/bigquery/query.html#ArrayQueryParameter.to_api_repr) has code to handle "RECORD" or "STRUCT" but doesn't check if the array is empty and so throw exceptions.
  
 **Describe the solution you'd like**
I'd a way to define the schema for the array of records so that it doesn't matter that it's empty - I can still use the BQ python client. There appears to be solutions  for typed languages https://github.com/googleapis/google-cloud-java/issues/2678 as well as dynamic languages https://github.com/googleapis/google-cloud-ruby/pull/4269 and losely typed languages https://github.com/googleapis/nodejs-bigquery/issues/400.

 **Describe alternatives you've considered**
Our current solution involves naming each individual field in the repeated struct  (eg fieldA_0, fieldB_0, fieldA_1,...)
and sending over MANY query parameters. As there's a limit to the number of named parameters the API allows, this is far from perfect and makes our code significantly harder to read and understand.

 **Additional context**
```python
from google.cloud import bigquery
client = bigquery.Client()

def query(params):
    sql = "SELECT * FROM UNNEST(@params) as param"
    query_parameter = bigquery.ArrayQueryParameter(
                "params",
                "RECORD",
                [
                    bigquery.StructQueryParameter.positional(
                        bigquery.ScalarQueryParameter("key", "STRING", key),
                        bigquery.ScalarQueryParameter("value", "STRING", value),
                    )
                    for key, value in (params.items())
                ],
    )
    for row in client.query(sql, job_config=bigquery.QueryJobConfig(query_parameters=[query_parameter])):
        print(dict(row))

query({"one": "a", "two": "b"}) # works
query({}) # IndexError: list index out of range
``` 


Discussion:
@michellen-hf Thanks for the detailed report and a runnable example, it made the reproduction easy! It appears that most (all?) of the BigQuery libraries for other languages suffered from the same issue.

The problem here is that when serializing data to the API format, the `ArrayQueryParameter` class determines the array type from the first array item, but does not check if the array is empty. I'll reclassify this as a bug.

(at the very least, the outcome should not be an uninformative unhandled `IndexError`)
After tinkering with BigQuery REST API, unsuccessfully trying various tricks, and reading the comment on a [related](https://github.com/googleapis/google-cloud-ruby/pull/4269#issuecomment-550562108) Ruby issue, it seems that the Python client, too, could benefit from an optional parameter (to `QueryJobConfig`) to specify query parameter types when that cannot be determined (example: empty arrays of STRUCT/RECORD elements).

I suppose loosening up the checks on the backend is not feasible? In the example below, the backend could tolerate empty (or missing) `parameterType.arrayType` values for ARRAY parameters if the corresponding `parameterValue` is empty - but that might have some other negative implications.

cc: @tswast @shollyman 

```json
"configuration": {
    "query": {
        "query": "SELECT * FROM UNNEST(@params) as param",
        "parameterMode": "NAMED",
        "queryParameters": [
        {
            "name": "params",
            "parameterType": {
                "type": "ARRAY",
                "arrayType": {
                    "type": "STRUCT",
                    "structTypes": [],
                }
            },
            "parameterValue": {
                "arrayValues": []
            }
        }
        ],
        "useLegacySql": false,
    }
}
```
Doubtful that the type parameter could be loosened. I think an extra parameter for array type would be beneficial.

Resolving patch:
diff --git a/google/cloud/bigquery/__init__.py b/google/cloud/bigquery/__init__.py
--- a/google/cloud/bigquery/__init__.py
+++ b/google/cloud/bigquery/__init__.py
@@ -66,8 +66,11 @@
 from google.cloud.bigquery.model import Model
 from google.cloud.bigquery.model import ModelReference
 from google.cloud.bigquery.query import ArrayQueryParameter
+from google.cloud.bigquery.query import ArrayQueryParameterType
 from google.cloud.bigquery.query import ScalarQueryParameter
+from google.cloud.bigquery.query import ScalarQueryParameterType
 from google.cloud.bigquery.query import StructQueryParameter
+from google.cloud.bigquery.query import StructQueryParameterType
 from google.cloud.bigquery.query import UDFResource
 from google.cloud.bigquery.retry import DEFAULT_RETRY
 from google.cloud.bigquery.routine import DeterminismLevel
@@ -93,6 +96,9 @@
     "ArrayQueryParameter",
     "ScalarQueryParameter",
     "StructQueryParameter",
+    "ArrayQueryParameterType",
+    "ScalarQueryParameterType",
+    "StructQueryParameterType",
     # Datasets
     "Dataset",
     "DatasetReference",
diff --git a/google/cloud/bigquery/enums.py b/google/cloud/bigquery/enums.py
--- a/google/cloud/bigquery/enums.py
+++ b/google/cloud/bigquery/enums.py
@@ -18,6 +18,7 @@
 import itertools
 
 from google.cloud.bigquery_v2 import types as gapic_types
+from google.cloud.bigquery.query import ScalarQueryParameterType
 
 
 class Compression(object):
@@ -215,6 +216,26 @@ class SqlTypeNames(str, enum.Enum):
     DATETIME = "DATETIME"
 
 
+class SqlParameterScalarTypes:
+    """Supported scalar SQL query parameter types as type objects."""
+
+    STRING = ScalarQueryParameterType("STRING")
+    BYTES = ScalarQueryParameterType("BYTES")
+    INTEGER = ScalarQueryParameterType("INT64")
+    INT64 = ScalarQueryParameterType("INT64")
+    FLOAT = ScalarQueryParameterType("FLOAT64")
+    FLOAT64 = ScalarQueryParameterType("FLOAT64")
+    NUMERIC = ScalarQueryParameterType("NUMERIC")
+    BIGNUMERIC = ScalarQueryParameterType("BIGNUMERIC")
+    BOOLEAN = ScalarQueryParameterType("BOOL")
+    BOOL = ScalarQueryParameterType("BOOL")
+    GEOGRAPHY = ScalarQueryParameterType("GEOGRAPHY")
+    TIMESTAMP = ScalarQueryParameterType("TIMESTAMP")
+    DATE = ScalarQueryParameterType("DATE")
+    TIME = ScalarQueryParameterType("TIME")
+    DATETIME = ScalarQueryParameterType("DATETIME")
+
+
 class WriteDisposition(object):
     """Specifies the action that occurs if destination table already exists.
 
diff --git a/google/cloud/bigquery/query.py b/google/cloud/bigquery/query.py
--- a/google/cloud/bigquery/query.py
+++ b/google/cloud/bigquery/query.py
@@ -48,6 +48,239 @@ def __ne__(self, other):
         return not self == other
 
 
+class _AbstractQueryParameterType:
+    """Base class for representing query parameter types.
+
+    https://cloud.google.com/bigquery/docs/reference/rest/v2/QueryParameter#queryparametertype
+    """
+
+    @classmethod
+    def from_api_repr(cls, resource):
+        """Factory: construct parameter type from JSON resource.
+
+        Args:
+            resource (Dict): JSON mapping of parameter
+
+        Returns:
+            google.cloud.bigquery.query.QueryParameterType: Instance
+        """
+        raise NotImplementedError
+
+    def to_api_repr(self):
+        """Construct JSON API representation for the parameter type.
+
+        Returns:
+            Dict: JSON mapping
+        """
+        raise NotImplementedError
+
+
+class ScalarQueryParameterType(_AbstractQueryParameterType):
+    """Type representation for scalar query parameters.
+
+    Args:
+        type_ (str):
+            One of 'STRING', 'INT64', 'FLOAT64', 'NUMERIC', 'BOOL', 'TIMESTAMP',
+            'DATETIME', or 'DATE'.
+        name (Optional[str]):
+            The name of the query parameter. Primarily used if the type is
+            one of the subfields in ``StructQueryParameterType`` instance.
+        description (Optional[str]):
+            The query parameter description. Primarily used if the type is
+            one of the subfields in ``StructQueryParameterType`` instance.
+    """
+
+    def __init__(self, type_, *, name=None, description=None):
+        self._type = type_
+        self.name = name
+        self.description = description
+
+    @classmethod
+    def from_api_repr(cls, resource):
+        """Factory: construct parameter type from JSON resource.
+
+        Args:
+            resource (Dict): JSON mapping of parameter
+
+        Returns:
+            google.cloud.bigquery.query.ScalarQueryParameterType: Instance
+        """
+        type_ = resource["type"]
+        return cls(type_)
+
+    def to_api_repr(self):
+        """Construct JSON API representation for the parameter type.
+
+        Returns:
+            Dict: JSON mapping
+        """
+        # Name and description are only used if the type is a field inside a struct
+        # type, but it's StructQueryParameterType's responsibilty to use these two
+        # attributes in the API representation when needed. Here we omit them.
+        return {"type": self._type}
+
+    def __repr__(self):
+        name = f", name={self.name!r}" if self.name is not None else ""
+        description = (
+            f", description={self.description!r}"
+            if self.description is not None
+            else ""
+        )
+        return f"{self.__class__.__name__}({self._type!r}{name}{description})"
+
+
+class ArrayQueryParameterType(_AbstractQueryParameterType):
+    """Type representation for array query parameters.
+
+    Args:
+        array_type (Union[ScalarQueryParameterType, StructQueryParameterType]):
+            The type of array elements.
+        name (Optional[str]):
+            The name of the query parameter. Primarily used if the type is
+            one of the subfields in ``StructQueryParameterType`` instance.
+        description (Optional[str]):
+            The query parameter description. Primarily used if the type is
+            one of the subfields in ``StructQueryParameterType`` instance.
+    """
+
+    def __init__(self, array_type, *, name=None, description=None):
+        self._array_type = array_type
+        self.name = name
+        self.description = description
+
+    @classmethod
+    def from_api_repr(cls, resource):
+        """Factory: construct parameter type from JSON resource.
+
+        Args:
+            resource (Dict): JSON mapping of parameter
+
+        Returns:
+            google.cloud.bigquery.query.ArrayQueryParameterType: Instance
+        """
+        array_item_type = resource["arrayType"]["type"]
+
+        if array_item_type in {"STRUCT", "RECORD"}:
+            klass = StructQueryParameterType
+        else:
+            klass = ScalarQueryParameterType
+
+        item_type_instance = klass.from_api_repr(resource["arrayType"])
+        return cls(item_type_instance)
+
+    def to_api_repr(self):
+        """Construct JSON API representation for the parameter type.
+
+        Returns:
+            Dict: JSON mapping
+        """
+        # Name and description are only used if the type is a field inside a struct
+        # type, but it's StructQueryParameterType's responsibilty to use these two
+        # attributes in the API representation when needed. Here we omit them.
+        return {
+            "type": "ARRAY",
+            "arrayType": self._array_type.to_api_repr(),
+        }
+
+    def __repr__(self):
+        name = f", name={self.name!r}" if self.name is not None else ""
+        description = (
+            f", description={self.description!r}"
+            if self.description is not None
+            else ""
+        )
+        return f"{self.__class__.__name__}({self._array_type!r}{name}{description})"
+
+
+class StructQueryParameterType(_AbstractQueryParameterType):
+    """Type representation for struct query parameters.
+
+    Args:
+        fields (Iterable[Union[ \
+            ArrayQueryParameterType, ScalarQueryParameterType, StructQueryParameterType \
+        ]]):
+            An non-empty iterable describing the struct's field types.
+        name (Optional[str]):
+            The name of the query parameter. Primarily used if the type is
+            one of the subfields in ``StructQueryParameterType`` instance.
+        description (Optional[str]):
+            The query parameter description. Primarily used if the type is
+            one of the subfields in ``StructQueryParameterType`` instance.
+    """
+
+    def __init__(self, *fields, name=None, description=None):
+        if not fields:
+            raise ValueError("Struct type must have at least one field defined.")
+
+        self._fields = fields  # fields is a tuple (immutable), no shallow copy needed
+        self.name = name
+        self.description = description
+
+    @property
+    def fields(self):
+        return self._fields  # no copy needed, self._fields is an immutable sequence
+
+    @classmethod
+    def from_api_repr(cls, resource):
+        """Factory: construct parameter type from JSON resource.
+
+        Args:
+            resource (Dict): JSON mapping of parameter
+
+        Returns:
+            google.cloud.bigquery.query.StructQueryParameterType: Instance
+        """
+        fields = []
+
+        for struct_field in resource["structTypes"]:
+            type_repr = struct_field["type"]
+            if type_repr["type"] in {"STRUCT", "RECORD"}:
+                klass = StructQueryParameterType
+            elif type_repr["type"] == "ARRAY":
+                klass = ArrayQueryParameterType
+            else:
+                klass = ScalarQueryParameterType
+
+            type_instance = klass.from_api_repr(type_repr)
+            type_instance.name = struct_field.get("name")
+            type_instance.description = struct_field.get("description")
+            fields.append(type_instance)
+
+        return cls(*fields)
+
+    def to_api_repr(self):
+        """Construct JSON API representation for the parameter type.
+
+        Returns:
+            Dict: JSON mapping
+        """
+        fields = []
+
+        for field in self._fields:
+            item = {"type": field.to_api_repr()}
+            if field.name is not None:
+                item["name"] = field.name
+            if field.description is not None:
+                item["description"] = field.description
+
+            fields.append(item)
+
+        return {
+            "type": "STRUCT",
+            "structTypes": fields,
+        }
+
+    def __repr__(self):
+        name = f", name={self.name!r}" if self.name is not None else ""
+        description = (
+            f", description={self.description!r}"
+            if self.description is not None
+            else ""
+        )
+        items = ", ".join(repr(field) for field in self._fields)
+        return f"{self.__class__.__name__}({items}{name}{description})"
+
+
 class _AbstractQueryParameter(object):
     """Base class for named / positional query parameters.
     """
@@ -184,28 +417,43 @@ class ArrayQueryParameter(_AbstractQueryParameter):
             Parameter name, used via ``@foo`` syntax.  If None, the
             parameter can only be addressed via position (``?``).
 
-        array_type (str):
-            Name of type of array elements.  One of `'STRING'`, `'INT64'`,
-            `'FLOAT64'`, `'NUMERIC'`, `'BIGNUMERIC'`, `'BOOL'`, `'TIMESTAMP'`, or `'DATE'`.
+        array_type (Union[str, ScalarQueryParameterType, StructQueryParameterType]):
+            The type of array elements. If given as a string, it must be one of
+            `'STRING'`, `'INT64'`, `'FLOAT64'`, `'NUMERIC'`, `'BIGNUMERIC'`, `'BOOL'`,
+            `'TIMESTAMP'`, `'DATE'`, or `'STRUCT'`/`'RECORD'`.
+            If the type is ``'STRUCT'``/``'RECORD'`` and ``values`` is empty,
+            the exact item type cannot be deduced, thus a ``StructQueryParameterType``
+            instance needs to be passed in.
 
-        values (List[appropriate scalar type]): The parameter array values.
+        values (List[appropriate type]): The parameter array values.
     """
 
     def __init__(self, name, array_type, values):
         self.name = name
-        self.array_type = array_type
         self.values = values
 
+        if isinstance(array_type, str):
+            if not values and array_type in {"RECORD", "STRUCT"}:
+                raise ValueError(
+                    "Missing detailed struct item type info for an empty array, "
+                    "please provide a StructQueryParameterType instance."
+                )
+        self.array_type = array_type
+
     @classmethod
     def positional(cls, array_type, values):
         """Factory for positional parameters.
 
         Args:
-            array_type (str):
-                Name of type of array elements.  One of `'STRING'`, `'INT64'`,
-                `'FLOAT64'`, `'NUMERIC'`, `'BIGNUMERIC'`, `'BOOL'`, `'TIMESTAMP'`, or `'DATE'`.
+            array_type (Union[str, ScalarQueryParameterType, StructQueryParameterType]):
+                The type of array elements. If given as a string, it must be one of
+                `'STRING'`, `'INT64'`, `'FLOAT64'`, `'NUMERIC'`, `'BIGNUMERIC'`,
+                `'BOOL'`, `'TIMESTAMP'`, `'DATE'`, or `'STRUCT'`/`'RECORD'`.
+                If the type is ``'STRUCT'``/``'RECORD'`` and ``values`` is empty,
+                the exact item type cannot be deduced, thus a ``StructQueryParameterType``
+                instance needs to be passed in.
 
-            values (List[appropriate scalar type]): The parameter array values.
+            values (List[appropriate type]): The parameter array values.
 
         Returns:
             google.cloud.bigquery.query.ArrayQueryParameter: Instance without name
@@ -263,22 +511,40 @@ def to_api_repr(self):
             Dict: JSON mapping
         """
         values = self.values
-        if self.array_type == "RECORD" or self.array_type == "STRUCT":
+
+        if self.array_type in {"RECORD", "STRUCT"} or isinstance(
+            self.array_type, StructQueryParameterType
+        ):
             reprs = [value.to_api_repr() for value in values]
-            a_type = reprs[0]["parameterType"]
             a_values = [repr_["parameterValue"] for repr_ in reprs]
+
+            if reprs:
+                a_type = reprs[0]["parameterType"]
+            else:
+                # This assertion always evaluates to True because the
+                # constructor disallows STRUCT/RECORD type defined as a
+                # string with empty values.
+                assert isinstance(self.array_type, StructQueryParameterType)
+                a_type = self.array_type.to_api_repr()
         else:
-            a_type = {"type": self.array_type}
-            converter = _SCALAR_VALUE_TO_JSON_PARAM.get(self.array_type)
+            # Scalar array item type.
+            if isinstance(self.array_type, str):
+                a_type = {"type": self.array_type}
+            else:
+                a_type = self.array_type.to_api_repr()
+
+            converter = _SCALAR_VALUE_TO_JSON_PARAM.get(a_type["type"])
             if converter is not None:
                 values = [converter(value) for value in values]
             a_values = [{"value": value} for value in values]
+
         resource = {
             "parameterType": {"type": "ARRAY", "arrayType": a_type},
             "parameterValue": {"arrayValues": a_values},
         }
         if self.name is not None:
             resource["name"] = self.name
+
         return resource
 
     def _key(self):
@@ -289,7 +555,14 @@ def _key(self):
         Returns:
             Tuple: The contents of this :class:`~google.cloud.bigquery.query.ArrayQueryParameter`.
         """
-        return (self.name, self.array_type.upper(), self.values)
+        if isinstance(self.array_type, str):
+            item_type = self.array_type
+        elif isinstance(self.array_type, ScalarQueryParameterType):
+            item_type = self.array_type._type
+        else:
+            item_type = "STRUCT"
+
+        return (self.name, item_type.upper(), self.values)
 
     def __eq__(self, other):
         if not isinstance(other, ArrayQueryParameter):


Test patch:
diff --git a/tests/system/test_client.py b/tests/system/test_client.py
--- a/tests/system/test_client.py
+++ b/tests/system/test_client.py
@@ -2168,7 +2168,9 @@ def test_query_w_query_params(self):
         from google.cloud.bigquery.job import QueryJobConfig
         from google.cloud.bigquery.query import ArrayQueryParameter
         from google.cloud.bigquery.query import ScalarQueryParameter
+        from google.cloud.bigquery.query import ScalarQueryParameterType
         from google.cloud.bigquery.query import StructQueryParameter
+        from google.cloud.bigquery.query import StructQueryParameterType
 
         question = "What is the answer to life, the universe, and everything?"
         question_param = ScalarQueryParameter(
@@ -2227,6 +2229,14 @@ def test_query_w_query_params(self):
         characters_param = ArrayQueryParameter(
             name=None, array_type="RECORD", values=[phred_param, bharney_param]
         )
+        empty_struct_array_param = ArrayQueryParameter(
+            name="empty_array_param",
+            values=[],
+            array_type=StructQueryParameterType(
+                ScalarQueryParameterType(name="foo", type_="INT64"),
+                ScalarQueryParameterType(name="bar", type_="STRING"),
+            ),
+        )
         hero_param = StructQueryParameter("hero", phred_name_param, phred_age_param)
         sidekick_param = StructQueryParameter(
             "sidekick", bharney_name_param, bharney_age_param
@@ -2317,6 +2327,11 @@ def test_query_w_query_params(self):
                 ],
                 "query_parameters": [characters_param],
             },
+            {
+                "sql": "SELECT @empty_array_param",
+                "expected": [],
+                "query_parameters": [empty_struct_array_param],
+            },
             {
                 "sql": "SELECT @roles",
                 "expected": {
diff --git a/tests/unit/test_query.py b/tests/unit/test_query.py
--- a/tests/unit/test_query.py
+++ b/tests/unit/test_query.py
@@ -43,6 +43,318 @@ def test___eq__(self):
         self.assertNotEqual(udf, wrong_type)
 
 
+class Test__AbstractQueryParameterType(unittest.TestCase):
+    @staticmethod
+    def _get_target_class():
+        from google.cloud.bigquery.query import _AbstractQueryParameterType
+
+        return _AbstractQueryParameterType
+
+    @classmethod
+    def _make_one(cls, *args, **kw):
+        return cls._get_target_class()(*args, **kw)
+
+    def test_from_api_virtual(self):
+        klass = self._get_target_class()
+        with self.assertRaises(NotImplementedError):
+            klass.from_api_repr({})
+
+    def test_to_api_virtual(self):
+        param_type = self._make_one()
+        with self.assertRaises(NotImplementedError):
+            param_type.to_api_repr()
+
+
+class Test_ScalarQueryParameterType(unittest.TestCase):
+    @staticmethod
+    def _get_target_class():
+        from google.cloud.bigquery.query import ScalarQueryParameterType
+
+        return ScalarQueryParameterType
+
+    def _make_one(self, *args, **kw):
+        return self._get_target_class()(*args, **kw)
+
+    def test_from_api_repr(self):
+        klass = self._get_target_class()
+        result = klass.from_api_repr({"type": "BOOLEAN"})
+        self.assertEqual(result._type, "BOOLEAN")
+        self.assertIsNone(result.name)
+        self.assertIsNone(result.description)
+
+    def test_to_api_repr(self):
+        param_type = self._make_one("BYTES", name="foo", description="bar")
+        result = param_type.to_api_repr()
+        self.assertEqual(result, {"type": "BYTES"})
+
+    def test_repr_no_optional_attrs(self):
+        param_type = self._make_one("BYTES")
+        self.assertEqual(repr(param_type), "ScalarQueryParameterType('BYTES')")
+
+    def test_repr_all_optional_attrs(self):
+        param_type = self._make_one("BYTES", name="foo", description="this is foo")
+        self.assertEqual(
+            repr(param_type),
+            "ScalarQueryParameterType('BYTES', name='foo', description='this is foo')",
+        )
+
+
+class Test_ArrayQueryParameterType(unittest.TestCase):
+    @staticmethod
+    def _get_target_class():
+        from google.cloud.bigquery.query import ArrayQueryParameterType
+
+        return ArrayQueryParameterType
+
+    def _make_one(self, *args, **kw):
+        return self._get_target_class()(*args, **kw)
+
+    def test_from_api_repr(self):
+        from google.cloud.bigquery.query import StructQueryParameterType
+
+        api_resource = {
+            "type": "ARRAY",
+            "arrayType": {
+                "type": "STRUCT",
+                "structTypes": [
+                    {
+                        "name": "weight",
+                        "type": {"type": "INTEGER"},
+                        "description": "in kg",
+                    },
+                    {"name": "last_name", "type": {"type": "STRING"}},
+                ],
+            },
+        }
+
+        klass = self._get_target_class()
+        result = klass.from_api_repr(api_resource)
+
+        self.assertIsNone(result.name)
+        self.assertIsNone(result.description)
+        item_type = result._array_type
+        self.assertIsInstance(item_type, StructQueryParameterType)
+
+        self.assertIsNone(item_type.name)
+        self.assertIsNone(item_type.description)
+
+        field = item_type.fields[0]
+        self.assertEqual(field.name, "weight")
+        self.assertEqual(field.description, "in kg")
+        self.assertEqual(field._type, "INTEGER")
+
+        field = item_type.fields[1]
+        self.assertEqual(field.name, "last_name")
+        self.assertIsNone(field.description)
+        self.assertEqual(field._type, "STRING")
+
+    def test_to_api_repr(self):
+        from google.cloud.bigquery.query import ScalarQueryParameterType
+        from google.cloud.bigquery.query import StructQueryParameterType
+
+        array_item_type = StructQueryParameterType(
+            ScalarQueryParameterType("INTEGER", name="weight", description="in kg"),
+            ScalarQueryParameterType("STRING", name="last_name"),
+        )
+        param_type = self._make_one(array_item_type, name="foo", description="bar")
+
+        result = param_type.to_api_repr()
+
+        expected_result = {
+            "type": "ARRAY",
+            "arrayType": {
+                "type": "STRUCT",
+                "structTypes": [
+                    {
+                        "name": "weight",
+                        "type": {"type": "INTEGER"},
+                        "description": "in kg",
+                    },
+                    {"name": "last_name", "type": {"type": "STRING"}},
+                ],
+            },
+        }
+        self.assertEqual(result, expected_result)
+
+    def test_repr_no_optional_attrs(self):
+        param_type = self._make_one("BOOLEAN")
+        self.assertEqual(repr(param_type), "ArrayQueryParameterType('BOOLEAN')")
+
+    def test_repr_all_optional_attrs(self):
+        param_type = self._make_one("INT64", name="bar", description="this is bar")
+        self.assertEqual(
+            repr(param_type),
+            "ArrayQueryParameterType('INT64', name='bar', description='this is bar')",
+        )
+
+
+class Test_StructQueryParameterType(unittest.TestCase):
+    @staticmethod
+    def _get_target_class():
+        from google.cloud.bigquery.query import StructQueryParameterType
+
+        return StructQueryParameterType
+
+    def _make_one(self, *args, **kw):
+        return self._get_target_class()(*args, **kw)
+
+    def test_raises_error_without_any_fields(self):
+        with self.assertRaisesRegex(ValueError, ".*at least one field.*"):
+            self._make_one()
+
+    def test_from_api_repr(self):
+        from google.cloud.bigquery.query import ArrayQueryParameterType
+        from google.cloud.bigquery.query import ScalarQueryParameterType
+
+        api_resource = {
+            "type": "STRUCT",
+            "structTypes": [
+                {
+                    "name": "age",
+                    "type": {"type": "INTEGER"},
+                    "description": "in years",
+                },
+                {
+                    "name": "aliases",
+                    "type": {"type": "ARRAY", "arrayType": {"type": "STRING"}},
+                },
+                {
+                    "description": "a nested struct",
+                    "type": {
+                        "type": "STRUCT",
+                        "structTypes": [
+                            {"type": {"type": "DATE"}, "name": "nested_date"},
+                            {
+                                "type": {"type": "BOOLEAN"},
+                                "description": "nested bool field",
+                            },
+                        ],
+                    },
+                },
+            ],
+        }
+
+        klass = self._get_target_class()
+        result = klass.from_api_repr(api_resource)
+
+        self.assertIsNone(result.name)
+        self.assertIsNone(result.description)
+        self.assertEqual(len(result.fields), 3)
+
+        field = result.fields[0]
+        self.assertIsInstance(field, ScalarQueryParameterType)
+        self.assertEqual(field.name, "age")
+        self.assertEqual(field.description, "in years")
+
+        field = result.fields[1]
+        self.assertIsInstance(field, ArrayQueryParameterType)
+        self.assertEqual(field.name, "aliases")
+        self.assertIsNone(field.description)
+        self.assertIsInstance(field._array_type, ScalarQueryParameterType)
+        self.assertEqual(field._array_type._type, "STRING")
+
+        field = result.fields[2]
+        self.assertIsInstance(field, self._get_target_class())
+        self.assertIsNone(field.name)
+        self.assertEqual(field.description, "a nested struct")
+
+        date_field = field.fields[0]
+        self.assertEqual(date_field._type, "DATE")
+        self.assertEqual(date_field.name, "nested_date")
+        self.assertIsNone(date_field.description)
+
+        bool_field = field.fields[1]
+        self.assertEqual(bool_field._type, "BOOLEAN")
+        self.assertIsNone(bool_field.name)
+        self.assertEqual(bool_field.description, "nested bool field")
+
+    def test_to_api_repr(self):
+        from google.cloud.bigquery.query import ScalarQueryParameterType
+
+        int_type = ScalarQueryParameterType("INTEGER", description="in years")
+        date_type = ScalarQueryParameterType("DATE", name="day_of_birth")
+        param_type = self._make_one(int_type, date_type, name="foo", description="bar")
+
+        result = param_type.to_api_repr()
+
+        expected_result = {
+            "type": "STRUCT",
+            "structTypes": [
+                {"type": {"type": "INTEGER"}, "description": "in years"},
+                {"name": "day_of_birth", "type": {"type": "DATE"}},
+            ],
+        }
+        self.assertEqual(result, expected_result)
+
+    def test_to_api_repr_nested(self):
+        from google.cloud.bigquery.query import ScalarQueryParameterType
+
+        struct_class = self._get_target_class()
+
+        int_type = ScalarQueryParameterType("INTEGER", description="in years")
+        nested_struct_type = struct_class(
+            ScalarQueryParameterType("DATE", name="nested_date"),
+            ScalarQueryParameterType("BOOLEAN", description="nested bool field"),
+            name="nested",
+        )
+        param_type = self._make_one(
+            int_type, nested_struct_type, name="foo", description="bar"
+        )
+
+        result = param_type.to_api_repr()
+
+        expected_result = {
+            "type": "STRUCT",
+            "structTypes": [
+                {"type": {"type": "INTEGER"}, "description": "in years"},
+                {
+                    "name": "nested",
+                    "type": {
+                        "type": "STRUCT",
+                        "structTypes": [
+                            {"type": {"type": "DATE"}, "name": "nested_date"},
+                            {
+                                "type": {"type": "BOOLEAN"},
+                                "description": "nested bool field",
+                            },
+                        ],
+                    },
+                },
+            ],
+        }
+        self.assertEqual(result, expected_result)
+
+    def test_repr_no_optional_attrs(self):
+        from google.cloud.bigquery.query import ScalarQueryParameterType
+
+        param_type = self._make_one(
+            ScalarQueryParameterType("BOOLEAN"), ScalarQueryParameterType("STRING")
+        )
+        expected = (
+            "StructQueryParameterType("
+            "ScalarQueryParameterType('BOOLEAN'), ScalarQueryParameterType('STRING')"
+            ")"
+        )
+        self.assertEqual(repr(param_type), expected)
+
+    def test_repr_all_optional_attrs(self):
+        from google.cloud.bigquery.query import ScalarQueryParameterType
+
+        param_type = self._make_one(
+            ScalarQueryParameterType("BOOLEAN"),
+            ScalarQueryParameterType("STRING"),
+            name="data_record",
+            description="this is it",
+        )
+        expected = (
+            "StructQueryParameterType("
+            "ScalarQueryParameterType('BOOLEAN'), ScalarQueryParameterType('STRING'), "
+            "name='data_record', description='this is it'"
+            ")"
+        )
+        self.assertEqual(repr(param_type), expected)
+
+
 class Test__AbstractQueryParameter(unittest.TestCase):
     @staticmethod
     def _get_target_class():
@@ -340,6 +652,10 @@ def test_ctor(self):
         self.assertEqual(param.array_type, "INT64")
         self.assertEqual(param.values, [1, 2])
 
+    def test_ctor_empty_struct_array_wo_type_info(self):
+        with self.assertRaisesRegex(ValueError, r"(?i)missing.*struct.*type info.*"):
+            self._make_one(name="foo", array_type="STRUCT", values=[])
+
     def test___eq__(self):
         param = self._make_one(name="foo", array_type="INT64", values=[123])
         self.assertEqual(param, param)
@@ -467,6 +783,19 @@ def test_to_api_repr_wo_name(self):
         param = klass.positional(array_type="INT64", values=[1, 2])
         self.assertEqual(param.to_api_repr(), EXPECTED)
 
+    def test_to_api_repr_array_type_as_type_instance(self):
+        from google.cloud.bigquery.query import ScalarQueryParameterType
+
+        EXPECTED = {
+            "parameterType": {"type": "ARRAY", "arrayType": {"type": "BOOLEAN"}},
+            "parameterValue": {"arrayValues": [{"value": "true"}, {"value": "false"}]},
+        }
+        klass = self._get_target_class()
+        param = klass.positional(
+            array_type=ScalarQueryParameterType("BOOLEAN"), values=[True, False],
+        )
+        self.assertEqual(param.to_api_repr(), EXPECTED)
+
     def test_to_api_repr_w_unknown_type(self):
         EXPECTED = {
             "parameterType": {"type": "ARRAY", "arrayType": {"type": "UNKNOWN"}},
@@ -503,6 +832,31 @@ def test_to_api_repr_w_record_type(self):
         param = klass.positional(array_type="RECORD", values=[struct])
         self.assertEqual(param.to_api_repr(), EXPECTED)
 
+    def test_to_api_repr_w_empty_array_of_records_type(self):
+        from google.cloud.bigquery.query import ScalarQueryParameterType
+        from google.cloud.bigquery.query import StructQueryParameterType
+
+        EXPECTED = {
+            "parameterType": {
+                "type": "ARRAY",
+                "arrayType": {
+                    "type": "STRUCT",
+                    "structTypes": [
+                        {"name": "foo", "type": {"type": "STRING"}},
+                        {"name": "bar", "type": {"type": "INT64"}},
+                    ],
+                },
+            },
+            "parameterValue": {"arrayValues": []},
+        }
+        item_type = StructQueryParameterType(
+            ScalarQueryParameterType("STRING", name="foo"),
+            ScalarQueryParameterType("INT64", name="bar"),
+        )
+        klass = self._get_target_class()
+        param = klass.positional(array_type=item_type, values=[])
+        self.assertEqual(param.to_api_repr(), EXPECTED)
+
     def test___eq___wrong_type(self):
         field = self._make_one("test", "STRING", ["value"])
         other = object()
@@ -547,11 +901,38 @@ def test___ne___different_values(self):
         field2 = self._make_one("test", "INT64", [12])
         self.assertNotEqual(field1, field2)
 
-    def test___repr__(self):
+    def test___repr__array_type_str(self):
         field1 = self._make_one("field1", "STRING", ["value"])
         expected = "ArrayQueryParameter('field1', 'STRING', ['value'])"
         self.assertEqual(repr(field1), expected)
 
+    def test___repr__array_type_scalar_type_instance(self):
+        from google.cloud.bigquery.query import ScalarQueryParameterType
+
+        int_items = self._make_one(
+            "int_items", ScalarQueryParameterType("INTEGER"), [64]
+        )
+        expected = "ArrayQueryParameter('int_items', 'INTEGER', [64])"
+        self.assertEqual(repr(int_items), expected)
+
+    def test___repr__array_type_struct_type_instance(self):
+        from google.cloud.bigquery.query import ScalarQueryParameterType
+        from google.cloud.bigquery.query import StructQueryParameterType
+
+        struct_items = self._make_one(
+            "struct_items",
+            StructQueryParameterType(
+                ScalarQueryParameterType("INTEGER", name="age"),
+                ScalarQueryParameterType("STRING", name="last_name"),
+            ),
+            [{"age": 18, "last_name": "Doe"}],
+        )
+        expected = (
+            "ArrayQueryParameter('struct_items', 'STRUCT', "
+            "[{'age': 18, 'last_name': 'Doe'}])"
+        )
+        self.assertEqual(repr(struct_items), expected)
+
 
 class Test_StructQueryParameter(unittest.TestCase):
     @staticmethod