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
28 changes: 21 additions & 7 deletions src/main/java/com/github/jinba1/blazedb/BlazeDB.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVPrinter;

import com.github.jinba1.blazedb.operator.LimitOperator;
import com.github.jinba1.blazedb.operator.Operator;

/**
Expand Down Expand Up @@ -76,10 +77,6 @@ static int run(String[] args) {
}

Operator rootOp = planned.root();
if (rootOp == null) {
System.err.println("Error: query could not be planned");
return 1;
}
if (maxTuples != null || timeoutMs != null) {
rootOp.attachBudget(new QueryBudget(maxTuples, timeoutMs));
}
Expand All @@ -91,6 +88,12 @@ static int run(String[] args) {
} catch (IOException e) {
System.err.println("Error: failed to write output: " + e.getMessage());
return 1;
} catch (RuntimeException e) {
// Engine bug, not a user error — fail with an exit code instead of an
// uncaught-exception crash; the trace stays on stderr for bug reports
System.err.println("Internal error: " + e);
e.printStackTrace();
return 1;
}
}

Expand All @@ -104,8 +107,9 @@ private static void usage() {
* on the root object of the operator tree. Writes the result to `outputFile`.
* @param root The root operator of the operator tree (assumed to be non-null).
* @param outputFile The name of the file where the result will be written.
* @return Execution metadata: rows written, LIMIT truncation, refine hint.
*/
public static void execute(Operator root, String outputFile) {
public static QueryResult execute(Operator root, String outputFile) {
File outputFileObj = new File(outputFile);
File parentDir = outputFileObj.getParentFile();
if (parentDir != null && !parentDir.exists()) {
Expand All @@ -117,6 +121,7 @@ public static void execute(Operator root, String outputFile) {

List<String> headers = root.getContext().getOrderedColumnNames(root.propagateSchemaId());
CSVFormat format = CSVFormat.RFC4180.builder().setRecordSeparator("\n").build();
long rows = 0;
try {
try (CSVPrinter printer = new CSVPrinter(new FileWriter(outputFile), format)) {
printer.printRecord(headers);
Expand All @@ -127,21 +132,30 @@ public static void execute(Operator root, String outputFile) {
fields.add(v.toString());
}
printer.printRecord(fields);
rows++;
}
}
} catch (QueryExecutionException e) {
} catch (RuntimeException e) {
// QueryExecutionException and internal errors alike: never leave a
// truncated file that looks like a complete result
deletePartialOutput(outputFileObj, outputFile);
throw e;
} catch (IOException e) {
// Disk full, permissions, output path is a directory, ... — swallowing this
// would make a broken or missing file look like success to callers
deletePartialOutput(outputFileObj, outputFile);
throw new QueryExecutionException(
throw new QueryExecutionException(ErrorCode.DATA_ERROR,
"Failed to write output file '" + outputFile + "': " + e.getMessage());
}

// The planner places Limit topmost, so the root knows whether the cap cut the result
boolean truncated = root instanceof LimitOperator limitOp && limitOp.wasTruncated();
QueryResult result = truncated ? QueryResult.truncated(rows) : QueryResult.complete(rows);

System.out.println("Query executed successfully!");
System.out.println("Output file: " + outputFile);
System.out.println("Rows: " + rows + (truncated ? " (truncated; more rows exist)" : ""));
return result;
}

/** Never leave a truncated file that looks like a complete result. */
Expand Down
37 changes: 32 additions & 5 deletions src/main/java/com/github/jinba1/blazedb/DBCatalog.java
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ public static synchronized void resetDBCatalog() {
private void loadDBCatalog(String dBDirectory) {
Path dataPath = Paths.get(dBDirectory).resolve(Constants.DATA_DIRECTORY_NAME);
if (!Files.isDirectory(dataPath)) {
throw new QueryExecutionException("Data directory not found: " + dataPath);
throw new QueryExecutionException(ErrorCode.DATA_ERROR,
"Data directory not found: " + dataPath);
}
try (Stream<Path> files = Files.list(dataPath)) {
List<Path> csvs = files
Expand All @@ -96,7 +97,8 @@ private void loadDBCatalog(String dBDirectory) {
loadTable(tableName, csv);
}
} catch (IOException e) {
throw new QueryExecutionException("Error scanning data directory " + dataPath + ": " + e.getMessage());
throw new QueryExecutionException(ErrorCode.DATA_ERROR,
"Error scanning data directory " + dataPath + ": " + e.getMessage());
}
}

Expand All @@ -107,7 +109,7 @@ private void loadTable(String tableName, Path csv) throws IOException {
try (CSVParser parser = CSVParser.parse(csv, StandardCharsets.UTF_8, format)) {
Iterator<CSVRecord> it = parser.iterator();
if (!it.hasNext()) {
throw new QueryExecutionException(
throw new QueryExecutionException(ErrorCode.DATA_ERROR,
"Table '" + tableName + "' has no header row (" + csv + ")");
}
CSVRecord header = it.next();
Expand All @@ -116,7 +118,7 @@ private void loadTable(String tableName, Path csv) throws IOException {
for (int i = 0; i < width; i++) {
String col = header.get(i).trim().toLowerCase();
if (columnMap.putIfAbsent(col, i) != null) {
throw new QueryExecutionException(
throw new QueryExecutionException(ErrorCode.DATA_ERROR,
"Table '" + tableName + "' has duplicate column '" + col + "' in header");
}
}
Expand All @@ -128,7 +130,8 @@ private void loadTable(String tableName, Path csv) throws IOException {
CSVRecord record = it.next();
rowNum++;
if (record.size() != width) {
throw new QueryExecutionException("Table '" + tableName + "' row " + rowNum
throw new QueryExecutionException(ErrorCode.DATA_ERROR,
"Table '" + tableName + "' row " + rowNum
+ ": expected " + width + " fields, found " + record.size());
}
for (int i = 0; i < width; i++) {
Expand Down Expand Up @@ -187,6 +190,30 @@ public List<ColumnType> getColumnTypes(String tableName) {
return dbColumnTypes.get(tableName);
}

/**
* Returns all table names in the catalog, sorted — for agent-legible
* "Available tables: ..." error messages.
* @return The sorted list of loaded table names
*/
public List<String> getTableNames() {
List<String> names = new ArrayList<>(dbLocations.keySet());
Collections.sort(names);
return names;
}

/**
* Builds the standard unknown-table failure, listing what is available so an
* agent caller can self-correct. One construction site keeps the wording from
* drifting between the FROM-clause and WHERE-clause detection paths.
* @param tableName The table name that failed to resolve
* @return The exception to throw; never null
*/
public QueryExecutionException unknownTable(String tableName) {
return new QueryExecutionException(ErrorCode.UNKNOWN_TABLE,
"Table '" + tableName + "' not found. Available tables: "
+ String.join(", ", getTableNames()) + ".");
}

/**
* Checks if a specified table exists in the database.
* @param tableName The name of the table to check
Expand Down
25 changes: 25 additions & 0 deletions src/main/java/com/github/jinba1/blazedb/ErrorCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.github.jinba1.blazedb;

/**
* Machine-readable category for a {@link QueryExecutionException}. Downstream surfaces
* (CLI today, REST/MCP later) classify failures by code instead of parsing message text:
* user errors map to 4xx-style handling, {@link #INTERNAL} to 5xx.
*/
public enum ErrorCode {
/** The SQL text could not be parsed. */
PARSE_ERROR,
/** The SQL parsed but uses constructs the engine does not support. */
UNSUPPORTED_SQL,
/** A referenced table does not exist in the catalog. */
UNKNOWN_TABLE,
/** A referenced column does not exist in the schema searched. */
UNKNOWN_COLUMN,
/** Values of incompatible types were compared, joined, or aggregated. */
TYPE_MISMATCH,
/** The query exceeded its tuple or time budget. */
BUDGET_EXCEEDED,
/** Source-data or I/O problem: unreadable file, malformed row, numeric overflow. */
DATA_ERROR,
/** An engine invariant broke — a bug, not a user error. */
INTERNAL
}
26 changes: 17 additions & 9 deletions src/main/java/com/github/jinba1/blazedb/ExpressionEvaluator.java
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,6 @@ public boolean evaluate(Expression expression, Tuple tuple) {
throw new RuntimeException("Expression evaluation did not produce a result");
}

boolean result = resultStack.peek();

return resultStack.pop();
}

Expand Down Expand Up @@ -205,8 +203,7 @@ public void visit(Column column) {
}

if (colIdx == null) {
throw new RuntimeException("Column '" + tableName + "." + columnName +
" not found in schema " + schemaId);
throw ctx.unknownColumn(tableName, columnName, schemaId);
}

valueStack.push(currentTuple.getAttribute(colIdx));
Expand Down Expand Up @@ -239,6 +236,18 @@ public void visit(net.sf.jsqlparser.expression.StringValue stringValue) {
*/
@Override
public void visitBinaryExpression(BinaryExpression expression) {
// Reject before evaluating children: an unsupported operator (e.g. OR) has
// boolean operands that push nothing onto the value stack, so popping below
// would fail with an opaque EmptyStackException instead of this message
if (!(expression instanceof Multiplication
|| expression instanceof EqualsTo || expression instanceof NotEqualsTo
|| expression instanceof GreaterThan || expression instanceof GreaterThanEquals
|| expression instanceof MinorThan || expression instanceof MinorThanEquals)) {
throw new QueryExecutionException(ErrorCode.UNSUPPORTED_SQL,
"Unsupported condition '" + expression
+ "'; supported comparators: =, !=, <, <=, >, >= combined with AND");
}

expression.getLeftExpression().accept(this);
expression.getRightExpression().accept(this);

Expand All @@ -248,15 +257,16 @@ public void visitBinaryExpression(BinaryExpression expression) {

if (expression instanceof Multiplication) {
if (!(left instanceof IntValue l) || !(right instanceof IntValue r)) {
throw new QueryExecutionException("Arithmetic requires int operands: cannot multiply "
throw new QueryExecutionException(ErrorCode.TYPE_MISMATCH,
"Arithmetic requires int operands: cannot multiply "
+ left.typeName() + " '" + left + "' with " + right.typeName() + " '" + right + "'");
}
valueStack.push(new IntValue(l.v() * r.v()));
return;
}

if (left.getClass() != right.getClass()) {
throw new QueryExecutionException("Type mismatch: cannot compare "
throw new QueryExecutionException(ErrorCode.TYPE_MISMATCH, "Type mismatch: cannot compare "
+ left.typeName() + " '" + left + "' with " + right.typeName() + " '" + right + "'");
}

Expand All @@ -270,10 +280,8 @@ public void visitBinaryExpression(BinaryExpression expression) {
resultStack.push(left.compareTo(right) >= 0);
} else if (expression instanceof MinorThan) {
resultStack.push(left.compareTo(right) < 0);
} else if (expression instanceof MinorThanEquals) {
} else { // MinorThanEquals: the only type left after the guard above
resultStack.push(left.compareTo(right) <= 0);
} else {
throw new UnsupportedOperationException("Unsupported binary expression: " + expression.getClass().getName());
}
}
/**
Expand Down
36 changes: 23 additions & 13 deletions src/main/java/com/github/jinba1/blazedb/ExpressionPreprocessor.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
*/
public class ExpressionPreprocessor extends ExpressionVisitorAdapter {

private final PlanContext ctx;
private final Stack<String> tableStack; // track table references
private final List<Expression> joinExpressions;
private final List<Expression> selectExpressions;
Expand All @@ -38,8 +39,10 @@ public class ExpressionPreprocessor extends ExpressionVisitorAdapter {
/**
* Constructs a new ExpressionPreprocessor.
* Initializes internal data structures for tracking table references and expressions.
* @param ctx The per-query context, used to build resolution-failure messages
*/
public ExpressionPreprocessor() {
public ExpressionPreprocessor(PlanContext ctx) {
this.ctx = ctx;
tableStack = new Stack<>();
joinExpressions = new LinkedList<>();
selectExpressions = new LinkedList<>();
Expand Down Expand Up @@ -164,7 +167,7 @@ public void visit(MinorThanEquals minorThanEquals) {
* Visits a column reference and pushes its table name onto the stack.
* Verifies that the column exists in the database schema.
* @param column The column reference to visit
* @throws UnsupportedOperationException If the column or table doesn't exist
* @throws QueryExecutionException If the column or table doesn't exist
*/
@Override
public void visit(Column column) {
Expand All @@ -174,10 +177,12 @@ public void visit(Column column) {
if (DBCatalog.getInstance().columnExists(tableName, columnName)) {
tableStack.push(tableName);
} else {
throw new UnsupportedOperationException(String.format("Table %s does not have column %s", tableName, columnName));
// base-table name doubles as its schema id, so the shared
// construction site applies here too
throw ctx.unknownColumn(tableName, columnName, tableName);
}
} else {
throw new UnsupportedOperationException(String.format("Table %s does not exist", tableName));
throw DBCatalog.getInstance().unknownTable(tableName);
}
}

Expand All @@ -199,22 +204,27 @@ public void visit(LongValue longValue) {
*/
@Override
public void visitBinaryExpression(BinaryExpression expression) {
// Reject before visiting children: an unsupported operator (e.g. OR) has
// comparison operands that push nothing onto the table stack, so popping
// below would fail with an opaque EmptyStackException instead of this message
if (!(expression instanceof EqualsTo || expression instanceof NotEqualsTo
|| expression instanceof GreaterThanEquals || expression instanceof GreaterThan
|| expression instanceof MinorThan || expression instanceof MinorThanEquals)) {
throw new QueryExecutionException(ErrorCode.UNSUPPORTED_SQL,
"Unsupported condition '" + expression
+ "'; supported comparators: =, !=, <, <=, >, >= combined with AND");
}

expression.getLeftExpression().accept(this);
expression.getRightExpression().accept(this);

String rightTable = tableStack.pop();
String leftTable = tableStack.pop();

if (expression instanceof EqualsTo || expression instanceof NotEqualsTo
|| expression instanceof GreaterThanEquals || expression instanceof GreaterThan
|| expression instanceof MinorThan || expression instanceof MinorThanEquals) {
if ((rightTable != null && leftTable != null) && (!rightTable.equals(leftTable))) {
joinExpressions.add(expression);
} else {
selectExpressions.add(expression);
}
if ((rightTable != null && leftTable != null) && (!rightTable.equals(leftTable))) {
joinExpressions.add(expression);
} else {
throw new UnsupportedOperationException("Unsupported binary expression: " + expression.getClass().getName());
selectExpressions.add(expression);
}

}
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/github/jinba1/blazedb/IntValue.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public int compareTo(Value other) {
if (other instanceof IntValue o) {
return Integer.compare(v, o.v);
}
throw new QueryExecutionException(
throw new QueryExecutionException(ErrorCode.TYPE_MISMATCH,
"Type mismatch: cannot compare int value '" + v + "' with "
+ other.typeName() + " value '" + other + "'");
}
Expand Down
37 changes: 37 additions & 0 deletions src/main/java/com/github/jinba1/blazedb/PlanContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,43 @@ private Integer smartResolveColumnIndex(String schemaId, String tableName, Strin
return index;
}

/**
* Comma-separated resolvable column names of a schema, in column order — for
* agent-legible error messages. Alias keys mapping to the same index are all
* listed, because each is a name the caller may legally write.
*/
public String availableColumns(String schemaId) {
return formatColumns(getSchema(schemaId));
}

/**
* Builds the standard unknown-column failure for a schema lookup miss, listing
* what is available so an agent caller can self-correct. One construction site
* keeps the wording aligned across projection, sort, aggregate, and evaluation.
* @param tableName The table qualifier as written in the query
* @param columnName The column name that failed to resolve
* @param schemaId The schema the lookup ran against
* @return The exception to throw; never null
*/
public QueryExecutionException unknownColumn(String tableName, String columnName,
String schemaId) {
return new QueryExecutionException(ErrorCode.UNKNOWN_COLUMN,
"Column '" + tableName + "." + columnName + "' not found. Available: "
+ availableColumns(schemaId) + ".");
}

/** Formats a schema map as a comma-separated column list, ordered by index then name. */
public static String formatColumns(Map<String, Integer> schema) {
if (schema == null || schema.isEmpty()) {
return "(none)";
}
return schema.entrySet().stream()
.sorted(Map.Entry.<String, Integer>comparingByValue()
.thenComparing(Map.Entry.comparingByKey()))
.map(Map.Entry::getKey)
.collect(java.util.stream.Collectors.joining(", "));
}

/**
* Result-header names for a schema, in column order. Ported verbatim from
* DBCatalog.getOrderedColumnNames: bare-ifies plain columns, keeps aggregate
Expand Down
Loading
Loading