Skip to content

feat: add support for named params #458

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions crates/pgt_lexer/src/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,18 @@ impl<'a> Lexer<'a> {
pgt_tokenizer::TokenKind::Eof => SyntaxKind::EOF,
pgt_tokenizer::TokenKind::Backtick => SyntaxKind::BACKTICK,
pgt_tokenizer::TokenKind::PositionalParam => SyntaxKind::POSITIONAL_PARAM,
pgt_tokenizer::TokenKind::NamedParam { kind } => {
match kind {
pgt_tokenizer::NamedParamKind::ColonIdentifier { terminated: false } => {
err = "Missing trailing \" to terminate the named parameter";
}
Comment on lines +137 to +139
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

junge, richtig geil

pgt_tokenizer::NamedParamKind::ColonString { terminated: false } => {
err = "Missing trailing ' to terminate the named parameter";
}
_ => {}
};
SyntaxKind::POSITIONAL_PARAM
}
pgt_tokenizer::TokenKind::QuotedIdent { terminated } => {
if !terminated {
err = "Missing trailing \" to terminate the quoted identifier"
Expand Down
30 changes: 30 additions & 0 deletions crates/pgt_lexer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,36 @@ mod tests {
assert!(!errors[0].message.to_string().is_empty());
}

#[test]
fn test_lexing_string_params_with_errors() {
let input = "SELECT :'unterminated string";
let lexed = lex(input);

// Should have tokens
assert!(!lexed.is_empty());

// Should have an error for unterminated string
let errors = lexed.errors();
assert!(!errors.is_empty());
// Check the error message exists
assert!(!errors[0].message.to_string().is_empty());
}

#[test]
fn test_lexing_identifier_params_with_errors() {
let input = "SELECT :\"unterminated string";
let lexed = lex(input);

// Should have tokens
assert!(!lexed.is_empty());

// Should have an error for unterminated string
let errors = lexed.errors();
assert!(!errors.is_empty());
// Check the error message exists
assert!(!errors[0].message.to_string().is_empty());
}

#[test]
fn test_token_ranges() {
let input = "SELECT id";
Expand Down
2 changes: 1 addition & 1 deletion crates/pgt_lexer_codegen/src/syntax_kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const PUNCT: &[(&str, &str)] = &[
("`", "BACKTICK"),
];

const EXTRA: &[&str] = &["POSITIONAL_PARAM", "ERROR", "COMMENT", "EOF"];
const EXTRA: &[&str] = &["POSITIONAL_PARAM", "NAMED_PARAM", "ERROR", "COMMENT", "EOF"];

const LITERALS: &[&str] = &[
"BIT_STRING",
Expand Down
69 changes: 66 additions & 3 deletions crates/pgt_tokenizer/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
mod cursor;
mod token;
use cursor::{Cursor, EOF_CHAR};
pub use token::{Base, LiteralKind, Token, TokenKind};
pub use token::{Base, LiteralKind, NamedParamKind, Token, TokenKind};

// via: https://github.com/postgres/postgres/blob/db0c96cc18aec417101e37e59fcc53d4bf647915/src/backend/parser/scan.l#L346
// ident_start [A-Za-z\200-\377_]
Expand Down Expand Up @@ -132,6 +132,46 @@ impl Cursor<'_> {
}
_ => TokenKind::Dot,
},
'@' => {
if is_ident_start(self.first()) {
// Named parameter with @ prefix.
self.eat_while(is_ident_cont);
TokenKind::NamedParam {
kind: NamedParamKind::AtPrefix,
}
} else {
TokenKind::At
}
}
':' => {
// Named parameters in psql with different substitution styles.
//
// https://www.postgresql.org/docs/current/app-psql.html#APP-PSQL-INTERPOLATION
match self.first() {
'\'' => {
// Named parameter with colon prefix and single quotes.
self.bump();
let terminated = self.single_quoted_string();
let kind = NamedParamKind::ColonString { terminated };
TokenKind::NamedParam { kind }
}
'"' => {
// Named parameter with colon prefix and double quotes.
self.bump();
let terminated = self.double_quoted_string();
let kind = NamedParamKind::ColonIdentifier { terminated };
TokenKind::NamedParam { kind }
}
c if is_ident_start(c) => {
// Named parameter with colon prefix.
self.eat_while(is_ident_cont);
TokenKind::NamedParam {
kind: NamedParamKind::ColonRaw,
}
}
_ => TokenKind::Colon,
}
}
// One-symbol tokens.
';' => TokenKind::Semi,
'\\' => TokenKind::Backslash,
Expand All @@ -140,11 +180,9 @@ impl Cursor<'_> {
')' => TokenKind::CloseParen,
'[' => TokenKind::OpenBracket,
']' => TokenKind::CloseBracket,
'@' => TokenKind::At,
'#' => TokenKind::Pound,
'~' => TokenKind::Tilde,
'?' => TokenKind::Question,
':' => TokenKind::Colon,
'$' => {
// Dollar quoted strings
if is_ident_start(self.first()) || self.first() == '$' {
Expand Down Expand Up @@ -613,6 +651,31 @@ mod tests {
}
tokens
}

#[test]
fn named_param_at() {
let result = lex("select 1 from c where id = @id;");
assert_debug_snapshot!(result);
}

#[test]
fn named_param_colon_raw() {
let result = lex("select 1 from c where id = :id;");
assert_debug_snapshot!(result);
}

#[test]
fn named_param_colon_string() {
let result = lex("select 1 from c where id = :'id';");
assert_debug_snapshot!(result);
}

#[test]
fn named_param_colon_identifier() {
let result = lex("select 1 from c where id = :\"id\";");
assert_debug_snapshot!(result);
}

#[test]
fn lex_statement() {
let result = lex("select 1;");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
source: crates/pgt_tokenizer/src/lib.rs
expression: result
snapshot_kind: text
---
[
"select" @ Ident,
" " @ Space,
"1" @ Literal { kind: Int { base: Decimal, empty_int: false } },
" " @ Space,
"from" @ Ident,
" " @ Space,
"c" @ Ident,
" " @ Space,
"where" @ Ident,
" " @ Space,
"id" @ Ident,
" " @ Space,
"=" @ Eq,
" " @ Space,
"@id" @ NamedParam { kind: AtPrefix },
";" @ Semi,
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
source: crates/pgt_tokenizer/src/lib.rs
expression: result
snapshot_kind: text
---
[
"select" @ Ident,
" " @ Space,
"1" @ Literal { kind: Int { base: Decimal, empty_int: false } },
" " @ Space,
"from" @ Ident,
" " @ Space,
"c" @ Ident,
" " @ Space,
"where" @ Ident,
" " @ Space,
"id" @ Ident,
" " @ Space,
"=" @ Eq,
" " @ Space,
":\"id\"" @ NamedParam { kind: ColonIdentifier { terminated: true } },
";" @ Semi,
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
source: crates/pgt_tokenizer/src/lib.rs
expression: result
snapshot_kind: text
---
[
"select" @ Ident,
" " @ Space,
"1" @ Literal { kind: Int { base: Decimal, empty_int: false } },
" " @ Space,
"from" @ Ident,
" " @ Space,
"c" @ Ident,
" " @ Space,
"where" @ Ident,
" " @ Space,
"id" @ Ident,
" " @ Space,
"=" @ Eq,
" " @ Space,
":id" @ NamedParam { kind: ColonRaw },
";" @ Semi,
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
source: crates/pgt_tokenizer/src/lib.rs
expression: result
snapshot_kind: text
---
[
"select" @ Ident,
" " @ Space,
"1" @ Literal { kind: Int { base: Decimal, empty_int: false } },
" " @ Space,
"from" @ Ident,
" " @ Space,
"c" @ Ident,
" " @ Space,
"where" @ Ident,
" " @ Space,
"id" @ Ident,
" " @ Space,
"=" @ Eq,
" " @ Space,
":'id'" @ NamedParam { kind: ColonString { terminated: true } },
";" @ Semi,
]
30 changes: 30 additions & 0 deletions crates/pgt_tokenizer/src/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ pub enum TokenKind {
///
/// see: <https://www.postgresql.org/docs/16/sql-expressions.html#SQL-EXPRESSIONS-PARAMETERS-POSITIONAL>
PositionalParam,
/// Named Parameter, e.g., `@name`
///
/// This is used in some ORMs and query builders, like sqlc.
NamedParam {
kind: NamedParamKind,
},
/// Quoted Identifier, e.g., `"update"` in `update "my_table" set "a" = 5;`
///
/// These are case-sensitive, unlike [`TokenKind::Ident`]
Expand All @@ -104,6 +110,30 @@ pub enum TokenKind {
},
}

#[derive(Debug, PartialEq, Clone, Copy)]
pub enum NamedParamKind {
/// e.g. `@name`
///
/// Used in:
/// - sqlc: https://docs.sqlc.dev/en/latest/howto/named_parameters.html
AtPrefix,

/// e.g. `:name` (raw substitution)
///
/// Used in: psql
ColonRaw,

/// e.g. `:'name'` (quoted string substitution)
///
/// Used in: psql
ColonString { terminated: bool },

/// e.g. `:"name"` (quoted identifier substitution)
///
/// Used in: psql
ColonIdentifier { terminated: bool },
}

/// Parsed token.
/// It doesn't contain information about data that has been parsed,
/// only the type of the token and its size.
Expand Down