Repository: googleapis/python-bigquery

Issue:
Support bound struct parameters with explicit types in db api interface
Allow passing struct data to queries if explicit type information is provided in the placeholder.

For example, given a table created with:

```
create table mydb.mytable (person struct<name string, children array<struct<name string, bdate date>>>)
```
You can insert data with:

```
insert into mydb.mytable (person) values (%(v:struct<name string, children array<struct<name string, bdate date>>>)s)
```
passing a value like:

```
dict(name='par',
                children=[
                    dict(name='ch1', bdate=datetime.date(2021, 1, 1)),
                    dict(name='ch2', bdate=datetime.date(2021, 1, 2)),
                    ])
```
We have to pass the type explicitly because struct data are dictionaries and may not preserve ordering reliably.

Even though, dictionaries are now ordered, the ordering is likely to be fragile.

```
>>> d = dict()
>>> d['a'] = 1
>>> d['b'] = 2
>>> d
{'a': 1, 'b': 2}
>>> del d['a']
>>> d['a'] = 4
>>> d
{'b': 2, 'a': 4}
```
¯\_(ツ)_/¯


Discussion:

Resolving patch:
diff --git a/google/cloud/bigquery/dbapi/_helpers.py b/google/cloud/bigquery/dbapi/_helpers.py
--- a/google/cloud/bigquery/dbapi/_helpers.py
+++ b/google/cloud/bigquery/dbapi/_helpers.py
@@ -18,18 +18,34 @@
 import decimal
 import functools
 import numbers
+import re
+import typing
 
 from google.cloud import bigquery
-from google.cloud.bigquery import table, enums
+from google.cloud.bigquery import table, enums, query
 from google.cloud.bigquery.dbapi import exceptions
 
 
 _NUMERIC_SERVER_MIN = decimal.Decimal("-9.9999999999999999999999999999999999999E+28")
 _NUMERIC_SERVER_MAX = decimal.Decimal("9.9999999999999999999999999999999999999E+28")
 
+type_parameters_re = re.compile(
+    r"""
+    \(
+    \s*[0-9]+\s*
+    (,
+    \s*[0-9]+\s*
+    )*
+    \)
+    """,
+    re.VERBOSE,
+)
+
 
 def _parameter_type(name, value, query_parameter_type=None, value_doc=""):
     if query_parameter_type:
+        # Strip type parameters
+        query_parameter_type = type_parameters_re.sub("", query_parameter_type)
         try:
             parameter_type = getattr(
                 enums.SqlParameterScalarTypes, query_parameter_type.upper()
@@ -113,6 +129,197 @@ def array_to_query_parameter(value, name=None, query_parameter_type=None):
     return bigquery.ArrayQueryParameter(name, array_type, value)
 
 
+def _parse_struct_fields(
+    fields,
+    base,
+    parse_struct_field=re.compile(
+        r"""
+        (?:(\w+)\s+)    # field name
+        ([A-Z0-9<> ,()]+)  # Field type
+        $""",
+        re.VERBOSE | re.IGNORECASE,
+    ).match,
+):
+    # Split a string of struct fields.  They're defined by commas, but
+    # we have to avoid splitting on commas internal to fields.  For
+    # example:
+    # name string, children array<struct<name string, bdate date>>
+    #
+    # only has 2 top-level fields.
+    fields = fields.split(",")
+    fields = list(reversed(fields))  # in the off chance that there are very many
+    while fields:
+        field = fields.pop()
+        while fields and field.count("<") != field.count(">"):
+            field += "," + fields.pop()
+
+        m = parse_struct_field(field.strip())
+        if not m:
+            raise exceptions.ProgrammingError(
+                f"Invalid struct field, {field}, in {base}"
+            )
+        yield m.group(1, 2)
+
+
+SCALAR, ARRAY, STRUCT = "sar"
+
+
+def _parse_type(
+    type_,
+    name,
+    base,
+    complex_query_parameter_parse=re.compile(
+        r"""
+        \s*
+        (ARRAY|STRUCT|RECORD)  # Type
+        \s*
+        <([A-Z0-9<> ,()]+)>      # Subtype(s)
+        \s*$
+        """,
+        re.IGNORECASE | re.VERBOSE,
+    ).match,
+):
+    if "<" not in type_:
+        # Scalar
+
+        # Strip type parameters
+        type_ = type_parameters_re.sub("", type_).strip()
+        try:
+            type_ = getattr(enums.SqlParameterScalarTypes, type_.upper())
+        except AttributeError:
+            raise exceptions.ProgrammingError(
+                f"The given parameter type, {type_},"
+                f"{' for ' + name if name else ''}"
+                f" is not a valid BigQuery scalar type, in {base}."
+            )
+        if name:
+            type_ = type_.with_name(name)
+        return SCALAR, type_
+
+    m = complex_query_parameter_parse(type_)
+    if not m:
+        raise exceptions.ProgrammingError(f"Invalid parameter type, {type_}")
+    tname, sub = m.group(1, 2)
+    if tname.upper() == "ARRAY":
+        sub_type = complex_query_parameter_type(None, sub, base)
+        if isinstance(sub_type, query.ArrayQueryParameterType):
+            raise exceptions.ProgrammingError(f"Array can't contain an array in {base}")
+        sub_type._complex__src = sub
+        return ARRAY, sub_type
+    else:
+        return STRUCT, _parse_struct_fields(sub, base)
+
+
+def complex_query_parameter_type(name: typing.Optional[str], type_: str, base: str):
+    """Construct a parameter type (`StructQueryParameterType`) for a complex type
+
+    or a non-complex type that's part of a complex type.
+
+    Examples:
+
+    array<struct<x float64, y float64>>
+
+    struct<name string, children array<struct<name string, bdate date>>>
+
+    This is used for computing array types.
+    """
+
+    type_type, sub_type = _parse_type(type_, name, base)
+    if type_type == SCALAR:
+        type_ = sub_type
+    elif type_type == ARRAY:
+        type_ = query.ArrayQueryParameterType(sub_type, name=name)
+    elif type_type == STRUCT:
+        fields = [
+            complex_query_parameter_type(field_name, field_type, base)
+            for field_name, field_type in sub_type
+        ]
+        type_ = query.StructQueryParameterType(*fields, name=name)
+    else:  # pragma: NO COVER
+        raise AssertionError("Bad type_type", type_type)  # Can't happen :)
+
+    return type_
+
+
+def complex_query_parameter(
+    name: typing.Optional[str], value, type_: str, base: typing.Optional[str] = None
+):
+    """
+    Construct a query parameter for a complex type (array or struct record)
+
+    or for a subtype, which may not be complex
+
+    Examples:
+
+    array<struct<x float64, y float64>>
+
+    struct<name string, children array<struct<name string, bdate date>>>
+
+    """
+    base = base or type_
+
+    type_type, sub_type = _parse_type(type_, name, base)
+
+    if type_type == SCALAR:
+        param = query.ScalarQueryParameter(name, sub_type._type, value)
+    elif type_type == ARRAY:
+        if not array_like(value):
+            raise exceptions.ProgrammingError(
+                f"Array type with non-array-like value"
+                f" with type {type(value).__name__}"
+            )
+        param = query.ArrayQueryParameter(
+            name,
+            sub_type,
+            value
+            if isinstance(sub_type, query.ScalarQueryParameterType)
+            else [
+                complex_query_parameter(None, v, sub_type._complex__src, base)
+                for v in value
+            ],
+        )
+    elif type_type == STRUCT:
+        if not isinstance(value, collections_abc.Mapping):
+            raise exceptions.ProgrammingError(f"Non-mapping value for type {type_}")
+        value_keys = set(value)
+        fields = []
+        for field_name, field_type in sub_type:
+            if field_name not in value:
+                raise exceptions.ProgrammingError(
+                    f"No field value for {field_name} in {type_}"
+                )
+            value_keys.remove(field_name)
+            fields.append(
+                complex_query_parameter(field_name, value[field_name], field_type, base)
+            )
+        if value_keys:
+            raise exceptions.ProgrammingError(f"Extra data keys for {type_}")
+
+        param = query.StructQueryParameter(name, *fields)
+    else:  # pragma: NO COVER
+        raise AssertionError("Bad type_type", type_type)  # Can't happen :)
+
+    return param
+
+
+def _dispatch_parameter(type_, value, name=None):
+    if type_ is not None and "<" in type_:
+        param = complex_query_parameter(name, value, type_)
+    elif isinstance(value, collections_abc.Mapping):
+        raise NotImplementedError(
+            f"STRUCT-like parameter values are not supported"
+            f"{' (parameter ' + name + ')' if name else ''},"
+            f" unless an explicit type is give in the parameter placeholder"
+            f" (e.g. '%({name if name else ''}:struct<...>)s')."
+        )
+    elif array_like(value):
+        param = array_to_query_parameter(value, name, type_)
+    else:
+        param = scalar_to_query_parameter(value, name, type_)
+
+    return param
+
+
 def to_query_parameters_list(parameters, parameter_types):
     """Converts a sequence of parameter values into query parameters.
 
@@ -126,19 +333,10 @@ def to_query_parameters_list(parameters, parameter_types):
         List[google.cloud.bigquery.query._AbstractQueryParameter]:
             A list of query parameters.
     """
-    result = []
-
-    for value, type_ in zip(parameters, parameter_types):
-        if isinstance(value, collections_abc.Mapping):
-            raise NotImplementedError("STRUCT-like parameter values are not supported.")
-        elif array_like(value):
-            param = array_to_query_parameter(value, None, type_)
-        else:
-            param = scalar_to_query_parameter(value, None, type_)
-
-        result.append(param)
-
-    return result
+    return [
+        _dispatch_parameter(type_, value)
+        for value, type_ in zip(parameters, parameter_types)
+    ]
 
 
 def to_query_parameters_dict(parameters, query_parameter_types):
@@ -154,28 +352,10 @@ def to_query_parameters_dict(parameters, query_parameter_types):
         List[google.cloud.bigquery.query._AbstractQueryParameter]:
             A list of named query parameters.
     """
-    result = []
-
-    for name, value in parameters.items():
-        if isinstance(value, collections_abc.Mapping):
-            raise NotImplementedError(
-                "STRUCT-like parameter values are not supported "
-                "(parameter {}).".format(name)
-            )
-        else:
-            query_parameter_type = query_parameter_types.get(name)
-            if array_like(value):
-                param = array_to_query_parameter(
-                    value, name=name, query_parameter_type=query_parameter_type
-                )
-            else:
-                param = scalar_to_query_parameter(
-                    value, name=name, query_parameter_type=query_parameter_type,
-                )
-
-        result.append(param)
-
-    return result
+    return [
+        _dispatch_parameter(query_parameter_types.get(name), value, name)
+        for name, value in parameters.items()
+    ]
 
 
 def to_query_parameters(parameters, parameter_types):
diff --git a/google/cloud/bigquery/dbapi/cursor.py b/google/cloud/bigquery/dbapi/cursor.py
--- a/google/cloud/bigquery/dbapi/cursor.py
+++ b/google/cloud/bigquery/dbapi/cursor.py
@@ -483,7 +483,33 @@ def _format_operation(operation, parameters):
 
 
 def _extract_types(
-    operation, extra_type_sub=re.compile(r"(%*)%(?:\(([^:)]*)(?::(\w+))?\))?s").sub
+    operation,
+    extra_type_sub=re.compile(
+        r"""
+        (%*)          # Extra %s.  We'll deal with these in the replacement code
+
+        %             # Beginning of replacement, %s, %(...)s
+
+        (?:\(         # Begin of optional name and/or type
+        ([^:)]*)      # name
+        (?::          # ':' introduces type
+          (             # start of type group
+            [a-zA-Z0-9<>, ]+  # First part, no parens
+
+            (?:               # start sets of parens + non-paren text
+              \([0-9 ,]+\)      # comma-separated groups of digits in parens
+                                # (e.g. string(10))
+              (?=[, >)])        # Must be followed by ,>) or space
+              [a-zA-Z0-9<>, ]*  # Optional non-paren chars
+            )*                # Can be zero or more of parens and following text
+          )             # end of type group
+        )?            # close type clause ":type"
+        \))?          # End of optional name and/or type
+
+        s             # End of replacement
+        """,
+        re.VERBOSE,
+    ).sub,
 ):
     """Remove type information from parameter placeholders.
 


Test patch:
diff --git a/tests/system/conftest.py b/tests/system/conftest.py
--- a/tests/system/conftest.py
+++ b/tests/system/conftest.py
@@ -31,9 +31,14 @@ def bqstorage_client(bigquery_client):
     return bigquery_storage.BigQueryReadClient(credentials=bigquery_client._credentials)
 
 
-@pytest.fixture
+@pytest.fixture(scope="session")
 def dataset_id(bigquery_client):
     dataset_id = f"bqsystem_{helpers.temp_suffix()}"
     bigquery_client.create_dataset(dataset_id)
     yield dataset_id
     bigquery_client.delete_dataset(dataset_id, delete_contents=True)
+
+
+@pytest.fixture
+def table_id(dataset_id):
+    return f"{dataset_id}.table_{helpers.temp_suffix()}"
diff --git a/tests/system/test_pandas.py b/tests/system/test_pandas.py
--- a/tests/system/test_pandas.py
+++ b/tests/system/test_pandas.py
@@ -149,7 +149,7 @@ def test_load_table_from_dataframe_w_nullable_int64_datatype(
     reason="Only `pandas version >=1.0.0` is supported",
 )
 def test_load_table_from_dataframe_w_nullable_int64_datatype_automatic_schema(
-    bigquery_client, dataset_id
+    bigquery_client, dataset_id, table_id
 ):
     """Test that a DataFrame containing column with None-type values and int64 datatype
     can be uploaded without specifying a schema.
@@ -157,9 +157,6 @@ def test_load_table_from_dataframe_w_nullable_int64_datatype_automatic_schema(
     https://github.com/googleapis/python-bigquery/issues/22
     """
 
-    table_id = "{}.{}.load_table_from_dataframe_w_nullable_int64_datatype".format(
-        bigquery_client.project, dataset_id
-    )
     df_data = collections.OrderedDict(
         [("x", pandas.Series([1, 2, None, 4], dtype="Int64"))]
     )
@@ -511,7 +508,7 @@ def test_load_table_from_dataframe_w_explicit_schema_source_format_csv(
 
 
 def test_load_table_from_dataframe_w_explicit_schema_source_format_csv_floats(
-    bigquery_client, dataset_id
+    bigquery_client, dataset_id, table_id
 ):
     from google.cloud.bigquery.job import SourceFormat
 
@@ -536,10 +533,6 @@ def test_load_table_from_dataframe_w_explicit_schema_source_format_csv_floats(
     )
     dataframe = pandas.DataFrame(df_data, dtype="object", columns=df_data.keys())
 
-    table_id = "{}.{}.load_table_from_dataframe_w_explicit_schema_csv".format(
-        bigquery_client.project, dataset_id
-    )
-
     job_config = bigquery.LoadJobConfig(
         schema=table_schema, source_format=SourceFormat.CSV
     )
diff --git a/tests/system/test_structs.py b/tests/system/test_structs.py
new file mode 100644
--- /dev/null
+++ b/tests/system/test_structs.py
@@ -0,0 +1,31 @@
+import datetime
+
+import pytest
+
+from google.cloud.bigquery.dbapi import connect
+
+person_type = "struct<name string," " children array<struct<name string, bdate date>>>"
+person_type_sized = (
+    "struct<name string(22)," " children array<struct<name string(22), bdate date>>>"
+)
+
+
+@pytest.mark.parametrize("person_type_decl", [person_type, person_type_sized])
+def test_structs(bigquery_client, dataset_id, person_type_decl, table_id):
+    conn = connect(bigquery_client)
+    cursor = conn.cursor()
+    cursor.execute(f"create table {table_id} (person {person_type_decl})")
+    data = dict(
+        name="par",
+        children=[
+            dict(name="ch1", bdate=datetime.date(2021, 1, 1)),
+            dict(name="ch2", bdate=datetime.date(2021, 1, 2)),
+        ],
+    )
+    cursor.execute(
+        f"insert into {table_id} (person) values (%(v:{person_type})s)", dict(v=data),
+    )
+
+    cursor.execute(f"select * from {table_id}")
+    [[result]] = list(cursor)
+    assert result == data
diff --git a/tests/unit/test_dbapi__helpers.py b/tests/unit/test_dbapi__helpers.py
--- a/tests/unit/test_dbapi__helpers.py
+++ b/tests/unit/test_dbapi__helpers.py
@@ -16,6 +16,7 @@
 import decimal
 import math
 import operator as op
+import re
 import unittest
 
 import pytest
@@ -394,11 +395,13 @@ def test_to_query_parameters_dict_w_types():
 
     assert sorted(
         _helpers.to_query_parameters(
-            dict(i=1, x=1.2, y=None, z=[]), dict(x="numeric", y="string", z="float64")
+            dict(i=1, x=1.2, y=None, q="hi", z=[]),
+            dict(x="numeric", y="string", q="string(9)", z="float64"),
         ),
         key=lambda p: p.name,
     ) == [
         bigquery.ScalarQueryParameter("i", "INT64", 1),
+        bigquery.ScalarQueryParameter("q", "STRING", "hi"),
         bigquery.ScalarQueryParameter("x", "NUMERIC", 1.2),
         bigquery.ScalarQueryParameter("y", "STRING", None),
         bigquery.ArrayQueryParameter("z", "FLOAT64", []),
@@ -409,10 +412,285 @@ def test_to_query_parameters_list_w_types():
     from google.cloud import bigquery
 
     assert _helpers.to_query_parameters(
-        [1, 1.2, None, []], [None, "numeric", "string", "float64"]
+        [1, 1.2, None, "hi", []], [None, "numeric", "string", "string(9)", "float64"]
     ) == [
         bigquery.ScalarQueryParameter(None, "INT64", 1),
         bigquery.ScalarQueryParameter(None, "NUMERIC", 1.2),
         bigquery.ScalarQueryParameter(None, "STRING", None),
+        bigquery.ScalarQueryParameter(None, "STRING", "hi"),
         bigquery.ArrayQueryParameter(None, "FLOAT64", []),
     ]
+
+
+@pytest.mark.parametrize(
+    "value,type_,expect",
+    [
+        (
+            [],
+            "ARRAY<INT64>",
+            {
+                "parameterType": {"type": "ARRAY", "arrayType": {"type": "INT64"}},
+                "parameterValue": {"arrayValues": []},
+            },
+        ),
+        (
+            [1, 2],
+            "ARRAY<INT64>",
+            {
+                "parameterType": {"type": "ARRAY", "arrayType": {"type": "INT64"}},
+                "parameterValue": {"arrayValues": [{"value": "1"}, {"value": "2"}]},
+            },
+        ),
+        (
+            dict(
+                name="par",
+                children=[
+                    dict(name="ch1", bdate=datetime.date(2021, 1, 1)),
+                    dict(name="ch2", bdate=datetime.date(2021, 1, 2)),
+                ],
+            ),
+            "struct<name string, children array<struct<name string, bdate date>>>",
+            {
+                "parameterType": {
+                    "structTypes": [
+                        {"name": "name", "type": {"type": "STRING"}},
+                        {
+                            "name": "children",
+                            "type": {
+                                "arrayType": {
+                                    "structTypes": [
+                                        {"name": "name", "type": {"type": "STRING"}},
+                                        {"name": "bdate", "type": {"type": "DATE"}},
+                                    ],
+                                    "type": "STRUCT",
+                                },
+                                "type": "ARRAY",
+                            },
+                        },
+                    ],
+                    "type": "STRUCT",
+                },
+                "parameterValue": {
+                    "structValues": {
+                        "children": {
+                            "arrayValues": [
+                                {
+                                    "structValues": {
+                                        "bdate": {"value": "2021-01-01"},
+                                        "name": {"value": "ch1"},
+                                    }
+                                },
+                                {
+                                    "structValues": {
+                                        "bdate": {"value": "2021-01-02"},
+                                        "name": {"value": "ch2"},
+                                    }
+                                },
+                            ]
+                        },
+                        "name": {"value": "par"},
+                    }
+                },
+            },
+        ),
+        (
+            dict(
+                name="par",
+                children=[
+                    dict(name="ch1", bdate=datetime.date(2021, 1, 1)),
+                    dict(name="ch2", bdate=datetime.date(2021, 1, 2)),
+                ],
+            ),
+            "struct<name string(9), children array<struct<name string(9), bdate date>>>",
+            {
+                "parameterType": {
+                    "structTypes": [
+                        {"name": "name", "type": {"type": "STRING"}},
+                        {
+                            "name": "children",
+                            "type": {
+                                "arrayType": {
+                                    "structTypes": [
+                                        {"name": "name", "type": {"type": "STRING"}},
+                                        {"name": "bdate", "type": {"type": "DATE"}},
+                                    ],
+                                    "type": "STRUCT",
+                                },
+                                "type": "ARRAY",
+                            },
+                        },
+                    ],
+                    "type": "STRUCT",
+                },
+                "parameterValue": {
+                    "structValues": {
+                        "children": {
+                            "arrayValues": [
+                                {
+                                    "structValues": {
+                                        "bdate": {"value": "2021-01-01"},
+                                        "name": {"value": "ch1"},
+                                    }
+                                },
+                                {
+                                    "structValues": {
+                                        "bdate": {"value": "2021-01-02"},
+                                        "name": {"value": "ch2"},
+                                    }
+                                },
+                            ]
+                        },
+                        "name": {"value": "par"},
+                    }
+                },
+            },
+        ),
+        (
+            ["1", "hi"],
+            "ARRAY<string(9)>",
+            {
+                "parameterType": {"type": "ARRAY", "arrayType": {"type": "STRING"}},
+                "parameterValue": {"arrayValues": [{"value": "1"}, {"value": "hi"}]},
+            },
+        ),
+    ],
+)
+def test_complex_query_parameter_type(type_, value, expect):
+    from google.cloud.bigquery.dbapi._helpers import complex_query_parameter
+
+    param = complex_query_parameter("test", value, type_).to_api_repr()
+    assert param.pop("name") == "test"
+    assert param == expect
+
+
+def _expected_error_match(expect):
+    return "^" + re.escape(expect) + "$"
+
+
+@pytest.mark.parametrize(
+    "value,type_,expect",
+    [
+        (
+            [],
+            "ARRAY<INT>",
+            "The given parameter type, INT,"
+            " is not a valid BigQuery scalar type, in ARRAY<INT>.",
+        ),
+        ([], "x<INT>", "Invalid parameter type, x<INT>"),
+        ({}, "struct<int>", "Invalid struct field, int, in struct<int>"),
+        (
+            {"x": 1},
+            "struct<x int>",
+            "The given parameter type, int,"
+            " for x is not a valid BigQuery scalar type, in struct<x int>.",
+        ),
+        ([], "x<<INT>", "Invalid parameter type, x<<INT>"),
+        (0, "ARRAY<INT64>", "Array type with non-array-like value with type int"),
+        (
+            [],
+            "ARRAY<ARRAY<INT64>>",
+            "Array can't contain an array in ARRAY<ARRAY<INT64>>",
+        ),
+        ([], "struct<x int>", "Non-mapping value for type struct<x int>"),
+        ({}, "struct<x int>", "No field value for x in struct<x int>"),
+        ({"x": 1, "y": 1}, "struct<x int64>", "Extra data keys for struct<x int64>"),
+        ([], "array<struct<xxx>>", "Invalid struct field, xxx, in array<struct<xxx>>"),
+        ([], "array<<>>", "Invalid parameter type, <>"),
+    ],
+)
+def test_complex_query_parameter_type_errors(type_, value, expect):
+    from google.cloud.bigquery.dbapi._helpers import complex_query_parameter
+    from google.cloud.bigquery.dbapi import exceptions
+
+    with pytest.raises(
+        exceptions.ProgrammingError, match=_expected_error_match(expect),
+    ):
+        complex_query_parameter("test", value, type_)
+
+
+@pytest.mark.parametrize(
+    "parameters,parameter_types,expect",
+    [
+        (
+            [[], dict(name="ch1", bdate=datetime.date(2021, 1, 1))],
+            ["ARRAY<INT64>", "struct<name string, bdate date>"],
+            [
+                {
+                    "parameterType": {"arrayType": {"type": "INT64"}, "type": "ARRAY"},
+                    "parameterValue": {"arrayValues": []},
+                },
+                {
+                    "parameterType": {
+                        "structTypes": [
+                            {"name": "name", "type": {"type": "STRING"}},
+                            {"name": "bdate", "type": {"type": "DATE"}},
+                        ],
+                        "type": "STRUCT",
+                    },
+                    "parameterValue": {
+                        "structValues": {
+                            "bdate": {"value": "2021-01-01"},
+                            "name": {"value": "ch1"},
+                        }
+                    },
+                },
+            ],
+        ),
+        (
+            dict(ids=[], child=dict(name="ch1", bdate=datetime.date(2021, 1, 1))),
+            dict(ids="ARRAY<INT64>", child="struct<name string, bdate date>"),
+            [
+                {
+                    "name": "ids",
+                    "parameterType": {"arrayType": {"type": "INT64"}, "type": "ARRAY"},
+                    "parameterValue": {"arrayValues": []},
+                },
+                {
+                    "name": "child",
+                    "parameterType": {
+                        "structTypes": [
+                            {"name": "name", "type": {"type": "STRING"}},
+                            {"name": "bdate", "type": {"type": "DATE"}},
+                        ],
+                        "type": "STRUCT",
+                    },
+                    "parameterValue": {
+                        "structValues": {
+                            "bdate": {"value": "2021-01-01"},
+                            "name": {"value": "ch1"},
+                        }
+                    },
+                },
+            ],
+        ),
+    ],
+)
+def test_to_query_parameters_complex_types(parameters, parameter_types, expect):
+    from google.cloud.bigquery.dbapi._helpers import to_query_parameters
+
+    result = [p.to_api_repr() for p in to_query_parameters(parameters, parameter_types)]
+    assert result == expect
+
+
+def test_to_query_parameters_struct_error():
+    from google.cloud.bigquery.dbapi._helpers import to_query_parameters
+
+    with pytest.raises(
+        NotImplementedError,
+        match=_expected_error_match(
+            "STRUCT-like parameter values are not supported, "
+            "unless an explicit type is give in the parameter placeholder "
+            "(e.g. '%(:struct<...>)s')."
+        ),
+    ):
+        to_query_parameters([dict(x=1)], [None])
+
+    with pytest.raises(
+        NotImplementedError,
+        match=_expected_error_match(
+            "STRUCT-like parameter values are not supported (parameter foo), "
+            "unless an explicit type is give in the parameter placeholder "
+            "(e.g. '%(foo:struct<...>)s')."
+        ),
+    ):
+        to_query_parameters(dict(foo=dict(x=1)), {})
diff --git a/tests/unit/test_dbapi_cursor.py b/tests/unit/test_dbapi_cursor.py
--- a/tests/unit/test_dbapi_cursor.py
+++ b/tests/unit/test_dbapi_cursor.py
@@ -809,6 +809,32 @@ def test__format_operation_no_placeholders(self):
             "values(%%%%%(foo:INT64)s, %(bar)s)",
             ("values(%%%%%(foo)s, %(bar)s)", dict(foo="INT64")),
         ),
+        (
+            "values(%%%%%(foo:struct<x string, y int64>)s, %(bar)s)",
+            ("values(%%%%%(foo)s, %(bar)s)", dict(foo="struct<x string, y int64>")),
+        ),
+        (
+            "values(%%%%%(foo:struct<x string(10), y int64>)s, %(bar)s)",
+            ("values(%%%%%(foo)s, %(bar)s)", dict(foo="struct<x string(10), y int64>")),
+        ),
+        (
+            "values(%(foo:struct<x string(10), y numeric(2, 9)>)s, %(bar)s)",
+            (
+                "values(%(foo)s, %(bar)s)",
+                dict(foo="struct<x string(10), y numeric(2, 9)>"),
+            ),
+        ),
+        (
+            "values(%(foo:struct<x string(10) , y numeric(2, 9)>)s, %(bar)s)",
+            (
+                "values(%(foo)s, %(bar)s)",
+                dict(foo="struct<x string(10) , y numeric(2, 9)>"),
+            ),
+        ),
+        (
+            "values(%(foo:string(10))s, %(bar)s)",
+            ("values(%(foo)s, %(bar)s)", dict(foo="string(10)")),
+        ),
     ],
 )
 def test__extract_types(inp, expect):