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
51 changes: 40 additions & 11 deletions isthmus-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,26 +27,55 @@ isthmus 0.1
```
$ ./isthmus-cli/build/native/nativeCompile/isthmus --help

Usage: isthmus [-hV] [--outputformat=<outputFormat>]
Usage: isthmus [-hV] [--stacktrace] [--outputformat=<outputFormat>]
[--unquotedcasing=<unquotedCasing>] [-c=<createStatements>]...
[-e=<sqlExpressions>...]... [<sql>]
Convert SQL Queries and SQL Expressions to Substrait
[<sql>] A SQL query
[<sql>] A SQL query
-c, --create=<createStatements>
One or multiple create table statements e.g. CREATE
TABLE T1(foo int, bar bigint)
One or multiple create table statements e.g. CREATE TABLE
T1(foo int, bar bigint)
-e, --expression=<sqlExpressions>...
One or more SQL expressions e.g. col + 1
-h, --help Show this help message and exit.
One or more SQL expressions e.g. col + 1
-h, --help Show this help message and exit.
--outputformat=<outputFormat>
Set the output format for the generated plan:
PROTOJSON, PROTOTEXT, BINARY
Set the output format for the generated plan: PROTOJSON,
PROTOTEXT, BINARY
--stacktrace Print the full stack trace of a conversion failure, not
just its message
--unquotedcasing=<unquotedCasing>
Calcite's casing policy for unquoted identifiers:
UNCHANGED, TO_UPPER, TO_LOWER
-V, --version Print version information and exit.
Calcite's casing policy for unquoted identifiers:
UNCHANGED, TO_UPPER, TO_LOWER
-V, --version Print version information and exit.
```

### Errors

A mistake in the SQL is reported as a message, along with a hint about the option to reach for where one applies:

```
$ ./isthmus-cli/build/native/nativeCompile/isthmus "SELECT lastName FROM Persons"

Error: From line 1, column 22 to line 1, column 28: Object 'PERSONS' not found

Hint: table definitions are not part of the query. Pass a CREATE TABLE
statement for each table it references using -c / --create:

isthmus -c "CREATE TABLE PERSONS (col1 INT, col2 VARCHAR)" "SELECT * FROM PERSONS"

Unquoted identifiers are upper-cased unless --unquotedcasing says otherwise.
```

Add `--stacktrace` to get the full stack trace of the failure as well. A conversion that fails for a reason that is not a recognizable problem with the input is always reported with its stack trace.

The exit code distinguishes the two kinds of mistake:

| Code | Meaning |
| --- | --- |
| 0 | The plan or extended expression was written to stdout |
| 1 | The SQL could not be converted, or the conversion hit a defect |
| 2 | The command line itself was wrong, e.g. no SQL was given |

## Example

### SQL to Substrait Plan
Expand Down
8 changes: 8 additions & 0 deletions isthmus-cli/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ dependencies {
implementation(libs.classgraph)
implementation(libs.guava)
compileOnly(libs.graal.sdk)
// Only to name the parser class the native image must keep reflectively callable; :isthmus
// brings it along at runtime.
compileOnly(libs.calcite.server) {
exclude(group = "commons-lang", module = "commons-lang")
.because(
"calcite-core brings in commons-lang:commons-lang:2.4 which has a security vulnerability"
)
}
implementation(libs.picocli)
annotationProcessor(libs.picocli.codegen)
implementation(libs.protobuf.java.util) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@
import org.apache.calcite.prepare.Prepare;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.ParameterException;
import picocli.CommandLine.Parameters;
import picocli.CommandLine.Spec;

/** Isthmus CLI entry point. */
@Command(
Expand Down Expand Up @@ -59,33 +62,50 @@ enum OutputFormat {
description = "Calcite's casing policy for unquoted identifiers: ${COMPLETION-CANDIDATES}")
private Casing unquotedCasing = Casing.TO_UPPER;

@Option(
names = {"--stacktrace"},
description = "Print the full stack trace of a conversion failure, not just its message")
private boolean stackTrace;
Comment thread
nielspardon marked this conversation as resolved.

@Spec private CommandSpec spec;

/**
* Standard Java Main method invoked by the isthmus CLI command.
*
* @param args Isthmus CLI arguments.
*/
public static void main(String... args) {
System.exit(createCommandLine().execute(args));
}
Comment thread
nielspardon marked this conversation as resolved.

/**
* Creates the {@link CommandLine} driving the isthmus CLI. Errors caused by the given SQL are
* reported by {@link IsthmusExecutionExceptionHandler} rather than as a stack trace.
*
* @return the configured {@link CommandLine}
*/
static CommandLine createCommandLine() {
CommandLine commandLine = new CommandLine(new IsthmusEntryPoint());
commandLine.setCaseInsensitiveEnumValuesAllowed(true);
CommandLine.ParseResult parseResult = commandLine.parseArgs(args);
if (parseResult.originalArgs().isEmpty()) { // If no arguments print usage help
commandLine.usage(System.out);
System.exit(0);
}
if (commandLine.isUsageHelpRequested()) {
commandLine.usage(System.out);
System.exit(0);
}
if (commandLine.isVersionHelpRequested()) {
commandLine.printVersionHelp(System.out);
System.exit(0);
}
int exitCode = commandLine.execute(args);
System.exit(exitCode);
commandLine.setExecutionExceptionHandler(new IsthmusExecutionExceptionHandler());
return commandLine;
}

@Override
public Integer call() throws Exception {
if (sqlExpressions == null && sql == null) {
throw new ParameterException(
spec.commandLine(),
"Missing SQL to convert: pass a SQL query as the first argument, "
+ "or SQL expressions with -e / --expression");
}
Comment thread
nielspardon marked this conversation as resolved.
if (sqlExpressions != null && sql != null) {
throw new ParameterException(
spec.commandLine(),
"Give either a SQL query or -e / --expression, not both: the query '"
+ sql
+ "' would be ignored");
}
ConverterProvider provider = ConverterProvider.builder().unquotedCasing(unquotedCasing).build();
// Isthmus image is parsing SQL Expression if that argument is defined
if (sqlExpressions != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
package io.substrait.isthmus.cli;

import java.io.PrintWriter;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.calcite.runtime.CalciteContextException;
import org.apache.calcite.sql.parser.SqlParseException;
import picocli.CommandLine;

/**
* Reports mistakes in the SQL handed to the CLI as a short message, with a hint about the option to
* reach for whenever the mistake can be identified.
*
* <p>Only failures that are recognizably caused by the input are reported this way. Anything else
* is rethrown so that its stack trace is still printed, as is the full stack trace of a recognized
* failure when {@code --stacktrace} is given.
*/
class IsthmusExecutionExceptionHandler implements CommandLine.IExecutionExceptionHandler {

/**
* Matches Calcite's complaint about a table (or other object) that the catalog does not hold. The
* second group holds the schema it looked in, for the qualified form of the message.
*/
private static final Pattern OBJECT_NOT_FOUND =
Pattern.compile("(?:Object|Table) '([^']+)' not found(?: within '([^']+)')?");

/** Matches Calcite's complaint about a column that none of the known tables holds. */
private static final Pattern COLUMN_NOT_FOUND = Pattern.compile("Column '([^']+)' not found");

/** Matches Calcite's complaint about an identifier that an expression cannot be resolved to. */
private static final Pattern UNKNOWN_IDENTIFIER = Pattern.compile("Unknown identifier '([^']+)'");

/** Matches Calcite's complaint about a query given where an expression was expected. */
private static final Pattern STATEMENT_AS_EXPRESSION =
Pattern.compile("Incorrect syntax near the keyword '(?:SELECT|WITH|VALUES|TABLE)'");

/** The message the CREATE statement parser reports for anything that is not a CREATE TABLE. */
private static final String NOT_A_CREATE_TABLE = "Not a valid CREATE TABLE statement.";

/** The message the CREATE statement parser reports for a CREATE TABLE AS SELECT. */
private static final String CTAS_NOT_SUPPORTED = "CTAS not supported.";

/** The message the CREATE statement parser reports for a CREATE TABLE without a column list. */
private static final String COLUMNS_REQUIRED = "Column definitions are required.";

/** The message the DDL converter reports for a CREATE TABLE without a query. */
private static final String CTAS_ONLY = "Only create table as select statements are supported";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This literal is a copy of the one in isthmus/src/main/java/io/substrait/isthmus/calcite/rel/DdlSqlToRelConverter.java:91 — a different Gradle module — matched with .equals(). Reword it there (a plausible, unrelated change) and isthmus "CREATE TABLE foo(a INT)" silently goes back to dumping a stack trace, with everything still compiling and every test in isthmus still green. NOT_A_CREATE_TABLE and CTAS_NOT_SUPPORTED have the same problem against the inline literals in SubstraitCreateStatementParser.

A dedicated exception type is the real fix; failing that, public static final constants exposed by the throwing classes would at least turn a rename into a compile error.

Minor, same area: isPlainCreateTableQuery(ex) is evaluated twice per failure — once in isInputError (line 105) and again at the top of hint() (line 127).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept the literals, because CI does catch the rename: the CLI tests assert on all three messages end to end (ctasPassedToCreateOptionSuggestsQueryArgument, queryPassedToCreateOptionSuggestsQueryArgument, and createTableAsQuerySuggestsCreateOption for CTAS_ONLY, which only prints its hint when the message matches exactly), so rewording any of them turns :isthmus-cli:test red in the same build. Exposing them as public static final in :isthmus would make error-message text part of that module's API, which I would rather not do for a coupling the tests already pin — happy to add the constants, or a dedicated exception type, if you disagree.

Left the double isPlainCreateTableQuery evaluation: it is an instanceof plus a String.equals on a path that is about to print and exit, and hoisting it means either threading a boolean through hint() or reordering the two checks into one method that does both jobs.


// The hints are hard-wrapped for a terminal rather than joined into single long lines.

private static final String CREATE_HINT =
"""
Hint: table definitions are not part of the query. Pass a CREATE TABLE
statement for each table it references using -c / --create:

isthmus -c "CREATE TABLE %1$s (col1 INT, col2 VARCHAR)" "SELECT * FROM %1$s\"""";

private static final String COLUMN_HINT =
"""
Hint: '%1$s' is not a column of any table defined with -c / --create. Check
the column names in the CREATE TABLE statement.""";

private static final String COLUMN_WITHOUT_CREATE_HINT =
"""
Hint: '%1$s' is not a column of any table, and no table was defined. Pass a
CREATE TABLE statement for each table the query reads using -c / --create:

isthmus -c "CREATE TABLE T (%1$s INT)" "SELECT %1$s FROM T\"""";

private static final String EXPRESSION_HINT =
"""
Hint: identifiers in a -e / --expression must be columns of a table defined
with -c / --create:

isthmus -c "CREATE TABLE T (%1$s INT)" -e "%1$s + 1\"""";

private static final String EXPRESSION_ARGUMENT_HINT =
"""
Hint: -e / --expression consumes every following argument, so a query
written after it is parsed as an expression. Pass the query on its own:

isthmus "SELECT * FROM FOO\"""";

private static final String QUERY_ARGUMENT_HINT =
"""
Hint: -c / --create takes plain CREATE TABLE statements; the query itself is
the first argument:

isthmus -c "CREATE TABLE FOO (col1 INT)" "SELECT * FROM FOO\"""";

private static final String COLUMN_LIST_HINT =
"""
Hint: -c / --create needs the columns of the table, with their types:

isthmus -c "CREATE TABLE FOO (col1 INT, col2 VARCHAR)" "SELECT * FROM FOO\"""";

/** Appended to the hints whose advice depends on how unquoted identifiers are cased. */
private static final String CASING_NOTE =
"\n\nUnquoted identifiers are upper-cased unless --unquotedcasing says otherwise.";

@Override
public int handleExecutionException(
Exception ex, CommandLine cmd, CommandLine.ParseResult parseResult) throws Exception {
if (!isInputError(ex)) {
// Not something the user can act on: keep the stack trace, it belongs in a bug report.
throw ex;
}

PrintWriter err = cmd.getErr();
err.println(cmd.getColorScheme().errorText("Error: " + ex.getMessage()));
Optional<String> hint = hint(ex, parseResult);
if (hint.isPresent()) {
err.println();
err.println(hint.get());
}
err.flush();

if (parseResult.hasMatchedOption("--stacktrace")) {
throw ex;
}
return cmd.getCommandSpec().exitCodeOnExecutionException();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recognised input errors return ExitCode.SOFTWARE — picocli's internal software error code — which is also what an unrecognised crash returns:

$ isthmus "SELECT * FROM foo"                          # bad input, message printed -> 1
$ isthmus -c "CREATE TABLE FOO" "SELECT * FROM FOO"    # NPE, trace printed        -> 1

Meanwhile missing input deliberately exits 2 (ExitCode.USAGE). So two members of the newly created "the input is wrong" category get different codes, and a script wrapping isthmus cannot tell "fix your SQL" from "file a bug". The new tests bake ExitCode.SOFTWARE in, so the split is now pinned.

Worth deciding explicitly: either input errors join missing input on 2, or the distinction gets documented. Not a blocker, but easier to settle before release than after.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Settled it as documentation rather than a move to 2: 1 for "the SQL could not be converted", 2 for "the command line itself was wrong". That keeps --stacktrace-worthy defects and bad SQL on the same code, which is the part your example is really about, but it is the split a compiler makes too — bad source is a normal failure, not a usage error — and it means adding a hint for a failure never silently changes a script's exit code. The three codes are now a table in the isthmus-cli readme.

}

/**
* Reports whether the given exception was caused by the SQL given to the CLI rather than by a
* defect.
*
* @param ex the exception thrown while converting
* @return true if the exception describes a problem with the input
*/
private static boolean isInputError(final Exception ex) {
if (ex instanceof SqlParseException) {
// Calcite funnels whatever the parser threw into a SqlParseException, leaving the position
// null unless the cause was a grammar or lexer error
// (SqlAbstractParserImpl.convertException). A missing position therefore means a defect
// rather than a problem with the SQL.
return ((SqlParseException) ex).getPos() != null;
}
// CalciteContextException is, by construction, a complaint about the SQL at a line and column.
return ex instanceof CalciteContextException || isPlainCreateTableQuery(ex);
}
Comment thread
nielspardon marked this conversation as resolved.

/**
* Reports whether the given exception is the DDL converter's rejection of a CREATE TABLE
* statement passed as the query.
*
* @param ex the exception thrown while converting
* @return true if a plain CREATE TABLE statement was given as the query
*/
private static boolean isPlainCreateTableQuery(final Exception ex) {
return ex instanceof IllegalArgumentException && CTAS_ONLY.equals(ex.getMessage());
}

/**
* Returns the hint to print for the given exception, if the message and the options it was given
* identify a mistake we can advise on.
*
* @param ex the exception thrown while converting
* @param parseResult the parsed command line the failure came from
* @return the hint to print below the error message, or empty if the mistake is not recognized
*/
private static Optional<String> hint(
final Exception ex, final CommandLine.ParseResult parseResult) {
if (isPlainCreateTableQuery(ex)) {
return Optional.of(QUERY_ARGUMENT_HINT);
}

String message = ex.getMessage();
if (message == null) {
return Optional.empty();
}
if (message.contains(NOT_A_CREATE_TABLE) || message.contains(CTAS_NOT_SUPPORTED)) {
return Optional.of(QUERY_ARGUMENT_HINT);
}
if (message.contains(COLUMNS_REQUIRED)) {
return Optional.of(COLUMN_LIST_HINT);
}

boolean expressions = parseResult.hasMatchedOption("-e");
if (expressions && STATEMENT_AS_EXPRESSION.matcher(message).find()) {
return Optional.of(EXPRESSION_ARGUMENT_HINT);
}

Matcher objectNotFound = OBJECT_NOT_FOUND.matcher(message);
if (objectNotFound.find()) {
return Optional.of(
withCasingNote(CREATE_HINT.formatted(objectName(objectNotFound)), parseResult));
}
Matcher columnNotFound = COLUMN_NOT_FOUND.matcher(message);
if (columnNotFound.find()) {
String hint = parseResult.hasMatchedOption("-c") ? COLUMN_HINT : COLUMN_WITHOUT_CREATE_HINT;
return Optional.of(withCasingNote(hint.formatted(columnNotFound.group(1)), parseResult));
}
Matcher unknownIdentifier = UNKNOWN_IDENTIFIER.matcher(message);
if (expressions && unknownIdentifier.find()) {
// Without -e this is a complaint about a -c statement, where naming the identifier as a
// column of a new table would be the wrong advice.
return Optional.of(
withCasingNote(EXPRESSION_HINT.formatted(unknownIdentifier.group(1)), parseResult));
}
Comment thread
nielspardon marked this conversation as resolved.
return Optional.empty();
}

/**
* Returns the name to suggest defining, qualified with the schema Calcite looked in when the
* message names one.
*
* @param objectNotFound the match of {@link #OBJECT_NOT_FOUND} against the error message
* @return the object name to name in the hint
*/
private static String objectName(final Matcher objectNotFound) {
String schema = objectNotFound.group(2);
return schema == null ? objectNotFound.group(1) : schema + "." + objectNotFound.group(1);
}

/**
* Appends the note about identifier casing to the given hint, unless the casing was chosen on the
* command line and the note would be both wrong and beside the point.
*
* @param hint the hint to print below the error message
* @param parseResult the parsed command line the failure came from
* @return the hint, with the casing note where it applies
*/
private static String withCasingNote(
final String hint, final CommandLine.ParseResult parseResult) {
return parseResult.hasMatchedOption("--unquotedcasing") ? hint : hint + CASING_NOTE;
}
}
Loading
Loading