Repository: tobymao/sqlglot

Issue:
Bigquery implicit unnest transpile error
**Before you file an issue**
- Make sure you specify the "read" dialect eg. parse_one(sql, read="spark")
- Check if the issue still exists on main 

**Fully reproducible code snippet**
Please include a fully reproducible code snippet or the input sql, dialect, and expected output.
```
>>> import sqlglot
>>> sqlglot.transpile("""WITH Coordinates AS (SELECT [1,2] AS position)
... SELECT results FROM Coordinates, Coordinates.position AS results;
... """, read="bigquery", write="presto")[0]
'WITH Coordinates AS (SELECT ARRAY[1, 2] AS position) SELECT results FROM Coordinates, Coordinates.position AS results'
```
Bigquery has implicit unnest, while the presto does not have, so this presto sql is wrong

**Official Documentation**
Please include links to official SQL documentation related to your issue.
https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax#implicit_unnesting


Discussion:
Thanks, will look into this soon.

Resolving patch:
diff --git a/sqlglot/dialects/bigquery.py b/sqlglot/dialects/bigquery.py
--- a/sqlglot/dialects/bigquery.py
+++ b/sqlglot/dialects/bigquery.py
@@ -268,12 +268,17 @@ def normalize_identifier(self, expression: E) -> E:
             while isinstance(parent, exp.Dot):
                 parent = parent.parent
 
-            # In BigQuery, CTEs aren't case-sensitive, but table names are (by default, at least).
-            # The following check is essentially a heuristic to detect tables based on whether or
-            # not they're qualified. It also avoids normalizing UDFs, because they're case-sensitive.
+            # In BigQuery, CTEs are case-insensitive, but UDF and table names are case-sensitive
+            # by default. The following check uses a heuristic to detect tables based on whether
+            # they are qualified. This should generally be correct, because tables in BigQuery
+            # must be qualified with at least a dataset, unless @@dataset_id is set.
             if (
                 not isinstance(parent, exp.UserDefinedFunction)
-                and not (isinstance(parent, exp.Table) and parent.db)
+                and not (
+                    isinstance(parent, exp.Table)
+                    and parent.db
+                    and not parent.meta.get("maybe_column")
+                )
                 and not expression.meta.get("is_table")
             ):
                 expression.set("this", expression.this.lower())
@@ -318,8 +323,8 @@ class Tokenizer(tokens.Tokenizer):
 
     class Parser(parser.Parser):
         PREFIXED_PIVOT_COLUMNS = True
-
         LOG_DEFAULTS_TO_LN = True
+        SUPPORTS_IMPLICIT_UNNEST = True
 
         FUNCTIONS = {
             **parser.Parser.FUNCTIONS,
diff --git a/sqlglot/dialects/redshift.py b/sqlglot/dialects/redshift.py
--- a/sqlglot/dialects/redshift.py
+++ b/sqlglot/dialects/redshift.py
@@ -70,6 +70,8 @@ class Parser(Postgres.Parser):
             "SYSDATE": lambda self: self.expression(exp.CurrentTimestamp, transaction=True),
         }
 
+        SUPPORTS_IMPLICIT_UNNEST = True
+
         def _parse_table(
             self,
             schema: bool = False,
@@ -124,27 +126,6 @@ def _parse_approximate_count(self) -> t.Optional[exp.ApproxDistinct]:
             self._retreat(index)
             return None
 
-        def _parse_query_modifiers(
-            self, this: t.Optional[exp.Expression]
-        ) -> t.Optional[exp.Expression]:
-            this = super()._parse_query_modifiers(this)
-
-            if this:
-                refs = set()
-
-                for i, join in enumerate(this.args.get("joins", [])):
-                    refs.add(
-                        (
-                            this.args["from"] if i == 0 else this.args["joins"][i - 1]
-                        ).this.alias.lower()
-                    )
-
-                    table = join.this
-                    if isinstance(table, exp.Table) and not join.args.get("on"):
-                        if table.parts[0].name.lower() in refs:
-                            table.replace(table.to_column())
-            return this
-
     class Tokenizer(Postgres.Tokenizer):
         BIT_STRINGS = []
         HEX_STRINGS = []
@@ -225,6 +206,18 @@ class Generator(Postgres.Generator):
 
         RESERVED_KEYWORDS = {*Postgres.Generator.RESERVED_KEYWORDS, "snapshot", "type"}
 
+        def unnest_sql(self, expression: exp.Unnest) -> str:
+            args = expression.expressions
+            num_args = len(args)
+
+            if num_args > 1:
+                self.unsupported(f"Unsupported number of arguments in UNNEST: {num_args}")
+                return ""
+
+            arg = self.sql(seq_get(args, 0))
+            alias = self.expressions(expression.args.get("alias"), key="columns")
+            return f"{arg} AS {alias}" if alias else arg
+
         def with_properties(self, properties: exp.Properties) -> str:
             """Redshift doesn't have `WITH` as part of their with_properties so we remove it"""
             return self.properties(properties, prefix=" ", suffix="")
diff --git a/sqlglot/parser.py b/sqlglot/parser.py
--- a/sqlglot/parser.py
+++ b/sqlglot/parser.py
@@ -1014,6 +1014,9 @@ class Parser(metaclass=_Parser):
     # If this is True and '(' is not found, the keyword will be treated as an identifier
     VALUES_FOLLOWED_BY_PAREN = True
 
+    # Whether implicit unnesting is supported, e.g. SELECT 1 FROM y.z AS z, z.a (Redshift)
+    SUPPORTS_IMPLICIT_UNNEST = False
+
     __slots__ = (
         "error_level",
         "error_message_context",
@@ -2452,6 +2455,33 @@ def _parse_subquery(
             alias=self._parse_table_alias() if parse_alias else None,
         )
 
+    def _implicit_unnests_to_explicit(self, this: E) -> E:
+        from sqlglot.optimizer.normalize_identifiers import normalize_identifiers as _norm
+
+        refs = {_norm(this.args["from"].this.copy(), dialect=self.dialect).alias_or_name}
+        for i, join in enumerate(this.args.get("joins") or []):
+            table = join.this
+            normalized_table = table.copy()
+            normalized_table.meta["maybe_column"] = True
+            normalized_table = _norm(normalized_table, dialect=self.dialect)
+
+            if isinstance(table, exp.Table) and not join.args.get("on"):
+                if normalized_table.parts[0].name in refs:
+                    table_as_column = table.to_column()
+                    unnest = exp.Unnest(expressions=[table_as_column])
+
+                    # Table.to_column creates a parent Alias node that we want to convert to
+                    # a TableAlias and attach to the Unnest, so it matches the parser's output
+                    if isinstance(table.args.get("alias"), exp.TableAlias):
+                        table_as_column.replace(table_as_column.this)
+                        exp.alias_(unnest, None, table=[table.args["alias"].this], copy=False)
+
+                    table.replace(unnest)
+
+            refs.add(normalized_table.alias_or_name)
+
+        return this
+
     def _parse_query_modifiers(
         self, this: t.Optional[exp.Expression]
     ) -> t.Optional[exp.Expression]:
@@ -2480,6 +2510,10 @@ def _parse_query_modifiers(
                                 offset.set("expressions", limit_by_expressions)
                         continue
                 break
+
+        if self.SUPPORTS_IMPLICIT_UNNEST and this and "from" in this.args:
+            this = self._implicit_unnests_to_explicit(this)
+
         return this
 
     def _parse_hint(self) -> t.Optional[exp.Hint]:


Test patch:
diff --git a/tests/dialects/test_bigquery.py b/tests/dialects/test_bigquery.py
--- a/tests/dialects/test_bigquery.py
+++ b/tests/dialects/test_bigquery.py
@@ -21,7 +21,6 @@ def test_bigquery(self):
         self.validate_identity("SELECT x, 1 AS y GROUP BY 1 ORDER BY 1")
         self.validate_identity("SELECT * FROM x.*")
         self.validate_identity("SELECT * FROM x.y*")
-
         self.validate_identity("CASE A WHEN 90 THEN 'red' WHEN 50 THEN 'blue' ELSE 'green' END")
         self.validate_identity("CREATE SCHEMA x DEFAULT COLLATE 'en'")
         self.validate_identity("CREATE TABLE x (y INT64) DEFAULT COLLATE 'en'")
@@ -190,6 +189,25 @@ def test_bigquery(self):
             r"REGEXP_EXTRACT(svc_plugin_output, '\\\\\\((.*)')",
         )
 
+        self.validate_all(
+            "SELECT results FROM Coordinates, Coordinates.position AS results",
+            write={
+                "bigquery": "SELECT results FROM Coordinates, UNNEST(Coordinates.position) AS results",
+                "presto": "SELECT results FROM Coordinates, UNNEST(Coordinates.position) AS _t(results)",
+            },
+        )
+        self.validate_all(
+            "SELECT results FROM Coordinates AS c, UNNEST(c.position) AS results",
+            read={
+                "presto": "SELECT results FROM Coordinates AS c, UNNEST(c.position) AS _t(results)",
+                "redshift": "SELECT results FROM Coordinates AS c, c.position AS results",
+            },
+            write={
+                "bigquery": "SELECT results FROM Coordinates AS c, UNNEST(c.position) AS results",
+                "presto": "SELECT results FROM Coordinates AS c, UNNEST(c.position) AS _t(results)",
+                "redshift": "SELECT results FROM Coordinates AS c, c.position AS results",
+            },
+        )
         self.validate_all(
             "TIMESTAMP(x)",
             write={
diff --git a/tests/dialects/test_redshift.py b/tests/dialects/test_redshift.py
--- a/tests/dialects/test_redshift.py
+++ b/tests/dialects/test_redshift.py
@@ -515,6 +515,11 @@ def test_no_schema_binding(self):
         )
 
     def test_column_unnesting(self):
+        self.validate_identity("SELECT c.*, o FROM bloo AS c, c.c_orders AS o")
+        self.validate_identity(
+            "SELECT c.*, o, l FROM bloo AS c, c.c_orders AS o, o.o_lineitems AS l"
+        )
+
         ast = parse_one("SELECT * FROM t.t JOIN t.c1 ON c1.c2 = t.c3", read="redshift")
         ast.args["from"].this.assert_is(exp.Table)
         ast.args["joins"][0].this.assert_is(exp.Table)
@@ -522,7 +527,7 @@ def test_column_unnesting(self):
 
         ast = parse_one("SELECT * FROM t AS t CROSS JOIN t.c1", read="redshift")
         ast.args["from"].this.assert_is(exp.Table)
-        ast.args["joins"][0].this.assert_is(exp.Column)
+        ast.args["joins"][0].this.assert_is(exp.Unnest)
         self.assertEqual(ast.sql("redshift"), "SELECT * FROM t AS t CROSS JOIN t.c1")
 
         ast = parse_one(
@@ -530,9 +535,9 @@ def test_column_unnesting(self):
         )
         joins = ast.args["joins"]
         ast.args["from"].this.assert_is(exp.Table)
-        joins[0].this.this.assert_is(exp.Column)
-        joins[1].this.this.assert_is(exp.Column)
-        joins[2].this.this.assert_is(exp.Dot)
+        joins[0].this.assert_is(exp.Unnest)
+        joins[1].this.assert_is(exp.Unnest)
+        joins[2].this.assert_is(exp.Unnest).expressions[0].assert_is(exp.Dot)
         self.assertEqual(
             ast.sql("redshift"), "SELECT * FROM x AS a, a.b AS c, c.d.e AS f, f.g.h.i.j.k AS l"
         )
diff --git a/tests/fixtures/optimizer/qualify_tables.sql b/tests/fixtures/optimizer/qualify_tables.sql
--- a/tests/fixtures/optimizer/qualify_tables.sql
+++ b/tests/fixtures/optimizer/qualify_tables.sql
@@ -19,6 +19,16 @@ SELECT 1 FROM x.y.z AS z;
 SELECT 1 FROM y.z AS z, z.a;
 SELECT 1 FROM c.y.z AS z, z.a;
 
+# title: bigquery implicit unnest syntax, coordinates.position should be a column, not a table
+# dialect: bigquery
+SELECT results FROM Coordinates, coordinates.position AS results;
+SELECT results FROM c.db.Coordinates AS Coordinates, UNNEST(coordinates.position) AS results;
+
+# title: bigquery implicit unnest syntax, table is already qualified
+# dialect: bigquery
+SELECT results FROM db.coordinates, Coordinates.position AS results;
+SELECT results FROM c.db.coordinates AS coordinates, UNNEST(Coordinates.position) AS results;
+
 # title: single cte
 WITH a AS (SELECT 1 FROM z) SELECT 1 FROM a;
 WITH a AS (SELECT 1 FROM c.db.z AS z) SELECT 1 FROM a AS a;
diff --git a/tests/test_optimizer.py b/tests/test_optimizer.py
--- a/tests/test_optimizer.py
+++ b/tests/test_optimizer.py
@@ -205,6 +205,7 @@ def test_qualify_tables(self):
             optimizer.qualify_tables.qualify_tables,
             db="db",
             catalog="c",
+            set_dialect=True,
         )
 
     def test_normalize(self):