Skip to content

Add support for table valued functions for SQL Server #1839

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 16 commits into from
May 23, 2025
Merged
Show file tree
Hide file tree
Changes from 14 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
24 changes: 22 additions & 2 deletions src/ast/data_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,17 @@ pub enum DataType {
/// Table type in [PostgreSQL], e.g. CREATE FUNCTION RETURNS TABLE(...).
///
/// [PostgreSQL]: https://www.postgresql.org/docs/15/sql-createfunction.html
Table(Vec<ColumnDef>),
/// [MsSQL]: https://learn.microsoft.com/en-us/sql/t-sql/statements/create-function-transact-sql?view=sql-server-ver16#c-create-a-multi-statement-table-valued-function
Table(Option<Vec<ColumnDef>>),
/// Table type with a name, e.g. CREATE FUNCTION RETURNS @result TABLE(...).
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we add a link to the docs that support NamedTable variant?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done 👍

///
/// [MsSQl]: https://learn.microsoft.com/en-us/sql/t-sql/statements/create-function-transact-sql?view=sql-server-ver16#table
NamedTable {
/// Table name.
name: ObjectName,
/// Table columns.
columns: Vec<ColumnDef>,
},
/// Fixed-length character type, e.g. CHARACTER(10).
Character(Option<CharacterLength>),
/// Fixed-length char type, e.g. CHAR(10).
Expand Down Expand Up @@ -716,7 +726,17 @@ impl fmt::Display for DataType {
DataType::Unspecified => Ok(()),
DataType::Trigger => write!(f, "TRIGGER"),
DataType::AnyType => write!(f, "ANY TYPE"),
DataType::Table(fields) => write!(f, "TABLE({})", display_comma_separated(fields)),
DataType::Table(fields) => match fields {
Some(fields) => {
write!(f, "TABLE({})", display_comma_separated(fields))
}
None => {
write!(f, "TABLE")
}
},
DataType::NamedTable { name, columns } => {
write!(f, "{} TABLE ({})", name, display_comma_separated(columns))
}
DataType::GeometricType(kind) => write!(f, "{}", kind),
}
}
Expand Down
6 changes: 6 additions & 0 deletions src/ast/ddl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2346,6 +2346,12 @@ impl fmt::Display for CreateFunction {
if let Some(CreateFunctionBody::Return(function_body)) = &self.function_body {
write!(f, " RETURN {function_body}")?;
}
if let Some(CreateFunctionBody::AsReturnExpr(function_body)) = &self.function_body {
write!(f, " AS RETURN {function_body}")?;
}
if let Some(CreateFunctionBody::AsReturnSelect(function_body)) = &self.function_body {
write!(f, " AS RETURN {function_body}")?;
}
if let Some(using) = &self.using {
write!(f, " {using}")?;
}
Expand Down
24 changes: 24 additions & 0 deletions src/ast/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8778,6 +8778,30 @@ pub enum CreateFunctionBody {
///
/// [PostgreSQL]: https://www.postgresql.org/docs/current/sql-createfunction.html
Return(Expr),

/// Function body expression using the 'AS RETURN' keywords
///
/// Example:
/// ```sql
/// CREATE FUNCTION myfunc(a INT, b INT)
/// RETURNS TABLE
/// AS RETURN (SELECT a + b AS sum);
/// ```
///
/// [MsSql]: https://learn.microsoft.com/en-us/sql/t-sql/statements/create-function-transact-sql
AsReturnExpr(Expr),

/// Function body expression using the 'AS RETURN' keywords, with an un-parenthesized SELECT query
///
/// Example:
/// ```sql
/// CREATE FUNCTION myfunc(a INT, b INT)
/// RETURNS TABLE
/// AS RETURN SELECT a + b AS sum;
/// ```
///
/// [MsSql]: https://learn.microsoft.com/en-us/sql/t-sql/statements/create-function-transact-sql?view=sql-server-ver16#select_stmt
AsReturnSelect(Select),
Copy link
Contributor

Choose a reason for hiding this comment

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

maybe we can also include a reference doc link here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done 👍

}

#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)]
Expand Down
98 changes: 79 additions & 19 deletions src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5204,19 +5204,79 @@ impl<'a> Parser<'a> {
let (name, args) = self.parse_create_function_name_and_params()?;

self.expect_keyword(Keyword::RETURNS)?;
let return_type = Some(self.parse_data_type()?);

self.expect_keyword_is(Keyword::AS)?;
let return_table = self.maybe_parse(|p| {
let return_table_name = p.parse_identifier()?;

if !p.peek_keyword(Keyword::TABLE) {
parser_err!(
"Expected TABLE keyword after return type",
p.peek_token().span.start
)?
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if !p.peek_keyword(Keyword::TABLE) {
parser_err!(
"Expected TABLE keyword after return type",
p.peek_token().span.start
)?
}
p.expect_keyword(Keyword::TABLE)?;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The reason I didn't do it this way is because expect_keyword (and expect_keyword_is) consume the token. That causes parse_data_type to break, because it uses the TABLE keyword to understand it should parse the table data type.

However, I suppose that I could just call prev_token() after expect to undo consuming the token. I will make this change.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done 👍


let table_column_defs = match p.parse_data_type()? {
DataType::Table(maybe_table_column_defs) => match maybe_table_column_defs {
Some(table_column_defs) => {
if table_column_defs.is_empty() {
parser_err!(
"Expected table column definitions after TABLE keyword",
p.peek_token().span.start
)?
}

table_column_defs
}
None => parser_err!(
"Expected table column definitions after TABLE keyword",
p.peek_token().span.start
)?,
},
_ => parser_err!(
"Expected table data type after TABLE keyword",
p.peek_token().span.start
)?,
};
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
DataType::Table(maybe_table_column_defs) => match maybe_table_column_defs {
Some(table_column_defs) => {
if table_column_defs.is_empty() {
parser_err!(
"Expected table column definitions after TABLE keyword",
p.peek_token().span.start
)?
}
table_column_defs
}
None => parser_err!(
"Expected table column definitions after TABLE keyword",
p.peek_token().span.start
)?,
},
_ => parser_err!(
"Expected table data type after TABLE keyword",
p.peek_token().span.start
)?,
};
DataType::Table(Some(table_column_defs)) !if table_column_defs.is_empty() => table_column_defs,
_ => parser_err!(
"Expected table data type after TABLE keyword",
p.peek_token().span.start
)?,
};

looks like this condition can be simplified?
Also could we add a negative test to assert the behavior on invalid data_types, noticed it seems to be lacking coverage in the PR tests

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I consolidated this per your suggestion, and I added a test to assert a parser error for an incorrect table definition 👍


Ok(DataType::NamedTable {
name: ObjectName(vec![ObjectNamePart::Identifier(return_table_name)]),
columns: table_column_defs,
})
})?;

let return_type = if return_table.is_some() {
return_table
} else {
Some(self.parse_data_type()?)
};

let begin_token = self.expect_keyword(Keyword::BEGIN)?;
let statements = self.parse_statement_list(&[Keyword::END])?;
let end_token = self.expect_keyword(Keyword::END)?;
let _ = self.parse_keyword(Keyword::AS);

let function_body = Some(CreateFunctionBody::AsBeginEnd(BeginEndStatements {
begin_token: AttachedToken(begin_token),
statements,
end_token: AttachedToken(end_token),
}));
let function_body = if self.peek_keyword(Keyword::BEGIN) {
let begin_token = self.expect_keyword(Keyword::BEGIN)?;
let statements = self.parse_statement_list(&[Keyword::END])?;
let end_token = self.expect_keyword(Keyword::END)?;

Some(CreateFunctionBody::AsBeginEnd(BeginEndStatements {
begin_token: AttachedToken(begin_token),
statements,
end_token: AttachedToken(end_token),
}))
} else if self.parse_keyword(Keyword::RETURN) {
if self.peek_token() == Token::LParen {
Some(CreateFunctionBody::AsReturnExpr(self.parse_expr()?))
} else if self.peek_keyword(Keyword::SELECT) {
let select = self.parse_select()?;
Some(CreateFunctionBody::AsReturnSelect(select))
} else {
parser_err!(
"Expected a subquery (or bare SELECT statement) after RETURN",
self.peek_token().span.start
)?
}
Comment on lines +5255 to +5259
Copy link
Contributor

Choose a reason for hiding this comment

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

can we add a test for this behavior? (e.g. one that passes a regular expression and the verifies that the parser rejects it)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done 👍

} else {
parser_err!("Unparsable function body", self.peek_token().span.start)?
};

Ok(Statement::CreateFunction(CreateFunction {
or_alter,
Expand Down Expand Up @@ -9784,8 +9844,14 @@ impl<'a> Parser<'a> {
Ok(DataType::AnyType)
}
Keyword::TABLE => {
let columns = self.parse_returns_table_columns()?;
Ok(DataType::Table(columns))
// an LParen after the TABLE keyword indicates that table columns are being defined
// whereas no LParen indicates an anonymous table expression will be returned
if self.peek_token() == Token::LParen {
let columns = self.parse_returns_table_columns()?;
Ok(DataType::Table(Some(columns)))
} else {
Ok(DataType::Table(None))
}
}
Keyword::SIGNED => {
if self.parse_keyword(Keyword::INTEGER) {
Expand Down Expand Up @@ -9826,13 +9892,7 @@ impl<'a> Parser<'a> {
}

fn parse_returns_table_column(&mut self) -> Result<ColumnDef, ParserError> {
let name = self.parse_identifier()?;
let data_type = self.parse_data_type()?;
Ok(ColumnDef {
name,
data_type,
options: Vec::new(), // No constraints expected here
})
self.parse_column_def()
}

fn parse_returns_table_columns(&mut self) -> Result<Vec<ColumnDef>, ParserError> {
Expand Down
58 changes: 58 additions & 0 deletions tests/sqlparser_mssql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,12 @@ fn parse_create_function() {
";
let _ = ms().verified_stmt(multi_statement_function);

let multi_statement_function_without_as = multi_statement_function.replace(" AS", "");
let _ = ms().one_statement_parses_to(
&multi_statement_function_without_as,
multi_statement_function,
);

let create_function_with_conditional = "\
CREATE FUNCTION some_scalar_udf() \
RETURNS INT \
Expand Down Expand Up @@ -288,6 +294,58 @@ fn parse_create_function() {
END\
";
let _ = ms().verified_stmt(create_function_with_return_expression);

let create_inline_table_value_function = "\
CREATE FUNCTION some_inline_tvf(@foo INT, @bar VARCHAR(256)) \
RETURNS TABLE \
AS \
RETURN (SELECT 1 AS col_1)\
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Parentheses are optional for inline tvf return queries, although I think the subquery expr expects/requires them currently.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added support for that syntax & added a new test case example

Copy link
Contributor Author

@aharpervc aharpervc May 7, 2025

Choose a reason for hiding this comment

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

UNION is also supported in this syntax but not in this current approach due to using parse_select, which is restricted. I'm comfortable leaving that for later

";
let _ = ms().verified_stmt(create_inline_table_value_function);

let create_inline_table_value_function_without_parentheses = "\
CREATE FUNCTION some_inline_tvf(@foo INT, @bar VARCHAR(256)) \
RETURNS TABLE \
AS \
RETURN SELECT 1 AS col_1\
";
let _ = ms().verified_stmt(create_inline_table_value_function_without_parentheses);

let create_inline_table_value_function_without_as =
create_inline_table_value_function.replace(" AS", "");
let _ = ms().one_statement_parses_to(
&create_inline_table_value_function_without_as,
create_inline_table_value_function,
);

let create_multi_statement_table_value_function = "\
CREATE FUNCTION some_multi_statement_tvf(@foo INT, @bar VARCHAR(256)) \
RETURNS @t TABLE (col_1 INT) \
AS \
BEGIN \
INSERT INTO @t SELECT 1; \
RETURN; \
END\
";
let _ = ms().verified_stmt(create_multi_statement_table_value_function);

let create_multi_statement_table_value_function_without_as =
create_multi_statement_table_value_function.replace(" AS", "");
let _ = ms().one_statement_parses_to(
&create_multi_statement_table_value_function_without_as,
create_multi_statement_table_value_function,
);

let create_multi_statement_table_value_function_with_constraints = "\
CREATE FUNCTION some_multi_statement_tvf(@foo INT, @bar VARCHAR(256)) \
RETURNS @t TABLE (col_1 INT NOT NULL) \
AS \
BEGIN \
INSERT INTO @t SELECT 1; \
RETURN @t; \
END\
";
let _ = ms().verified_stmt(create_multi_statement_table_value_function_with_constraints);
}

#[test]
Expand Down