Repository: tobymao/sqlglot

Issue:
Version 23.12.2 fails to parse some COPY clauses
Looks as if in prior versions it didn't fully parse and fell back to the default Command but with 23.12.2 it's trying to parse and failing. Maybe we can have it fall back to Command as we work through various options COPY has?

**Fully reproducible code snippet**

```python
from sqlglot import parse_one

q = """
copy into temp FROM @random_stage/path/
FILE_FORMAT = (
    TYPE=CSV
    FIELD_DELIMITER='|'
    NULL_IF=()
    FIELD_OPTIONALLY_ENCLOSED_BY='"'
    TIMESTAMP_FORMAT='TZHTZM YYYY-MM-DD HH24:MI:SS.FF9'
    DATE_FORMAT='TZHTZM YYYY-MM-DD HH24:MI:SS.FF9'
    BINARY_FORMAT=BASE64
  )
"""

pq = parse_one(q, read="snowflake")
print("Parsed:", pq, "\n")
```

The above gives the following using 23.12.1:
```
✗ python test_sqlglot.py 
'copy into temp FROM @random_stage/path/
FILE_FORMAT = (
    TYPE=CSV
    FIELD_DELIMITER='|'
    NUL' contains unsupported syntax. Falling back to parsing as a 'Command'.
Parsed: COPY into temp FROM @random_stage/path/
FILE_FORMAT = (
    TYPE=CSV
    FIELD_DELIMITER='|'
    NULL_IF=()
    FIELD_OPTIONALLY_ENCLOSED_BY='"'
    TIMESTAMP_FORMAT='TZHTZM YYYY-MM-DD HH24:MI:SS.FF9'
    DATE_FORMAT='TZHTZM YYYY-MM-DD HH24:MI:SS.FF9'
    BINARY_FORMAT=BASE64
  ) 
```

And the following using 23.12.2:
```
✗ python test_sqlglot.py       
Traceback (most recent call last):
  File "/Users/dangoldin/code/analysis/test_sqlglot.py", line 494, in <module>
    pq = parse_one(q, read="snowflake")
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/__init__.py", line 139, in parse_one
    result = dialect.parse(sql, **opts)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/dialects/dialect.py", line 506, in parse
    return self.parser(**opts).parse(self.tokenize(sql), sql)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 1175, in parse
    return self._parse(
           ^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 1241, in _parse
    expressions.append(parse_method(self))
                       ^^^^^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 1470, in _parse_statement
    return self.STATEMENT_PARSERS[self._prev.token_type](self)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 631, in <lambda>
    TokenType.COPY: lambda self: self._parse_copy(),
                                 ^^^^^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 6363, in _parse_copy
    files = self._parse_csv(self._parse_conjunction)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 5649, in _parse_csv
    parse_result = parse_method()
                   ^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 3808, in _parse_conjunction
    return self._parse_tokens(self._parse_equality, self.CONJUNCTION)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 5663, in _parse_tokens
    this = parse_method()
           ^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 3811, in _parse_equality
    return self._parse_tokens(self._parse_comparison, self.EQUALITY)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 5670, in _parse_tokens
    expression=parse_method(),
               ^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 3814, in _parse_comparison
    return self._parse_tokens(self._parse_range, self.COMPARISON)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 5663, in _parse_tokens
    this = parse_method()
           ^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 3817, in _parse_range
    this = this or self._parse_bitwise()
                   ^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 3941, in _parse_bitwise
    this = self._parse_term()
           ^^^^^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 3973, in _parse_term
    return self._parse_tokens(self._parse_factor, self.TERM)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 5663, in _parse_tokens
    this = parse_method()
           ^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 3977, in _parse_factor
    this = parse_method()
           ^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 3998, in _parse_unary
    return self._parse_at_time_zone(self._parse_type())
                                    ^^^^^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 4020, in _parse_type
    this = self._parse_column()
           ^^^^^^^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 4220, in _parse_column
    this = self._parse_column_reference()
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 4224, in _parse_column_reference
    this = self._parse_field()
           ^^^^^^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 4347, in _parse_field
    field = self._parse_primary() or self._parse_function(
            ^^^^^^^^^^^^^^^^^^^^^
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 4330, in _parse_primary
    self._match_r_paren(expression=this)
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 6196, in _match_r_paren
    self.raise_error("Expecting )")
  File "/Users/dangoldin/.virtualenvs/lib/python3.11/site-packages/sqlglot/parser.py", line 1285, in raise_error
    raise error
sqlglot.errors.ParseError: Expecting ). Line 5, Col: 17.
  
copy into temp FROM @random_stage/path/
FILE_FORMAT = (
    TYPE=CSV
    FIELD_DELIMITER='|'
    NULL_IF=()
    FIELD_OPTIONALLY_ENCLOSED_BY='"'
    TIMESTAMP_FORMAT='TZHTZM YYYY-MM-DD HH24
```

**Official Documentation**
Please include links to official SQL documentation related to your issue.



Discussion:
Sorry for the trouble @dangoldin, this was an oversight on our end. Should have deployed a minor version. We'll fix this soon and look into deploying a patch.
All good and appreciate the responsiveness. I just fell back to 23.12.1 in my code and would attempt a fix here but haven't been keeping up with the changes here so it was quicker to quickly file this.
No worries. This is probably an edge case, because we _do_ have some [logic](https://github.com/tobymao/sqlglot/commit/d2a6f16c35cbe355932d0e0eab2fc6ba096d8a97#diff-63eb8dd82d3561bc414e3e13cf59516bd6584c5766a10072157a8a5aee4ad85fR6370-R6372) that tries to parse this into a `Command` as a fallback case, so it fails somewhere in between.

Resolving patch:
diff --git a/sqlglot/dialects/snowflake.py b/sqlglot/dialects/snowflake.py
--- a/sqlglot/dialects/snowflake.py
+++ b/sqlglot/dialects/snowflake.py
@@ -440,7 +440,7 @@ class Parser(parser.Parser):
 
         PROPERTY_PARSERS = {
             **parser.Parser.PROPERTY_PARSERS,
-            "LOCATION": lambda self: self._parse_location(),
+            "LOCATION": lambda self: self._parse_location_property(),
         }
 
         SHOW_PARSERS = {
@@ -676,10 +676,13 @@ def _parse_alter_table_swap(self) -> exp.SwapTable:
             self._match_text_seq("WITH")
             return self.expression(exp.SwapTable, this=self._parse_table(schema=True))
 
-        def _parse_location(self) -> exp.LocationProperty:
+        def _parse_location_property(self) -> exp.LocationProperty:
             self._match(TokenType.EQ)
             return self.expression(exp.LocationProperty, this=self._parse_location_path())
 
+        def _parse_file_location(self) -> t.Optional[exp.Expression]:
+            return self._parse_table_parts()
+
         def _parse_location_path(self) -> exp.Var:
             parts = [self._advance_any(ignore_reserved=True)]
 
@@ -1037,3 +1040,11 @@ def struct_sql(self, expression: exp.Struct) -> str:
                     values.append(e)
 
             return self.func("OBJECT_CONSTRUCT", *flatten(zip(keys, values)))
+
+        def copyparameter_sql(self, expression: exp.CopyParameter) -> str:
+            option = self.sql(expression, "this")
+            if option.upper() == "FILE_FORMAT":
+                values = self.expressions(expression, key="expression", flat=True, sep=" ")
+                return f"{option} = ({values})"
+
+            return super().copyparameter_sql(expression)
diff --git a/sqlglot/generator.py b/sqlglot/generator.py
--- a/sqlglot/generator.py
+++ b/sqlglot/generator.py
@@ -3795,7 +3795,7 @@ def credentials_sql(self, expression: exp.Credentials) -> str:
         if isinstance(cred_expr, exp.Literal):
             # Redshift case: CREDENTIALS <string>
             credentials = self.sql(expression, "credentials")
-            credentials = f"CREDENTIALS {credentials}"
+            credentials = f"CREDENTIALS {credentials}" if credentials else ""
         else:
             # Snowflake case: CREDENTIALS = (...)
             credentials = self.expressions(expression, key="credentials", flat=True, sep=" ")
@@ -3805,7 +3805,7 @@ def credentials_sql(self, expression: exp.Credentials) -> str:
         storage = f" {storage}" if storage else ""
 
         encryption = self.expressions(expression, key="encryption", flat=True, sep=" ")
-        encryption = f"ENCRYPTION = ({encryption})" if encryption else ""
+        encryption = f" ENCRYPTION = ({encryption})" if encryption else ""
 
         iam_role = self.sql(expression, "iam_role")
         iam_role = f"IAM_ROLE {iam_role}" if iam_role else ""
@@ -3821,7 +3821,6 @@ def copy_sql(self, expression: exp.Copy) -> str:
 
         credentials = self.sql(expression, "credentials")
         credentials = f" {credentials}" if credentials else ""
-
         kind = " FROM " if expression.args.get("kind") else " TO "
         files = self.expressions(expression, key="files", flat=True)
 
diff --git a/sqlglot/parser.py b/sqlglot/parser.py
--- a/sqlglot/parser.py
+++ b/sqlglot/parser.py
@@ -4315,7 +4315,9 @@ def _parse_primary(self) -> t.Optional[exp.Expression]:
 
             this = self._parse_query_modifiers(seq_get(expressions, 0))
 
-            if isinstance(this, exp.UNWRAPPED_QUERIES):
+            if not this and self._match(TokenType.R_PAREN, advance=False):
+                this = self.expression(exp.Tuple)
+            elif isinstance(this, exp.UNWRAPPED_QUERIES):
                 this = self._parse_set_operations(
                     self._parse_subquery(this=this, parse_alias=False)
                 )
@@ -6305,6 +6307,15 @@ def _parse_with_operator(self) -> t.Optional[exp.Expression]:
 
         return self.expression(exp.WithOperator, this=this, op=op)
 
+    def _parse_wrapped_options(self) -> t.List[t.Optional[exp.Expression]]:
+        opts = []
+        self._match(TokenType.EQ)
+        self._match(TokenType.L_PAREN)
+        while self._curr and not self._match(TokenType.R_PAREN):
+            opts.append(self._parse_conjunction())
+            self._match(TokenType.COMMA)
+        return opts
+
     def _parse_copy_parameters(self) -> t.List[exp.CopyParameter]:
         sep = TokenType.COMMA if self.dialect.COPY_PARAMS_ARE_CSV else None
 
@@ -6312,12 +6323,19 @@ def _parse_copy_parameters(self) -> t.List[exp.CopyParameter]:
         while self._curr and not self._match(TokenType.R_PAREN, advance=False):
             option = self._parse_unquoted_field()
             value = None
+
             # Some options are defined as functions with the values as params
             if not isinstance(option, exp.Func):
+                prev = self._prev.text.upper()
                 # Different dialects might separate options and values by white space, "=" and "AS"
                 self._match(TokenType.EQ)
                 self._match(TokenType.ALIAS)
-                value = self._parse_unquoted_field()
+
+                if prev == "FILE_FORMAT" and self._match(TokenType.L_PAREN):
+                    # Snowflake FILE_FORMAT case
+                    value = self._parse_wrapped_options()
+                else:
+                    value = self._parse_unquoted_field()
 
             param = self.expression(exp.CopyParameter, this=option, expression=value)
             options.append(param)
@@ -6328,24 +6346,18 @@ def _parse_copy_parameters(self) -> t.List[exp.CopyParameter]:
         return options
 
     def _parse_credentials(self) -> t.Optional[exp.Credentials]:
-        def parse_options():
-            opts = []
-            self._match(TokenType.EQ)
-            self._match(TokenType.L_PAREN)
-            while self._curr and not self._match(TokenType.R_PAREN):
-                opts.append(self._parse_conjunction())
-            return opts
-
         expr = self.expression(exp.Credentials)
 
         if self._match_text_seq("STORAGE_INTEGRATION", advance=False):
             expr.set("storage", self._parse_conjunction())
         if self._match_text_seq("CREDENTIALS"):
             # Snowflake supports CREDENTIALS = (...), while Redshift CREDENTIALS <string>
-            creds = parse_options() if self._match(TokenType.EQ) else self._parse_field()
+            creds = (
+                self._parse_wrapped_options() if self._match(TokenType.EQ) else self._parse_field()
+            )
             expr.set("credentials", creds)
         if self._match_text_seq("ENCRYPTION"):
-            expr.set("encryption", parse_options())
+            expr.set("encryption", self._parse_wrapped_options())
         if self._match_text_seq("IAM_ROLE"):
             expr.set("iam_role", self._parse_field())
         if self._match_text_seq("REGION"):
@@ -6353,7 +6365,10 @@ def parse_options():
 
         return expr
 
-    def _parse_copy(self):
+    def _parse_file_location(self) -> t.Optional[exp.Expression]:
+        return self._parse_field()
+
+    def _parse_copy(self) -> exp.Copy | exp.Command:
         start = self._prev
 
         self._match(TokenType.INTO)
@@ -6366,7 +6381,7 @@ def _parse_copy(self):
 
         kind = self._match(TokenType.FROM) or not self._match_text_seq("TO")
 
-        files = self._parse_csv(self._parse_conjunction)
+        files = self._parse_csv(self._parse_file_location)
         credentials = self._parse_credentials()
 
         self._match_text_seq("WITH")


Test patch:
diff --git a/tests/dialects/test_snowflake.py b/tests/dialects/test_snowflake.py
--- a/tests/dialects/test_snowflake.py
+++ b/tests/dialects/test_snowflake.py
@@ -104,9 +104,6 @@ def test_snowflake(self):
         self.validate_identity(
             "SELECT * FROM DATA AS DATA_L ASOF JOIN DATA AS DATA_R MATCH_CONDITION (DATA_L.VAL > DATA_R.VAL) ON DATA_L.ID = DATA_R.ID"
         )
-        self.validate_identity(
-            "COPY INTO mytable (col1, col2) FROM 's3://mybucket/data/files' FILES = ('file1', 'file2') PATTERN = 'pattern' FILE_FORMAT = (FORMAT_NAME = my_csv_format) PARSE_HEADER = TRUE"
-        )
         self.validate_identity(
             "REGEXP_REPLACE('target', 'pattern', '\n')",
             "REGEXP_REPLACE('target', 'pattern', '\\n')",
@@ -1833,3 +1830,17 @@ def test_try_cast(self):
 
         expression = annotate_types(expression)
         self.assertEqual(expression.sql(dialect="snowflake"), "SELECT TRY_CAST(FOO() AS TEXT)")
+
+    def test_copy(self):
+        self.validate_identity(
+            """COPY INTO mytable (col1, col2) FROM 's3://mybucket/data/files' FILES = ('file1', 'file2') PATTERN = 'pattern' FILE_FORMAT = (FORMAT_NAME = my_csv_format NULL_IF = ('str1', 'str2')) PARSE_HEADER = TRUE"""
+        )
+        self.validate_identity(
+            """COPY INTO temp FROM @random_stage/path/ FILE_FORMAT = (TYPE = CSV FIELD_DELIMITER = '|' NULL_IF = () FIELD_OPTIONALLY_ENCLOSED_BY = '"' TIMESTAMP_FORMAT = 'TZHTZM YYYY-MM-DD HH24:MI:SS.FF9' DATE_FORMAT = 'TZHTZM YYYY-MM-DD HH24:MI:SS.FF9' BINARY_FORMAT = BASE64) VALIDATION_MODE = 'RETURN_3_ROWS'"""
+        )
+        self.validate_identity(
+            """COPY INTO load1 FROM @%load1/data1/ FILES = ('test1.csv', 'test2.csv') FORCE = TRUE"""
+        )
+        self.validate_identity(
+            """COPY INTO mytable FROM 'azure://myaccount.blob.core.windows.net/mycontainer/data/files' CREDENTIALS = (AZURE_SAS_TOKEN = 'token') ENCRYPTION = (TYPE = 'AZURE_CSE' MASTER_KEY = 'kPx...') FILE_FORMAT = (FORMAT_NAME = my_csv_format)"""
+        )