From f16743ed2b41b299b748dfbef10c8262f308ab38 Mon Sep 17 00:00:00 2001 From: cetra3 Date: Wed, 25 Feb 2026 10:54:00 +0000 Subject: [PATCH] Support `${placeholder}` syntax in tokenizer Add support for dollar-brace placeholders (`${name}`, `${1}`, etc.) in the tokenizer, storing the full `${...}` string in the existing `Token::Placeholder` / `Value::Placeholder` variants with zero breaking changes. Unterminated `${name` produces a clear error. --- src/tokenizer.rs | 45 +++++++++++++++++++++++++++++++++++++++ tests/sqlparser_common.rs | 5 ++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/tokenizer.rs b/src/tokenizer.rs index 5ca686d46..8149bfa95 100644 --- a/src/tokenizer.rs +++ b/src/tokenizer.rs @@ -1920,6 +1920,21 @@ impl<'a> Tokenizer<'a> { chars.next(); + // Handle ${placeholder} syntax + if matches!(chars.peek(), Some('{')) { + chars.next(); // consume '{' + let placeholder = peeking_take_while(chars, |ch| ch != '}'); + if matches!(chars.peek(), Some('}')) { + chars.next(); // consume '}' + return Ok(Token::Placeholder(format!("${{{placeholder}}}"))); + } else { + return self.tokenizer_error( + chars.location(), + "Unterminated dollar-brace placeholder, expected '}'", + ); + } + } + // If the dialect does not support dollar-quoted strings, then `$$` is rather a placeholder. if matches!(chars.peek(), Some('$')) && !self.dialect.supports_dollar_placeholder() { chars.next(); @@ -3218,6 +3233,36 @@ mod tests { ); } + #[test] + fn tokenize_dollar_brace_placeholder() { + let sql = String::from("SELECT ${name}, ${1}"); + let dialect = GenericDialect {}; + let tokens = Tokenizer::new(&dialect, &sql).tokenize().unwrap(); + assert_eq!( + tokens, + vec![ + Token::make_keyword("SELECT"), + Token::Whitespace(Whitespace::Space), + Token::Placeholder("${name}".into()), + Token::Comma, + Token::Whitespace(Whitespace::Space), + Token::Placeholder("${1}".into()), + ] + ); + } + + #[test] + fn tokenize_dollar_brace_placeholder_unterminated() { + let sql = String::from("SELECT ${name"); + let dialect = GenericDialect {}; + let result = Tokenizer::new(&dialect, &sql).tokenize(); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err + .to_string() + .contains("Unterminated dollar-brace placeholder")); + } + #[test] fn tokenize_nested_dollar_quoted_strings() { let sql = String::from("SELECT $tag$dollar $nested$ string$tag$"); diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index a3b5404d3..068a8c768 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -10415,7 +10415,7 @@ fn test_placeholder() { }) ); - let sql = "SELECT $fromage_français, :x, ?123"; + let sql = "SELECT $fromage_français, :x, ?123, ${placeholder}"; let ast = dialects.verified_only_select(sql); assert_eq!( ast.projection, @@ -10429,6 +10429,9 @@ fn test_placeholder() { UnnamedExpr(Expr::Value( (Value::Placeholder("?123".into())).with_empty_span() )), + UnnamedExpr(Expr::Value( + (Value::Placeholder("${placeholder}".into())).with_empty_span() + )), ] ); }