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
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ public int getAffectedRows(SQL sql) throws SQLException {

@Override
public List<String> parseSQL(SQL sql) {
return SQLUtils.parseStatements(sql.getSql(), this.druidDbType).stream()
return SqlValidator.parse(this.druidDbType, sql.getSql()).stream()
.map(stmt -> SQLUtils.toSQLString(stmt, this.druidDbType))
.toList();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package org.jumpserver.chen.framework.datasource.sql;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public record SqlValidationResult(
boolean parseable,
int statementCount,
String statementType,
List<String> tables,
List<String> columns,
List<String> errors
) {
public SqlValidationResult {
tables = tables == null ? List.of() : List.copyOf(tables);
columns = columns == null ? List.of() : List.copyOf(columns);
errors = errors == null ? List.of() : List.copyOf(errors);
statementType = statementType == null ? "UNKNOWN" : statementType;
}

public Map<String, Object> toAnalysisMap() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("valid", parseable);
result.put("parseable", parseable);
result.put("statementCount", statementCount);
result.put("statementType", statementType);
result.put("tables", tables);
result.put("columns", columns);
result.put("errors", errors);
return result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package org.jumpserver.chen.framework.datasource.sql;

import com.alibaba.druid.DbType;
import com.alibaba.druid.sql.SQLUtils;
import com.alibaba.druid.sql.ast.SQLStatement;
import com.alibaba.druid.sql.visitor.SchemaStatVisitor;
import com.alibaba.druid.stat.TableStat;
import org.apache.commons.lang3.StringUtils;

import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;

public final class SqlValidator {
public static final String QUERY_UNSUPPORTED_MESSAGE =
"This SQL is not supported by Query because Chen cannot parse it with Druid. Use Console to draft and run it.";

private static final int MAX_OBJECTS = 50;
private static final int MAX_ANALYSIS_COLUMNS = 512;

private SqlValidator() {
}

public static List<SQLStatement> parse(DbType dbType, String sql) {
return SQLUtils.parseStatements(sql, dbType);
}

public static SqlValidationResult validate(DbType dbType, String sql) {
if (StringUtils.isBlank(sql)) {
return unparseable("SQL statement is empty");
}

List<SQLStatement> statements;
try {
statements = parse(dbType, sql);
if (statements == null || statements.isEmpty()) {
return unparseable("SQL statement is empty");
}
} catch (RuntimeException e) {
return unparseable(safeError(e));
}

LinkedHashSet<String> tables = new LinkedHashSet<>();
LinkedHashSet<String> columns = new LinkedHashSet<>();
String statementType = statements.size() == 1 ? statementType(statements.get(0)) : "MULTI";
for (SQLStatement statement : statements) {
try {
SchemaStatVisitor visitor = SQLUtils.createSchemaStatVisitor(dbType);
statement.accept(visitor);
for (TableStat.Name table : visitor.getTables().keySet()) {
addBounded(tables, table.toString(), MAX_OBJECTS);
}
Collection<TableStat.Column> statementColumns = visitor.getColumns();
for (TableStat.Column column : statementColumns) {
addBounded(columns, column.toString(), MAX_ANALYSIS_COLUMNS);
}
} catch (RuntimeException ignored) {
// Some vendor-specific statements are syntactically valid but do not support schema statistics.
}
}
return new SqlValidationResult(
true,
statements.size(),
statementType,
List.copyOf(tables),
List.copyOf(columns),
List.of()
);
}

private static SqlValidationResult unparseable(String error) {
return new SqlValidationResult(
false,
0,
"UNKNOWN",
List.of(),
List.of(),
List.of(error)
);
}

private static String statementType(SQLStatement statement) {
String name = statement.getClass().getSimpleName().toUpperCase(Locale.ROOT);
if (name.startsWith("SQL")) {
name = name.substring(3);
}
if (name.endsWith("STATEMENT")) {
name = name.substring(0, name.length() - "STATEMENT".length());
}
return name;
}

private static String safeError(RuntimeException error) {
String message = StringUtils.defaultIfBlank(error.getMessage(), error.getClass().getSimpleName());
return message.length() > 4096 ? message.substring(0, 4096) : message;
}

private static void addBounded(Set<String> values, String value, int max) {
if (values.size() < max && StringUtils.isNotBlank(value)) {
values.add(value);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.jumpserver.chen.framework.datasource.sql.SQLExecutePlan;
import org.jumpserver.chen.framework.datasource.sql.SQLIdentifier;
import org.jumpserver.chen.framework.datasource.sql.SQLQueryParams;
import org.jumpserver.chen.framework.datasource.sql.SqlValidator;

import java.sql.Connection;
import java.sql.SQLException;
Expand Down Expand Up @@ -67,7 +68,7 @@ public SQLExecutePlan createPlan(SQL sql) throws SQLException {
@Override
public List<String> parseSQL(SQL sql) {
var dbType = this.getDbType();
var statements = SQLUtils.parseStatements(sql.getSql(), dbType);
var statements = SqlValidator.parse(dbType, sql.getSql());
for (var i = 0; i < statements.size() - 1; i++) {
if (!statements.get(i).isAfterSemi()) {
throw new ParserException("Multiple SQL statements must be separated by semicolons");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,8 @@ static List<Map<String, Object>> toolDefinitions() {
tools.add(tool(
"validate_sql",
"Validate SQL draft",
"Parse SQL locally in Chen and return statement count, type, referenced objects and risk. "
"Parse SQL locally in Chen with Druid and return statement count, type and referenced objects. "
+ "valid=false means Chen cannot parse the SQL, not that it is illegal or unsafe. "
+ "This never executes SQL.",
"{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"sql\"],"
+ "\"properties\":{\"sql\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":131072,"
Expand All @@ -337,12 +338,14 @@ static List<Map<String, Object>> toolDefinitions() {
Map<String, Object> proposalTool = tool(
"propose_sql",
"Propose SQL draft",
"Validate exactly one SQL statement, prepare a draft for explicit user review, and wait for the "
+ "user to apply or reject it. This never executes SQL.",
"Prepare SQL as a draft for explicit user review, and wait for the "
+ "user to apply or reject it. Query only accepts SQL Chen can parse with Druid; "
+ "unparseable vendor-native SQL must be proposed in Console, which inserts it with an explicit notice. "
+ "This never executes SQL.",
"{\"type\":\"object\",\"additionalProperties\":false,"
+ "\"required\":[\"sql\",\"explanation\"],\"properties\":{"
+ "\"sql\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":131072,"
+ "\"description\":\"Exactly one complete SQL statement in the verified dialect\"},"
+ "\"description\":\"Complete SQL in the verified dialect\"},"
+ "\"explanation\":{\"type\":\"string\",\"maxLength\":4096,"
+ "\"description\":\"Concise explanation for the user reviewing the draft\"}}}",
false
Expand Down
Loading