Repository: tobymao/sqlglot

Issue:
tsql: table hint "...WITH (HOLDLOCK)..." causing parse error
HOLDLOCK is a table hint and is causing a parse error in the statement below. HOLDLOCK is one of many table hints that applies to many statement types [[DELETE](https://learn.microsoft.com/en-us/sql/t-sql/statements/delete-transact-sql?view=sql-server-ver16) [INSERT](https://learn.microsoft.com/en-us/sql/t-sql/statements/insert-transact-sql?view=sql-server-ver16) [SELECT](https://learn.microsoft.com/en-us/sql/t-sql/queries/select-transact-sql?view=sql-server-ver16) [UPDATE](https://learn.microsoft.com/en-us/sql/t-sql/queries/update-transact-sql?view=sql-server-ver16) [MERGE](https://learn.microsoft.com/en-us/sql/t-sql/statements/merge-transact-sql?view=sql-server-ver16)]. 

It would be good to parse `WITH  ( <table_hint> [ [ , ] ...n ] )` [ tsql table hints](https://learn.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-table?view=sql-server-ver16&viewFallbackFrom=azure-sqldw-latest)

```python
sql = """
MERGE mytable WITH (HOLDLOCK) AS T
USING mytable_merge AS S
ON (T.user_id = S.user_id)
WHEN NOT MATCHED BY TARGET
THEN INSERT(c1, c2);
"""
import sqlglot
sqlglot.parse(sql, read="tsql")
```
Produces ParseError `ParseError: Expected table name but got . Line 2, Col: 32`

where 
```python
sql = """
MERGE mytable AS T
USING mytable_merge AS S
ON (T.user_id = S.user_id)
WHEN NOT MATCHED BY TARGET
THEN INSERT(c1, c2);
"""
import sqlglot
sqlglot.parse(sql, read="tsql")
```
Produces the AST.

**Official Documentation**
The `WITH (HOLDLOCK)` is [mentioned here in the MERGE docs](https://learn.microsoft.com/en-us/sql/t-sql/statements/merge-transact-sql?view=sql-server-ver16#concurrency-considerations-for-merge)

`HOLDLOCK` is a [table hint ](https://learn.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-table?view=sql-server-ver16)

> HOLDLOCK
Equivalent to SERIALIZABLE. For more information, see SERIALIZABLE later in this article. HOLDLOCK applies only to the table or view for which it is specified and only for the duration of the transaction defined by the statement that it is used in. HOLDLOCK can't be used in a SELECT statement that includes the FOR BROWSE option.

Table Hints:
> WITH  ( <table_hint> [ [ , ] ...n ] )

> <table_hint> ::=
{ NOEXPAND [ , INDEX ( <index_value> [ , ...n ] ) | INDEX = ( <index_value> ) ]
  | INDEX ( <index_value> [ , ...n ] ) | INDEX = ( <index_value> )
  | FORCESEEK [ ( <index_value> ( <index_column_name> [ , ... ] ) ) ]
  | FORCESCAN
  | HOLDLOCK
  | NOLOCK
  | NOWAIT
  | PAGLOCK
  | READCOMMITTED
  | READCOMMITTEDLOCK
  | READPAST
  | READUNCOMMITTED
  | REPEATABLEREAD
  | ROWLOCK
  | SERIALIZABLE
  | SNAPSHOT
  | SPATIAL_WINDOW_MAX_CELLS = <integer_value>
  | TABLOCK
  | TABLOCKX
  | UPDLOCK
  | XLOCK
}

> <table_hint_limited> ::=
{
    KEEPIDENTITY
  | KEEPDEFAULTS
  | HOLDLOCK
  | IGNORE_CONSTRAINTS
  | IGNORE_TRIGGERS
  | NOLOCK
  | NOWAIT
  | PAGLOCK
  | READCOMMITTED
  | READCOMMITTEDLOCK
  | READPAST
  | REPEATABLEREAD
  | ROWLOCK
  | SERIALIZABLE
  | SNAPSHOT
  | TABLOCK
  | TABLOCKX
  | UPDLOCK
  | XLOCK
}



Discussion:
Error traceback
```
1 sql = """
      2 MERGE mytable WITH (HOLDLOCK) AS T
      3 USING mytable_merge AS S
   (...)
      6 THEN INSERT(c1, c2);
      7 """
      8 import sqlglot
----> 9 sqlglot.parse(sql, read="tsql")

File /local_disk0/.ephemeral_nfs/envs/pythonEnv-6bc04fc0-872f-42cc-aa1e-70968fabeb08/lib/python3.10/site-packages/sqlglot/__init__.py:86, in parse(sql, read, dialect, **opts)
     73 """
     74 Parses the given SQL string into a collection of syntax trees, one per parsed SQL statement.
     75 
   (...)
     83     The resulting syntax tree collection.
     84 """
     85 dialect = Dialect.get_or_raise(read or dialect)()
---> 86 return dialect.parse(sql, **opts)

File /local_disk0/.ephemeral_nfs/envs/pythonEnv-6bc04fc0-872f-42cc-aa1e-70968fabeb08/lib/python3.10/site-packages/sqlglot/dialects/dialect.py:289, in Dialect.parse(self, sql, **opts)
    288 def parse(self, sql: str, **opts) -> t.List[t.Optional[exp.Expression]]:
--> 289     return self.parser(**opts).parse(self.tokenize(sql), sql)

File /local_disk0/.ephemeral_nfs/envs/pythonEnv-6bc04fc0-872f-42cc-aa1e-70968fabeb08/lib/python3.10/site-packages/sqlglot/parser.py:893, in Parser.parse(self, raw_tokens, sql)
    879 def parse(
    880     self, raw_tokens: t.List[Token], sql: t.Optional[str] = None
    881 ) -> t.List[t.Optional[exp.Expression]]:
    882     """
    883     Parses a list of tokens and returns a list of syntax trees, one tree
    884     per parsed SQL statement.
   (...)
    891         The list of the produced syntax trees.
    892     """
--> 893     return self._parse(
    894         parse_method=self.__class__._parse_statement, raw_tokens=raw_tokens, sql=sql
    895     )

File /local_disk0/.ephemeral_nfs/envs/pythonEnv-6bc04fc0-872f-42cc-aa1e-70968fabeb08/lib/python3.10/site-packages/sqlglot/parser.py:959, in Parser._parse(self, parse_method, raw_tokens, sql)
    956 self._tokens = tokens
    957 self._advance()
--> 959 expressions.append(parse_method(self))
    961 if self._index < len(self._tokens):
    962     self.raise_error("Invalid expression / Unexpected token")

File /local_disk0/.ephemeral_nfs/envs/pythonEnv-6bc04fc0-872f-42cc-aa1e-70968fabeb08/lib/python3.10/site-packages/sqlglot/parser.py:1143, in Parser._parse_statement(self)
   1140     return None
   1142 if self._match_set(self.STATEMENT_PARSERS):
-> 1143     return self.STATEMENT_PARSERS[self._prev.token_type](self)
   1145 if self._match_set(Tokenizer.COMMANDS):
   1146     return self._parse_command()

File /local_disk0/.ephemeral_nfs/envs/pythonEnv-6bc04fc0-872f-42cc-aa1e-70968fabeb08/lib/python3.10/site-packages/sqlglot/parser.py:531, in Parser.<lambda>(self)
    452 COLUMN_OPERATORS = {
    453     TokenType.DOT: None,
    454     TokenType.DCOLON: lambda self, this, to: self.expression(
   (...)
    483     ),
    484 }
    486 EXPRESSION_PARSERS = {
    487     exp.Cluster: lambda self: self._parse_sort(exp.Cluster, TokenType.CLUSTER_BY),
    488     exp.Column: lambda self: self._parse_column(),
   (...)
    512     "JOIN_TYPE": lambda self: self._parse_join_parts(),
    513 }
    515 STATEMENT_PARSERS = {
    516     TokenType.ALTER: lambda self: self._parse_alter(),
    517     TokenType.BEGIN: lambda self: self._parse_transaction(),
    518     TokenType.CACHE: lambda self: self._parse_cache(),
    519     TokenType.COMMIT: lambda self: self._parse_commit_or_rollback(),
    520     TokenType.COMMENT: lambda self: self._parse_comment(),
    521     TokenType.CREATE: lambda self: self._parse_create(),
    522     TokenType.DELETE: lambda self: self._parse_delete(),
    523     TokenType.DESC: lambda self: self._parse_describe(),
    524     TokenType.DESCRIBE: lambda self: self._parse_describe(),
    525     TokenType.DROP: lambda self: self._parse_drop(),
    526     TokenType.FROM: lambda self: exp.select("*").from_(
    527         t.cast(exp.From, self._parse_from(skip_from_token=True))
    528     ),
    529     TokenType.INSERT: lambda self: self._parse_insert(),
    530     TokenType.LOAD: lambda self: self._parse_load(),
--> 531     TokenType.MERGE: lambda self: self._parse_merge(),
    532     TokenType.PIVOT: lambda self: self._parse_simplified_pivot(),
    533     TokenType.PRAGMA: lambda self: self.expression(exp.Pragma, this=self._parse_expression()),
    534     TokenType.ROLLBACK: lambda self: self._parse_commit_or_rollback(),
    535     TokenType.SET: lambda self: self._parse_set(),
    536     TokenType.UNCACHE: lambda self: self._parse_uncache(),
    537     TokenType.UPDATE: lambda self: self._parse_update(),
    538     TokenType.USE: lambda self: self.expression(
    539         exp.Use,
    540         kind=self._match_texts(("ROLE", "WAREHOUSE", "DATABASE", "SCHEMA"))
    541         and exp.var(self._prev.text),
    542         this=self._parse_table(schema=False),
    543     ),
    544 }
    546 UNARY_PARSERS = {
    547     TokenType.PLUS: lambda self: self._parse_unary(),  # Unary + is handled as a no-op
    548     TokenType.NOT: lambda self: self.expression(exp.Not, this=self._parse_equality()),
    549     TokenType.TILDA: lambda self: self.expression(exp.BitwiseNot, this=self._parse_unary()),
    550     TokenType.DASH: lambda self: self.expression(exp.Neg, this=self._parse_unary()),
    551 }
    553 PRIMARY_PARSERS = {
    554     TokenType.STRING: lambda self, token: self.expression(
    555         exp.Literal, this=token.text, is_string=True
   (...)
    574     TokenType.SESSION_PARAMETER: lambda self, _: self._parse_session_parameter(),
    575 }

File /local_disk0/.ephemeral_nfs/envs/pythonEnv-6bc04fc0-872f-42cc-aa1e-70968fabeb08/lib/python3.10/site-packages/sqlglot/parser.py:4624, in Parser._parse_merge(self)
   4621 target = self._parse_table()
   4623 self._match(TokenType.USING)
-> 4624 using = self._parse_table()
   4626 self._match(TokenType.ON)
   4627 on = self._parse_conjunction()

File /local_disk0/.ephemeral_nfs/envs/pythonEnv-6bc04fc0-872f-42cc-aa1e-70968fabeb08/lib/python3.10/site-packages/sqlglot/parser.py:2490, in Parser._parse_table(self, schema, joins, alias_tokens, parse_bracket)
   2488 bracket = parse_bracket and self._parse_bracket(None)
   2489 bracket = self.expression(exp.Table, this=bracket) if bracket else None
-> 2490 this: exp.Expression = bracket or self._parse_table_parts(schema=schema)
   2492 if schema:
   2493     return self._parse_schema(this=this)

File /local_disk0/.ephemeral_nfs/envs/pythonEnv-6bc04fc0-872f-42cc-aa1e-70968fabeb08/lib/python3.10/site-packages/sqlglot/dialects/tsql.py:494, in TSQL.Parser._parse_table_parts(self, schema)
    493 def _parse_table_parts(self, schema: bool = False) -> exp.Table:
--> 494     table = super()._parse_table_parts(schema=schema)
    495     table.set("system_time", self._parse_system_time())
    496     return table

File /local_disk0/.ephemeral_nfs/envs/pythonEnv-6bc04fc0-872f-42cc-aa1e-70968fabeb08/lib/python3.10/site-packages/sqlglot/parser.py:2457, in Parser._parse_table_parts(self, schema)
   2454         table = self._parse_table_part(schema=schema)
   2456 if not table:
-> 2457     self.raise_error(f"Expected table name but got {self._curr}")
   2459 return self.expression(
   2460     exp.Table, this=table, db=db, catalog=catalog, pivots=self._parse_pivots()
   2461 )

File /local_disk0/.ephemeral_nfs/envs/pythonEnv-6bc04fc0-872f-42cc-aa1e-70968fabeb08/lib/python3.10/site-packages/sqlglot/parser.py:1003, in Parser.raise_error(self, message, token)
    991 error = ParseError.new(
    992     f"{message}. Line {token.line}, Col: {token.col}.\n"
    993     f"  {start_context}\033[4m{highlight}\033[0m{end_context}",
   (...)
    999     end_context=end_context,
   1000 )
   1002 if self.error_level == ErrorLevel.IMMEDIATE:
-> 1003     raise error
   1005 self.errors.append(error)

ParseError: Expected table name but got <Token token_type: TokenType.ALIAS, text: AS, line: 2, col: 32, start: 31, end: 32, comments: []>. Line 2, Col: 32.
  
MERGE mytable WITH (HOLDLOCK) AS T
USING mytable_merge AS S
ON (T.user_id = S.user_id)
WHEN NOT MATCHED BY TARGET
THEN INSERT(c1, c2
```
I know what the issue is. Will take a look soon, thanks for the report.

Resolving patch:
diff --git a/sqlglot/generator.py b/sqlglot/generator.py
--- a/sqlglot/generator.py
+++ b/sqlglot/generator.py
@@ -2528,10 +2528,21 @@ def when_sql(self, expression: exp.When) -> str:
         return f"WHEN {matched}{source}{condition} THEN {then}"
 
     def merge_sql(self, expression: exp.Merge) -> str:
-        this = self.sql(expression, "this")
+        table = expression.this
+        table_alias = ""
+
+        hints = table.args.get("hints")
+        if hints and table.alias and isinstance(hints[0], exp.WithTableHint):
+            # T-SQL syntax is MERGE ... <target_table> [WITH (<merge_hint>)] [[AS] table_alias]
+            table = table.copy()
+            table_alias = f" AS {self.sql(table.args['alias'].pop())}"
+
+        this = self.sql(table)
         using = f"USING {self.sql(expression, 'using')}"
         on = f"ON {self.sql(expression, 'on')}"
-        return f"MERGE INTO {this} {using} {on} {self.expressions(expression, sep=' ')}"
+        expressions = self.expressions(expression, sep=" ")
+
+        return f"MERGE INTO {this}{table_alias} {using} {on} {expressions}"
 
     def tochar_sql(self, expression: exp.ToChar) -> str:
         if expression.args.get("format"):
diff --git a/sqlglot/parser.py b/sqlglot/parser.py
--- a/sqlglot/parser.py
+++ b/sqlglot/parser.py
@@ -4620,6 +4620,9 @@ def _parse_merge(self) -> exp.Merge:
         self._match(TokenType.INTO)
         target = self._parse_table()
 
+        if target and self._match(TokenType.ALIAS, advance=False):
+            target.set("alias", self._parse_table_alias())
+
         self._match(TokenType.USING)
         using = self._parse_table()
 


Test patch:
diff --git a/tests/dialects/test_tsql.py b/tests/dialects/test_tsql.py
--- a/tests/dialects/test_tsql.py
+++ b/tests/dialects/test_tsql.py
@@ -17,6 +17,11 @@ def test_tsql(self):
                 "spark": "DROP TABLE IF EXISTS TempTableName",
             },
         )
+
+        self.validate_identity(
+            "MERGE INTO mytable WITH (HOLDLOCK) AS T USING mytable_merge AS S "
+            "ON (T.user_id = S.user_id) WHEN NOT MATCHED THEN INSERT (c1, c2) VALUES (S.c1, S.c2)"
+        )
         self.validate_identity("UPDATE x SET y = 1 OUTPUT x.a, x.b INTO @y FROM y")
         self.validate_identity("UPDATE x SET y = 1 OUTPUT x.a, x.b FROM y")
         self.validate_identity("INSERT INTO x (y) OUTPUT x.a, x.b INTO l SELECT * FROM z")