From b6aeee5d30265659390950ca1c1c07f3246acb8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Sat, 26 Sep 2026 23:01:38 +0300 Subject: [PATCH 1/3] feat: append filesystem and YAML reasons to file diagnostics Unreadable configuration files and schema or queries sources, and failures to create an output directory, write a generated file, read or write the output manifest, or delete a stale generated file now end with the reason the filesystem reports. A malformed configuration file reports the YAML problem with its one-based line and column. --- .../sqlcj/compiler/DefaultSourceLoader.java | 4 +- .../dev/sqlcj/config/YamlConfigLoader.java | 80 ++++++++++++++++--- .../java/dev/sqlcj/io/FileSystemReason.java | 50 ++++++++++++ .../dev/sqlcj/io/GeneratedFileWriter.java | 12 ++- .../dev/sqlcj/io/GeneratedOutputManifest.java | 18 ++++- .../cli/command/GenerateCommandTest.java | 51 +++++++++--- .../compiler/DefaultSourceLoaderTest.java | 6 +- .../SqlcjCompilerIntegrationTest.java | 4 +- .../sqlcj/config/YamlConfigLoaderTest.java | 32 +++++--- .../dev/sqlcj/io/GeneratedFileWriterTest.java | 7 +- .../sqlcj/io/GeneratedOutputManifestTest.java | 4 +- 11 files changed, 222 insertions(+), 46 deletions(-) create mode 100644 sqlcj-cli/src/main/java/dev/sqlcj/io/FileSystemReason.java diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/DefaultSourceLoader.java b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/DefaultSourceLoader.java index 1b1193e..907ab16 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/DefaultSourceLoader.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/DefaultSourceLoader.java @@ -4,6 +4,7 @@ import dev.sqlcj.config.SqlConfig; import dev.sqlcj.io.DefaultFileLoader; import dev.sqlcj.io.FileLoader; +import dev.sqlcj.io.FileSystemReason; import dev.sqlcj.parser.DefaultQueryParser; import dev.sqlcj.parser.Query; import dev.sqlcj.parser.QueryParser; @@ -53,7 +54,8 @@ private String read(Path path, String kind) { return fileLoader.read(path); } catch (IOException e) { throw new CompilationException( - "Cannot read %s source: %s".formatted(kind, path), + "Cannot read %s source: %s: %s" + .formatted(kind, path, FileSystemReason.of(e)), e ); } diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/config/YamlConfigLoader.java b/sqlcj-cli/src/main/java/dev/sqlcj/config/YamlConfigLoader.java index 5e76457..1e71b9d 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/config/YamlConfigLoader.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/config/YamlConfigLoader.java @@ -1,5 +1,8 @@ package dev.sqlcj.config; +import dev.sqlcj.io.FileSystemReason; +import org.snakeyaml.engine.v2.exceptions.Mark; +import org.snakeyaml.engine.v2.exceptions.MarkedYamlEngineException; import tools.jackson.core.JacksonException; import tools.jackson.core.JsonToken; import tools.jackson.databind.DeserializationFeature; @@ -12,11 +15,13 @@ import tools.jackson.databind.type.LogicalType; import tools.jackson.dataformat.yaml.YAMLMapper; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Optional; public class YamlConfigLoader implements ConfigLoader { @@ -55,16 +60,12 @@ public Config load(Path path) { } private Config read(Path configFile) { - if (!Files.isReadable(configFile)) { - throw new ConfigurationException( - "Cannot read configuration file: " + configFile - ); - } + byte[] content = readBytes(configFile); Config config; try { - config = OBJECT_MAPPER.readValue(configFile.toFile(), Config.class); + config = OBJECT_MAPPER.readValue(content, Config.class); } catch (UnrecognizedPropertyException e) { throw new ConfigurationException( "Invalid configuration in %s: unknown field '%s'" @@ -74,10 +75,7 @@ private Config read(Path configFile) { } catch (MismatchedInputException e) { throw invalidValue(configFile, e); } catch (JacksonException e) { - throw new ConfigurationException( - "Malformed configuration file: " + configFile, - e - ); + throw malformed(configFile, e); } if (config == null) { @@ -89,6 +87,68 @@ private Config read(Path configFile) { return config; } + private byte[] readBytes(Path configFile) { + try { + return Files.readAllBytes(configFile); + } catch (IOException e) { + throw new ConfigurationException( + "Cannot read configuration file: %s: %s" + .formatted(configFile, FileSystemReason.of(e)), + e + ); + } + } + + /** + * Translates a YAML syntax failure into a configuration diagnostic naming + * the configuration file and the problem the YAML parser reports, with the + * one-based line and column it reports the problem at. + */ + private ConfigurationException malformed(Path configFile, JacksonException e) { + MarkedYamlEngineException yamlFailure = yamlFailure(e); + + Optional mark = yamlFailure == null + ? Optional.empty() + : yamlFailure.getProblemMark(); + + String detail = mark + .map( + problemMark -> "%s at line %d, column %d".formatted( + yamlFailure.getProblem(), + problemMark.getLine() + 1, + problemMark.getColumn() + 1 + ) + ) + .orElseGet(() -> firstLine(e.getOriginalMessage(), e)); + + return new ConfigurationException( + "Malformed configuration file: %s: %s".formatted(configFile, detail), + e + ); + } + + /** The YAML failure the exception chain carries, or {@code null}. */ + private MarkedYamlEngineException yamlFailure(JacksonException e) { + for (Throwable cause = e; cause != null; cause = cause.getCause()) { + if (cause instanceof MarkedYamlEngineException yamlFailure) { + return yamlFailure; + } + } + + return null; + } + + private String firstLine(String message, JacksonException e) { + if (message == null || message.isBlank()) { + return e.getClass().getSimpleName(); + } + + return message.lines() + .findFirst() + .orElse(message) + .trim(); + } + /** * Translates a Jackson mapping failure into a configuration diagnostic * naming the configuration file, the offending property path, and the diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/io/FileSystemReason.java b/sqlcj-cli/src/main/java/dev/sqlcj/io/FileSystemReason.java new file mode 100644 index 0000000..0f0f71d --- /dev/null +++ b/sqlcj-cli/src/main/java/dev/sqlcj/io/FileSystemReason.java @@ -0,0 +1,50 @@ +package dev.sqlcj.io; + +import java.io.IOException; +import java.nio.file.AccessDeniedException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.FileSystemException; +import java.nio.file.NoSuchFileException; + +/** + * The corrective fact of a filesystem failure, such as {@code No such file or + * directory}. + * + *

The fact never repeats the path, because every diagnostic that uses it + * already names the file or directory it failed on. + */ +public final class FileSystemReason { + + private FileSystemReason() { + } + + /** The corrective fact of one filesystem failure. */ + public static String of(IOException failure) { + if (failure instanceof NoSuchFileException) { + return "No such file or directory"; + } + + if (failure instanceof AccessDeniedException) { + return "Permission denied"; + } + + if (failure instanceof FileAlreadyExistsException) { + return "File exists"; + } + + if (failure instanceof FileSystemException fileSystemFailure && fileSystemFailure.getReason() != null) { + return fileSystemFailure.getReason().trim(); + } + + String message = failure.getMessage(); + + if (message == null || message.isBlank()) { + return failure.getClass().getSimpleName(); + } + + return message.lines() + .findFirst() + .orElse(message) + .trim(); + } +} diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/io/GeneratedFileWriter.java b/sqlcj-cli/src/main/java/dev/sqlcj/io/GeneratedFileWriter.java index cd656a3..d679d17 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/io/GeneratedFileWriter.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/io/GeneratedFileWriter.java @@ -18,7 +18,11 @@ public void write(GeneratedFile file, Path outputDirectory) throws IOException { try { Files.createDirectories(parent); } catch (IOException e) { - throw new IOException("Cannot create output directory: " + parent, e); + throw new IOException( + "Cannot create output directory: %s: %s" + .formatted(parent, FileSystemReason.of(e)), + e + ); } } @@ -30,7 +34,11 @@ public void write(GeneratedFile file, Path outputDirectory) throws IOException { StandardOpenOption.TRUNCATE_EXISTING ); } catch (IOException e) { - throw new IOException("Cannot write generated file: " + outputFile, e); + throw new IOException( + "Cannot write generated file: %s: %s" + .formatted(outputFile, FileSystemReason.of(e)), + e + ); } } } diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/io/GeneratedOutputManifest.java b/sqlcj-cli/src/main/java/dev/sqlcj/io/GeneratedOutputManifest.java index ba0aeaa..ccb7f75 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/io/GeneratedOutputManifest.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/io/GeneratedOutputManifest.java @@ -39,7 +39,11 @@ public List read(Path outputDirectory) throws IOException { Files.readAllLines(manifestPath, StandardCharsets.UTF_8) ); } catch (IOException e) { - throw new IOException("Cannot read output manifest: " + manifestPath, e); + throw new IOException( + "Cannot read output manifest: %s: %s" + .formatted(manifestPath, FileSystemReason.of(e)), + e + ); } } @@ -84,7 +88,11 @@ public void write(Path outputDirectory, List generatedPaths) throws IOExce StandardOpenOption.TRUNCATE_EXISTING ); } catch (IOException e) { - throw new IOException("Cannot write output manifest: " + manifestPath, e); + throw new IOException( + "Cannot write output manifest: %s: %s" + .formatted(manifestPath, FileSystemReason.of(e)), + e + ); } } @@ -118,7 +126,11 @@ private void deleteStaleEntry( Files.delete(file); } catch (IOException e) { - throw new IOException("Cannot delete stale generated file: " + file, e); + throw new IOException( + "Cannot delete stale generated file: %s: %s" + .formatted(file, FileSystemReason.of(e)), + e + ); } } 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 138b8d7..958cf1f 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 @@ -135,12 +135,38 @@ void shouldFailWithoutFallbackWhenExplicitConfigurationIsMissing() throws Except Result result = runGenerate("--config", "missing.yaml"); assertEquals(1, result.exitCode(), result.error()); - assertTrue( - result.error().contains( - "sqlcj: Cannot read configuration file: " - + workingDirectory.resolve("missing.yaml") + assertEquals( + List.of( + "sqlcj: Cannot read configuration file: %s: No such file or directory" + .formatted(workingDirectory.resolve("missing.yaml")) ), - result.error() + result.error().lines().toList() + ); + assertFalse(result.error().contains("\tat ")); + assertFalse(Files.exists(workingDirectory.resolve("generated"))); + } + + @Test + void shouldFailWithConciseDiagnosticForMalformedYaml() throws Exception { + Files.writeString( + workingDirectory.resolve("sqlcj.yaml"), + """ + version: "1" + sql: [ + """ + ); + + Result result = runGenerate(); + + assertEquals(1, result.exitCode(), result.error()); + assertEquals( + List.of( + "sqlcj: Malformed configuration file: %s: " + .formatted(workingDirectory.resolve("sqlcj.yaml")) + + "expected , but found '' " + + "at line 2, column 3" + ), + result.error().lines().toList() ); assertFalse(result.error().contains("\tat ")); assertFalse(Files.exists(workingDirectory.resolve("generated"))); @@ -190,11 +216,12 @@ void shouldFailWithConciseDiagnosticForUnreadableSource() throws Exception { Result result = runGenerate(); assertEquals(1, result.exitCode(), result.error()); - assertTrue(result.error().contains("sqlcj: Cannot read queries source")); - assertTrue( - result.error().contains( - workingDirectory.resolve("missing.sql").toString() - ) + assertEquals( + List.of( + "sqlcj: Cannot read queries source: %s: No such file or directory" + .formatted(workingDirectory.resolve("missing.sql")) + ), + result.error().lines().toList() ); assertFalse(result.error().contains("\tat ")); assertFalse(Files.exists(workingDirectory.resolve("generated"))); @@ -276,8 +303,8 @@ void shouldFailWithConciseDiagnosticForUnwritableOutputDirectory() throws Except assertEquals( List.of( - "sqlcj: Cannot create output directory: " - + workingDirectory.resolve("generated/dev/example/generated") + "sqlcj: Cannot create output directory: %s: Not a directory" + .formatted(workingDirectory.resolve("generated/dev/example/generated")) ), result.error().lines().toList() ); diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/DefaultSourceLoaderTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/DefaultSourceLoaderTest.java index ae5fdc9..a6832ae 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/DefaultSourceLoaderTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/DefaultSourceLoaderTest.java @@ -141,7 +141,8 @@ void shouldRejectUnreadableQueriesSource() throws IOException { ); assertEquals( - "Cannot read queries source: " + missingQueries, + "Cannot read queries source: %s: No such file or directory" + .formatted(missingQueries), exception.getMessage() ); } @@ -171,7 +172,8 @@ void shouldRejectUnreadableSchemaSource() throws IOException { ); assertEquals( - "Cannot read schema source: " + missingSchema, + "Cannot read schema source: %s: No such file or directory" + .formatted(missingSchema), exception.getMessage() ); } 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 ee6bbdf..36fa41f 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java @@ -1620,7 +1620,7 @@ void shouldReportTheOutputManifestItCannotRead() throws IOException { ); assertEquals( - "Cannot read output manifest: " + manifestFile, + "Cannot read output manifest: %s: Is a directory".formatted(manifestFile), exception.getMessage() ); @@ -1677,7 +1677,7 @@ name VARCHAR(255) ); assertEquals( - "Cannot write generated file: " + generatedFile, + "Cannot write generated file: %s: Is a directory".formatted(generatedFile), exception.getMessage() ); } diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/config/YamlConfigLoaderTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/config/YamlConfigLoaderTest.java index 6e95f6e..025b3a1 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/config/YamlConfigLoaderTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/config/YamlConfigLoaderTest.java @@ -12,7 +12,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; class YamlConfigLoaderTest { @@ -178,10 +177,25 @@ void shouldRejectMissingConfigurationFile() { () -> configLoader.load(configFile) ); - assertTrue( - exception.getMessage().contains( - "Cannot read configuration file: " + configFile - ), + assertEquals( + "Cannot read configuration file: %s: No such file or directory" + .formatted(configFile), + exception.getMessage() + ); + } + + @Test + void shouldRejectConfigurationFileThatIsADirectory() throws IOException { + Path configFile = Files.createDirectory(tempDir.resolve("sqlcj.yaml")); + + ConfigurationException exception = assertThrows( + ConfigurationException.class, + () -> configLoader.load(configFile) + ); + + assertEquals( + "Cannot read configuration file: %s: Is a directory" + .formatted(configFile), exception.getMessage() ); } @@ -198,10 +212,10 @@ void shouldRejectMalformedYaml() throws IOException { () -> configLoader.load(configFile) ); - assertTrue( - exception.getMessage().contains( - "Malformed configuration file: " + configFile - ), + assertEquals( + "Malformed configuration file: %s: expected , but found " + .formatted(configFile) + + "'' at line 2, column 3", exception.getMessage() ); } diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/io/GeneratedFileWriterTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/io/GeneratedFileWriterTest.java index 02b535a..802bbcc 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/io/GeneratedFileWriterTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/io/GeneratedFileWriterTest.java @@ -76,8 +76,8 @@ void shouldReportTheDirectoryItCannotCreate() throws IOException { ); assertEquals( - "Cannot create output directory: " - + outputDirectory.resolve(file.path()).getParent(), + "Cannot create output directory: %s: Not a directory" + .formatted(outputDirectory.resolve(file.path()).getParent()), failure.getMessage() ); } @@ -99,7 +99,8 @@ void shouldReportTheFileItCannotWrite() throws IOException { ); assertEquals( - "Cannot write generated file: " + outputDirectory.resolve(file.path()), + "Cannot write generated file: %s: Is a directory" + .formatted(outputDirectory.resolve(file.path())), failure.getMessage() ); } diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/io/GeneratedOutputManifestTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/io/GeneratedOutputManifestTest.java index fcb974a..45dcf5f 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/io/GeneratedOutputManifestTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/io/GeneratedOutputManifestTest.java @@ -82,7 +82,7 @@ void shouldReportTheManifestItCannotRead() throws IOException { ); assertEquals( - "Cannot read output manifest: " + manifestFile, + "Cannot read output manifest: %s: Is a directory".formatted(manifestFile), failure.getMessage() ); } @@ -103,7 +103,7 @@ void shouldReportTheManifestItCannotWrite() throws IOException { ); assertEquals( - "Cannot write output manifest: " + manifestFile, + "Cannot write output manifest: %s: Is a directory".formatted(manifestFile), failure.getMessage() ); } From 75f9d0ee03b6c673071dc41e36761eff9610f104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Sat, 26 Sep 2026 23:03:18 +0300 Subject: [PATCH 2/3] feat: report the parser's reason for schema and query syntax errors A schema syntax error states the unexpected token with its line and column instead of "Failed to parse schema", and a query syntax error states the parser's reason after the query's header location. A lexical error such as an unterminated string literal uses the parser's own wording, so no diagnostic names a parser exception class. --- .../schema/parser/DefaultSchemaParser.java | 3 +- .../java/dev/sqlcj/sql/SqlParseException.java | 4 +- .../java/dev/sqlcj/sql/SqlParseReason.java | 86 +++++++++++++++++++ .../main/java/dev/sqlcj/sql/SqlParser.java | 2 +- .../cli/command/GenerateCommandTest.java | 85 ++++++++++++++++++ .../parser/DefaultSchemaParserTest.java | 46 ++++++++++ .../java/dev/sqlcj/sql/SqlParserTest.java | 41 +++++++++ 7 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParseReason.java diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java b/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java index a5f98c2..8c0f52f 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java @@ -6,6 +6,7 @@ import dev.sqlcj.schema.ConstraintType; import dev.sqlcj.schema.Schema; import dev.sqlcj.schema.Table; +import dev.sqlcj.sql.SqlParseReason; import net.sf.jsqlparser.JSQLParserException; import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.schema.MultiPartName; @@ -47,7 +48,7 @@ public Schema parse(String sql) { return new Schema(tables); } catch (JSQLParserException e) { - throw new SchemaParseException("Failed to parse schema", e); + throw new SchemaParseException(SqlParseReason.of(e, true), e); } } diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParseException.java b/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParseException.java index 2fbf1c6..42c1b18 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParseException.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParseException.java @@ -6,7 +6,7 @@ public SqlParseException(String message) { super(message); } - public SqlParseException(Throwable cause) { - super(cause); + public SqlParseException(String message, Throwable cause) { + super(message, cause); } } diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParseReason.java b/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParseReason.java new file mode 100644 index 0000000..d880fe0 --- /dev/null +++ b/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParseReason.java @@ -0,0 +1,86 @@ +package dev.sqlcj.sql; + +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: ";" }. + * + *

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. + */ +public final class SqlParseReason { + + private SqlParseReason() { + } + + /** + * The corrective fact of one SQL syntax failure, optionally followed by the + * line and column of the unexpected token. + * + *

A caller that already names a location, such as the header line of the + * failing query, asks for the reason without one. + */ + public static String of(Throwable failure, boolean withLocation) { + ParseException parseFailure = parseFailure(failure); + + if (parseFailure == null) { + return firstLine(innermostCause(failure)); + } + + String reason = firstLine(parseFailure); + + Token token = parseFailure.currentToken == null + ? null + : parseFailure.currentToken.next; + + if (!withLocation || token == null) { + return reason; + } + + return "%s at line %d, column %d" + .formatted(reason, token.beginLine, token.beginColumn); + } + + /** 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()) { + if (cause instanceof ParseException parseFailure) { + return parseFailure; + } + } + + return null; + } + + /** + * The innermost cause of the exception chain, which states a lexical failure + * in its own wording while every wrapper around it repeats its class name. + */ + private static Throwable innermostCause(Throwable failure) { + Throwable cause = failure; + + while (cause.getCause() != null && cause.getCause() != cause) { + cause = cause.getCause(); + } + + return cause; + } + + private static String firstLine(Throwable failure) { + String message = failure.getMessage(); + + if (message == null || message.isBlank()) { + return failure.getClass().getSimpleName(); + } + + return message.lines() + .findFirst() + .orElse(message) + .trim(); + } +} diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParser.java b/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParser.java index 0cd6648..c650ae4 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParser.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParser.java @@ -28,7 +28,7 @@ public ParsedSql parse(String sql) { try { statement = CCJSqlParserUtil.parse(sql, parser::set); } catch (JSQLParserException e) { - throw new SqlParseException(e); + throw new SqlParseException(SqlParseReason.of(e, false), e); } if (statement == null || parser.get() == null) { 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 958cf1f..99923e4 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 @@ -172,6 +172,91 @@ void shouldFailWithConciseDiagnosticForMalformedYaml() throws Exception { assertFalse(Files.exists(workingDirectory.resolve("generated"))); } + @Test + void shouldFailWithConciseDiagnosticForSchemaSyntaxError() throws Exception { + Files.writeString( + workingDirectory.resolve("schema.sql"), + """ + CREATE TABLE users ( + id BIGINT NOT NULL, + name VARCHAR(255) + ; + """ + ); + Files.writeString(workingDirectory.resolve("queries.sql"), QUERIES); + Files.writeString( + workingDirectory.resolve("sqlcj.yaml"), + """ + version: "1" + sql: + - name: Users + schema: schema.sql + queries: queries.sql + java: + package: dev.example.generated + out: generated + """ + ); + + Result result = runGenerate(); + + assertEquals(1, result.exitCode(), result.error()); + assertEquals( + List.of( + "sqlcj: Invalid schema source %s: " + .formatted(workingDirectory.resolve("schema.sql")) + + "Encountered unexpected token: \";\" " + + "at line 4, column 1" + ), + result.error().lines().toList() + ); + assertFalse(result.error().contains("\tat ")); + assertFalse(Files.exists(workingDirectory.resolve("generated"))); + } + + @Test + void shouldFailWithConciseDiagnosticForQuerySyntaxError() throws Exception { + Files.writeString(workingDirectory.resolve("schema.sql"), SCHEMA); + Files.writeString( + workingDirectory.resolve("queries.sql"), + """ + -- name: GetUser :one + SELECT id, name + FROM users + WHERE id = $1 + AND AND name = $2; + """ + ); + Files.writeString( + workingDirectory.resolve("sqlcj.yaml"), + """ + version: "1" + sql: + - name: Users + schema: schema.sql + queries: queries.sql + java: + package: dev.example.generated + out: generated + """ + ); + + Result result = runGenerate(); + + assertEquals(1, result.exitCode(), result.error()); + assertEquals( + List.of( + "sqlcj: Invalid query 'GetUser' in %s at line 1: " + .formatted(workingDirectory.resolve("queries.sql")) + + "Encountered unexpected token: \"AND\" \"AND\"" + ), + result.error().lines().toList() + ); + assertFalse(result.error().contains("net.sf.jsqlparser")); + assertFalse(result.error().contains("\tat ")); + assertFalse(Files.exists(workingDirectory.resolve("generated"))); + } + @Test void shouldFailWithConciseDiagnosticForMalformedConfiguration() throws Exception { Files.writeString( 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 3d373d6..6aefcb6 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 @@ -419,6 +419,52 @@ CREATE TABLE users ( ); } + @Test + void shouldReportSyntaxFailureWithItsReasonAndLocation() { + String sql = """ + CREATE TABLE users ( + id BIGINT NOT NULL, + name VARCHAR(255) + ; + """; + + SchemaParseException exception = assertThrows( + SchemaParseException.class, + () -> parser.parse(sql) + ); + + assertEquals( + "Encountered unexpected token: \";\" at line 4, column 1", + exception.getMessage() + ); + } + + /** + * A lexical failure such as an unterminated string literal reaches the + * compiler wrapped in exceptions that repeat their cause's class name, so the + * reason states the lexical wording alone, which already names its location. + */ + @Test + void shouldReportLexicalFailureWithoutExceptionClassNames() { + String sql = """ + CREATE TABLE users ( + id BIGINT NOT NULL, + name VARCHAR(255) DEFAULT 'x); + """; + + SchemaParseException exception = assertThrows( + SchemaParseException.class, + () -> parser.parse(sql) + ); + + assertEquals( + "Lexical error at line 4, column 0." + + " Encountered: after prefix \"\\'x);\\n\"", + exception.getMessage() + ); + assertFalse(exception.getMessage().contains("net.sf.jsqlparser")); + } + @Test void shouldThrowSchemaParseExceptionForInvalidSql() { String sql = """ 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 3a30de4..842d660 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/sql/SqlParserTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/sql/SqlParserTest.java @@ -136,6 +136,47 @@ void shouldRejectDollarQuotedText() { ); } + /** + * The compiler names the failing query and its header line, so the reason + * carries the parser's own wording without an exception class name. + */ + @Test + void shouldReportSyntaxFailureWithoutExceptionClassNames() { + SqlParseException exception = assertThrows( + SqlParseException.class, + () -> parser.parse(""" + SELECT id, name + FROM users + WHERE id = $1 + AND AND name = $2""") + ); + + assertEquals( + "Encountered unexpected token: \"AND\" \"AND\"", + exception.getMessage() + ); + } + + /** + * A lexical failure such as an unterminated string literal reaches the + * compiler wrapped in exceptions that repeat their cause's class name, so the + * reason states the lexical wording alone. + */ + @Test + void shouldReportLexicalFailureWithoutExceptionClassNames() { + SqlParseException exception = assertThrows( + SqlParseException.class, + () -> parser.parse("SELECT id, name FROM users WHERE name = 'abc;") + ); + + assertEquals( + "Lexical error at line 1, column 46." + + " Encountered: after prefix \"\\'abc;\"", + exception.getMessage() + ); + assertFalse(exception.getMessage().contains("net.sf.jsqlparser")); + } + /** A block comment ends at its first delimiter and does not nest. */ @Test void shouldPreserveParameterTextInsideBlockCommentWithNestedDelimiter() { From 2175ed9289c95a4c0282751bb611bdc4a4945489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Sat, 26 Sep 2026 23:03:26 +0300 Subject: [PATCH 3/3] docs: document the corrective facts in CLI diagnostics --- README.md | 5 +++-- docs/configuration.md | 20 ++++++++++++-------- docs/postgresql.md | 14 ++++++++------ 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 645b2b1..5f3d7da 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,9 @@ A relative `--config` value is resolved against the directory the command is run in, while the relative paths inside the file stay relative to the file's own directory. `generate` exits with status `0` on success, and with status `1` after printing one diagnostic for invalid configuration, an unreadable source, a -query it cannot compile, an output directory or file it cannot write, a stale -generated file it cannot delete, or an output manifest it cannot read or write. +schema it cannot parse, a query it cannot compile, an output directory or file +it cannot write, a stale generated file it cannot delete, or an output manifest +it cannot read or write. ## Getting started diff --git a/docs/configuration.md b/docs/configuration.md index b6a5f1c..a7f5461 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -289,17 +289,21 @@ sqlcj: Generated file paths for repositories 'UserData' and 'Userdata' differ on Invalid configuration, unreadable SQL sources, and output that cannot be written or cleaned up make `sqlcj generate` print a single message on standard error and -exit with a non-zero status. The message names the configuration file and, when -available, the offending field, value, source path, or output path: +exit with status `1`. The message names the configuration file and, when +available, the offending field, value, source path, or output path, and ends +with the fact that explains the failure, such as the reason the filesystem +reports or the problem the YAML parser reports with its line and column: ```text +sqlcj: Cannot read configuration file: /home/dev/project/sqlcj.yaml: No such file or directory +sqlcj: Malformed configuration file: /home/dev/project/sqlcj.yaml: expected , but found '' at line 2, column 3 sqlcj: Invalid configuration in /home/dev/project/sqlcj.yaml: 'java.package' value 'dev.class.generated' is not a valid Java package name -sqlcj: Cannot read queries source: /home/dev/project/sql/missing.sql -sqlcj: Cannot create output directory: /home/dev/project/target/generated-sources/sqlcj/dev/example/generated -sqlcj: Cannot write generated file: /home/dev/project/target/generated-sources/sqlcj/dev/example/generated/UsersRepository.java -sqlcj: Cannot read output manifest: /home/dev/project/target/generated-sources/sqlcj/sqlcj-manifest.txt -sqlcj: Cannot delete stale generated file: /home/dev/project/target/generated-sources/sqlcj/dev/example/generated/OrdersRepository.java -sqlcj: Cannot write output manifest: /home/dev/project/target/generated-sources/sqlcj/sqlcj-manifest.txt +sqlcj: Cannot read queries source: /home/dev/project/sql/missing.sql: No such file or directory +sqlcj: Cannot create output directory: /home/dev/project/target/generated-sources/sqlcj/dev/example/generated: Not a directory +sqlcj: Cannot write generated file: /home/dev/project/target/generated-sources/sqlcj/dev/example/generated/UsersRepository.java: Permission denied +sqlcj: Cannot read output manifest: /home/dev/project/target/generated-sources/sqlcj/sqlcj-manifest.txt: Permission denied +sqlcj: Cannot delete stale generated file: /home/dev/project/target/generated-sources/sqlcj/dev/example/generated/OrdersRepository.java: Permission denied +sqlcj: Cannot write output manifest: /home/dev/project/target/generated-sources/sqlcj/sqlcj-manifest.txt: Permission denied ``` ## Generated Output and Failures diff --git a/docs/postgresql.md b/docs/postgresql.md index 8f08999..2fba9a0 100644 --- a/docs/postgresql.md +++ b/docs/postgresql.md @@ -219,20 +219,22 @@ accepted: ### Failure Behavior -An unsupported type or an unsupported statement stops compilation. `sqlcj -generate` prints a single message on standard error and exits with a non-zero -status. Because every configured source is analyzed and generated before the -run writes its first file, such a failure writes no generated file, and output +An unsupported type, an unsupported statement, or a schema sqlcj cannot parse +stops compilation. `sqlcj generate` prints a single message on standard error +and exits with status `1`. Because every configured source is analyzed and +generated before the run writes its first file, such a failure writes no +generated file, and output written by an earlier successful run is left unchanged. The writing step itself is sequential rather than atomic; see [Generated Output and Failures](configuration.md#generated-output-and-failures). -The message names the schema source and the offending type or statement: +The message names the schema source and the offending type or statement, or the +syntax error with the line and column it was found at: ```text sqlcj: Invalid schema source /home/dev/project/schema.sql: Unsupported SQL column type: TIMESTAMPTZ sqlcj: Invalid schema source /home/dev/project/schema.sql: Unsupported schema statement: Alter -sqlcj: Invalid schema source /home/dev/project/schema.sql: Failed to parse schema +sqlcj: Invalid schema source /home/dev/project/schema.sql: Encountered unexpected token: ";" at line 4, column 1 ``` ## Verified by