diff --git a/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/BlankLines.java b/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/BlankLines.java new file mode 100644 index 0000000..d7e5cb2 --- /dev/null +++ b/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/BlankLines.java @@ -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. + *
+ * 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(); + } +} diff --git a/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/DeparserColumResolver.java b/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/DeparserColumResolver.java index 8d3228c..a89e1a5 100644 --- a/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/DeparserColumResolver.java +++ b/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/DeparserColumResolver.java @@ -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)); } /** diff --git a/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/TranspilerSqlGuard.java b/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/TranspilerSqlGuard.java index 19a6078..cfb8f6a 100644 --- a/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/TranspilerSqlGuard.java +++ b/guard/jsqltranspiler/src/main/java/org/eclipse/daanse/sql/guard/jsqltranspiler/TranspilerSqlGuard.java @@ -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(); diff --git a/guard/jsqltranspiler/src/test/java/org/eclipse/daanse/sql/guard/jsqltranspiler/integration/SqlGuardTest.java b/guard/jsqltranspiler/src/test/java/org/eclipse/daanse/sql/guard/jsqltranspiler/integration/SqlGuardTest.java index d4248be..97559a2 100644 --- a/guard/jsqltranspiler/src/test/java/org/eclipse/daanse/sql/guard/jsqltranspiler/integration/SqlGuardTest.java +++ b/guard/jsqltranspiler/src/test/java/org/eclipse/daanse/sql/guard/jsqltranspiler/integration/SqlGuardTest.java @@ -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 = """ @@ -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 = """ @@ -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();