Repository: tobymao/sqlglot

Issue:
DuckDB struct syntax fails to parse
DuckDB has [a Python-like struct construction syntax](https://duckdb.org/docs/sql/data_types/struct.html#creating-structs) that sqlglot cannot parse right now:

```
In [7]: import sqlglot as sg

In [8]: sg.__version__
Out[8]: '10.5.6'

In [9]: sg.parse_one("SELECT {'a': 1} AS x", read="duckdb")
...
ParseError: Invalid expression / Unexpected token. Line 1, Col: 8.
  SELECT {'a': 1} AS x
```

Here it is in the DuckDB CLI:

```
❯ duckdb <<< "SELECT {'a': 1} AS x"
┌───────────────────┐
│         x         │
│ struct(a integer) │
├───────────────────┤
│ {'a': 1}          │
└───────────────────┘
```

Discussion:

Patch:
diff --git a/sqlglot/dialects/duckdb.py b/sqlglot/dialects/duckdb.py
index 81941f75..4646eb43 100644
--- a/sqlglot/dialects/duckdb.py
+++ b/sqlglot/dialects/duckdb.py
@@ -66,12 +66,11 @@ def _sort_array_reverse(args):
     return exp.SortArray(this=seq_get(args, 0), asc=exp.false())
 
 
-def _struct_pack_sql(self, expression):
+def _struct_sql(self, expression):
     args = [
-        self.binary(e, ":=") if isinstance(e, exp.EQ) else self.sql(e)
-        for e in expression.expressions
+        f"'{e.name or e.this.name}': {self.sql(e, 'expression')}" for e in expression.expressions
     ]
-    return f"STRUCT_PACK({', '.join(args)})"
+    return f"{{{', '.join(args)}}}"
 
 
 def _datatype_sql(self, expression):
@@ -153,7 +152,7 @@ class DuckDB(Dialect):
             exp.StrToDate: lambda self, e: f"CAST({_str_to_time_sql(self, e)} AS DATE)",
             exp.StrToTime: _str_to_time_sql,
             exp.StrToUnix: lambda self, e: f"EPOCH(STRPTIME({self.sql(e, 'this')}, {self.format_time(e)}))",
-            exp.Struct: _struct_pack_sql,
+            exp.Struct: _struct_sql,
             exp.TableSample: no_tablesample_sql,
             exp.TimeStrToDate: lambda self, e: f"CAST({self.sql(e, 'this')} AS DATE)",
             exp.TimeStrToTime: timestrtotime_sql,
diff --git a/sqlglot/parser.py b/sqlglot/parser.py
index d2a7d206..f06a9c8f 100644
--- a/sqlglot/parser.py
+++ b/sqlglot/parser.py
@@ -2633,9 +2633,10 @@ class Parser(metaclass=_Parser):
         return self.expression(exp.PrimaryKey, expressions=expressions, options=options)
 
     def _parse_bracket(self, this: t.Optional[exp.Expression]) -> t.Optional[exp.Expression]:
-        if not self._match(TokenType.L_BRACKET):
+        if not self._match_set((TokenType.L_BRACKET, TokenType.L_BRACE)):
             return this
 
+        bracket_kind = self._prev.token_type
         expressions: t.List[t.Optional[exp.Expression]]
 
         if self._match(TokenType.COLON):
@@ -2643,14 +2644,19 @@ class Parser(metaclass=_Parser):
         else:
             expressions = self._parse_csv(lambda: self._parse_slice(self._parse_conjunction()))
 
-        if not this or this.name.upper() == "ARRAY":
+        # https://duckdb.org/docs/sql/data_types/struct.html#creating-structs
+        if bracket_kind == TokenType.L_BRACE:
+            this = self.expression(exp.Struct, expressions=expressions)
+        elif not this or this.name.upper() == "ARRAY":
             this = self.expression(exp.Array, expressions=expressions)
         else:
             expressions = apply_index_offset(expressions, -self.index_offset)
             this = self.expression(exp.Bracket, this=this, expressions=expressions)
 
-        if not self._match(TokenType.R_BRACKET):
+        if not self._match(TokenType.R_BRACKET) and bracket_kind == TokenType.L_BRACKET:
             self.raise_error("Expected ]")
+        elif not self._match(TokenType.R_BRACE) and bracket_kind == TokenType.L_BRACE:
+            self.raise_error("Expected }")
 
         this.comments = self._prev_comments
         return self._parse_bracket(this)
diff --git a/sqlglot/tokens.py b/sqlglot/tokens.py
index 80ca3034..7f67928a 100644
--- a/sqlglot/tokens.py
+++ b/sqlglot/tokens.py
@@ -451,9 +451,11 @@ class Tokenizer(metaclass=_Tokenizer):
         },
         **{
             f"{prefix}{key}": TokenType.BLOCK_END
-            for key in ("}}", "%}", "#}")
+            for key in ("%}", "#}")
             for prefix in ("", "+", "-")
         },
+        "+}}": TokenType.BLOCK_END,
+        "-}}": TokenType.BLOCK_END,
         "/*+": TokenType.HINT,
         "==": TokenType.EQ,
         "::": TokenType.DCOLON,


Test patch:
diff --git a/tests/dialects/test_duckdb.py b/tests/dialects/test_duckdb.py
index a37062ca..f6446ca3 100644
--- a/tests/dialects/test_duckdb.py
+++ b/tests/dialects/test_duckdb.py
@@ -75,6 +75,19 @@ class TestDuckDB(Validator):
         )
 
     def test_duckdb(self):
+        self.validate_identity("SELECT {'a': 1} AS x")
+        self.validate_identity("SELECT {'a': {'b': {'c': 1}}, 'd': {'e': 2}} AS x")
+        self.validate_identity("SELECT {'x': 1, 'y': 2, 'z': 3}")
+        self.validate_identity(
+            "SELECT {'yes': 'duck', 'maybe': 'goose', 'huh': NULL, 'no': 'heron'}"
+        )
+        self.validate_identity("SELECT {'key1': 'string', 'key2': 1, 'key3': 12.345}")
+        self.validate_identity("SELECT ROW(x, x + 1, y) FROM (SELECT 1 AS x, 'a' AS y)")
+        self.validate_identity("SELECT (x, x + 1, y) FROM (SELECT 1 AS x, 'a' AS y)")
+        self.validate_identity("SELECT a.x FROM (SELECT {'x': 1, 'y': 2, 'z': 3} AS a)")
+        self.validate_identity(
+            "SELECT a['x space'] FROM (SELECT {'x space': 1, 'y': 2, 'z': 3} AS a)"
+        )
         self.validate_all(
             "CREATE TABLE IF NOT EXISTS table (cola INT, colb STRING) USING ICEBERG PARTITIONED BY (colb)",
             write={
@@ -229,10 +242,17 @@ class TestDuckDB(Validator):
         self.validate_all(
             "STRUCT_PACK(x := 1, y := '2')",
             write={
-                "duckdb": "STRUCT_PACK(x := 1, y := '2')",
+                "duckdb": "{'x': 1, 'y': '2'}",
                 "spark": "STRUCT(x = 1, y = '2')",
             },
         )
+        self.validate_all(
+            "STRUCT_PACK(key1 := 'value1', key2 := 42)",
+            write={
+                "duckdb": "{'key1': 'value1', 'key2': 42}",
+                "spark": "STRUCT(key1 = 'value1', key2 = 42)",
+            },
+        )
         self.validate_all(
             "ARRAY_SORT(x)",
             write={
diff --git a/tests/test_tokens.py b/tests/test_tokens.py
index 13768493..2c3b874d 100644
--- a/tests/test_tokens.py
+++ b/tests/test_tokens.py
@@ -40,7 +40,8 @@ class TestTokens(unittest.TestCase):
                 (TokenType.SELECT, "SELECT"),
                 (TokenType.BLOCK_START, "{{"),
                 (TokenType.VAR, "x"),
-                (TokenType.BLOCK_END, "}}"),
+                (TokenType.R_BRACE, "}"),
+                (TokenType.R_BRACE, "}"),
                 (TokenType.COMMA, ","),
                 (TokenType.BLOCK_START, "{{-"),
                 (TokenType.VAR, "x"),
@@ -55,7 +56,8 @@ class TestTokens(unittest.TestCase):
                 (TokenType.VAR, "a"),
                 (TokenType.BLOCK_START, "{{+"),
                 (TokenType.VAR, "b"),
-                (TokenType.BLOCK_END, "}}"),
+                (TokenType.R_BRACE, "}"),
+                (TokenType.R_BRACE, "}"),
                 (TokenType.BLOCK_START, "{%"),
                 (TokenType.VAR, "endfor"),
                 (TokenType.BLOCK_END, "%}"),