Skip to content
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2024 Contributors to the Eclipse Foundation.
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* SmartCity Jena - initial
* Stefan Bischof (bipolis.org) - initial
*/

package org.eclipse.daanse.sql.guard.jsqltranspiler;

/**
* Collapses runs of blank lines outside string literals, quoted identifiers and comments into one line break.
* <p>
* JSqlParser reads two or more consecutive blank lines as the end of a statement (its {@code ST_SEMICOLON} token),
* so a well-formatted single query with an empty paragraph in the middle parsed as two broken statements and the
* guard refused it. Blank lines are whitespace in SQL; inside a literal they are data and stay. A text that is two
* statements separated only by blank lines becomes one text the parser refuses -- it is never let through.
*/
final class BlankLines {

private BlankLines() {
}

static String collapse(String sql) {
StringBuilder out = new StringBuilder(sql.length());
int i = 0;
int n = sql.length();
while (i < n) {
char c = sql.charAt(i);
if (c == '\'' || c == '"' || c == '`') {
int end = closing(sql, i, c);
out.append(sql, i, end);
i = end;
} else if (c == '-' && i + 1 < n && sql.charAt(i + 1) == '-') {
int end = sql.indexOf('\n', i);
end = end < 0 ? n : end;
out.append(sql, i, end);
i = end;
} else if (c == '/' && i + 1 < n && sql.charAt(i + 1) == '*') {
int end = sql.indexOf("*/", i + 2);
end = end < 0 ? n : end + 2;
out.append(sql, i, end);
i = end;
} else if (c == '\n' || c == '\r') {
// one line break for the whole run of breaks and the blanks between them
int j = i;
while (j < n && (sql.charAt(j) == '\n' || sql.charAt(j) == '\r' || sql.charAt(j) == ' '
|| sql.charAt(j) == '\t')) {
j++;
}
out.append('\n');
i = j;
} else {
out.append(c);
i++;
}
}
return out.toString();
}

/** Where a quoted part ends: after its closing quote, a doubled quote standing for the quote itself. */
private static int closing(String sql, int start, char quote) {
int i = start + 1;
while (i < sql.length()) {
if (sql.charAt(i) == quote) {
if (i + 1 < sql.length() && sql.charAt(i + 1) == quote) {
i += 2;
continue;
}
return i + 1;
}
i++;
}
return sql.length();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,19 @@ public DeparserColumResolver(JdbcMetaData metaData, Dialect dialect, DialectDepa
}

@Override
/**
* Parses the text as a script and resolves its one statement. {@code CCJSqlParserUtil.parse} stops after the first
* statement and would resolve "SELECT ...; DROP ..." as if the rest were not there; a text of more than one
* statement is refused instead, as the guard refuses it.
*/
public String getResolvedStatementText(String sqlStr) throws JSQLParserException {
return getResolvedStatementText(CCJSqlParserUtil.parse(sqlStr));
net.sf.jsqlparser.statement.Statements statements =
CCJSqlParserUtil.parseStatements(BlankLines.collapse(sqlStr));
if (statements == null || statements.size() != 1) {
throw new JSQLParserException("expected exactly one statement, the text holds "
+ (statements == null ? 0 : statements.size()));
}
return getResolvedStatementText(statements.get(0));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ public String guard(String sqlStr) throws GuardException {
// parse the whole input as a script: CCJSqlParserUtil.parse() stops after the
// first statement and would silently drop a trailing "; DROP TABLE ..." (or
// any other statement) from what is validated and rewritten.
Statements statements = CCJSqlParserUtil.parseStatements(sqlStr);
Statements statements = CCJSqlParserUtil.parseStatements(BlankLines.collapse(sqlStr));
if (statements == null || statements.isEmpty()) {
// e.g. a comment-only input parses to an empty script
throw new EmptyStatementGuardException();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ public class SqlGuardTest {

private static final String SQL_MULTIPLE_CTE_WRONG_COLUMN = """
WITH cte1 AS (SELECT foo.id FROM foo),
cte2 AS (SELECT fooFact.wrongCol FROM fooFact)
cte2 AS (SELECT fooFact.wrongCol FROM fooFact)
SELECT * FROM cte1, cte2""";

private static final String SQL_CTE_UPDATE = """
Expand All @@ -253,7 +253,7 @@ cte2 AS (SELECT fooFact.wrongCol FROM fooFact)

private static final String SQL_MULTIPLE_CTE_UPDATE = """
WITH x AS (UPDATE foo SET name='a' RETURNING id),
y AS (UPDATE foo SET name='b' RETURNING id)
y AS (UPDATE foo SET name='b' RETURNING id)
SELECT id FROM foo""";

private static final String SQL_CTE_INSERT = """
Expand Down Expand Up @@ -435,6 +435,32 @@ void testName(@InjectService SqlGuardFactory sqlGuardFactory) throws Exception {
assertThat(result).isEqualTo(SELECT_FROM_FOO_RESULT);
}

/**
* JSqlParser reads two or more blank lines as the end of a statement: a formatted query with an empty
* paragraph in it was refused as two broken statements. Blank lines are whitespace.
*/
@Test
void aQueryWithBlankLinesInItIsOneStatement(@InjectService SqlGuardFactory sqlGuardFactory) throws Exception {
SqlGuard guard = sqlGuardFactory.create("", SCH, schemaWithTwoTableTwoCol(), List.of(), dialect);
assertThat(guard.guard("select *\n\n\n\nfrom foo")).isEqualTo(SELECT_FROM_FOO_RESULT);
assertThat(guard.guard("select *\r\n \r\n\t\r\n\r\nfrom foo")).isEqualTo(SELECT_FROM_FOO_RESULT);
}

/** Two statements separated only by blank lines stay refused: collapsing them gives no valid statement. */
@Test
void twoStatementsSeparatedByBlankLinesAreStillRefused(@InjectService SqlGuardFactory sqlGuardFactory) {
SqlGuard guard = sqlGuardFactory.create("", SCH, schemaWithTwoTableTwoCol(), List.of(), dialect);
assertThatThrownBy(() -> guard.guard("select * from foo\n\n\n\ndelete from foo"))
.isInstanceOf(GuardException.class);
}

/** Blank lines inside a string literal are data and reach the database as written. */
@Test
void blankLinesInALiteralStay(@InjectService SqlGuardFactory sqlGuardFactory) throws Exception {
SqlGuard guard = sqlGuardFactory.create("", SCH, schemaWithTwoTableTwoCol(), List.of(), dialect);
assertThat(guard.guard("select * from foo where foo.name = 'a\n\n\nb'")).contains("'a\n\n\nb'");
}

@Test
void testInnerJoin(@InjectService SqlGuardFactory sqlGuardFactory) throws Exception {
DatabaseCatalog databaseCatalog = schemaWithTwoTableTwoCol();
Expand Down
Loading