Repository: tobymao/sqlglot

Issue:
Clickhouse: `CREATE TABLE` with primary key fails
`1.sql`
```sql
CREATE TABLE test (id UInt64, timestamp DateTime, data String, max_hits UInt64, sum_hits UInt64) ENGINE = MergeTree
PRIMARY KEY (id, toStartOfDay(timestamp), timestamp)
TTL timestamp + INTERVAL 1 DAY
GROUP BY id, toStartOfDay(timestamp)
SET
   max_hits = max(max_hits),
   sum_hits = sum(sum_hits)
```

Works fine in actual Clickhouse:
```
$ cat 1.sql | clickhouse-client
$
$ clickhouse-client -q 'DROP table test'
$
```
Fails in `sqlglot`
```
sqlglot.errors.ParseError: Invalid expression / Unexpected token. Line 2, Col: 11.
  t (id UInt64, timestamp DateTime, data String, max_hits UInt64, sum_hits UInt64) ENGINE = MergeTree
PRIMARY KEY (id, toStartOfDay(timestamp), timestamp) TTL timestamp + INTERVAL 1 DAY
GROUP BY id, toStartOfDay(t
```

ClickHouse version: 23.4.2
CLickhouse: `CREATE TABLE` with `SETTINGS` clause fails
```
CREATE TABLE test (id String, data String) ENGINE = AggregatingMergeTree()
    ORDER BY tuple()
SETTINGS
    max_suspicious_broken_parts=500
    parts_to_throw_insert=100
```

```
sqlglot.errors.ParseError: Invalid expression / Unexpected token. Line 3, Col: 8.
CREATE TABLE test (id String, data String) ENGINE = AggregatingMergeTree()
    ORDER BY tuple()
SETTINGS
    max_suspicious_broken_parts=500
    parts_to_throw_insert=100
```



Discussion:

FYI your statement has a [syntax error](https://github.com/tobymao/sqlglot/assets/46752250/002a3907-96ff-408a-b201-0f6c7fb5b5ff); you [need a comma](https://github.com/tobymao/sqlglot/assets/46752250/717df5f9-7012-49c0-83fb-b4c9e5df3d52) after `500`.



Resolving patch:
diff --git a/sqlglot/expressions.py b/sqlglot/expressions.py
--- a/sqlglot/expressions.py
+++ b/sqlglot/expressions.py
@@ -1116,6 +1116,10 @@ class Comment(Expression):
     arg_types = {"this": True, "kind": True, "expression": True, "exists": False}
 
 
+class TTL(Expression):
+    pass
+
+
 class ColumnConstraint(Expression):
     arg_types = {"this": False, "kind": True}
 
@@ -1846,6 +1850,10 @@ class SetProperty(Property):
     arg_types = {"multi": True}
 
 
+class SettingsProperty(Property):
+    arg_types = {"expressions": True}
+
+
 class SortKeyProperty(Property):
     arg_types = {"this": True, "compound": False}
 
diff --git a/sqlglot/generator.py b/sqlglot/generator.py
--- a/sqlglot/generator.py
+++ b/sqlglot/generator.py
@@ -79,6 +79,7 @@ class Generator:
         exp.OnCommitProperty: lambda self, e: "ON COMMIT PRESERVE ROWS",
         exp.ReturnsProperty: lambda self, e: self.naked_property(e),
         exp.SetProperty: lambda self, e: f"{'MULTI' if e.args.get('multi') else ''}SET",
+        exp.SettingsProperty: lambda self, e: f"SETTINGS {self.expressions(e)}",
         exp.SqlSecurityProperty: lambda self, e: f"SQL SECURITY {'DEFINER' if e.args.get('definer') else 'INVOKER'}",
         exp.TemporaryProperty: lambda self, e: f"{'GLOBAL ' if e.args.get('global_') else ''}TEMPORARY",
         exp.TransientProperty: lambda self, e: "TRANSIENT",
@@ -185,6 +186,7 @@ class Generator:
         exp.FallbackProperty: exp.Properties.Location.POST_NAME,
         exp.FileFormatProperty: exp.Properties.Location.POST_WITH,
         exp.FreespaceProperty: exp.Properties.Location.POST_NAME,
+        exp.Group: exp.Properties.Location.POST_SCHEMA,
         exp.IsolatedLoadingProperty: exp.Properties.Location.POST_NAME,
         exp.JournalProperty: exp.Properties.Location.POST_NAME,
         exp.LanguageProperty: exp.Properties.Location.POST_SCHEMA,
@@ -198,6 +200,7 @@ class Generator:
         exp.OnCommitProperty: exp.Properties.Location.POST_EXPRESSION,
         exp.Order: exp.Properties.Location.POST_SCHEMA,
         exp.PartitionedByProperty: exp.Properties.Location.POST_WITH,
+        exp.PrimaryKey: exp.Properties.Location.POST_SCHEMA,
         exp.Property: exp.Properties.Location.POST_WITH,
         exp.ReturnsProperty: exp.Properties.Location.POST_SCHEMA,
         exp.RowFormatProperty: exp.Properties.Location.POST_SCHEMA,
@@ -205,6 +208,8 @@ class Generator:
         exp.RowFormatSerdeProperty: exp.Properties.Location.POST_SCHEMA,
         exp.SchemaCommentProperty: exp.Properties.Location.POST_SCHEMA,
         exp.SerdeProperties: exp.Properties.Location.POST_SCHEMA,
+        exp.Set: exp.Properties.Location.POST_SCHEMA,
+        exp.SettingsProperty: exp.Properties.Location.POST_SCHEMA,
         exp.SetProperty: exp.Properties.Location.POST_CREATE,
         exp.SortKeyProperty: exp.Properties.Location.POST_SCHEMA,
         exp.SqlSecurityProperty: exp.Properties.Location.POST_CREATE,
@@ -212,6 +217,7 @@ class Generator:
         exp.TableFormatProperty: exp.Properties.Location.POST_WITH,
         exp.TemporaryProperty: exp.Properties.Location.POST_CREATE,
         exp.TransientProperty: exp.Properties.Location.POST_CREATE,
+        exp.TTL: exp.Properties.Location.POST_SCHEMA,
         exp.VolatileProperty: exp.Properties.Location.POST_CREATE,
         exp.WithDataProperty: exp.Properties.Location.POST_EXPRESSION,
         exp.WithJournalTableProperty: exp.Properties.Location.POST_NAME,
@@ -1884,6 +1890,9 @@ def comment_sql(self, expression: exp.Comment) -> str:
         expression_sql = self.sql(expression, "expression")
         return f"COMMENT{exists_sql}ON {kind} {this} IS {expression_sql}"
 
+    def ttl_sql(self, expression: exp.TTL) -> str:
+        return f"TTL {self.sql(expression, 'this')}"
+
     def transaction_sql(self, expression: exp.Transaction) -> str:
         return "BEGIN"
 
diff --git a/sqlglot/parser.py b/sqlglot/parser.py
--- a/sqlglot/parser.py
+++ b/sqlglot/parser.py
@@ -594,6 +594,7 @@ class Parser(metaclass=_Parser):
         "FORMAT": lambda self: self._parse_property_assignment(exp.FileFormatProperty),
         "FREESPACE": lambda self: self._parse_freespace(),
         "GLOBAL": lambda self: self._parse_temporary(global_=True),
+        "GROUP BY": lambda self: self._parse_group(skip_group_by_token=True),
         "IMMUTABLE": lambda self: self.expression(
             exp.StabilityProperty, this=exp.Literal.string("IMMUTABLE")
         ),
@@ -623,10 +624,14 @@ class Parser(metaclass=_Parser):
         "PARTITION BY": lambda self: self._parse_partitioned_by(),
         "PARTITIONED BY": lambda self: self._parse_partitioned_by(),
         "PARTITIONED_BY": lambda self: self._parse_partitioned_by(),
+        "PRIMARY KEY": lambda self: self._parse_primary_key(),
         "RETURNS": lambda self: self._parse_returns(),
         "ROW": lambda self: self._parse_row(),
         "ROW_FORMAT": lambda self: self._parse_property_assignment(exp.RowFormatProperty),
-        "SET": lambda self: self.expression(exp.SetProperty, multi=False),
+        "SET": lambda self: self._parse_set_property(),
+        "SETTINGS": lambda self: self.expression(
+            exp.SettingsProperty, expressions=self._parse_csv(self._parse_set_item)
+        ),
         "SORTKEY": lambda self: self._parse_sortkey(),
         "STABLE": lambda self: self.expression(
             exp.StabilityProperty, this=exp.Literal.string("STABLE")
@@ -637,6 +642,7 @@ class Parser(metaclass=_Parser):
         "TEMP": lambda self: self._parse_temporary(global_=False),
         "TEMPORARY": lambda self: self._parse_temporary(global_=False),
         "TRANSIENT": lambda self: self.expression(exp.TransientProperty),
+        "TTL": lambda self: self._parse_ttl(),
         "USING": lambda self: self._parse_property_assignment(exp.TableFormatProperty),
         "VOLATILE": lambda self: self._parse_volatile_property(),
         "WITH": lambda self: self._parse_with_property(),
@@ -681,6 +687,7 @@ class Parser(metaclass=_Parser):
         "TITLE": lambda self: self.expression(
             exp.TitleColumnConstraint, this=self._parse_var_or_string()
         ),
+        "TTL": lambda self: self._parse_ttl(),
         "UNIQUE": lambda self: self._parse_unique(),
         "UPPERCASE": lambda self: self.expression(exp.UppercaseColumnConstraint),
     }
@@ -1044,6 +1051,9 @@ def _parse_comment(self, allow_exists: bool = True) -> exp.Expression:
             exp.Comment, this=this, kind=kind.text, expression=self._parse_string(), exists=exists
         )
 
+    def _parse_ttl(self) -> exp.Expression:
+        return self.expression(exp.TTL, this=self._parse_bitwise())
+
     def _parse_statement(self) -> t.Optional[exp.Expression]:
         if self._curr is None:
             return None
@@ -1555,6 +1565,14 @@ def _parse_create_like(self) -> t.Optional[exp.Expression]:
             )
         return self.expression(exp.LikeProperty, this=table, expressions=options)
 
+    def _parse_set_property(self) -> exp.Expression:
+        # In the case of "CREATE SET ..." we're currently at the third token, so we need to look
+        # back two tokens to decide whether we're returning a SetProperty or a Set expression.
+        if self._index >= 2 and self._tokens[self._index - 2].token_type == TokenType.CREATE:
+            return self.expression(exp.SetProperty, multi=False)
+
+        return self.expression(exp.Set, expressions=self._parse_csv(self._parse_set_item))
+
     def _parse_sortkey(self, compound: bool = False) -> exp.Expression:
         return self.expression(
             exp.SortKeyProperty, this=self._parse_wrapped_csv(self._parse_id_var), compound=compound
@@ -2486,7 +2504,7 @@ def _parse_group(self, skip_group_by_token: bool = False) -> t.Optional[exp.Expr
                 cube = with_ or self._parse_wrapped_csv(self._parse_column)
                 elements["cube"].extend(ensure_list(cube))
 
-            if not (expressions or grouping_sets or rollup or cube):
+            if not (grouping_sets or rollup or cube):
                 break
 
         return self.expression(exp.Group, **elements)  # type: ignore
@@ -3395,7 +3413,7 @@ def _parse_primary_key(self) -> exp.Expression:
         if not self._match(TokenType.L_PAREN, advance=False):
             return self.expression(exp.PrimaryKeyColumnConstraint, desc=desc)
 
-        expressions = self._parse_wrapped_id_vars()
+        expressions = self._parse_wrapped_csv(self._parse_field)
         options = self._parse_key_constraint_options()
         return self.expression(exp.PrimaryKey, expressions=expressions, options=options)
 


Test patch:
diff --git a/tests/dialects/test_clickhouse.py b/tests/dialects/test_clickhouse.py
--- a/tests/dialects/test_clickhouse.py
+++ b/tests/dialects/test_clickhouse.py
@@ -89,3 +89,74 @@ def test_signed_and_unsigned_types(self):
                 f"pow(2, 32)::{data_type}",
                 write={"clickhouse": f"CAST(POWER(2, 32) AS {data_type})"},
             )
+
+    def test_ddl(self):
+        self.validate_all(
+            """
+            CREATE TABLE example1 (
+               timestamp DateTime,
+               x UInt32 TTL now() + INTERVAL 1 MONTH,
+               y String TTL timestamp + INTERVAL 1 DAY,
+               z String
+            )
+            ENGINE = MergeTree
+            ORDER BY tuple()
+            """,
+            write={
+                "clickhouse": """CREATE TABLE example1 (
+  timestamp DateTime64,
+  x UInt32 TTL now() + INTERVAL '1' MONTH,
+  y TEXT TTL timestamp + INTERVAL '1' DAY,
+  z TEXT
+)
+ENGINE=MergeTree
+ORDER BY tuple()""",
+            },
+            pretty=True,
+        )
+        self.validate_all(
+            """
+            CREATE TABLE test (id UInt64, timestamp DateTime64, data String, max_hits UInt64, sum_hits UInt64) ENGINE = MergeTree
+            PRIMARY KEY (id, toStartOfDay(timestamp), timestamp)
+            TTL timestamp + INTERVAL 1 DAY
+            GROUP BY id, toStartOfDay(timestamp)
+            SET
+               max_hits = max(max_hits),
+               sum_hits = sum(sum_hits)
+            """,
+            write={
+                "clickhouse": """CREATE TABLE test (
+  id UInt64,
+  timestamp DateTime64,
+  data TEXT,
+  max_hits UInt64,
+  sum_hits UInt64
+)
+ENGINE=MergeTree
+PRIMARY KEY (id, toStartOfDay(timestamp), timestamp)
+TTL timestamp + INTERVAL '1' DAY
+GROUP BY id, toStartOfDay(timestamp)
+SET max_hits = MAX(max_hits), sum_hits = SUM(sum_hits)""",
+            },
+            pretty=True,
+        )
+        self.validate_all(
+            """
+            CREATE TABLE test (id String, data String) ENGINE = AggregatingMergeTree()
+                ORDER BY tuple()
+            SETTINGS
+                max_suspicious_broken_parts=500,
+                parts_to_throw_insert=100
+            """,
+            write={
+                "clickhouse": """CREATE TABLE test (
+  id TEXT,
+  data TEXT
+)
+ENGINE=AggregatingMergeTree()
+ORDER BY tuple()
+SETTINGS   max_suspicious_broken_parts = 500,
+  parts_to_throw_insert = 100""",
+            },
+            pretty=True,
+        )