diff --git a/docs/postgresql.md b/docs/postgresql.md index f9c92b2..649cc02 100644 --- a/docs/postgresql.md +++ b/docs/postgresql.md @@ -251,7 +251,7 @@ query, its source, its header line, and the offending column and recorded type: ```text sqlcj: Invalid schema source /home/dev/project/schema.sql: Unsupported schema statement: Alter -sqlcj: Invalid schema source /home/dev/project/schema.sql: Encountered unexpected token: ";" at line 4, column 1 +sqlcj: Invalid schema source /home/dev/project/schema.sql: Encountered unexpected token: ";" at line 4, column 1 sqlcj: Invalid query 'ListTags' in /home/dev/project/queries.sql at line 5: Column 'tags' has unsupported type VARCHAR[] ``` diff --git a/pom.xml b/pom.xml index ae85dee..9776f4a 100644 --- a/pom.xml +++ b/pom.xml @@ -25,7 +25,7 @@ ${project.basedir}/config/eclipse-java-formatter.xml 4.7.7 3.2.1 - 5.3 + 5.4 6.1.2 2.4.240 1.21.4 diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParameterCompiler.java b/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParameterCompiler.java index ecdecbb..ca16455 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParameterCompiler.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParameterCompiler.java @@ -2,7 +2,6 @@ import net.sf.jsqlparser.parser.CCJSqlParserConstants; import net.sf.jsqlparser.parser.Node; -import net.sf.jsqlparser.parser.SimpleNode; import net.sf.jsqlparser.parser.Token; import java.util.ArrayList; @@ -47,7 +46,7 @@ final class SqlParameterCompiler { private static final int SOURCE_OFFSET = 1; SqlParameters compile(String sql, Node astRoot) { - if (!(astRoot instanceof SimpleNode node)) { + if (!(astRoot instanceof Node node)) { throw new SqlParseException("SQL parse tree is unavailable."); } diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParseReason.java b/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParseReason.java index d880fe0..647fe0f 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParseReason.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParseReason.java @@ -1,17 +1,20 @@ package dev.sqlcj.sql; +import net.sf.jsqlparser.parser.CCJSqlParserConstants; import net.sf.jsqlparser.parser.ParseException; import net.sf.jsqlparser.parser.Token; /** * The corrective fact of a SQL syntax failure, such as {@code Encountered - * unexpected token: ";" }. + * unexpected token: ";"}. * *

The SQL parser reports a syntax failure through a chain of wrapping * exceptions whose outer messages repeat the class name of the failure they - * wrap, so the fact is taken from the {@link ParseException} that chain - * carries, or from its innermost cause when it carries none, and never names an - * exception class. + * wrap, and whose {@link ParseException} states the unexpected token with + * parser-internal token kinds and lexical states. The fact is therefore + * composed from the unexpected token that chain carries, or taken from the + * innermost cause when it carries no {@link ParseException}, and never names an + * exception class or a parser internal. */ public final class SqlParseReason { @@ -32,13 +35,17 @@ public static String of(Throwable failure, boolean withLocation) { return firstLine(innermostCause(failure)); } - String reason = firstLine(parseFailure); - Token token = parseFailure.currentToken == null ? null : parseFailure.currentToken.next; - if (!withLocation || token == null) { + if (token == null) { + return firstLine(parseFailure); + } + + String reason = unexpected(token); + + if (!withLocation) { return reason; } @@ -46,6 +53,23 @@ public static String of(Throwable failure, boolean withLocation) { .formatted(reason, token.beginLine, token.beginColumn); } + /** + * States the token the parser did not expect. The end of input has no image + * to quote, and a token image that spans lines is quoted with its line + * breaks escaped so the reason stays one line. + */ + private static String unexpected(Token token) { + if (token.kind == CCJSqlParserConstants.EOF) { + return "Encountered unexpected end of input"; + } + + return "Encountered unexpected token: \"%s\"".formatted( + token.image + .replace("\r", "\\r") + .replace("\n", "\\n") + ); + } + /** The parse failure the exception chain carries, or {@code null}. */ private static ParseException parseFailure(Throwable failure) { for (Throwable cause = failure; cause != null; cause = cause.getCause()) { diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/cli/command/GenerateCommandTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/cli/command/GenerateCommandTest.java index 99923e4..8e96588 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/cli/command/GenerateCommandTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/cli/command/GenerateCommandTest.java @@ -205,7 +205,7 @@ name VARCHAR(255) List.of( "sqlcj: Invalid schema source %s: " .formatted(workingDirectory.resolve("schema.sql")) - + "Encountered unexpected token: \";\" " + + "Encountered unexpected token: \";\" " + "at line 4, column 1" ), result.error().lines().toList() @@ -248,7 +248,7 @@ void shouldFailWithConciseDiagnosticForQuerySyntaxError() throws Exception { List.of( "sqlcj: Invalid query 'GetUser' in %s at line 1: " .formatted(workingDirectory.resolve("queries.sql")) - + "Encountered unexpected token: \"AND\" \"AND\"" + + "Encountered unexpected token: \"AND\"" ), result.error().lines().toList() ); diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java index 132658e..502c8c6 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java @@ -2310,7 +2310,7 @@ CREATE TABLE orders ( assertEquals( "Invalid schema source %s: ".formatted(broken) - + "Encountered unexpected token: \";\" " + + "Encountered unexpected token: \";\" " + "at line 4, column 1", exception.getMessage() ); diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java index 1501b1c..6fa578d 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java @@ -553,7 +553,30 @@ name VARCHAR(255) ); assertEquals( - "Encountered unexpected token: \";\" at line 4, column 1", + "Encountered unexpected token: \";\" at line 4, column 1", + exception.getMessage() + ); + } + + /** + * A syntax failure at the end of input has no token image to quote, so the + * reason states the end of input and where the input ended. + */ + @Test + void shouldReportSyntaxFailureAtEndOfInputWithItsLocation() { + String sql = """ + CREATE TABLE users ( + id BIGINT NOT NULL, + name VARCHAR(255) + """; + + SchemaParseException exception = assertThrows( + SchemaParseException.class, + () -> parser.parse(sql) + ); + + assertEquals( + "Encountered unexpected end of input at line 3, column 19", exception.getMessage() ); } @@ -578,7 +601,7 @@ CREATE TABLE users ( assertEquals( "Lexical error at line 4, column 0." - + " Encountered: after prefix \"\\'x);\\n\"", + + " Encountered: after: \"\\'x);\\n\"", exception.getMessage() ); assertFalse(exception.getMessage().contains("net.sf.jsqlparser")); diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/sql/SqlParameterCompilerTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/sql/SqlParameterCompilerTest.java index b396d5c..ab011e2 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/sql/SqlParameterCompilerTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/sql/SqlParameterCompilerTest.java @@ -1,7 +1,7 @@ package dev.sqlcj.sql; import net.sf.jsqlparser.parser.CCJSqlParserConstants; -import net.sf.jsqlparser.parser.SimpleNode; +import net.sf.jsqlparser.parser.Node; import net.sf.jsqlparser.parser.Token; import org.junit.jupiter.api.Test; @@ -107,7 +107,7 @@ private SqlParameters compile(String sql, Token... tokens) { tokens[index].next = tokens[index + 1]; } - SimpleNode node = new SimpleNode(0); + Node node = new Node(0); node.jjtSetFirstToken(tokens[0]); node.jjtSetLastToken(tokens[tokens.length - 1]); diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/sql/SqlParserTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/sql/SqlParserTest.java index 842d660..732b1a8 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/sql/SqlParserTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/sql/SqlParserTest.java @@ -128,17 +128,25 @@ void shouldNotReportAnonymousParameterForProtectedText() { ); } + /** Dollar-quoted text is a literal, so a {@code $N} inside it is text. */ @Test - void shouldRejectDollarQuotedText() { - assertThrows( - SqlParseException.class, - () -> parser.parse("SELECT $$ $1 dollar quoted $$ FROM users WHERE id = $1") + void shouldPreserveParameterTextInsideDollarQuotedText() { + ParsedSql parsedSql = parser.parse( + "SELECT $$ $1 dollar quoted $$ FROM users WHERE id = $1" ); + + assertEquals( + "SELECT $$ $1 dollar quoted $$ FROM users WHERE id = ?", + parsedSql.parameters().executableSql() + ); + + assertEquals(List.of(1), parsedSql.parameters().indexes()); } /** * The compiler names the failing query and its header line, so the reason - * carries the parser's own wording without an exception class name. + * states the unexpected token alone, without a location and without an + * exception class name. */ @Test void shouldReportSyntaxFailureWithoutExceptionClassNames() { @@ -152,7 +160,32 @@ void shouldReportSyntaxFailureWithoutExceptionClassNames() { ); assertEquals( - "Encountered unexpected token: \"AND\" \"AND\"", + "Encountered unexpected token: \"AND\"", + exception.getMessage() + ); + } + + /** A syntax failure at the end of input has no token image to quote. */ + @Test + void shouldReportSyntaxFailureAtEndOfInput() { + SqlParseException exception = assertThrows( + SqlParseException.class, + () -> parser.parse("SELECT id FROM users WHERE id =") + ); + + assertEquals("Encountered unexpected end of input", exception.getMessage()); + } + + /** A reason stays one line, so a token image that spans lines is escaped. */ + @Test + void shouldEscapeLineBreaksOfTheUnexpectedToken() { + SqlParseException exception = assertThrows( + SqlParseException.class, + () -> parser.parse("SELECT id FROM users WHERE id = 'x' 'a\nb'") + ); + + assertEquals( + "Encountered unexpected token: \"'a\\nb'\"", exception.getMessage() ); } @@ -171,21 +204,21 @@ void shouldReportLexicalFailureWithoutExceptionClassNames() { assertEquals( "Lexical error at line 1, column 46." - + " Encountered: after prefix \"\\'abc;\"", + + " Encountered: after: \"\\'abc;\"", exception.getMessage() ); assertFalse(exception.getMessage().contains("net.sf.jsqlparser")); } - /** A block comment ends at its first delimiter and does not nest. */ + /** A block comment nests as in PostgreSQL and ends at its last delimiter. */ @Test - void shouldPreserveParameterTextInsideBlockCommentWithNestedDelimiter() { + void shouldPreserveParameterTextInsideNestedBlockComment() { ParsedSql parsedSql = parser.parse( - "SELECT id FROM users /* outer /* $8 inner */ WHERE id = $1" + "SELECT id FROM users /* outer /* $8 inner */ $9 still comment */ WHERE id = $1" ); assertEquals( - "SELECT id FROM users /* outer /* $8 inner */ WHERE id = ?", + "SELECT id FROM users /* outer /* $8 inner */ $9 still comment */ WHERE id = ?", parsedSql.parameters().executableSql() );