Skip to content

Commit e244e4b

Browse files
authored
feat: support typed result columns in PostgreSQL ROWS FROM (#2648)
* feat: support typed result columns in PostgreSQL ROWS FROM * test: store ROWS FROM fixtures as structured test data
1 parent a8ef0b2 commit e244e4b

7 files changed

Lines changed: 226 additions & 7 deletions

File tree

src/main/java/net/sf/jsqlparser/expression/Function.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
import net.sf.jsqlparser.statement.select.Limit;
1818
import net.sf.jsqlparser.statement.select.OrderByElement;
1919

20+
import net.sf.jsqlparser.statement.create.table.ColumnDefinition;
21+
import net.sf.jsqlparser.statement.select.PlainSelect;
22+
2023
import java.io.Serializable;
2124
import java.util.ArrayList;
2225
import java.util.Arrays;
@@ -46,6 +49,7 @@ public class Function extends ASTNodeAccessImpl implements Expression {
4649
private KeepExpression keep = null;
4750
private String onOverflowTruncate = null;
4851
private String extraKeyword = null;
52+
private List<ColumnDefinition> resultColumnDefinitions;
4953

5054
/**
5155
* Generic keyword arguments captured inside function parentheses, e.g.
@@ -55,6 +59,23 @@ public class Function extends ASTNodeAccessImpl implements Expression {
5559
*/
5660
private List<KeywordArgument> keywordArguments = null;
5761

62+
/** Column definitions supplied for a record-returning function inside ROWS FROM. */
63+
public List<ColumnDefinition> getResultColumnDefinitions() {
64+
return resultColumnDefinitions;
65+
}
66+
67+
public void setResultColumnDefinitions(List<ColumnDefinition> resultColumnDefinitions) {
68+
this.resultColumnDefinitions = resultColumnDefinitions;
69+
}
70+
71+
public StringBuilder appendResultColumnDefinitionsTo(StringBuilder builder) {
72+
if (resultColumnDefinitions != null) {
73+
builder.append(" AS ")
74+
.append(PlainSelect.getStringList(resultColumnDefinitions, true, true));
75+
}
76+
return builder;
77+
}
78+
5879
public Function() {}
5980

6081
public Function(String name, Expression... parameters) {
@@ -446,7 +467,8 @@ public String toString() {
446467
ans = "{fn " + ans + "}";
447468
}
448469

449-
return ans;
470+
return resultColumnDefinitions == null ? ans
471+
: appendResultColumnDefinitionsTo(new StringBuilder(ans)).toString();
450472
}
451473

452474
public Function withAttribute(Expression attribute) {

src/main/java/net/sf/jsqlparser/statement/select/TableFunction.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
package net.sf.jsqlparser.statement.select;
1111

1212
import java.util.List;
13+
import java.util.function.Consumer;
1314
import net.sf.jsqlparser.expression.Alias;
1415
import net.sf.jsqlparser.expression.Expression;
1516
import net.sf.jsqlparser.expression.Function;
@@ -228,13 +229,24 @@ public FromItem setSampleClause(SampleClause sampleClause) {
228229
}
229230

230231
public StringBuilder appendTo(StringBuilder builder) {
232+
return appendTo(builder, builder::append);
233+
}
234+
235+
public StringBuilder appendTo(StringBuilder builder, Consumer<Expression> expressionPrinter) {
231236
if (prefix != null) {
232237
builder.append(prefix).append(" ");
233238
}
234239
if (rowsFromFunctions != null) {
235-
builder.append("ROWS FROM ").append(rowsFromFunctions);
240+
builder.append("ROWS FROM (");
241+
for (int i = 0; i < rowsFromFunctions.size(); i++) {
242+
if (i > 0) {
243+
builder.append(", ");
244+
}
245+
expressionPrinter.accept(rowsFromFunctions.get(i));
246+
}
247+
builder.append(")");
236248
} else {
237-
builder.append(function);
249+
expressionPrinter.accept(function);
238250
}
239251

240252
if (withClause != null) {

src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1016,7 +1016,7 @@ public <S> StringBuilder visit(Function function, S context) {
10161016
if (function.isEscaped()) {
10171017
builder.append("}");
10181018
}
1019-
return builder;
1019+
return function.appendResultColumnDefinitionsTo(builder);
10201020
}
10211021

10221022
@Override

src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -906,7 +906,8 @@ public <S> StringBuilder visit(TableStatement tableStatement, S context) {
906906

907907
@Override
908908
public <S> StringBuilder visit(TableFunction tableFunction, S context) {
909-
tableFunction.appendTo(builder);
909+
tableFunction.appendTo(builder,
910+
expression -> expression.accept(expressionVisitor, context));
910911
return builder;
911912
}
912913

src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13571,17 +13571,52 @@ ParenthesedExpressionList<Function> RowsFromFunctionList():
1357113571
}
1357213572
{
1357313573
"("
13574-
function = Function() { functions.add(function); }
13574+
function = RowsFromFunction() { functions.add(function); }
1357513575
(
1357613576
","
13577-
function = Function() { functions.add(function); }
13577+
function = RowsFromFunction() { functions.add(function); }
1357813578
)*
1357913579
")"
1358013580
{
1358113581
return functions;
1358213582
}
1358313583
}
1358413584

13585+
/** A function and its optional PostgreSQL record column definition list. */
13586+
Function RowsFromFunction():
13587+
{
13588+
Function function;
13589+
List<ColumnDefinition> columns = new ArrayList<ColumnDefinition>();
13590+
ColumnDefinition column;
13591+
}
13592+
{
13593+
function=Function()
13594+
[ <K_AS> "("
13595+
column=RowsFromColumnDefinition() { columns.add(column); }
13596+
( "," column=RowsFromColumnDefinition() { columns.add(column); } )*
13597+
")" { function.setResultColumnDefinitions(columns); }
13598+
]
13599+
{ return function; }
13600+
}
13601+
13602+
ColumnDefinition RowsFromColumnDefinition():
13603+
{
13604+
String name;
13605+
String collation;
13606+
String part;
13607+
ColDataType type;
13608+
ColumnDefinition column;
13609+
}
13610+
{
13611+
name=RelObjectName() type=ColDataType()
13612+
{ column = new ColumnDefinition(name, type); }
13613+
[ <K_COLLATE> collation=RelObjectName()
13614+
( "." part=RelObjectName() { collation += "." + part; } )*
13615+
{ column.addColumnOptions(ColumnOption.collate(collation)); }
13616+
]
13617+
{ return column; }
13618+
}
13619+
1358513620
List<Index.ColumnParams> ColumnNamesWithParamsList() : {
1358613621
List<Index.ColumnParams> colNames = new ArrayList<Index.ColumnParams>();
1358713622
String columnName;
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*-
2+
* #%L
3+
* JSQLParser library
4+
* %%
5+
* Copyright (C) 2004 - 2026 JSQLParser
6+
* %%
7+
* Dual licensed under GNU LGPL 2.1 or Apache License 2.0
8+
* #L%
9+
*/
10+
package net.sf.jsqlparser.statement.select;
11+
12+
import static org.junit.jupiter.api.Assertions.assertEquals;
13+
import static org.junit.jupiter.api.Assertions.assertNotNull;
14+
import static org.junit.jupiter.api.Assertions.assertNull;
15+
import static org.junit.jupiter.api.Assertions.assertThrows;
16+
import static org.junit.jupiter.api.Assertions.assertTrue;
17+
18+
import java.util.List;
19+
import java.util.Set;
20+
import net.sf.jsqlparser.JSQLParserException;
21+
import net.sf.jsqlparser.expression.Function;
22+
import net.sf.jsqlparser.expression.LongValue;
23+
import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect;
24+
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
25+
import net.sf.jsqlparser.statement.create.table.ColDataType;
26+
import net.sf.jsqlparser.statement.create.table.ColumnDefinition;
27+
import net.sf.jsqlparser.util.TablesNamesFinder;
28+
import net.sf.jsqlparser.util.deparser.ExpressionDeParser;
29+
import net.sf.jsqlparser.util.deparser.SelectDeParser;
30+
import net.sf.jsqlparser.util.deparser.StatementDeParser;
31+
import org.junit.jupiter.api.Test;
32+
import org.junit.jupiter.params.ParameterizedTest;
33+
import org.junit.jupiter.params.provider.CsvFileSource;
34+
35+
class RowsFromColumnDefinitionTest {
36+
37+
// Accepted/rejected cases are data records, not executable SQL batch scripts.
38+
// Both outcomes were checked against PostgreSQL 18.6.
39+
@ParameterizedTest
40+
@CsvFileSource(resources = "rows-from-cases.tsv", delimiter = '\t')
41+
void preservesValidDefinitionsAndRejectsMalformedLists(boolean valid, String sql)
42+
throws JSQLParserException {
43+
if (!valid) {
44+
assertThrows(JSQLParserException.class, () -> parse(sql));
45+
return;
46+
}
47+
PlainSelect select = parse(sql);
48+
StringBuilder visitor = new StringBuilder();
49+
select.accept(new StatementDeParser(visitor));
50+
for (String rendered : List.of(select.toString(), visitor.toString())) {
51+
PlainSelect reparsed = parse(rendered);
52+
assertEquals(select.toString(), reparsed.toString());
53+
assertEquals(select.getFromItem().getClass(), reparsed.getFromItem().getClass());
54+
}
55+
}
56+
57+
@Test
58+
void eachFunctionOwnsItsDefinitionsIndependentlyOfTheOuterAlias()
59+
throws JSQLParserException {
60+
PlainSelect select = parse("SELECT * FROM ROWS FROM ("
61+
+ "json_to_record('{}') AS (amount numeric(10, 2), label text COLLATE \"C\"), "
62+
+ "generate_series(1, 2), json_to_record('{}') AS (items integer[])) "
63+
+ "WITH ORDINALITY AS r(total, name, n, data, ord)");
64+
TableFunction table = (TableFunction) select.getFromItem();
65+
assertTrue(table.isRowsFrom());
66+
assertEquals(3, table.getRowsFromFunctions().size());
67+
assertEquals("ORDINALITY", table.getWithClause());
68+
assertEquals(5, table.getAlias().getAliasColumns().size());
69+
List<ColumnDefinition> first = table.getFunctions().get(0).getResultColumnDefinitions();
70+
assertEquals("amount", first.get(0).getColumnName());
71+
assertEquals(10, first.get(0).getColDataType().getPrecision());
72+
assertEquals(2, first.get(0).getColDataType().getScale());
73+
assertEquals("\"C\"", first.get(1).getColumnOptions().get(0).getCollation());
74+
assertNull(table.getFunctions().get(1).getResultColumnDefinitions());
75+
assertEquals(1, table.getFunctions().get(2).getResultColumnDefinitions().get(0)
76+
.getColDataType().getArrayData().size());
77+
78+
first.get(0).setColumnName("changed");
79+
first.get(0).setColDataType(new ColDataType("bigint"));
80+
TableFunction reparsed = (TableFunction) parse(select.toString()).getFromItem();
81+
assertEquals("changed", reparsed.getFunctions().get(0).getResultColumnDefinitions()
82+
.get(0).getColumnName());
83+
assertEquals("bigint", reparsed.getFunctions().get(0).getResultColumnDefinitions()
84+
.get(0).getColDataType().getDataType());
85+
}
86+
87+
@Test
88+
void legacyFunctionListEditsKeepDefinitionsAttachedToTheirFunction()
89+
throws JSQLParserException {
90+
TableFunction table = (TableFunction) parse("SELECT * FROM ROWS FROM ("
91+
+ "json_to_record('{}') AS (a integer), generate_series(1, 2))").getFromItem();
92+
Function record = table.getRowsFromFunctions().remove(0);
93+
table.getRowsFromFunctions().add(record);
94+
TableFunction reparsed = (TableFunction) parse("SELECT * FROM " + table).getFromItem();
95+
assertNull(reparsed.getFunctions().get(0).getResultColumnDefinitions());
96+
assertNotNull(reparsed.getFunctions().get(1).getResultColumnDefinitions());
97+
}
98+
99+
@Test
100+
void tableFunctionRendererVisitsFunctionArguments() throws JSQLParserException {
101+
PlainSelect select = parse("SELECT * FROM ROWS FROM (json_to_record('{}') AS (a integer), "
102+
+ "generate_series(1, 2)) WITH ORDINALITY AS r(a, n, ord)");
103+
String original = select.toString();
104+
StringBuilder sql = new StringBuilder();
105+
ExpressionDeParser expressions = new ExpressionDeParser() {
106+
@Override
107+
public <S> StringBuilder visit(LongValue value, S context) {
108+
return getBuilder().append(value.getValue() + 10);
109+
}
110+
};
111+
select.accept(new StatementDeParser(expressions, new SelectDeParser(), sql));
112+
assertTrue(sql.toString().contains("generate_series(11, 12)"));
113+
assertTrue(sql.toString().contains("AS (a integer)"));
114+
assertEquals(original, select.toString());
115+
assertEquals(sql.toString(), parse(sql.toString()).toString());
116+
}
117+
118+
@Test
119+
void tableDiscoveryStillVisitsSubqueriesInRecordFunctionArguments()
120+
throws JSQLParserException {
121+
String sql = "SELECT * FROM ROWS FROM ("
122+
+ "json_to_record((SELECT payload FROM events)) AS (a integer))";
123+
assertEquals(Set.of("events"), TablesNamesFinder.findTables(sql));
124+
}
125+
126+
private static PlainSelect parse(String sql) throws JSQLParserException {
127+
return (PlainSelect) CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.POSTGRESQL));
128+
}
129+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
true SELECT * FROM ROWS FROM (json_to_recordset('[{"a":1}]') AS (a integer)) WITH ORDINALITY
2+
true SELECT * FROM ROWS FROM (json_to_recordset('[{"a":1,"b":"x"}]') AS (a integer, b text)) AS r
3+
true SELECT * FROM ROWS FROM (json_to_recordset('[{"a":1}]') AS (a integer), generate_series(1, 2)) WITH ORDINALITY AS r(a, n, ord)
4+
true SELECT * FROM ROWS FROM (generate_series(1, 2), jsonb_to_recordset('[{"a":1.25}]'::jsonb) AS (a numeric(10, 2))) AS r(n, amount)
5+
true SELECT * FROM ROWS FROM (json_to_record('{"a":"x"}') AS (a text COLLATE "C"))
6+
true SELECT * FROM ROWS FROM (json_to_record('{"a":"x"}') AS (a text COLLATE pg_catalog."C"))
7+
true SELECT * FROM ROWS FROM (json_to_record('{"a":[1,2]}') AS (a integer[]))
8+
true SELECT * FROM ROWS FROM (json_to_record('{"a":"2026-01-01T12:00:00Z"}') AS (a timestamp(6) with time zone))
9+
true SELECT * FROM ROWS FROM (json_to_record('{"odd name":1}') AS ("odd name" integer)) AS r("renamed")
10+
true SELECT r.* FROM (VALUES ('[{"a":1}]'::json)) AS src(payload) CROSS JOIN LATERAL ROWS FROM (json_to_recordset(src.payload) AS (a integer)) WITH ORDINALITY AS r
11+
true SELECT * FROM ROWS FROM (generate_series(1, 2), generate_series(1, 1)) AS r(a, b)
12+
true SELECT * FROM ROWS FROM (json_to_record('{"a":1}') AS (a integer), json_to_record('{"b":2}') AS (b integer))
13+
false SELECT * FROM ROWS FROM (json_to_record('{}') AS ())
14+
false SELECT * FROM ROWS FROM (json_to_record('{}') AS (a))
15+
false SELECT * FROM ROWS FROM (json_to_record('{}') AS (a integer,))
16+
false SELECT * FROM ROWS FROM (json_to_record('{}') AS (a integer NOT NULL))
17+
false SELECT * FROM ROWS FROM (json_to_record('{}') AS (a integer DEFAULT 1))
18+
false SELECT * FROM ROWS FROM (json_to_record('{}') AS r(a integer))
19+
false SELECT * FROM ROWS FROM (json_to_record('{}') AS (a integer) WITH ORDINALITY)
20+
false SELECT * FROM ROWS FROM ()

0 commit comments

Comments
 (0)