Skip to content

PostgreSQL: Add support for DO statement - #2618

Open
devtechedge wants to merge 9 commits into
apache:mainfrom
devtechedge:postgres-do-statement
Open

devtechedge wants to merge 9 commits into
apache:mainfrom
devtechedge:postgres-do-statement

Conversation

@devtechedge

@devtechedge devtechedge commented Oct 7, 2026 •

Copy link
Copy Markdown

DO blocks like DO $$BEGIN RAISE NOTICE 'hi'; END$$ currently fail with "Expected: an SQL statement, found: DO".

This adds Statement::Do { language, code } for PostgreSQL's DO statement, gated behind a new Dialect::supports_do_statement() that is enabled for PostgreSQL.

The code block must be a string literal (single-quoted, dollar-quoted, or E''), and LANGUAGE lang is accepted either before or after it, as the PostgreSQL grammar allows.

Display normalizes to the documented DO [ LANGUAGE lang ] code order, and a repeated LANGUAGE is rejected.

Tests in tests/sqlparser_common.rs cover both orders, the round trip, the error cases, and dialects without support.

The implementation and tests were drafted with an AI coding assistant (Grok 4.7 effort: xHigh), and I reviewed the diff, ran the test suite and the fuzzer, and stand by the change.

Fixes #2609

@LucaCappelletti94

Copy link
Copy Markdown
Contributor

Please add examples of this syntax to the fuzzer corpus

@LucaCappelletti94 LucaCappelletti94 added enhancement New feature or request waiting on reviewer PR needs to be reviewed waiting on contributor The review needs further refinements by its author and removed waiting on reviewer PR needs to be reviewed labels Oct 12, 2026
@codecov-commenter

codecov-commenter commented Oct 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.53846% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.27%. Comparing base (7b5821d) to head (2f5cd83).

Files with missing lines Patch % Lines
src/ast/mod.rs 76.92% 3 Missing ⚠️
src/ast/spans.rs 88.23% 1 Missing and 1 partial ⚠️
src/parser/mod.rs 87.50% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2618      +/-   ##
==========================================
+ Coverage   81.24%   81.27%   +0.02%     
==========================================
  Files          42       42              
  Lines       33901    33951      +50     
  Branches    33901    33951      +50     
==========================================
+ Hits        27543    27592      +49     
+ Misses       2792     2790       -2     
- Partials     3566     3569       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@devtechedge
devtechedge force-pushed the postgres-do-statement branch from bad1741 to 0198d07 Compare October 12, 2026 16:52
@devtechedge

Copy link
Copy Markdown
Author

@LucaCappelletti94 Good call, the fuzzer should know about this syntax too.

I added five seeds under fuzz/fuzz_seeds/: postgres_do.sql, postgres_do_language_before.sql, postgres_do_language_after.sql, postgres_do_tagged_dollar_quote.sql and postgres_do_single_quoted.sql.

Together they cover a plain $$ body, LANGUAGE plpgsql on either side of the body, a tagged dollar quote with a nested $$ inside, and a single-quoted body.

fmt, clippy and the full test suite pass with them added.

Happy to add or reshape any of them if you had other cases in mind 🙂

Comment thread src/ast/mod.rs Outdated
Comment on lines +4937 to +4942
Do {
/// Optional procedural language name.
language: Option<Ident>,
/// The anonymous code block, a string literal.
code: ValueWithSpan,
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Refactor this to a named struct, as every new statement variant does. I will do another review round after this refactoring as I expect most of the code will change.

Suggested change
Do {
/// Optional procedural language name.
language: Option<Ident>,
/// The anonymous code block, a string literal.
code: ValueWithSpan,
},
Do(DoStatement),

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks @LucaCappelletti94!

I refactored it into a named DoStatement struct (Statement::Do(DoStatement)), with its own Display and From<DoStatement> for Statement, following the pattern of the other statement structs.

I also gave it a Spanned impl covering the language and code block, with a small span test.

Parsing behavior and existing tests are unchanged, and fmt, clippy and the full test suite pass.

Ready for another look whenever you have time.

@devtechedge
devtechedge force-pushed the postgres-do-statement branch from f58b626 to adc68d1 Compare October 14, 2026 04:29
Comment thread src/parser/mod.rs Outdated
match &self.peek_token_ref().token {
Token::SingleQuotedString(_)
| Token::DollarQuotedString(_)
| Token::EscapedStringLiteral(_) => {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You should accept the Unicode escape string as a code block too. PostgreSQL 18 runs DO U&'BEGIN NULL; END', and the PostgreSQL dialect tokenizes it as Token::UnicodeStringLiteral, so this match rejects it with Expected: string literal, found: U&'BEGIN NULL; END'.

Suggested change
| Token::EscapedStringLiteral(_) => {}
| Token::EscapedStringLiteral(_)
| Token::UnicodeStringLiteral(_) => {}

Comment thread tests/sqlparser_common.rs
_ => unreachable!(),
}

dialects.verified_stmt("DO 'BEGIN NULL; END'");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

And its red test.

Suggested change
dialects.verified_stmt("DO 'BEGIN NULL; END'");
dialects.verified_stmt("DO 'BEGIN NULL; END'");
dialects.verified_stmt("DO U&'BEGIN NULL; END'");

@devtechedge

Copy link
Copy Markdown
Author

Thanks @LucaCappelletti94.

I've applied both suggestions: parse_do now accepts Unicode escape strings (U&'...') as the code block, and I added DO U&'BEGIN NULL; END' to parse_do_statement.

fmt and the full test suite pass locally.

Comment thread src/parser/mod.rs
Comment on lines +1517 to +1539
pub fn parse_do(&mut self) -> Result<Statement, ParserError> {
let mut language = self.parse_do_language()?;
match &self.peek_token_ref().token {
Token::SingleQuotedString(_)
| Token::DollarQuotedString(_)
| Token::EscapedStringLiteral(_)
| Token::UnicodeStringLiteral(_) => {}
_ => return self.expected_ref("string literal", self.peek_token_ref()),
}
let code = self.parse_value()?;
if language.is_none() {
language = self.parse_do_language()?;
}
Ok(Statement::Do(DoStatement { language, code }))
}

fn parse_do_language(&mut self) -> Result<Option<Ident>, ParserError> {
if self.parse_keyword(Keyword::LANGUAGE) {
Ok(Some(self.parse_identifier()?))
} else {
Ok(None)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You should parse DO the way PostgreSQL's dostmt_opt_list does. That means any run of code blocks and LANGUAGE items, with the name either an identifier or any string literal, and the missing-code and duplicate checks left to the server. The repo's fuzz_postgres_accepts target panics on DO LANGUAGE plpgsql, DO $$a$$ $$b$$, a repeated LANGUAGE and DO LANGUAGE $$plpgsql$$ $$a$$, all of which PostgreSQL's grammar accepts, and fuzzing from this PR's seeds reaches one within seconds.

Please use the fuzzer locally before the next review round to catch these things.

Suggested change
pub fn parse_do(&mut self) -> Result<Statement, ParserError> {
let mut language = self.parse_do_language()?;
match &self.peek_token_ref().token {
Token::SingleQuotedString(_)
| Token::DollarQuotedString(_)
| Token::EscapedStringLiteral(_)
| Token::UnicodeStringLiteral(_) => {}
_ => return self.expected_ref("string literal", self.peek_token_ref()),
}
let code = self.parse_value()?;
if language.is_none() {
language = self.parse_do_language()?;
}
Ok(Statement::Do(DoStatement { language, code }))
}
fn parse_do_language(&mut self) -> Result<Option<Ident>, ParserError> {
if self.parse_keyword(Keyword::LANGUAGE) {
Ok(Some(self.parse_identifier()?))
} else {
Ok(None)
}
}
pub fn parse_do(&mut self) -> Result<Statement, ParserError> {
let mut options = Vec::new();
loop {
let language = self.parse_keyword(Keyword::LANGUAGE);
let string_literal = matches!(
self.peek_token_ref().token,
Token::SingleQuotedString(_)
| Token::DollarQuotedString(_)
| Token::EscapedStringLiteral(_)
| Token::UnicodeStringLiteral(_)
);
let option = match (language, string_literal) {
(true, true) => DoOption::LanguageLiteral(self.parse_value()?),
(true, false) => DoOption::Language(self.parse_identifier()?),
(false, true) => DoOption::Code(self.parse_value()?),
(false, false) if options.is_empty() => {
return self.expected_ref("string literal", self.peek_token_ref());
}
(false, false) => break,
};
options.push(option);
}
Ok(Statement::Do(DoStatement { options }))
}

Comment thread src/ast/mod.rs
Comment on lines +11609 to +11624
pub struct DoStatement {
/// Optional procedural language name.
pub language: Option<Ident>,
/// The anonymous code block, a string literal.
pub code: ValueWithSpan,
}

impl fmt::Display for DoStatement {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "DO ")?;
if let Some(language) = &self.language {
write!(f, "LANGUAGE {language} ")?;
}
write!(f, "{}", self.code)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You should store the items in source order, so every accepted form prints back as written.

Suggested change
pub struct DoStatement {
/// Optional procedural language name.
pub language: Option<Ident>,
/// The anonymous code block, a string literal.
pub code: ValueWithSpan,
}
impl fmt::Display for DoStatement {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "DO ")?;
if let Some(language) = &self.language {
write!(f, "LANGUAGE {language} ")?;
}
write!(f, "{}", self.code)
}
}
pub struct DoStatement {
/// The code blocks and `LANGUAGE` options, in source order.
pub options: Vec<DoOption>,
}
impl fmt::Display for DoStatement {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "DO {}", display_separated(&self.options, " "))
}
}
/// An item of a [`DoStatement`].
#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))]
pub enum DoOption {
/// `LANGUAGE lang_name`
Language(Ident),
/// `LANGUAGE` followed by the name as a string literal.
LanguageLiteral(ValueWithSpan),
/// The anonymous code block, a string literal.
Code(ValueWithSpan),
}
impl fmt::Display for DoOption {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
DoOption::Language(language) => write!(f, "LANGUAGE {language}"),
DoOption::LanguageLiteral(language) => write!(f, "LANGUAGE {language}"),
DoOption::Code(code) => write!(f, "{code}"),
}
}
}

Comment thread src/ast/spans.rs Outdated
Join, JoinConstraint, JoinOperator, JsonPath, JsonPathElem, LateralView, LimitClause,
MatchRecognizePattern, Measure, Merge, MergeAction, MergeClause, MergeInsertExpr,
MergeInsertKind, MergeUpdateExpr, MergeUpdateKind, NamedParenthesizedList,
CreateTableOptions, Cte, Delete, DoStatement, DoUpdate, ExceptSelectItem,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You should import the new DoOption.

Suggested change
CreateTableOptions, Cte, Delete, DoStatement, DoUpdate, ExceptSelectItem,
CreateTableOptions, Cte, Delete, DoOption, DoStatement, DoUpdate, ExceptSelectItem,

Comment thread src/ast/spans.rs
Comment on lines +2636 to +2646
impl Spanned for DoStatement {
fn span(&self) -> Span {
let DoStatement { language, code } = self;
union_spans(
language
.iter()
.map(|l| l.span)
.chain(core::iter::once(code.span)),
)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You should span the option list, each item by its name or literal.

Suggested change
impl Spanned for DoStatement {
fn span(&self) -> Span {
let DoStatement { language, code } = self;
union_spans(
language
.iter()
.map(|l| l.span)
.chain(core::iter::once(code.span)),
)
}
}
impl Spanned for DoStatement {
fn span(&self) -> Span {
union_spans(self.options.iter().map(|option| option.span()))
}
}
impl Spanned for DoOption {
fn span(&self) -> Span {
match self {
DoOption::Language(language) => language.span,
DoOption::LanguageLiteral(value) | DoOption::Code(value) => value.span,
}
}
}

Comment thread tests/sqlparser_common.rs Outdated
Comment on lines +20519 to +20551
match dialects.verified_stmt("DO $$BEGIN RAISE NOTICE 'hi'; END$$") {
Statement::Do(DoStatement { language, code }) => {
assert_eq!(language, None);
assert_eq!(
code.value,
Value::DollarQuotedString(DollarQuotedString {
value: "BEGIN RAISE NOTICE 'hi'; END".to_string(),
tag: None,
})
);
}
_ => unreachable!(),
}

match dialects.verified_stmt("DO LANGUAGE plpgsql $body$BEGIN NULL; END$body$") {
Statement::Do(DoStatement { language, .. }) => {
assert_eq!(language, Some(Ident::new("plpgsql")));
}
_ => unreachable!(),
}

dialects.verified_stmt("DO 'BEGIN NULL; END'");
dialects.verified_stmt("DO U&'BEGIN NULL; END'");
dialects.one_statement_parses_to(
"DO $$BEGIN NULL; END$$ LANGUAGE plpgsql",
"DO LANGUAGE plpgsql $$BEGIN NULL; END$$",
);
assert_eq!(
dialects
.parse_sql_statements("DO LANGUAGE plpgsql $$BEGIN NULL; END$$ LANGUAGE sql")
.unwrap_err(),
ParserError::ParserError("Expected: end of statement, found: LANGUAGE".to_string())
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

More red tests.

Suggested change
match dialects.verified_stmt("DO $$BEGIN RAISE NOTICE 'hi'; END$$") {
Statement::Do(DoStatement { language, code }) => {
assert_eq!(language, None);
assert_eq!(
code.value,
Value::DollarQuotedString(DollarQuotedString {
value: "BEGIN RAISE NOTICE 'hi'; END".to_string(),
tag: None,
})
);
}
_ => unreachable!(),
}
match dialects.verified_stmt("DO LANGUAGE plpgsql $body$BEGIN NULL; END$body$") {
Statement::Do(DoStatement { language, .. }) => {
assert_eq!(language, Some(Ident::new("plpgsql")));
}
_ => unreachable!(),
}
dialects.verified_stmt("DO 'BEGIN NULL; END'");
dialects.verified_stmt("DO U&'BEGIN NULL; END'");
dialects.one_statement_parses_to(
"DO $$BEGIN NULL; END$$ LANGUAGE plpgsql",
"DO LANGUAGE plpgsql $$BEGIN NULL; END$$",
);
assert_eq!(
dialects
.parse_sql_statements("DO LANGUAGE plpgsql $$BEGIN NULL; END$$ LANGUAGE sql")
.unwrap_err(),
ParserError::ParserError("Expected: end of statement, found: LANGUAGE".to_string())
);
match dialects.verified_stmt("DO $$BEGIN RAISE NOTICE 'hi'; END$$") {
Statement::Do(DoStatement { options }) => assert_eq!(
options,
vec![DoOption::Code(
Value::DollarQuotedString(DollarQuotedString {
value: "BEGIN RAISE NOTICE 'hi'; END".to_string(),
tag: None,
})
.with_empty_span()
)]
),
_ => unreachable!(),
}
match dialects.verified_stmt("DO LANGUAGE plpgsql $body$BEGIN NULL; END$body$") {
Statement::Do(DoStatement { options }) => {
assert_eq!(options[0], DoOption::Language(Ident::new("plpgsql")));
}
_ => unreachable!(),
}
dialects.verified_stmt("DO 'BEGIN NULL; END'");
dialects.verified_stmt("DO U&'BEGIN NULL; END'");
dialects.verified_stmt("DO $$BEGIN NULL; END$$ LANGUAGE plpgsql");
dialects.verified_stmt("DO LANGUAGE 'plpgsql' $$BEGIN NULL; END$$");
dialects.verified_stmt("DO LANGUAGE $$plpgsql$$ $$BEGIN NULL; END$$");
dialects.verified_stmt("DO $$BEGIN NULL; END$$ LANGUAGE E'plpgsql'");
dialects.verified_stmt("DO LANGUAGE plpgsql");
dialects.verified_stmt("DO $$BEGIN NULL; END$$ $$BEGIN NULL; END$$");
dialects.verified_stmt("DO LANGUAGE plpgsql LANGUAGE sql $$BEGIN NULL; END$$");

Accept any run of code blocks and LANGUAGE items, with the language
name as an identifier or any string literal, and keep them in source
order so every accepted form round-trips. Missing-code and duplicate
checks are left to the server, matching PostgreSQL's grammar.
@devtechedge

Copy link
Copy Markdown
Author

Thanks, both reviews are addressed in 4e846e6.

parse_do now follows dostmt_opt_list using your suggestion: any run of code blocks and LANGUAGE items, with the language as an identifier or any string literal, kept in source order as Vec<DoOption> so every form round-trips.

I added all your test cases.

I ran fuzz_postgres_accepts from the DO seeds for 8 minutes (about 4.4M execs) and nothing came from DO parsing.

What it did turn up are older issues elsewhere:

  • odd operator expressions in UPDATE … SET (e.g. ! + 0)
  • non-ASCII dollar-quote tags failing in the tokenizer, which also breaks SELECT

Happy to look at those in separate PRs.

@devtechedge
devtechedge force-pushed the postgres-do-statement branch from 4e846e6 to 3ce05be Compare October 15, 2026 22:25

@LucaCappelletti94 LucaCappelletti94 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Also add this red test in the postgres test suite. Please do check whether I missed any other DO related syntaxes, I am not omniscient.

    dialects.verified_stmt("DO LANGUAGE plpgsql LANGUAGE sql $$BEGIN NULL; END$$");
    dialects.one_statement_parses_to(
        "DO LANGUAGE N'BEGIN NULL; END'",
        "DO LANGUAGE nchar 'BEGIN NULL; END'",
    );

Comment thread src/parser/mod.rs
);
let option = match (language, string_literal) {
(true, true) => DoOption::LanguageLiteral(self.parse_value()?),
(true, false) => DoOption::Language(self.parse_identifier()?),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You should read LANGUAGE N'...' the way PostgreSQL does, as the language nchar followed by a code block. PostgreSQL's scanner splits N'...' into the keyword NCHAR and a string, and NCHAR is a non-reserved word, so DO LANGUAGE N'BEGIN NULL; END' passes pg_query. fuzz_postgres_accepts panics on it with Expected: identifier, found: N'BEGIN NULL; END', reached from this PR's seeds in about two minutes.

Suggested change
(true, false) => DoOption::Language(self.parse_identifier()?),
(true, false) => match &self.peek_token_ref().token {
// PostgreSQL lexes `N'...'` as the keyword `NCHAR` followed by a string
Token::NationalStringLiteral(code) => {
let code = Value::SingleQuotedString(code.clone());
let span = self.next_token().span;
options.push(DoOption::Language(Ident::with_span(span, "nchar")));
DoOption::Code(code.with_span(span))
}
_ => DoOption::Language(self.parse_identifier()?),
},

@devtechedge

Copy link
Copy Markdown
Author

Thank you for the detailed review and for the suggested fix, I really appreciate it.

I will add the test in the Postgres suite and implement the N'...' handling as you described.

I will also check for any other DO forms I may have missed.

So I can plan my time well, could you please give me a rough idea of how many more rounds of changes you expect for this PR?

I am very happy to keep iterating until you are satisfied with it, and a rough sense of the remaining scope will simply help me plan and make sure I cover everything carefully.

@LucaCappelletti94

Copy link
Copy Markdown
Contributor

So I can plan my time well, could you please give me a rough idea of how many more rounds of changes you expect for this PR?

Absolutely no idea, until we can't find any fault in it anymore I guess.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request PostgreSQL waiting on contributor The review needs further refinements by its author

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PostgreSQL DO statement

3 participants