Repository: ibis-project/ibis

Issue:
bug: struct field names in DDLs should be quoted
### What happened?

found in #8765.

```python
import ibis

url = "https://storage.googleapis.com/ibis-debugging/election2020_single_chunk.jsonl"
t = ibis.read_json(url)
t = t.cache()
```

gives `ParserException: Parser Error: syntax error at or near "order"`

### What version of ibis are you using?

main

### What backend(s) are you using, if any?

duckdb

### Relevant log output

_No response_

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:
@NickCrews Thanks for the issue! It would appear that the yaks require shaving 😂 
🐄 🪒 
Based only on the error message I suspect something isn't quoted where it should be, since "order" is part of the "order by" keyword sequence.
You are correct. It looks like struct field names should be quoted. Post #8772, I see the sql is

<details>

```sql
CREATE TEMPORARY TABLE "ibis_cache_2qenquif65d65pb2apheifvvfu" (
    "data" STRUCT(
        races STRUCT(
            race_id TEXT,
            race_slug TEXT,
            url TEXT,
            state_page_url TEXT,
            ap_polls_page TEXT,
            race_type TEXT,
            election_type TEXT,
            election_date DATE,
            runoff BOOLEAN,
            race_name TEXT,
            office TEXT,
            officeid TEXT,
            race_rating TEXT,
            seat TEXT,
            seat_name TEXT,
            state_id TEXT,
            state_slug TEXT,
            state_name TEXT,
            state_nyt_abbrev TEXT,
            state_shape TEXT,
            state_aspect_ratio DOUBLE,
            party_id TEXT,
            uncontested BOOLEAN,
            report BOOLEAN,
            result TEXT,
            result_source TEXT,
            gain BOOLEAN,
            lost_seat TEXT,
            votes BIGINT,
            electoral_votes BIGINT,
            absentee_votes BIGINT,
            absentee_counties BIGINT,
            absentee_count_progress TEXT,
            absentee_outstanding JSON,
            absentee_max_ballots JSON,
            provisional_outstanding JSON,
            provisional_count_progress JSON,
            poll_display TEXT,
            poll_countdown_display TEXT,
            poll_waiting_display TEXT,
            poll_time TEXT,
            poll_time_short TEXT,
            precincts_reporting BIGINT,
            precincts_total BIGINT,
            reporting_display TEXT,
            reporting_value TEXT,
            eevp BIGINT,
            tot_exp_vote BIGINT,
            eevp_source TEXT,
            eevp_value TEXT,
            eevp_display TEXT,
            county_data_source TEXT,
            incumbent_party TEXT,
            no_forecast BOOLEAN,
            last_updated TEXT,
            candidates STRUCT(
                candidate_id TEXT,
                candidate_key TEXT,
                first_name TEXT,
                last_name TEXT,
                order BIGINT,
                name_display TEXT,
                party_id TEXT,
                incumbent BOOLEAN,
                runoff BOOLEAN,
                winner BOOLEAN,
                votes BIGINT,
                percent DOUBLE,
                percent_display TEXT,
                electoral_votes BIGINT,
                absentee_votes BIGINT,
                absentee_percent DOUBLE,
                img_url TEXT,
                has_image BOOLEAN,
                link TEXT,
                pronoun TEXT,
                result_source TEXT
            ) [],
            has_incumbent BOOLEAN,
            leader_margin_value DOUBLE,
            leader_margin_votes BIGINT,
            leader_margin_display TEXT,
            leader_margin_name_display TEXT,
            leader_party_id TEXT,
            counties STRUCT(
                fips TEXT,
                name TEXT,
                votes BIGINT,
                absentee_votes BIGINT,
                reporting BIGINT,
                precincts BIGINT,
                absentee_method TEXT,
                eevp BIGINT,
                tot_exp_vote BIGINT,
                eevp_value TEXT,
                eevp_display TEXT,
                eevp_source TEXT,
                turnout_stage BIGINT,
                absentee_count_progress TEXT,
                absentee_outstanding JSON,
                absentee_max_ballots BIGINT,
                provisional_outstanding JSON,
                provisional_count_progress JSON,
                results STRUCT(
                    trumpd BIGINT,
                    bidenj BIGINT,
                    jorgensenj BIGINT,
                    venturaj BIGINT,
                    pierceb BIGINT,
                    blankenshipd BIGINT,
                    de_la_fuenter BIGINT,
                    write_ins BIGINT,
                    la_rivag BIGINT,
                    collinsp BIGINT,
                    westk BIGINT,
                    hawkinsh BIGINT,
                    gammonc BIGINT,
                    carrollb BIGINT,
                    myersj BIGINT,
                    hammonsb BIGINT,
                    charlesm BIGINT,
                    kopitkek BIGINT,
                    mchughj BIGINT,
                    jacob_fambrop BIGINT,
                    huberb BIGINT,
                    hunterd BIGINT,
                    kennedya BIGINT,
                    scottj BIGINT,
                    kishorej BIGINT,
                    kingr BIGINT,
                    simmonsj BIGINT,
                    boddiep BIGINT,
                    hoeflingt BIGINT,
                    segalj BIGINT,
                    tittles BIGINT,
                    none_of_these_candidates BIGINT,
                    paigeh BIGINT,
                    lafontainec BIGINT,
                    duncanr BIGINT,
                    mccormick BIGINT,
                    swingg BIGINT,
                    scalfz BIGINT
                ),
                results_absentee STRUCT(
                    trumpd BIGINT,
                    bidenj BIGINT,
                    jorgensenj BIGINT,
                    venturaj BIGINT,
                    pierceb BIGINT,
                    blankenshipd BIGINT,
                    de_la_fuenter BIGINT,
                    write_ins BIGINT,
                    la_rivag BIGINT,
                    collinsp BIGINT,
                    westk BIGINT,
                    hawkinsh BIGINT,
                    gammonc BIGINT,
                    carrollb BIGINT,
                    myersj BIGINT,
                    hammonsb BIGINT,
                    charlesm BIGINT,
                    kopitkek BIGINT,
                    mchughj BIGINT,
                    jacob_fambrop BIGINT,
                    huberb BIGINT,
                    hunterd BIGINT,
                    kennedya BIGINT,
                    scottj BIGINT,
                    kishorej BIGINT,
                    kingr BIGINT,
                    simmonsj BIGINT,
                    boddiep BIGINT,
                    hoeflingt BIGINT,
                    segalj BIGINT,
                    tittles BIGINT,
                    none_of_these_candidates BIGINT,
                    paigeh BIGINT,
                    lafontainec BIGINT,
                    duncanr BIGINT,
                    mccormick BIGINT,
                    swingg BIGINT,
                    scalfz BIGINT
                ),
                last_updated TEXT,
                leader_margin_value DOUBLE,
                leader_margin_display TEXT,
                leader_margin_name_display TEXT,
                leader_party_id TEXT,
                margin2020 DOUBLE,
                votes2016 BIGINT,
                margin2016 DOUBLE,
                votes2012 BIGINT,
                margin2012 DOUBLE
            ) [],
            votes2016 BIGINT,
            margin2016 DOUBLE,
            clinton2016 BIGINT,
            trump2016 BIGINT,
            votes2012 BIGINT,
            margin2012 DOUBLE,
            expectations_text TEXT,
            expectations_text_short TEXT,
            absentee_ballot_deadline BIGINT,
            absentee_postmark_deadline BIGINT,
            update_sentences STRUCT(
                top_level STRUCT(
                    timestamp BIGINT,
                    is_new BOOLEAN,
                    hide_timestamp BOOLEAN,
                    overrideText TEXT,
                    sentence TEXT,
                    generatedText TEXT,
                    sentence_type TEXT
                ),
                winner_card_leadin STRUCT(
                    timestamp BIGINT,
                    is_new BOOLEAN,
                    hide_timestamp BOOLEAN,
                    overrideText TEXT,
                    sentence TEXT,
                    generatedText TEXT,
                    sentence_type TEXT
                ),
                eevp STRUCT(
                    sentence TEXT,
                    timestamp BIGINT,
                    is_new BOOLEAN,
                    hide_timestamp BOOLEAN,
                    overrideText JSON,
                    generatedText TEXT,
                    sentence_type TEXT
                ),
                eevp_leadin STRUCT(
                    sentence TEXT,
                    timestamp BIGINT,
                    is_new BOOLEAN,
                    hide_timestamp BOOLEAN,
                    overrideText JSON,
                    generatedText TEXT,
                    sentence_type TEXT
                ),
                counties STRUCT(
                    sentence TEXT,
                    timestamp BIGINT,
                    is_new BOOLEAN,
                    hide_timestamp BOOLEAN,
                    overrideText TEXT,
                    generatedText TEXT,
                    sentence_type TEXT
                )
            ),
            race_diff STRUCT(
                race_slug TEXT,
                boolean_things_that_happened STRUCT(
                    zero_votes BOOLEAN,
                    results_expected_within_hour BOOLEAN,
                    show_nothing_votes_decreased BOOLEAN,
                    went_to_runoff BOOLEAN,
                    race_won BOOLEAN,
                    race_just_won BOOLEAN,
                    race_unwon BOOLEAN,
                    additional_votes_reported BOOLEAN,
                    additional_precincts_reported BOOLEAN,
                    has_eevp BOOLEAN,
                    first_precincts_reported BOOLEAN,
                    first_votes_reported BOOLEAN,
                    candidate_took_the_lead BOOLEAN,
                    candidate_took_the_lead_first_alignment BOOLEAN,
                    candidate_took_the_lead_final_alignment BOOLEAN,
                    candidates_tied BOOLEAN,
                    precinct_percentage_points_grew_by_more_than_2_percent BOOLEAN,
                    precinct_percentage_points_grew_by_more_than_5_percent BOOLEAN,
                    eevp_grew_by_more_than_2_percent BOOLEAN,
                    eevp_grew_by_more_than_5_percent BOOLEAN,
                    candidate_lead_margin_grew_by_more_than_1_with_more_than_5_percent_precincts_reporting BOOLEAN,
                    candidate_lead_margin_shrank_by_more_than_1_with_more_than_5_percent_precincts_reporting BOOLEAN,
                    candidate_lead_margin_grew_by_more_than_1_with_more_than_5_eevp BOOLEAN,
                    candidate_lead_margin_shrank_by_more_than_1_with_more_than_5_eevp BOOLEAN,
                    top_two_candidates_less_than_three_percentage_points_away_with_at_least_50_percent_reporting BOOLEAN,
                    prev_top_two_candidates_less_than_three_percentage_points_away_with_at_least_50_percent_reporting BOOLEAN,
                    precinct_percentage_reporting_passes_80 BOOLEAN,
                    precinct_percentage_reporting_passes_90 BOOLEAN,
                    precinct_percentage_reporting_reaches_100 BOOLEAN,
                    top_two_candidates_less_than_three_percentage_points_away_with_at_least_50_eevp BOOLEAN,
                    prev_top_two_candidates_less_than_three_percentage_points_away_with_at_least_50_eevp BOOLEAN,
                    first_votes_for_some_county BOOLEAN,
                    candidate_wins_some_county BOOLEAN,
                    additional_votes_for_at_least_one_county BOOLEAN,
                    first_votes_for_only_one_county BOOLEAN,
                    biden_takes_at_least_one_more_2016_trump_county BOOLEAN,
                    trump_takes_at_least_one_more_2016_clinton_county BOOLEAN,
                    at_least_one_county_flipped BOOLEAN,
                    at_least_one_county_just_flipped BOOLEAN
                ),
                details_about_changes JSON
            ),
            winnerCalledTimestamp BIGINT,
            timeseries STRUCT(
                vote_shares STRUCT(trumpd DOUBLE, bidenj DOUBLE),
                votes BIGINT,
                eevp BIGINT,
                eevp_source TEXT,
                timestamp TEXT
            ) [],
            edison_exit_polls_page TEXT,
            townships STRUCT(
                name TEXT,
                fips TEXT,
                fips_town TEXT,
                votes BIGINT,
                absentee_votes BIGINT,
                reporting BIGINT,
                precincts BIGINT,
                absentee_method TEXT,
                results STRUCT(
                    bidenj BIGINT,
                    trumpd BIGINT,
                    jorgensenj BIGINT,
                    hawkinsh BIGINT,
                    write_ins BIGINT,
                    de_la_fuenter BIGINT,
                    carrollb BIGINT,
                    la_rivag BIGINT,
                    westk BIGINT,
                    paigeh BIGINT,
                    lafontainec BIGINT,
                    kennedya BIGINT,
                    blankenshipd BIGINT,
                    kopitkek BIGINT,
                    segalj BIGINT,
                    collinsp BIGINT,
                    duncanr BIGINT,
                    huberb BIGINT,
                    mccormick BIGINT,
                    pierceb BIGINT,
                    scalfz BIGINT,
                    swingg BIGINT
                ),
                eevp BIGINT,
                tot_exp_vote BIGINT,
                eevp_value TEXT,
                eevp_display TEXT,
                turnout_stage BIGINT,
                last_updated TEXT,
                results_absentee STRUCT(
                    bidenj BIGINT,
                    trumpd BIGINT,
                    jorgensenj BIGINT,
                    hawkinsh BIGINT,
                    write_ins BIGINT,
                    de_la_fuenter BIGINT,
                    carrollb BIGINT,
                    la_rivag BIGINT,
                    westk BIGINT,
                    paigeh BIGINT,
                    lafontainec BIGINT,
                    kennedya BIGINT,
                    blankenshipd BIGINT,
                    kopitkek BIGINT,
                    segalj BIGINT,
                    collinsp BIGINT,
                    duncanr BIGINT,
                    huberb BIGINT,
                    mccormick BIGINT,
                    pierceb BIGINT,
                    scalfz BIGINT,
                    swingg BIGINT
                ),
                leader_margin_value DOUBLE,
                leader_margin_display TEXT,
                leader_margin_name_display TEXT,
                leader_party_id TEXT
            ) [],
            nyt_race_description TEXT,
            precinct_metadata JSON,
            model_metadata JSON,
            electoral_vote_details STRUCT(
                type TEXT,
                name TEXT,
                electoral_votes BIGINT,
                winning_candidate_id TEXT,
                source TEXT,
                accept_ap BOOLEAN
            ) [],
            congressional_districts STRUCT(
                votes BIGINT,
                name TEXT,
                reporting BIGINT,
                precincts BIGINT,
                results STRUCT(
                    jorgensenj BIGINT,
                    trumpd BIGINT,
                    hawkinsh BIGINT,
                    bidenj BIGINT,
                    de_la_fuenter BIGINT,
                    write_ins BIGINT
                )
            ) []
        ) [],
        party_control STRUCT(
            race_type TEXT,
            state_id TEXT,
            needed_for_control BIGINT,
            total BIGINT,
            no_election STRUCT(democrat BIGINT, republican BIGINT, other BIGINT),
            winner TEXT,
            parties STRUCT(
                democrat STRUCT(
                    party_id TEXT,
                    name_display TEXT,
                    name_abbr TEXT,
                    count BIGINT,
                    votes BIGINT,
                    percent TEXT,
                    change BIGINT,
                    flip_count BIGINT,
                    leader_count BIGINT,
                    change_full TEXT,
                    change_abbr TEXT,
                    flip_text_full TEXT,
                    flip_text_extra TEXT,
                    flip_text_abbr TEXT,
                    winner BOOLEAN
                ),
                republican STRUCT(
                    party_id TEXT,
                    name_display TEXT,
                    name_abbr TEXT,
                    count BIGINT,
                    votes BIGINT,
                    percent TEXT,
                    change BIGINT,
                    flip_count BIGINT,
                    leader_count BIGINT,
                    change_full TEXT,
                    change_abbr TEXT,
                    flip_text_full TEXT,
                    flip_text_extra TEXT,
                    flip_text_abbr TEXT,
                    winner BOOLEAN
                ),
                other STRUCT(
                    party_id TEXT,
                    name_display TEXT,
                    name_abbr TEXT,
                    count BIGINT,
                    votes BIGINT,
                    percent TEXT,
                    change BIGINT,
                    flip_count BIGINT,
                    leader_count BIGINT,
                    change_full TEXT,
                    change_abbr TEXT,
                    flip_text_full TEXT,
                    flip_text_extra TEXT,
                    flip_text_abbr TEXT,
                    winner BOOLEAN
                )
            ),
            winnerCalledTimestamp BIGINT
        ) [],
        liveUpdates STRUCT(
            id TEXT,
            author TEXT,
            author_title_or_location TEXT,
            text TEXT,
            link_url TEXT,
            link_text TEXT,
            linked_state_1 TEXT,
            linked_state_2 TEXT,
            linked_state_3 TEXT,
            image_url TEXT,
            image_credit TEXT,
            datetime BIGINT,
            author_headshot TEXT,
            is_today BOOLEAN,
            linked_states STRUCT(state_id TEXT, state_name TEXT, url TEXT) [],
            hide_from_homepage BOOLEAN,
            hide_from_liveblog BOOLEAN,
            include_on_homepage BOOLEAN,
            include_in_reporter_updates_feed BOOLEAN,
            office TEXT,
            type TEXT,
            call_type TEXT,
            race_id TEXT,
            winner STRUCT(
                candidate_id TEXT,
                candidate_key TEXT,
                first_name TEXT,
                last_name TEXT,
                order BIGINT,
                name_display TEXT,
                party_id TEXT,
                incumbent BOOLEAN,
                runoff BOOLEAN,
                winner BOOLEAN,
                votes BIGINT,
                percent DOUBLE,
                percent_display TEXT,
                electoral_votes BIGINT,
                absentee_votes BIGINT,
                absentee_percent DOUBLE,
                img_url TEXT,
                has_image BOOLEAN,
                link TEXT,
                result_source TEXT,
                pronoun TEXT
            ),
            party_id TEXT,
            candidate_last_name TEXT,
            candidate_name_display TEXT,
            candidate_id TEXT,
            race_call_party_winner TEXT,
            state_name TEXT,
            link TEXT,
            bop BOOLEAN
        ) []
    ),
    "meta" STRUCT(version BIGINT, track DATE, timestamp TEXT)
)
```

</details>
miminum repro for a new test:

```python
import ibis

s = ibis.struct({"order": 4})
s.as_table().cache()
```

Resolving patch:
diff --git a/ibis/backends/bigquery/__init__.py b/ibis/backends/bigquery/__init__.py
--- a/ibis/backends/bigquery/__init__.py
+++ b/ibis/backends/bigquery/__init__.py
@@ -1038,7 +1038,7 @@ def create_table(
 
         column_defs = [
             sge.ColumnDef(
-                this=name,
+                this=sg.to_identifier(name, quoted=self.compiler.quoted),
                 kind=BigQueryType.from_ibis(typ),
                 constraints=(
                     None
@@ -1150,7 +1150,7 @@ def _get_udf_source(self, udf_node: ops.ScalarUDF):
 
         signature = [
             sge.ColumnDef(
-                this=sg.to_identifier(name),
+                this=sg.to_identifier(name, quoted=self.compiler.quoted),
                 kind=type_mapper.from_ibis(param.annotation.pattern.dtype),
             )
             for name, param in udf_node.__signature__.parameters.items()
diff --git a/ibis/backends/clickhouse/__init__.py b/ibis/backends/clickhouse/__init__.py
--- a/ibis/backends/clickhouse/__init__.py
+++ b/ibis/backends/clickhouse/__init__.py
@@ -652,7 +652,7 @@ def create_table(
             this=sg.table(name, db=database),
             expressions=[
                 sge.ColumnDef(
-                    this=sg.to_identifier(name),
+                    this=sg.to_identifier(name, quoted=self.compiler.quoted),
                     kind=self.compiler.type_mapper.from_ibis(typ),
                 )
                 for name, typ in schema.items()
diff --git a/ibis/backends/sql/datatypes.py b/ibis/backends/sql/datatypes.py
--- a/ibis/backends/sql/datatypes.py
+++ b/ibis/backends/sql/datatypes.py
@@ -323,7 +323,11 @@ def _from_ibis_Map(cls, dtype: dt.Map) -> sge.DataType:
     @classmethod
     def _from_ibis_Struct(cls, dtype: dt.Struct) -> sge.DataType:
         fields = [
-            sge.ColumnDef(this=str(name), kind=cls.from_ibis(field))
+            sge.ColumnDef(
+                # always quote struct fields to allow reserved words as field names
+                this=sg.to_identifier(name, quoted=True),
+                kind=cls.from_ibis(field),
+            )
             for name, field in dtype.items()
         ]
         return sge.DataType(this=typecode.STRUCT, expressions=fields, nested=True)


Test patch:
diff --git a/ibis/backends/clickhouse/tests/snapshots/test_functions/test_cast_string_col/structa_string_b_int64/out.sql b/ibis/backends/clickhouse/tests/snapshots/test_functions/test_cast_string_col/structa_string_b_int64/out.sql
--- a/ibis/backends/clickhouse/tests/snapshots/test_functions/test_cast_string_col/structa_string_b_int64/out.sql
+++ b/ibis/backends/clickhouse/tests/snapshots/test_functions/test_cast_string_col/structa_string_b_int64/out.sql
@@ -1,3 +1,3 @@
 SELECT
-  CAST("t0"."string_col" AS Tuple(a Nullable(String), b Nullable(Int64))) AS "Cast(string_col, !struct<a: string, b: int64>)"
+  CAST("t0"."string_col" AS Tuple("a" Nullable(String), "b" Nullable(Int64))) AS "Cast(string_col, !struct<a: string, b: int64>)"
 FROM "functional_alltypes" AS "t0"
\ No newline at end of file
diff --git a/ibis/backends/tests/snapshots/test_sql/test_union_aliasing/clickhouse/out.sql b/ibis/backends/tests/snapshots/test_sql/test_union_aliasing/clickhouse/out.sql
--- a/ibis/backends/tests/snapshots/test_sql/test_union_aliasing/clickhouse/out.sql
+++ b/ibis/backends/tests/snapshots/test_sql/test_union_aliasing/clickhouse/out.sql
@@ -26,7 +26,7 @@ WITH "t5" AS (
           SELECT
             "t0"."field_of_study",
             arrayJoin(
-              [CAST(tuple('1970-71', "t0"."1970-71") AS Tuple(years Nullable(String), degrees Nullable(Int64))), CAST(tuple('1975-76', "t0"."1975-76") AS Tuple(years Nullable(String), degrees Nullable(Int64))), CAST(tuple('1980-81', "t0"."1980-81") AS Tuple(years Nullable(String), degrees Nullable(Int64))), CAST(tuple('1985-86', "t0"."1985-86") AS Tuple(years Nullable(String), degrees Nullable(Int64))), CAST(tuple('1990-91', "t0"."1990-91") AS Tuple(years Nullable(String), degrees Nullable(Int64))), CAST(tuple('1995-96', "t0"."1995-96") AS Tuple(years Nullable(String), degrees Nullable(Int64))), CAST(tuple('2000-01', "t0"."2000-01") AS Tuple(years Nullable(String), degrees Nullable(Int64))), CAST(tuple('2005-06', "t0"."2005-06") AS Tuple(years Nullable(String), degrees Nullable(Int64))), CAST(tuple('2010-11', "t0"."2010-11") AS Tuple(years Nullable(String), degrees Nullable(Int64))), CAST(tuple('2011-12', "t0"."2011-12") AS Tuple(years Nullable(String), degrees Nullable(Int64))), CAST(tuple('2012-13', "t0"."2012-13") AS Tuple(years Nullable(String), degrees Nullable(Int64))), CAST(tuple('2013-14', "t0"."2013-14") AS Tuple(years Nullable(String), degrees Nullable(Int64))), CAST(tuple('2014-15', "t0"."2014-15") AS Tuple(years Nullable(String), degrees Nullable(Int64))), CAST(tuple('2015-16', "t0"."2015-16") AS Tuple(years Nullable(String), degrees Nullable(Int64))), CAST(tuple('2016-17', "t0"."2016-17") AS Tuple(years Nullable(String), degrees Nullable(Int64))), CAST(tuple('2017-18', "t0"."2017-18") AS Tuple(years Nullable(String), degrees Nullable(Int64))), CAST(tuple('2018-19', "t0"."2018-19") AS Tuple(years Nullable(String), degrees Nullable(Int64))), CAST(tuple('2019-20', "t0"."2019-20") AS Tuple(years Nullable(String), degrees Nullable(Int64)))]
+              [CAST(tuple('1970-71', "t0"."1970-71") AS Tuple("years" Nullable(String), "degrees" Nullable(Int64))), CAST(tuple('1975-76', "t0"."1975-76") AS Tuple("years" Nullable(String), "degrees" Nullable(Int64))), CAST(tuple('1980-81', "t0"."1980-81") AS Tuple("years" Nullable(String), "degrees" Nullable(Int64))), CAST(tuple('1985-86', "t0"."1985-86") AS Tuple("years" Nullable(String), "degrees" Nullable(Int64))), CAST(tuple('1990-91', "t0"."1990-91") AS Tuple("years" Nullable(String), "degrees" Nullable(Int64))), CAST(tuple('1995-96', "t0"."1995-96") AS Tuple("years" Nullable(String), "degrees" Nullable(Int64))), CAST(tuple('2000-01', "t0"."2000-01") AS Tuple("years" Nullable(String), "degrees" Nullable(Int64))), CAST(tuple('2005-06', "t0"."2005-06") AS Tuple("years" Nullable(String), "degrees" Nullable(Int64))), CAST(tuple('2010-11', "t0"."2010-11") AS Tuple("years" Nullable(String), "degrees" Nullable(Int64))), CAST(tuple('2011-12', "t0"."2011-12") AS Tuple("years" Nullable(String), "degrees" Nullable(Int64))), CAST(tuple('2012-13', "t0"."2012-13") AS Tuple("years" Nullable(String), "degrees" Nullable(Int64))), CAST(tuple('2013-14', "t0"."2013-14") AS Tuple("years" Nullable(String), "degrees" Nullable(Int64))), CAST(tuple('2014-15', "t0"."2014-15") AS Tuple("years" Nullable(String), "degrees" Nullable(Int64))), CAST(tuple('2015-16', "t0"."2015-16") AS Tuple("years" Nullable(String), "degrees" Nullable(Int64))), CAST(tuple('2016-17', "t0"."2016-17") AS Tuple("years" Nullable(String), "degrees" Nullable(Int64))), CAST(tuple('2017-18', "t0"."2017-18") AS Tuple("years" Nullable(String), "degrees" Nullable(Int64))), CAST(tuple('2018-19', "t0"."2018-19") AS Tuple("years" Nullable(String), "degrees" Nullable(Int64))), CAST(tuple('2019-20', "t0"."2019-20") AS Tuple("years" Nullable(String), "degrees" Nullable(Int64)))]
             ) AS "__pivoted__"
           FROM "humanities" AS "t0"
         ) AS "t1"
diff --git a/ibis/backends/tests/snapshots/test_sql/test_union_aliasing/trino/out.sql b/ibis/backends/tests/snapshots/test_sql/test_union_aliasing/trino/out.sql
--- a/ibis/backends/tests/snapshots/test_sql/test_union_aliasing/trino/out.sql
+++ b/ibis/backends/tests/snapshots/test_sql/test_union_aliasing/trino/out.sql
@@ -31,19 +31,19 @@ WITH "t5" AS (
             1,
             GREATEST(
               CARDINALITY(
-                ARRAY[CAST(ROW('1970-71', "t0"."1970-71") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1975-76', "t0"."1975-76") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1980-81', "t0"."1980-81") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1985-86', "t0"."1985-86") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1990-91', "t0"."1990-91") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1995-96', "t0"."1995-96") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2000-01', "t0"."2000-01") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2005-06', "t0"."2005-06") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2010-11', "t0"."2010-11") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2011-12', "t0"."2011-12") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2012-13', "t0"."2012-13") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2013-14', "t0"."2013-14") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2014-15', "t0"."2014-15") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2015-16', "t0"."2015-16") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2016-17', "t0"."2016-17") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2017-18', "t0"."2017-18") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2018-19', "t0"."2018-19") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2019-20', "t0"."2019-20") AS ROW(years VARCHAR, degrees BIGINT))]
+                ARRAY[CAST(ROW('1970-71', "t0"."1970-71") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1975-76', "t0"."1975-76") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1980-81', "t0"."1980-81") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1985-86', "t0"."1985-86") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1990-91', "t0"."1990-91") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1995-96', "t0"."1995-96") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2000-01', "t0"."2000-01") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2005-06', "t0"."2005-06") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2010-11', "t0"."2010-11") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2011-12', "t0"."2011-12") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2012-13', "t0"."2012-13") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2013-14', "t0"."2013-14") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2014-15', "t0"."2014-15") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2015-16', "t0"."2015-16") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2016-17', "t0"."2016-17") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2017-18', "t0"."2017-18") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2018-19', "t0"."2018-19") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2019-20', "t0"."2019-20") AS ROW("years" VARCHAR, "degrees" BIGINT))]
               )
             )
           )) AS _u(pos)
-          CROSS JOIN UNNEST(ARRAY[CAST(ROW('1970-71', "t0"."1970-71") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1975-76', "t0"."1975-76") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1980-81', "t0"."1980-81") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1985-86', "t0"."1985-86") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1990-91', "t0"."1990-91") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1995-96', "t0"."1995-96") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2000-01', "t0"."2000-01") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2005-06', "t0"."2005-06") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2010-11', "t0"."2010-11") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2011-12', "t0"."2011-12") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2012-13', "t0"."2012-13") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2013-14', "t0"."2013-14") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2014-15', "t0"."2014-15") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2015-16', "t0"."2015-16") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2016-17', "t0"."2016-17") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2017-18', "t0"."2017-18") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2018-19', "t0"."2018-19") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2019-20', "t0"."2019-20") AS ROW(years VARCHAR, degrees BIGINT))]) WITH ORDINALITY AS _u_2("__pivoted__", pos_2)
+          CROSS JOIN UNNEST(ARRAY[CAST(ROW('1970-71', "t0"."1970-71") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1975-76', "t0"."1975-76") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1980-81', "t0"."1980-81") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1985-86', "t0"."1985-86") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1990-91', "t0"."1990-91") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1995-96', "t0"."1995-96") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2000-01', "t0"."2000-01") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2005-06', "t0"."2005-06") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2010-11', "t0"."2010-11") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2011-12', "t0"."2011-12") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2012-13', "t0"."2012-13") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2013-14', "t0"."2013-14") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2014-15', "t0"."2014-15") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2015-16', "t0"."2015-16") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2016-17', "t0"."2016-17") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2017-18', "t0"."2017-18") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2018-19', "t0"."2018-19") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2019-20', "t0"."2019-20") AS ROW("years" VARCHAR, "degrees" BIGINT))]) WITH ORDINALITY AS _u_2("__pivoted__", pos_2)
           WHERE
             _u.pos = _u_2.pos_2
             OR (
               _u.pos > CARDINALITY(
-                ARRAY[CAST(ROW('1970-71', "t0"."1970-71") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1975-76', "t0"."1975-76") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1980-81', "t0"."1980-81") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1985-86', "t0"."1985-86") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1990-91', "t0"."1990-91") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1995-96', "t0"."1995-96") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2000-01', "t0"."2000-01") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2005-06', "t0"."2005-06") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2010-11', "t0"."2010-11") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2011-12', "t0"."2011-12") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2012-13', "t0"."2012-13") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2013-14', "t0"."2013-14") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2014-15', "t0"."2014-15") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2015-16', "t0"."2015-16") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2016-17', "t0"."2016-17") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2017-18', "t0"."2017-18") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2018-19', "t0"."2018-19") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2019-20', "t0"."2019-20") AS ROW(years VARCHAR, degrees BIGINT))]
+                ARRAY[CAST(ROW('1970-71', "t0"."1970-71") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1975-76', "t0"."1975-76") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1980-81', "t0"."1980-81") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1985-86', "t0"."1985-86") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1990-91', "t0"."1990-91") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1995-96', "t0"."1995-96") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2000-01', "t0"."2000-01") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2005-06', "t0"."2005-06") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2010-11', "t0"."2010-11") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2011-12', "t0"."2011-12") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2012-13', "t0"."2012-13") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2013-14', "t0"."2013-14") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2014-15', "t0"."2014-15") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2015-16', "t0"."2015-16") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2016-17', "t0"."2016-17") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2017-18', "t0"."2017-18") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2018-19', "t0"."2018-19") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2019-20', "t0"."2019-20") AS ROW("years" VARCHAR, "degrees" BIGINT))]
               )
               AND _u_2.pos_2 = CARDINALITY(
-                ARRAY[CAST(ROW('1970-71', "t0"."1970-71") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1975-76', "t0"."1975-76") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1980-81', "t0"."1980-81") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1985-86', "t0"."1985-86") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1990-91', "t0"."1990-91") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('1995-96', "t0"."1995-96") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2000-01', "t0"."2000-01") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2005-06', "t0"."2005-06") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2010-11', "t0"."2010-11") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2011-12', "t0"."2011-12") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2012-13', "t0"."2012-13") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2013-14', "t0"."2013-14") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2014-15', "t0"."2014-15") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2015-16', "t0"."2015-16") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2016-17', "t0"."2016-17") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2017-18', "t0"."2017-18") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2018-19', "t0"."2018-19") AS ROW(years VARCHAR, degrees BIGINT)), CAST(ROW('2019-20', "t0"."2019-20") AS ROW(years VARCHAR, degrees BIGINT))]
+                ARRAY[CAST(ROW('1970-71', "t0"."1970-71") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1975-76', "t0"."1975-76") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1980-81', "t0"."1980-81") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1985-86', "t0"."1985-86") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1990-91', "t0"."1990-91") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('1995-96', "t0"."1995-96") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2000-01', "t0"."2000-01") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2005-06', "t0"."2005-06") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2010-11', "t0"."2010-11") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2011-12', "t0"."2011-12") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2012-13', "t0"."2012-13") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2013-14', "t0"."2013-14") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2014-15', "t0"."2014-15") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2015-16', "t0"."2015-16") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2016-17', "t0"."2016-17") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2017-18', "t0"."2017-18") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2018-19', "t0"."2018-19") AS ROW("years" VARCHAR, "degrees" BIGINT)), CAST(ROW('2019-20', "t0"."2019-20") AS ROW("years" VARCHAR, "degrees" BIGINT))]
               )
             )
         ) AS "t1"
diff --git a/ibis/backends/tests/test_struct.py b/ibis/backends/tests/test_struct.py
--- a/ibis/backends/tests/test_struct.py
+++ b/ibis/backends/tests/test_struct.py
@@ -1,20 +1,25 @@
 from __future__ import annotations
 
+import contextlib
 from collections.abc import Mapping
 
 import numpy as np
 import pandas as pd
 import pandas.testing as tm
 import pytest
+import sqlglot as sg
 from pytest import param
 
 import ibis
 import ibis.expr.datatypes as dt
+from ibis import util
 from ibis.backends.tests.errors import (
+    ClickHouseDatabaseError,
     PsycoPg2InternalError,
     PsycoPg2SyntaxError,
     Py4JJavaError,
 )
+from ibis.common.exceptions import IbisError
 
 pytestmark = [
     pytest.mark.never(["mysql", "sqlite", "mssql"], reason="No struct support"),
@@ -154,3 +159,87 @@ def test_field_access_after_case(con):
     x = ibis.case().when(True, s).else_(ibis.struct({"a": 4})).end()
     y = x.a
     assert con.to_pandas(y) == 3
+
+
+@pytest.mark.notimpl(
+    ["postgres"], reason="struct literals not implemented", raises=PsycoPg2SyntaxError
+)
+@pytest.mark.notimpl(["flink"], raises=IbisError, reason="not implemented in ibis")
+@pytest.mark.parametrize(
+    "nullable",
+    [
+        param(
+            True,
+            marks=[
+                pytest.mark.notyet(
+                    ["clickhouse"],
+                    raises=ClickHouseDatabaseError,
+                    reason="ClickHouse doesn't support nested nullable types",
+                )
+            ],
+            id="nullable",
+        ),
+        param(
+            False,
+            marks=[
+                pytest.mark.notyet(
+                    ["clickhouse"],
+                    raises=sg.ParseError,
+                    reason="sqlglot fails to parse",
+                ),
+                pytest.mark.notyet(
+                    ["polars"],
+                    raises=AssertionError,
+                    reason="polars doesn't support non-nullable types",
+                ),
+                pytest.mark.notyet(
+                    ["risingwave"],
+                    reason="non-nullable struct types not implemented",
+                    raises=PsycoPg2InternalError,
+                ),
+                pytest.mark.notimpl(
+                    ["pyspark"],
+                    raises=AssertionError,
+                    reason="non-nullable struct types not yet implemented in Ibis's PySpark backend",
+                ),
+            ],
+            id="non-nullable",
+        ),
+    ],
+)
+@pytest.mark.broken(
+    ["trino"],
+    raises=sg.ParseError,
+    reason="trino returns unquoted and therefore unparsable struct field names",
+)
+@pytest.mark.notyet(
+    ["snowflake"],
+    raises=AssertionError,
+    reason="snowflake doesn't have strongly typed structs",
+)
+def test_keyword_fields(con, nullable):
+    schema = ibis.schema(
+        {
+            "struct": dt.Struct(
+                {
+                    "select": "int",
+                    "from": "int",
+                    "where": "int",
+                    "order": "int",
+                    "left join": "int",
+                    "full outer join": "int",
+                },
+                nullable=nullable,
+            )
+        }
+    )
+
+    name = util.gen_name("struct_keywords")
+    t = con.create_table(name, schema=schema)
+
+    try:
+        assert t.schema() == schema
+        assert t.count().execute() == 0
+    finally:
+        with contextlib.suppress(NotImplementedError):
+            con.drop_table(name, force=True)