This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 274c020823f2 chore: camel-jbang - Add SQL query dev-console, TUI tab,
and MCP tools
274c020823f2 is described below
commit 274c020823f2397b01b2e75f7521586543b4a231
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Jun 29 07:47:45 2026 +0200
chore: camel-jbang - Add SQL query dev-console, TUI tab, and MCP tools
Add sql-query dev-console for executing SQL queries and row updates
against DataSource beans via JDBC. Includes:
- SqlQueryDevConsole with support for SELECT, INSERT, UPDATE, DELETE
and inline row editing with auto-generated UPDATE via PK detection
- camel sql CLI command for terminal-based database queries
- SQL Query TUI tab with multi-line input, query history (Ctrl+E),
datasource switching, and tabular result display
- tui_execute_sql, tui_update_row, and tui_set_input MCP tools for
AI agent integration
- Enriched tui_get_table MCP response with SQL input state and
editability metadata
- Support loading SQL from file via file: prefix
- SQL example with 5s query period
Closes #24281
Co-Authored-By: Claude <[email protected]>
---
.../apache/camel/catalog/dev-consoles.properties | 1 +
.../camel/catalog/dev-consoles/sql-query.json | 15 +
.../impl/console/SqlQueryDevConsoleConfigurer.java | 77 ++
.../org/apache/camel/dev-console/sql-query.json | 15 +
...rg.apache.camel.impl.console.SqlQueryDevConsole | 2 +
.../org/apache/camel/dev-console/sql-query | 2 +
.../org/apache/camel/dev-consoles.properties | 2 +-
.../camel/impl/console/SqlQueryDevConsole.java | 531 ++++++++++
.../pages/jbang-commands/camel-jbang-cmd-sql.adoc | 29 +
.../ROOT/pages/jbang-commands/camel-jbang-cmd.adoc | 1 +
.../camel/cli/connector/LocalCliConnector.java | 54 +
.../META-INF/camel-jbang-commands-metadata.json | 2 +-
.../dsl/jbang/core/commands/CamelJBangMain.java | 1 +
.../core/commands/action/CamelSqlQueryAction.java | 174 ++++
.../camel/dsl/jbang/core/common/RuntimeHelper.java | 44 +-
.../src/main/resources/examples/sql/sql.camel.yaml | 2 +-
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 82 +-
.../dsl/jbang/core/commands/tui/ClasspathTab.java | 8 +
.../camel/dsl/jbang/core/commands/tui/HttpTab.java | 17 +
.../dsl/jbang/core/commands/tui/InputHistory.java | 153 +++
.../dsl/jbang/core/commands/tui/MonitorTab.java | 4 +
.../dsl/jbang/core/commands/tui/SpansTab.java | 8 +
.../dsl/jbang/core/commands/tui/SqlQueryTab.java | 1057 ++++++++++++++++++++
.../dsl/jbang/core/commands/tui/TuiMcpServer.java | 94 ++
24 files changed, 2355 insertions(+), 20 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties
index 536dbe0e6b98..217aa1876741 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles.properties
@@ -56,6 +56,7 @@ service
sftp
simple-language
source
+sql-query
startup-recorder
stub
system-properties
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/sql-query.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/sql-query.json
new file mode 100644
index 000000000000..1b98f12a4690
--- /dev/null
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/sql-query.json
@@ -0,0 +1,15 @@
+{
+ "console": {
+ "kind": "console",
+ "group": "camel",
+ "name": "sql-query",
+ "title": "SQL Query",
+ "description": "Execute SQL queries on DataSource beans",
+ "deprecated": false,
+ "javaType": "org.apache.camel.impl.console.SqlQueryDevConsole",
+ "groupId": "org.apache.camel",
+ "artifactId": "camel-console",
+ "version": "4.21.0-SNAPSHOT"
+ }
+}
+
diff --git
a/core/camel-console/src/generated/java/org/apache/camel/impl/console/SqlQueryDevConsoleConfigurer.java
b/core/camel-console/src/generated/java/org/apache/camel/impl/console/SqlQueryDevConsoleConfigurer.java
new file mode 100644
index 000000000000..61bc950601a3
--- /dev/null
+++
b/core/camel-console/src/generated/java/org/apache/camel/impl/console/SqlQueryDevConsoleConfigurer.java
@@ -0,0 +1,77 @@
+/* Generated by camel build tools - do NOT edit this file! */
+package org.apache.camel.impl.console;
+
+import javax.annotation.processing.Generated;
+import java.util.Map;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.spi.ExtendedPropertyConfigurerGetter;
+import org.apache.camel.spi.PropertyConfigurerGetter;
+import org.apache.camel.spi.ConfigurerStrategy;
+import org.apache.camel.spi.GeneratedPropertyConfigurer;
+import org.apache.camel.util.CaseInsensitiveMap;
+import org.apache.camel.impl.console.SqlQueryDevConsole;
+
+/**
+ * Generated by camel build tools - do NOT edit this file!
+ */
+@Generated("org.apache.camel.maven.packaging.GenerateConfigurerMojo")
+@SuppressWarnings("unchecked")
+public class SqlQueryDevConsoleConfigurer extends
org.apache.camel.support.component.PropertyConfigurerSupport implements
GeneratedPropertyConfigurer, ExtendedPropertyConfigurerGetter {
+
+ private static final Map<String, Object> ALL_OPTIONS;
+ static {
+ Map<String, Object> map = new CaseInsensitiveMap();
+ map.put("CamelContext", org.apache.camel.CamelContext.class);
+ map.put("DefaultMaxRows", int.class);
+ map.put("DefaultQueryTimeout", int.class);
+ ALL_OPTIONS = map;
+ }
+
+ @Override
+ public boolean configure(CamelContext camelContext, Object obj, String
name, Object value, boolean ignoreCase) {
+ org.apache.camel.impl.console.SqlQueryDevConsole target =
(org.apache.camel.impl.console.SqlQueryDevConsole) obj;
+ switch (ignoreCase ? name.toLowerCase() : name) {
+ case "camelcontext":
+ case "camelContext": target.setCamelContext(property(camelContext,
org.apache.camel.CamelContext.class, value)); return true;
+ case "defaultmaxrows":
+ case "defaultMaxRows": target.setDefaultMaxRows(property(camelContext,
int.class, value)); return true;
+ case "defaultquerytimeout":
+ case "defaultQueryTimeout":
target.setDefaultQueryTimeout(property(camelContext, int.class, value)); return
true;
+ default: return false;
+ }
+ }
+
+ @Override
+ public Map<String, Object> getAllOptions(Object target) {
+ return ALL_OPTIONS;
+ }
+
+ @Override
+ public Class<?> getOptionType(String name, boolean ignoreCase) {
+ switch (ignoreCase ? name.toLowerCase() : name) {
+ case "camelcontext":
+ case "camelContext": return org.apache.camel.CamelContext.class;
+ case "defaultmaxrows":
+ case "defaultMaxRows": return int.class;
+ case "defaultquerytimeout":
+ case "defaultQueryTimeout": return int.class;
+ default: return null;
+ }
+ }
+
+ @Override
+ public Object getOptionValue(Object obj, String name, boolean ignoreCase) {
+ org.apache.camel.impl.console.SqlQueryDevConsole target =
(org.apache.camel.impl.console.SqlQueryDevConsole) obj;
+ switch (ignoreCase ? name.toLowerCase() : name) {
+ case "camelcontext":
+ case "camelContext": return target.getCamelContext();
+ case "defaultmaxrows":
+ case "defaultMaxRows": return target.getDefaultMaxRows();
+ case "defaultquerytimeout":
+ case "defaultQueryTimeout": return target.getDefaultQueryTimeout();
+ default: return null;
+ }
+ }
+}
+
diff --git
a/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/sql-query.json
b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/sql-query.json
new file mode 100644
index 000000000000..1b98f12a4690
--- /dev/null
+++
b/core/camel-console/src/generated/resources/META-INF/org/apache/camel/dev-console/sql-query.json
@@ -0,0 +1,15 @@
+{
+ "console": {
+ "kind": "console",
+ "group": "camel",
+ "name": "sql-query",
+ "title": "SQL Query",
+ "description": "Execute SQL queries on DataSource beans",
+ "deprecated": false,
+ "javaType": "org.apache.camel.impl.console.SqlQueryDevConsole",
+ "groupId": "org.apache.camel",
+ "artifactId": "camel-console",
+ "version": "4.21.0-SNAPSHOT"
+ }
+}
+
diff --git
a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.impl.console.SqlQueryDevConsole
b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.impl.console.SqlQueryDevConsole
new file mode 100644
index 000000000000..cf682ab4c486
--- /dev/null
+++
b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.impl.console.SqlQueryDevConsole
@@ -0,0 +1,2 @@
+# Generated by camel build tools - do NOT edit this file!
+class=org.apache.camel.impl.console.SqlQueryDevConsoleConfigurer
diff --git
a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-console/sql-query
b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-console/sql-query
new file mode 100644
index 000000000000..9ddcee3835ca
--- /dev/null
+++
b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-console/sql-query
@@ -0,0 +1,2 @@
+# Generated by camel build tools - do NOT edit this file!
+class=org.apache.camel.impl.console.SqlQueryDevConsole
diff --git
a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties
b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties
index e25def6b5f32..40b26b866b52 100644
---
a/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties
+++
b/core/camel-console/src/generated/resources/META-INF/services/org/apache/camel/dev-consoles.properties
@@ -1,5 +1,5 @@
# Generated by camel build tools - do NOT edit this file!
-dev-consoles=bean blocked browse circuit-breaker consumer context datasource
debug endpoint errors eval-language event gc health inflight internal-tasks
java-security jvm log memory message-history processor producer properties
receive reload rest rest-spec route route-controller route-dump route-group
route-structure route-topology send service simple-language source
startup-recorder system-properties thread top trace transformers
type-converters variables
+dev-consoles=bean blocked browse circuit-breaker consumer context datasource
debug endpoint errors eval-language event gc health inflight internal-tasks
java-security jvm log memory message-history processor producer properties
receive reload rest rest-spec route route-controller route-dump route-group
route-structure route-topology send service simple-language source sql-query
startup-recorder system-properties thread top trace transformers
type-converters variables
groupId=org.apache.camel
artifactId=camel-console
version=4.21.0-SNAPSHOT
diff --git
a/core/camel-console/src/main/java/org/apache/camel/impl/console/SqlQueryDevConsole.java
b/core/camel-console/src/main/java/org/apache/camel/impl/console/SqlQueryDevConsole.java
new file mode 100644
index 000000000000..4bd24bba64d2
--- /dev/null
+++
b/core/camel-console/src/main/java/org/apache/camel/impl/console/SqlQueryDevConsole.java
@@ -0,0 +1,531 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.impl.console;
+
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.sql.DataSource;
+
+import org.apache.camel.spi.Configurer;
+import org.apache.camel.spi.Metadata;
+import org.apache.camel.spi.annotations.DevConsole;
+import org.apache.camel.support.console.AbstractDevConsole;
+import org.apache.camel.util.StopWatch;
+import org.apache.camel.util.TimeUtils;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+@DevConsole(name = "sql-query", displayName = "SQL Query", description =
"Execute SQL queries on DataSource beans")
+@Configurer(extended = true)
+public class SqlQueryDevConsole extends AbstractDevConsole {
+
+ /**
+ * The SQL query to execute
+ */
+ public static final String SQL = "sql";
+
+ /**
+ * Name of the DataSource bean in the registry (auto-detected if only one
exists)
+ */
+ public static final String DATASOURCE = "datasource";
+
+ /**
+ * Maximum number of rows to return
+ */
+ public static final String MAX_ROWS = "maxRows";
+
+ /**
+ * Query timeout in seconds
+ */
+ public static final String QUERY_TIMEOUT = "queryTimeout";
+
+ /**
+ * Action type: "query" (default) or "update-row"
+ */
+ public static final String ACTION_TYPE = "actionType";
+
+ /**
+ * Table name for update-row action
+ */
+ public static final String TABLE = "table";
+
+ /**
+ * Primary key column-value pairs as JSON string for update-row action
+ */
+ public static final String PRIMARY_KEY_VALUES = "primaryKeyValues";
+
+ /**
+ * Changed column-value pairs as JSON string for update-row action
+ */
+ public static final String COLUMN_VALUES = "columnValues";
+
+ @Metadata(defaultValue = "100",
+ description = "Maximum number of rows to return from a query")
+ private int defaultMaxRows = 100;
+
+ @Metadata(defaultValue = "30",
+ description = "Default query timeout in seconds")
+ private int defaultQueryTimeout = 30;
+
+ public SqlQueryDevConsole() {
+ super("camel", "sql-query", "SQL Query", "Execute SQL queries on
DataSource beans");
+ }
+
+ public int getDefaultMaxRows() {
+ return defaultMaxRows;
+ }
+
+ public void setDefaultMaxRows(int defaultMaxRows) {
+ this.defaultMaxRows = defaultMaxRows;
+ }
+
+ public int getDefaultQueryTimeout() {
+ return defaultQueryTimeout;
+ }
+
+ public void setDefaultQueryTimeout(int defaultQueryTimeout) {
+ this.defaultQueryTimeout = defaultQueryTimeout;
+ }
+
+ @Override
+ protected String doCallText(Map<String, Object> options) {
+ StringBuilder sb = new StringBuilder();
+
+ String sql = (String) options.get(SQL);
+ if (sql == null || sql.isBlank()) {
+ sb.append("No SQL query provided\n");
+ return sb.toString();
+ }
+
+ String dsName = (String) options.get(DATASOURCE);
+ int maxRows = parseIntOption(options, MAX_ROWS, defaultMaxRows);
+ int queryTimeout = parseIntOption(options, QUERY_TIMEOUT,
defaultQueryTimeout);
+
+ DataSource ds;
+ String resolvedName;
+ if (dsName != null && !dsName.isBlank()) {
+ ds = getCamelContext().getRegistry().lookupByNameAndType(dsName,
DataSource.class);
+ resolvedName = dsName;
+ if (ds == null) {
+ sb.append(String.format("DataSource '%s' not found in
registry%n", dsName));
+ return sb.toString();
+ }
+ } else {
+ Map<String, DataSource> all =
getCamelContext().getRegistry().findByTypeWithName(DataSource.class);
+ if (all.isEmpty()) {
+ sb.append("No DataSource found in registry\n");
+ return sb.toString();
+ }
+ if (all.size() > 1) {
+ sb.append(String.format("Multiple DataSources found: %s.
Specify one with --datasource%n",
+ String.join(", ", all.keySet())));
+ return sb.toString();
+ }
+ Map.Entry<String, DataSource> single =
all.entrySet().iterator().next();
+ ds = single.getValue();
+ resolvedName = single.getKey();
+ }
+
+ StopWatch watch = new StopWatch();
+ try (Connection conn = ds.getConnection();
+ Statement stmt = conn.createStatement()) {
+
+ stmt.setMaxRows(maxRows);
+ stmt.setQueryTimeout(queryTimeout);
+
+ boolean hasResultSet = stmt.execute(sql);
+ long elapsed = watch.taken();
+
+ if (hasResultSet) {
+ try (ResultSet rs = stmt.getResultSet()) {
+ ResultSetMetaData meta = rs.getMetaData();
+ int colCount = meta.getColumnCount();
+
+ sb.append(String.format("DataSource: %s%n", resolvedName));
+ sb.append(String.format("Elapsed: %s%n%n",
TimeUtils.printDuration(elapsed)));
+
+ // column headers
+ for (int i = 1; i <= colCount; i++) {
+ if (i > 1) {
+ sb.append(" | ");
+ }
+ sb.append(meta.getColumnLabel(i));
+ }
+ sb.append("\n");
+
+ int rowCount = 0;
+ while (rs.next()) {
+ for (int i = 1; i <= colCount; i++) {
+ if (i > 1) {
+ sb.append(" | ");
+ }
+ Object val = rs.getObject(i);
+ sb.append(val != null ? String.valueOf(val) :
"null");
+ }
+ sb.append("\n");
+ rowCount++;
+ }
+ sb.append(String.format("%n%d row(s)%n", rowCount));
+ if (rowCount >= maxRows) {
+ sb.append("(truncated)\n");
+ }
+ }
+ } else {
+ int updateCount = stmt.getUpdateCount();
+ sb.append(String.format("DataSource: %s%n", resolvedName));
+ sb.append(String.format("Update count: %d%n", updateCount));
+ sb.append(String.format("Elapsed: %s%n",
TimeUtils.printDuration(elapsed)));
+ }
+ } catch (Exception e) {
+ long elapsed = watch.taken();
+ sb.append(String.format("Error: %s%n", e.getMessage()));
+ sb.append(String.format("Elapsed: %s%n",
TimeUtils.printDuration(elapsed)));
+ }
+
+ return sb.toString();
+ }
+
+ @Override
+ protected JsonObject doCallJson(Map<String, Object> options) {
+ String actionType = (String) options.get(ACTION_TYPE);
+ if ("update-row".equals(actionType)) {
+ return doUpdateRow(options);
+ }
+ return doQuery(options);
+ }
+
+ private JsonObject doQuery(Map<String, Object> options) {
+ JsonObject root = new JsonObject();
+
+ String sql = (String) options.get(SQL);
+ if (sql == null || sql.isBlank()) {
+ root.put("status", "error");
+ root.put("message", "No SQL query provided");
+ return root;
+ }
+
+ String dsName = (String) options.get(DATASOURCE);
+ int maxRows = parseIntOption(options, MAX_ROWS, defaultMaxRows);
+ int queryTimeout = parseIntOption(options, QUERY_TIMEOUT,
defaultQueryTimeout);
+
+ DataSource ds = resolveDataSource(dsName, root);
+ if (ds == null) {
+ return root;
+ }
+ String resolvedName = root.getString("datasource");
+
+ StopWatch watch = new StopWatch();
+ try (Connection conn = ds.getConnection();
+ Statement stmt = conn.createStatement()) {
+
+ stmt.setMaxRows(maxRows);
+ stmt.setQueryTimeout(queryTimeout);
+
+ boolean hasResultSet = stmt.execute(sql);
+ long elapsed = watch.taken();
+
+ root.put("status", "success");
+ root.put("elapsed", elapsed);
+
+ if (hasResultSet) {
+ // read all data and metadata from ResultSet first, then close
it
+ // before any DatabaseMetaData calls (some drivers only
support one
+ // open ResultSet per connection)
+ String singleTable = null;
+ String catalog = null;
+ String schema = null;
+ JsonArray columns = new JsonArray();
+ JsonArray rows = new JsonArray();
+ int rowCount = 0;
+ String[] colLabels;
+
+ try (ResultSet rs = stmt.getResultSet()) {
+ ResultSetMetaData meta = rs.getMetaData();
+ int colCount = meta.getColumnCount();
+ colLabels = new String[colCount];
+
+ // detect single-table query for editability
+ boolean allSameTable = true;
+ for (int i = 1; i <= colCount; i++) {
+ colLabels[i - 1] = meta.getColumnLabel(i);
+ String tn = meta.getTableName(i);
+ if (tn == null || tn.isEmpty()) {
+ allSameTable = false;
+ } else if (singleTable == null) {
+ singleTable = tn;
+ catalog = meta.getCatalogName(i);
+ schema = meta.getSchemaName(i);
+ } else if (!singleTable.equalsIgnoreCase(tn)) {
+ allSameTable = false;
+ }
+ }
+ if (!allSameTable) {
+ singleTable = null;
+ }
+
+ for (int i = 1; i <= colCount; i++) {
+ JsonObject col = new JsonObject();
+ col.put("name", meta.getColumnLabel(i));
+ col.put("type", meta.getColumnTypeName(i));
+ columns.add(col);
+ }
+
+ while (rs.next()) {
+ JsonObject row = new JsonObject();
+ for (int i = 1; i <= colCount; i++) {
+ Object val = rs.getObject(i);
+ if (val == null) {
+ row.put(colLabels[i - 1], null);
+ } else if (val instanceof Number n) {
+ row.put(colLabels[i - 1], n);
+ } else if (val instanceof Boolean b) {
+ row.put(colLabels[i - 1], b);
+ } else {
+ row.put(colLabels[i - 1], String.valueOf(val));
+ }
+ }
+ rows.add(row);
+ rowCount++;
+ }
+ }
+
+ // ResultSet is now closed — safe to query DatabaseMetaData
+ Set<String> pkColumns = new LinkedHashSet<>();
+ if (singleTable != null) {
+ try {
+ DatabaseMetaData dbMeta = conn.getMetaData();
+ try (ResultSet pkRs = dbMeta.getPrimaryKeys(
+ catalog != null && !catalog.isEmpty() ?
catalog : null,
+ schema != null && !schema.isEmpty() ? schema :
null,
+ singleTable)) {
+ while (pkRs.next()) {
+ pkColumns.add(pkRs.getString("COLUMN_NAME"));
+ }
+ }
+ } catch (Exception e) {
+ // PK lookup failed — not editable
+ }
+ }
+
+ // annotate columns with PK info
+ if (singleTable != null && !pkColumns.isEmpty()) {
+ for (int i = 0; i < columns.size(); i++) {
+ JsonObject col = (JsonObject) columns.get(i);
+ col.put("primaryKey",
pkColumns.contains(col.getString("name")));
+ }
+ root.put("tableName", singleTable);
+ JsonArray pkArr = new JsonArray();
+ pkColumns.forEach(pkArr::add);
+ root.put("primaryKeys", pkArr);
+ root.put("editable", true);
+ }
+
+ root.put("columns", columns);
+ root.put("rows", rows);
+ root.put("rowCount", rowCount);
+ root.put("truncated", rowCount >= maxRows);
+ } else {
+ int updateCount = stmt.getUpdateCount();
+ root.put("updateCount", updateCount);
+ }
+ } catch (Exception e) {
+ long elapsed = watch.taken();
+ root.put("status", "error");
+ root.put("elapsed", elapsed);
+ root.put("message", e.getMessage());
+ }
+
+ return root;
+ }
+
+ private JsonObject doUpdateRow(Map<String, Object> options) {
+ JsonObject root = new JsonObject();
+
+ String tableName = (String) options.get(TABLE);
+ if (tableName == null || tableName.isBlank()) {
+ root.put("status", "error");
+ root.put("message", "No table name provided");
+ return root;
+ }
+
+ String pkJson = (String) options.get(PRIMARY_KEY_VALUES);
+ String colJson = (String) options.get(COLUMN_VALUES);
+ if (pkJson == null || colJson == null) {
+ root.put("status", "error");
+ root.put("message", "Missing primaryKeyValues or columnValues");
+ return root;
+ }
+
+ JsonObject pkValues;
+ JsonObject colValues;
+ try {
+ pkValues = (JsonObject) Jsoner.deserialize(pkJson);
+ colValues = (JsonObject) Jsoner.deserialize(colJson);
+ } catch (Exception e) {
+ root.put("status", "error");
+ root.put("message", "Invalid JSON: " + e.getMessage());
+ return root;
+ }
+
+ if (colValues.isEmpty()) {
+ root.put("status", "error");
+ root.put("message", "No columns to update");
+ return root;
+ }
+
+ String dsName = (String) options.get(DATASOURCE);
+ DataSource ds = resolveDataSource(dsName, root);
+ if (ds == null) {
+ return root;
+ }
+
+ // build UPDATE table SET col1=?, col2=? WHERE pk1=? AND pk2=?
+ List<String> setCols = new ArrayList<>(colValues.keySet());
+ List<String> pkCols = new ArrayList<>(pkValues.keySet());
+
+ StringBuilder sb = new StringBuilder("UPDATE ");
+ sb.append(tableName).append(" SET ");
+ for (int i = 0; i < setCols.size(); i++) {
+ if (i > 0) {
+ sb.append(", ");
+ }
+ sb.append(setCols.get(i)).append(" = ?");
+ }
+ sb.append(" WHERE ");
+ for (int i = 0; i < pkCols.size(); i++) {
+ if (i > 0) {
+ sb.append(" AND ");
+ }
+ sb.append(pkCols.get(i)).append(" = ?");
+ }
+
+ StopWatch watch = new StopWatch();
+ try (Connection conn = ds.getConnection();
+ PreparedStatement ps = conn.prepareStatement(sb.toString())) {
+
+ int idx = 1;
+ for (String col : setCols) {
+ setParameter(ps, idx++, colValues.get(col));
+ }
+ for (String col : pkCols) {
+ setParameter(ps, idx++, pkValues.get(col));
+ }
+
+ int updateCount = ps.executeUpdate();
+ long elapsed = watch.taken();
+ root.put("status", "success");
+ root.put("elapsed", elapsed);
+ root.put("updateCount", updateCount);
+ } catch (Exception e) {
+ long elapsed = watch.taken();
+ root.put("status", "error");
+ root.put("elapsed", elapsed);
+ root.put("message", e.getMessage());
+ }
+
+ return root;
+ }
+
+ private DataSource resolveDataSource(String dsName, JsonObject root) {
+ if (dsName != null && !dsName.isBlank()) {
+ DataSource ds =
getCamelContext().getRegistry().lookupByNameAndType(dsName, DataSource.class);
+ if (ds == null) {
+ root.put("status", "error");
+ root.put("message", String.format("DataSource '%s' not found
in registry", dsName));
+ return null;
+ }
+ root.put("datasource", dsName);
+ return ds;
+ }
+
+ Map<String, DataSource> all =
getCamelContext().getRegistry().findByTypeWithName(DataSource.class);
+ if (all.isEmpty()) {
+ root.put("status", "error");
+ root.put("message", "No DataSource found in registry");
+ return null;
+ }
+ if (all.size() > 1) {
+ root.put("status", "error");
+ root.put("message", "Multiple DataSources found, specify one: " +
String.join(", ", all.keySet()));
+ JsonArray names = new JsonArray();
+ all.keySet().forEach(names::add);
+ root.put("availableDataSources", names);
+ return null;
+ }
+ Map.Entry<String, DataSource> single =
all.entrySet().iterator().next();
+ root.put("datasource", single.getKey());
+ return single.getValue();
+ }
+
+ private static void setParameter(PreparedStatement ps, int index, Object
value) throws Exception {
+ if (value == null) {
+ ps.setNull(index, java.sql.Types.NULL);
+ } else if (value instanceof Number n) {
+ if (value instanceof Integer || value instanceof Long) {
+ ps.setLong(index, n.longValue());
+ } else {
+ ps.setDouble(index, n.doubleValue());
+ }
+ } else if (value instanceof Boolean b) {
+ ps.setBoolean(index, b);
+ } else {
+ String s = String.valueOf(value);
+ if ("null".equalsIgnoreCase(s)) {
+ ps.setNull(index, java.sql.Types.NULL);
+ } else if ("true".equalsIgnoreCase(s) ||
"false".equalsIgnoreCase(s)) {
+ ps.setBoolean(index, Boolean.parseBoolean(s));
+ } else {
+ // try numeric types so the JDBC driver gets the right type
+ try {
+ ps.setLong(index, Long.parseLong(s));
+ } catch (NumberFormatException e1) {
+ try {
+ ps.setDouble(index, Double.parseDouble(s));
+ } catch (NumberFormatException e2) {
+ ps.setString(index, s);
+ }
+ }
+ }
+ }
+ }
+
+ private static int parseIntOption(Map<String, Object> options, String key,
int defaultValue) {
+ Object val = options.get(key);
+ if (val instanceof Number n) {
+ return n.intValue();
+ }
+ if (val instanceof String s && !s.isBlank()) {
+ try {
+ return Integer.parseInt(s);
+ } catch (NumberFormatException e) {
+ // ignore
+ }
+ }
+ return defaultValue;
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-sql.adoc
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-sql.adoc
new file mode 100644
index 000000000000..39ffafc1c416
--- /dev/null
+++
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-sql.adoc
@@ -0,0 +1,29 @@
+
+// AUTO-GENERATED by camel-package-maven-plugin - DO NOT EDIT THIS FILE
+= camel cmd sql
+
+Execute SQL query on a DataSource
+
+
+== Usage
+
+[source,bash]
+----
+camel cmd sql [options]
+----
+
+
+
+== Options
+
+[cols="2,5,1,2",options="header"]
+|===
+| Option | Description | Default | Type
+| `--datasource` | Name of the DataSource bean (auto-detected if only one
exists) | | String
+| `--max-rows` | Maximum number of rows to return | 100 | int
+| `--query,--sql` | The SQL query to execute, or file:<path> to load from a
file | | String
+| `--query-timeout` | Query timeout in seconds | 30 | int
+| `-h,--help` | Display the help and sub-commands | | boolean
+|===
+
+
diff --git
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc
index b3e799143a74..4e261d317b2d 100644
--- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc
+++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc
@@ -34,6 +34,7 @@ camel cmd [options]
| xref:jbang-commands/camel-jbang-cmd-route-topology.adoc[route-topology] |
Display inter-route topology connections
| xref:jbang-commands/camel-jbang-cmd-send.adoc[send] | Send messages to
endpoints
| xref:jbang-commands/camel-jbang-cmd-span.adoc[span] | Display OpenTelemetry
spans from running Camel integrations
+| xref:jbang-commands/camel-jbang-cmd-sql.adoc[sql] | Execute SQL query on a
DataSource
| xref:jbang-commands/camel-jbang-cmd-start-group.adoc[start-group] | Start
Camel route groups
| xref:jbang-commands/camel-jbang-cmd-start-route.adoc[start-route] | Start
Camel routes
| xref:jbang-commands/camel-jbang-cmd-stop-group.adoc[stop-group] | Stop Camel
route groups
diff --git
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
index 21688dde8ca6..fe943b169bb7 100644
---
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
+++
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
@@ -371,6 +371,10 @@ public class LocalCliConnector extends ServiceSupport
implements CliConnector, C
doActionReceiveTask(root);
} else if ("readme".equals(action)) {
doActionReadmeTask(root);
+ } else if ("sql-query".equals(action)) {
+ doActionSqlQueryTask(root);
+ } else if ("sql-update-row".equals(action)) {
+ doActionSqlUpdateRowTask(root);
} else if ("cli-debug".equals(action)) {
doActionCliDebug(root);
}
@@ -1012,6 +1016,56 @@ public class LocalCliConnector extends ServiceSupport
implements CliConnector, C
}
}
+ private void doActionSqlQueryTask(JsonObject root) throws Exception {
+ DevConsole dc =
camelContext.getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class)
+ .resolveById("sql-query");
+ if (dc != null) {
+ String sql = root.getStringOrDefault("sql", "");
+ if (sql.startsWith("file:")) {
+ File f = new File(sql.substring(5));
+ if (f.exists() && f.isFile()) {
+ sql = Files.readString(f.toPath()).trim();
+ }
+ }
+ String datasource = root.getString("datasource");
+ int maxRows = root.getIntegerOrDefault("maxRows", 100);
+ int queryTimeout = root.getIntegerOrDefault("queryTimeout", 30);
+ Map<String, Object> args = new HashMap<>();
+ args.put("sql", sql);
+ if (datasource != null) {
+ args.put("datasource", datasource);
+ }
+ args.put("maxRows", String.valueOf(maxRows));
+ args.put("queryTimeout", String.valueOf(queryTimeout));
+ JsonObject json = (JsonObject) dc.call(DevConsole.MediaType.JSON,
args);
+ LOG.trace("Updating output file: {}", outputFile);
+ IOHelper.writeText(json.toJson(), outputFile);
+ } else {
+ IOHelper.writeText("{}", outputFile);
+ }
+ }
+
+ private void doActionSqlUpdateRowTask(JsonObject root) throws Exception {
+ DevConsole dc =
camelContext.getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class)
+ .resolveById("sql-query");
+ if (dc != null) {
+ Map<String, Object> args = new HashMap<>();
+ args.put("actionType", "update-row");
+ args.put("table", root.getString("table"));
+ String datasource = root.getString("datasource");
+ if (datasource != null) {
+ args.put("datasource", datasource);
+ }
+ args.put("primaryKeyValues", root.getString("primaryKeyValues"));
+ args.put("columnValues", root.getString("columnValues"));
+ JsonObject json = (JsonObject) dc.call(DevConsole.MediaType.JSON,
args);
+ LOG.trace("Updating output file: {}", outputFile);
+ IOHelper.writeText(json.toJson(), outputFile);
+ } else {
+ IOHelper.writeText("{}", outputFile);
+ }
+ }
+
private void doActionLoadTask(JsonObject root) throws Exception {
List<String> files = root.getCollection("source");
boolean restart = root.getBooleanOrDefault("restart", false);
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
index 19c7b6505642..6ee1954980ad 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
+++
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
@@ -3,7 +3,7 @@
{ "name": "ask", "fullName": "ask", "description": "Ask a question about a
running Camel application using AI", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.Ask", "options": [ { "names":
"--api-key", "description": "API key. Also reads ANTHROPIC_API_KEY,
OPENAI_API_KEY, or LLM_API_KEY env vars", "javaType": "java.lang.String",
"type": "string" }, { "names": "--api-type", "description": "API type:
'ollama', 'openai', or 'anthropic'", "javaType": "LlmClient.ApiType", "type"
[...]
{ "name": "bind", "fullName": "bind", "description": "DEPRECATED: Bind
source and sink Kamelets as a new Camel integration", "deprecated": true,
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.bind.Bind", "options":
[ { "names": "--error-handler", "description": "Add error handler
(none|log|sink:<endpoint>). Sink endpoints are expected in the format
[[apigroup\/]version:]kind:[namespace\/]name, plain Camel URIs or Kamelet
name.", "javaType": "java.lang.String", "type": "stri [...]
{ "name": "catalog", "fullName": "catalog", "description": "List artifacts
from Camel Catalog", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.catalog.CatalogCommand", "options": [
{ "names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"component", "fullName": "catalog component", "description": "List components
from the Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.co [...]
- { "name": "cmd", "fullName": "cmd", "description": "Performs commands in
the running Camel integrations, such as start\/stop route, or change logging
levels.", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"browse", "fullName": "cmd browse", "description": "Browse pending messages on
endpoints [...]
+ { "name": "cmd", "fullName": "cmd", "description": "Performs commands in
the running Camel integrations, such as start\/stop route, or change logging
levels.", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"browse", "fullName": "cmd browse", "description": "Browse pending messages on
endpoints [...]
{ "name": "completion", "fullName": "completion", "description": "Generate
completion script for bash\/zsh", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.Complete", "options": [ { "names":
"-h,--help", "description": "Display the help and sub-commands", "javaType":
"boolean", "type": "boolean" } ] },
{ "name": "config", "fullName": "config", "description": "Get and set user
configuration values", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.config.ConfigCommand", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get",
"fullName": "config get", "description": "Display user configuration value",
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.config. [...]
{ "name": "debug", "fullName": "debug", "description": "Debug local Camel
integration", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Debug",
"options": [ { "names": "--ago", "description": "Use ago instead of yyyy-MM-dd
HH:mm:ss in timestamp.", "javaType": "boolean", "type": "boolean" }, { "names":
"--background", "description": "Run in the background", "defaultValue":
"false", "javaType": "boolean", "type": "boolean" }, { "names":
"--background-wait", "description": "To [...]
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
index 5113540c571f..da28f9ad87bb 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
@@ -121,6 +121,7 @@ public class CamelJBangMain implements Callable<Integer> {
.addSubcommand("route-structure", new CommandLine(new
CamelRouteStructureAction(this)))
.addSubcommand("route-topology", new CommandLine(new
CamelRouteTopologyAction(this)))
.addSubcommand("send", new CommandLine(new
CamelSendAction(this)))
+ .addSubcommand("sql", new CommandLine(new
CamelSqlQueryAction(this)))
.addSubcommand("span", new CommandLine(new
CamelSpanAction(this)))
.addSubcommand("start-group", new CommandLine(new
CamelRouteGroupStartAction(this)))
.addSubcommand("start-route", new CommandLine(new
CamelRouteStartAction(this)))
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSqlQueryAction.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSqlQueryAction.java
new file mode 100644
index 000000000000..179a7a235d4f
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSqlQueryAction.java
@@ -0,0 +1,174 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.action;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.common.PathUtils;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import picocli.CommandLine;
+
[email protected](name = "sql",
+ description = "Execute SQL query on a DataSource",
sortOptions = false,
+ showDefaultValues = true,
+ footer = {
+ "%nExamples:",
+ " camel cmd sql myApp --query=\"SELECT * FROM
orders\"",
+ " camel cmd sql myApp --query=\"SELECT * FROM
users\" --datasource=myDS --max-rows=50",
+ " camel cmd sql myApp
--query=\"file:query.sql\"" })
+public class CamelSqlQueryAction extends ActionBaseCommand {
+
+ @CommandLine.Parameters(description = "Name or pid of running Camel
integration",
+ arity = "1")
+ String name;
+
+ @CommandLine.Option(names = { "--query", "--sql" }, required = true,
+ description = "The SQL query to execute, or
file:<path> to load from a file")
+ String query;
+
+ @CommandLine.Option(names = { "--datasource" },
+ description = "Name of the DataSource bean
(auto-detected if only one exists)")
+ String datasource;
+
+ @CommandLine.Option(names = { "--max-rows" }, defaultValue = "100",
+ description = "Maximum number of rows to return")
+ int maxRows = 100;
+
+ @CommandLine.Option(names = { "--query-timeout" }, defaultValue = "30",
+ description = "Query timeout in seconds")
+ int queryTimeout = 30;
+
+ public CamelSqlQueryAction(CamelJBangMain main) {
+ super(main);
+ }
+
+ @Override
+ public Integer doCall() throws Exception {
+ List<Long> pids = findPids(name);
+ if (pids.size() != 1) {
+ printer().println("Name or pid " + name + " matches " + pids.size()
+ + " running Camel integrations. Specify a name
or PID that matches exactly one.");
+ return 1;
+ }
+
+ long pid = pids.get(0);
+
+ Path outputFile = getOutputFile(Long.toString(pid));
+ PathUtils.deleteFile(outputFile);
+
+ JsonObject root = new JsonObject();
+ root.put("action", "sql-query");
+ root.put("sql", query);
+ if (datasource != null) {
+ root.put("datasource", datasource);
+ }
+ root.put("maxRows", maxRows);
+ root.put("queryTimeout", queryTimeout);
+
+ Path actionFile = getActionFile(Long.toString(pid));
+ Files.writeString(actionFile, root.toJson());
+
+ JsonObject jo = getJsonObject(outputFile, (queryTimeout + 10) * 1000L);
+ try {
+ if (jo == null) {
+ printer().println("Timeout waiting for SQL query response");
+ return 1;
+ }
+
+ String status = jo.getString("status");
+ if ("error".equals(status)) {
+ printer().println("Error: " + jo.getString("message"));
+ return 1;
+ }
+
+ if (jo.containsKey("updateCount")) {
+ int updateCount = jo.getInteger("updateCount");
+ long elapsed = jo.getLongOrDefault("elapsed", 0);
+ printer().println("Update count: " + updateCount);
+ printer().println("Elapsed: " + elapsed + "ms");
+ return 0;
+ }
+
+ JsonArray columns = jo.getCollection("columns");
+ JsonArray rows = jo.getCollection("rows");
+ if (columns == null || rows == null) {
+ printer().println("No result data");
+ return 0;
+ }
+
+ // compute column widths
+ String[] colNames = new String[columns.size()];
+ int[] widths = new int[columns.size()];
+ for (int i = 0; i < columns.size(); i++) {
+ JsonObject col = (JsonObject) columns.get(i);
+ colNames[i] = col.getString("name");
+ widths[i] = colNames[i].length();
+ }
+ for (Object rowObj : rows) {
+ JsonObject row = (JsonObject) rowObj;
+ for (int i = 0; i < colNames.length; i++) {
+ Object val = row.get(colNames[i]);
+ int len = val != null ? String.valueOf(val).length() : 4;
+ widths[i] = Math.max(widths[i], len);
+ }
+ }
+
+ // print header
+ StringBuilder header = new StringBuilder();
+ StringBuilder separator = new StringBuilder();
+ for (int i = 0; i < colNames.length; i++) {
+ if (i > 0) {
+ header.append(" | ");
+ separator.append("-+-");
+ }
+ header.append(String.format("%-" + widths[i] + "s",
colNames[i]));
+ separator.append("-".repeat(widths[i]));
+ }
+ printer().println(header.toString());
+ printer().println(separator.toString());
+
+ // print rows
+ for (Object rowObj : rows) {
+ JsonObject row = (JsonObject) rowObj;
+ StringBuilder line = new StringBuilder();
+ for (int i = 0; i < colNames.length; i++) {
+ if (i > 0) {
+ line.append(" | ");
+ }
+ Object val = row.get(colNames[i]);
+ String s = val != null ? String.valueOf(val) : "null";
+ line.append(String.format("%-" + widths[i] + "s", s));
+ }
+ printer().println(line.toString());
+ }
+
+ int rowCount = jo.getIntegerOrDefault("rowCount", rows.size());
+ boolean truncated = jo.getBooleanOrDefault("truncated", false);
+ long elapsed = jo.getLongOrDefault("elapsed", 0);
+ printer().println();
+ printer().println(rowCount + " row(s)" + (truncated ? "
(truncated)" : "") + " in " + elapsed + "ms");
+ } finally {
+ PathUtils.deleteFile(outputFile);
+ }
+
+ return 0;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
index a23378060634..a492e4e57d72 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
@@ -183,6 +183,10 @@ public final class RuntimeHelper {
* @return the raw response string, or a timeout message if no
response was received
*/
public static String executeAction(long pid, String action,
Consumer<JsonObject> configure) {
+ return executeAction(pid, action, configure, ACTION_TIMEOUT_MS);
+ }
+
+ public static String executeAction(long pid, String action,
Consumer<JsonObject> configure, long timeoutMs) {
String requestId = UUID.randomUUID().toString().substring(0, 8);
Path camelDir = CommandLineHelper.getCamelDir();
Path outputFile = camelDir.resolve(pid + "-output-" + requestId +
".json");
@@ -199,7 +203,7 @@ public final class RuntimeHelper {
try {
StopWatch watch = new StopWatch();
- while (watch.taken() < ACTION_TIMEOUT_MS) {
+ while (watch.taken() < timeoutMs) {
try {
Thread.sleep(POLL_INTERVAL_MS);
if (Files.exists(outputFile) &&
outputFile.toFile().length() > 0) {
@@ -272,6 +276,44 @@ public final class RuntimeHelper {
}
}
+ public static JsonObject executeRowUpdate(
+ long pid, String table, String datasource, String pkValuesJson,
String colValuesJson) {
+ String result = executeAction(pid, "sql-update-row", root -> {
+ root.put("table", table);
+ if (datasource != null) {
+ root.put("datasource", datasource);
+ }
+ root.put("primaryKeyValues", pkValuesJson);
+ root.put("columnValues", colValuesJson);
+ });
+ try {
+ return (JsonObject) Jsoner.deserialize(result);
+ } catch (Exception e) {
+ JsonObject wrapper = new JsonObject();
+ wrapper.put("result", result);
+ return wrapper;
+ }
+ }
+
+ public static JsonObject executeSqlQuery(long pid, String sql, String
datasource, int maxRows, int queryTimeout) {
+ long timeout = (queryTimeout + 10) * 1000L;
+ String result = executeAction(pid, "sql-query", root -> {
+ root.put("sql", sql);
+ if (datasource != null) {
+ root.put("datasource", datasource);
+ }
+ root.put("maxRows", maxRows);
+ root.put("queryTimeout", queryTimeout);
+ }, timeout);
+ try {
+ return (JsonObject) Jsoner.deserialize(result);
+ } catch (Exception e) {
+ JsonObject wrapper = new JsonObject();
+ wrapper.put("result", result);
+ return wrapper;
+ }
+ }
+
public static JsonObject readStatusFromFile(Path path) {
try {
if (Files.exists(path) && path.toFile().length() > 0) {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/sql/sql.camel.yaml
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/sql/sql.camel.yaml
index 3153d25c1917..34d02a5efe2d 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/sql/sql.camel.yaml
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/sql/sql.camel.yaml
@@ -29,7 +29,7 @@
uri: timer
parameters:
timerName: select
- delay: 5000
+ period: 5000
steps:
- to:
uri: sql
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index d8626cfe95c8..23a61d45d60e 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -231,6 +231,7 @@ public class CamelMonitor extends CamelCommand {
private ProcessTab processTab;
private OverviewTab overviewTab;
private DataSourceTab dataSourceTab;
+ private SqlQueryTab sqlQueryTab;
// "Switch integration" popup state
private boolean showSwitchPopup;
@@ -285,6 +286,7 @@ public class CamelMonitor extends CamelCommand {
routesTab = new RoutesTab(ctx);
consumersTab = new ConsumersTab(ctx);
dataSourceTab = new DataSourceTab(ctx);
+ sqlQueryTab = new SqlQueryTab(ctx);
endpointsTab = new EndpointsTab(ctx, metrics);
httpTab = new HttpTab(ctx);
healthTab = new HealthTab(ctx);
@@ -444,7 +446,7 @@ public class CamelMonitor extends CamelCommand {
return true;
}
if (ke.isDown()) {
- morePopupState.selectNext(14);
+ morePopupState.selectNext(15);
return true;
}
int shortcutSel = morePopupShortcut(ke);
@@ -467,10 +469,11 @@ public class CamelMonitor extends CamelCommand {
case 7 -> inflightTab;
case 8 -> memoryTab;
case 9 -> metricsTab;
- case 10 -> spansTab;
- case 11 -> processTab;
- case 12 -> startupTab;
- case 13 -> threadsTab;
+ case 10 -> sqlQueryTab;
+ case 11 -> spansTab;
+ case 12 -> processTab;
+ case 13 -> startupTab;
+ case 14 -> threadsTab;
default -> null;
};
if (activeMoreTab != null) {
@@ -546,7 +549,9 @@ public class CamelMonitor extends CamelCommand {
boolean logSearchActive = tabsState.selected() == TAB_LOG &&
logTab.isSearchInputActive();
boolean spanFilterActive = tabsState.selected() == TAB_MORE &&
activeMoreTab == spansTab
&& spansTab.isFilterInputActive();
- boolean textEditing = probeEditing || logSearchActive ||
spanFilterActive;
+ boolean sqlInputActive = tabsState.selected() == TAB_MORE &&
activeMoreTab == sqlQueryTab
+ && sqlQueryTab.isInputActive();
+ boolean textEditing = probeEditing || logSearchActive ||
spanFilterActive || sqlInputActive;
if (!textEditing && (ke.isCharIgnoreCase('q') || ke.isCtrlC())) {
runner.quit();
return true;
@@ -783,6 +788,10 @@ public class CamelMonitor extends CamelCommand {
filesBrowser.handlePaste(pe.text());
return true;
}
+ if (activeMoreTab == sqlQueryTab && sqlQueryTab.isInputActive()) {
+ sqlQueryTab.handlePaste(pe.text());
+ return true;
+ }
return false;
}
@@ -948,6 +957,7 @@ public class CamelMonitor extends CamelCommand {
configurationTab.onIntegrationChanged();
consumersTab.onIntegrationChanged();
dataSourceTab.onIntegrationChanged();
+ sqlQueryTab.onIntegrationChanged();
circuitBreakerTab.onIntegrationChanged();
inflightTab.onIntegrationChanged();
spansTab.onIntegrationChanged();
@@ -1320,7 +1330,7 @@ public class CamelMonitor extends CamelCommand {
private void renderMorePopup(Frame frame, Rect area) {
int popupW = 22;
- int popupH = 16;
+ int popupH = 17;
// Position just below the "0 More▾" tab label
int dividerW = CharWidth.of(" | ");
int tabBarX = 0;
@@ -1352,6 +1362,7 @@ public class CamelMonitor extends CamelCommand {
ListItem.from(Line.from(Span.raw(" "), Span.styled("I",
keyStyle), Span.raw("nflight"))),
ListItem.from(Line.from(Span.raw(" "), Span.styled("M",
keyStyle), Span.raw("emory"))),
ListItem.from(Line.from(Span.raw(" M"), Span.styled("e",
keyStyle), Span.raw("trics"))),
+ ListItem.from(Line.from(Span.raw(" S"), Span.styled("Q",
keyStyle), Span.raw("L Query"))),
ListItem.from(Line.from(Span.raw(" "), Span.styled("O",
keyStyle), Span.raw("Tel Spans"))),
ListItem.from(Line.from(Span.raw(" "), Span.styled("P",
keyStyle), Span.raw("rocess"))),
ListItem.from(Line.from(Span.raw(" "), Span.styled("S",
keyStyle), Span.raw("tartup"))),
@@ -1463,18 +1474,21 @@ public class CamelMonitor extends CamelCommand {
if (ke.isChar('e')) {
return 9;
}
- if (ke.isChar('o')) {
+ if (ke.isChar('q')) {
return 10;
}
- if (ke.isChar('p')) {
+ if (ke.isChar('o')) {
return 11;
}
- if (ke.isChar('s')) {
+ if (ke.isChar('p')) {
return 12;
}
- if (ke.isChar('t')) {
+ if (ke.isChar('s')) {
return 13;
}
+ if (ke.isChar('t')) {
+ return 14;
+ }
return -1;
}
@@ -2599,7 +2613,8 @@ public class CamelMonitor extends CamelCommand {
private static final String[] MORE_TAB_NAMES = {
"Beans", "Browse", "Circuit Breaker", "Classpath", "Configuration",
- "Consumers", "DataSource", "Inflight", "Memory", "Metrics",
"Spans", "Process", "Startup", "Threads"
+ "Consumers", "DataSource", "Inflight", "Memory", "Metrics", "SQL
Query", "Spans", "Process", "Startup",
+ "Threads"
};
String navigateToTab(String tabName) {
@@ -2625,10 +2640,11 @@ public class CamelMonitor extends CamelCommand {
case 7 -> inflightTab;
case 8 -> memoryTab;
case 9 -> metricsTab;
- case 10 -> spansTab;
- case 11 -> processTab;
- case 12 -> startupTab;
- case 13 -> threadsTab;
+ case 10 -> sqlQueryTab;
+ case 11 -> spansTab;
+ case 12 -> processTab;
+ case 13 -> startupTab;
+ case 14 -> threadsTab;
default -> null;
};
if (activeMoreTab != null) {
@@ -3092,6 +3108,14 @@ public class CamelMonitor extends CamelCommand {
return tab != null && tab.setFilter(filter);
}
+ boolean setTabInputValue(String tabName, String field, String value) {
+ if (tabName != null) {
+ navigateToTab(tabName);
+ }
+ MonitorTab tab = activeTab();
+ return tab != null && tab.setInputValue(field, value);
+ }
+
String toggleTraceDisplay(String section, Boolean enabled) {
return historyTab.toggleDisplaySection(section, enabled);
}
@@ -3109,6 +3133,32 @@ public class CamelMonitor extends CamelCommand {
return RuntimeHelper.sendMessage(pid, endpoint, body, headers);
}
+ JsonObject executeSql(String sql, String datasource, int maxRows, int
queryTimeout) {
+ if (ctx.selectedPid == null) {
+ return null;
+ }
+ long pid;
+ try {
+ pid = Long.parseLong(ctx.selectedPid);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ return RuntimeHelper.executeSqlQuery(pid, sql, datasource, maxRows,
queryTimeout);
+ }
+
+ JsonObject updateRow(String table, String datasource, String pkValuesJson,
String colValuesJson) {
+ if (ctx.selectedPid == null) {
+ return null;
+ }
+ long pid;
+ try {
+ pid = Long.parseLong(ctx.selectedPid);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ return RuntimeHelper.executeRowUpdate(pid, table, datasource,
pkValuesJson, colValuesJson);
+ }
+
String controlIntegration(String action) {
if (action == null || action.isBlank()) {
return "Error: action is required";
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ClasspathTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ClasspathTab.java
index ac960570239b..d9525c6ffbed 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ClasspathTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ClasspathTab.java
@@ -358,6 +358,14 @@ class ClasspathTab implements MonitorTab {
return true;
}
+ @Override
+ public boolean setInputValue(String field, String value) {
+ if ("filter".equals(field)) {
+ return setFilter(value);
+ }
+ return false;
+ }
+
@Override
public SelectionContext getSelectionContext() {
return null;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpTab.java
index 4f14b398e58e..e39f75cab22c 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpTab.java
@@ -1626,6 +1626,23 @@ class HttpTab implements MonitorTab {
""";
}
+ @Override
+ public boolean setInputValue(String field, String value) {
+ if (!probeMode) {
+ return false;
+ }
+ String v = value != null ? value : "";
+ if ("path".equals(field)) {
+ probePathState.setText(v);
+ return true;
+ }
+ if ("body".equals(field)) {
+ probeBodyState.setText(v);
+ return true;
+ }
+ return false;
+ }
+
@Override
public JsonObject getTableDataAsJson() {
IntegrationInfo info = ctx.findSelectedIntegration();
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InputHistory.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InputHistory.java
new file mode 100644
index 000000000000..d132557af381
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InputHistory.java
@@ -0,0 +1,153 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.Clear;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Title;
+import dev.tamboui.widgets.list.ListItem;
+import dev.tamboui.widgets.list.ListState;
+import dev.tamboui.widgets.list.ListWidget;
+
+/**
+ * Reusable input history with popup selector. Stores string entries and
renders a centered popup overlay for selection.
+ */
+class InputHistory {
+
+ private static final int MAX_ENTRIES = 20;
+
+ private final List<String> entries = new ArrayList<>();
+ private final ListState listState = new ListState();
+ private boolean popupVisible;
+ private String selected;
+
+ void add(String entry) {
+ if (entry == null || entry.isBlank()) {
+ return;
+ }
+ entries.remove(entry);
+ entries.add(0, entry);
+ if (entries.size() > MAX_ENTRIES) {
+ entries.remove(entries.size() - 1);
+ }
+ }
+
+ boolean isEmpty() {
+ return entries.isEmpty();
+ }
+
+ boolean isPopupVisible() {
+ return popupVisible;
+ }
+
+ void showPopup() {
+ popupVisible = true;
+ listState.select(0);
+ selected = null;
+ }
+
+ void hidePopup() {
+ popupVisible = false;
+ selected = null;
+ }
+
+ /**
+ * Returns the entry selected on Enter, then resets. Returns null if
nothing was selected.
+ */
+ String takeSelected() {
+ String s = selected;
+ selected = null;
+ return s;
+ }
+
+ boolean handleKeyEvent(KeyEvent ke) {
+ if (!popupVisible) {
+ return false;
+ }
+ if (ke.isCancel()) {
+ hidePopup();
+ return true;
+ }
+ if (ke.isUp()) {
+ listState.selectPrevious();
+ return true;
+ }
+ if (ke.isDown()) {
+ listState.selectNext(entries.size());
+ return true;
+ }
+ if (ke.isConfirm()) {
+ Integer sel = listState.selected();
+ if (sel != null && sel >= 0 && sel < entries.size()) {
+ selected = entries.get(sel);
+ }
+ popupVisible = false;
+ return true;
+ }
+ return true;
+ }
+
+ void renderPopup(Frame frame, Rect area, String title) {
+ if (!popupVisible || entries.isEmpty()) {
+ return;
+ }
+
+ int maxLabelLen = 0;
+ for (String q : entries) {
+ String oneLine = q.replace('\n', ' ');
+ maxLabelLen = Math.max(maxLabelLen, oneLine.length() + 4);
+ }
+ int popupW = Math.min(area.width() - 4, Math.max(30, maxLabelLen + 4));
+ int popupH = Math.min(area.height() - 4, entries.size() + 2);
+ int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
+ int y = area.top() + 2;
+ Rect popup = new Rect(x, y, Math.min(popupW, area.width()),
Math.min(popupH, area.height() - 2));
+
+ frame.renderWidget(Clear.INSTANCE, popup);
+
+ ListItem[] items = new ListItem[entries.size()];
+ for (int i = 0; i < entries.size(); i++) {
+ String label = entries.get(i).replace('\n', ' ');
+ if (label.length() > popupW - 6) {
+ label = label.substring(0, popupW - 9) + "...";
+ }
+ items[i] = ListItem.from(Line.from(Span.raw(" " + label)));
+ }
+
+ ListWidget list = ListWidget.builder()
+ .items(items)
+ .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue())
+ .highlightSymbol("")
+ .block(Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(Title.from(Line.from(Span.styled(" " + title +
" ", Style.EMPTY.fg(Color.YELLOW).bold()))))
+ .build())
+ .build();
+ frame.renderStatefulWidget(list, popup, listState);
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
index 511679e38f1f..1eabd85d3632 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
@@ -62,4 +62,8 @@ interface MonitorTab {
default boolean setFilter(String filter) {
return false;
}
+
+ default boolean setInputValue(String field, String value) {
+ return false;
+ }
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SpansTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SpansTab.java
index cc314bedba23..24731224b944 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SpansTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SpansTab.java
@@ -227,6 +227,14 @@ class SpansTab implements MonitorTab {
return true;
}
+ @Override
+ public boolean setInputValue(String field, String value) {
+ if ("filter".equals(field)) {
+ return setFilter(value);
+ }
+ return false;
+ }
+
@Override
public void navigateUp() {
if (!waterfallView) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlQueryTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlQueryTab.java
new file mode 100644
index 000000000000..f0db2c068485
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlQueryTab.java
@@ -0,0 +1,1057 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.text.Text;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.Clear;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Title;
+import dev.tamboui.widgets.input.TextArea;
+import dev.tamboui.widgets.input.TextAreaState;
+import dev.tamboui.widgets.input.TextInput;
+import dev.tamboui.widgets.input.TextInputState;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import dev.tamboui.widgets.table.Cell;
+import dev.tamboui.widgets.table.Row;
+import dev.tamboui.widgets.table.Table;
+import dev.tamboui.widgets.table.TableState;
+import org.apache.camel.dsl.jbang.core.common.PathUtils;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+
+import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.*;
+
+class SqlQueryTab implements MonitorTab {
+
+ private final MonitorContext ctx;
+ private final TextAreaState sqlInput = new TextAreaState();
+ private final TableState tableState = new TableState();
+ private final AtomicBoolean executing = new AtomicBoolean();
+ private final InputHistory sqlHistory = new InputHistory();
+
+ // datasource selection
+ private List<String> dsNames = new ArrayList<>();
+ private int selectedDs;
+ private boolean focusOnInput = true;
+
+ // results
+ private String[] columnNames;
+ private boolean[] columnIsPk;
+ private List<JsonObject> resultRows;
+ private int rowCount;
+ private boolean truncated;
+ private long elapsed;
+ private String errorMessage;
+ private Integer updateCount;
+
+ // editability metadata from response
+ private String tableName;
+ private String[] primaryKeys;
+
+ // edit row state
+ private boolean editMode;
+ private int editField;
+ private int editScrollOffset;
+ private TextInputState[] editInputs;
+ private String[] editOriginalValues;
+ private int editRowIndex;
+ private String editUpdateMessage;
+
+ // store last query for re-execution after update
+ private String lastSql;
+ private String lastDsName;
+
+ SqlQueryTab(MonitorContext ctx) {
+ this.ctx = ctx;
+ }
+
+ boolean isInputActive() {
+ return editMode || focusOnInput;
+ }
+
+ void handlePaste(String text) {
+ if (editMode) {
+ if (editInputs != null && editField >= 0 && editField <
editInputs.length
+ && !columnIsPk[editField]) {
+ FormHelper.handlePaste(text, editInputs[editField]);
+ }
+ } else if (focusOnInput) {
+ sqlInput.insert(text);
+ }
+ }
+
+ @Override
+ public void onIntegrationChanged() {
+ IntegrationInfo info = ctx.findSelectedIntegration();
+ dsNames.clear();
+ selectedDs = 0;
+ if (info != null) {
+ for (DataSourceInfo ds : info.dataSources) {
+ dsNames.add(ds.name);
+ }
+ }
+ clearResults();
+ closeEditMode();
+ }
+
+ @Override
+ public void onTabSelected() {
+ focusOnInput = true;
+ onIntegrationChanged();
+ }
+
+ @Override
+ public boolean handleKeyEvent(KeyEvent ke) {
+ if (executing.get()) {
+ return true;
+ }
+
+ // edit mode takes priority
+ if (editMode) {
+ return handleEditKeyEvent(ke);
+ }
+
+ // history popup takes priority
+ if (sqlHistory.isPopupVisible()) {
+ sqlHistory.handleKeyEvent(ke);
+ String selected = sqlHistory.takeSelected();
+ if (selected != null) {
+ sqlInput.clear();
+ sqlInput.insert(selected);
+ }
+ return true;
+ }
+
+ if (ke.isCancel()) {
+ if (!focusOnInput && resultRows != null) {
+ focusOnInput = true;
+ return true;
+ }
+ return false;
+ }
+
+ // F5 to execute query
+ if (focusOnInput && ke.code() == KeyCode.F5) {
+ executeQuery();
+ return true;
+ }
+
+ // Tab to toggle focus between input and results
+ if (ke.code() == KeyCode.TAB && resultRows != null &&
!resultRows.isEmpty()) {
+ focusOnInput = !focusOnInput;
+ return true;
+ }
+
+ // Ctrl+E to open history popup
+ if (focusOnInput && ke.hasCtrl() && ke.isCharIgnoreCase('e') &&
!sqlHistory.isEmpty()) {
+ sqlHistory.showPopup();
+ return true;
+ }
+
+ // Enter: newline in input, or open edit in results
+ if (ke.isConfirm()) {
+ if (focusOnInput) {
+ sqlInput.insert('\n');
+ return true;
+ }
+ // results mode: open edit if editable
+ if (isEditable()) {
+ openEditMode();
+ }
+ return true;
+ }
+
+ if (focusOnInput) {
+ // datasource cycling with Ctrl+Left/Right
+ if (ke.hasCtrl() && ke.isLeft() && dsNames.size() > 1) {
+ selectedDs = (selectedDs - 1 + dsNames.size()) %
dsNames.size();
+ return true;
+ }
+ if (ke.hasCtrl() && ke.isRight() && dsNames.size() > 1) {
+ selectedDs = (selectedDs + 1) % dsNames.size();
+ return true;
+ }
+
+ // cursor movement
+ if (ke.isUp()) {
+ sqlInput.moveCursorUp();
+ return true;
+ }
+ if (ke.isDown()) {
+ sqlInput.moveCursorDown();
+ return true;
+ }
+ if (ke.isLeft()) {
+ sqlInput.moveCursorLeft();
+ return true;
+ }
+ if (ke.isRight()) {
+ sqlInput.moveCursorRight();
+ return true;
+ }
+ if (ke.isHome()) {
+ sqlInput.moveCursorToLineStart();
+ return true;
+ }
+ if (ke.isEnd()) {
+ sqlInput.moveCursorToLineEnd();
+ return true;
+ }
+ if (ke.isDeleteBackward()) {
+ sqlInput.deleteBackward();
+ return true;
+ }
+ if (ke.isDeleteForward()) {
+ sqlInput.deleteForward();
+ return true;
+ }
+
+ // typed character
+ if (ke.code() == KeyCode.CHAR) {
+ sqlInput.insert(ke.character());
+ return true;
+ }
+
+ return true;
+ }
+
+ // results navigation
+ if (ke.isUp()) {
+ navigateUp();
+ return true;
+ }
+ if (ke.isDown()) {
+ navigateDown();
+ return true;
+ }
+
+ return true;
+ }
+
+ private boolean handleEditKeyEvent(KeyEvent ke) {
+ if (ke.isCancel()) {
+ closeEditMode();
+ return true;
+ }
+
+ // F5 to save changes
+ if (ke.code() == KeyCode.F5) {
+ saveEditedRow();
+ return true;
+ }
+
+ // navigate fields
+ if (ke.isUp()) {
+ moveEditField(-1);
+ return true;
+ }
+ if (ke.isDown() || ke.code() == KeyCode.TAB) {
+ moveEditField(1);
+ return true;
+ }
+
+ // edit current field (only non-PK)
+ if (editField >= 0 && editField < editInputs.length &&
!columnIsPk[editField]) {
+ FormHelper.handleTextInput(ke, editInputs[editField]);
+ }
+ return true;
+ }
+
+ private void moveEditField(int direction) {
+ int count = columnNames.length;
+ int next = editField;
+ for (int i = 0; i < count; i++) {
+ next = (next + direction + count) % count;
+ if (!columnIsPk[next]) {
+ editField = next;
+ return;
+ }
+ }
+ }
+
+ @Override
+ public boolean handleEscape() {
+ if (editMode) {
+ closeEditMode();
+ return true;
+ }
+ if (sqlHistory.isPopupVisible()) {
+ sqlHistory.hidePopup();
+ return true;
+ }
+ if (!focusOnInput && resultRows != null) {
+ focusOnInput = true;
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public void navigateUp() {
+ if (!focusOnInput) {
+ tableState.selectPrevious();
+ }
+ }
+
+ @Override
+ public void navigateDown() {
+ if (!focusOnInput && resultRows != null) {
+ tableState.selectNext(resultRows.size());
+ }
+ }
+
+ @Override
+ public void render(Frame frame, Rect area) {
+ if (area.height() < 4) {
+ return;
+ }
+
+ // layout: datasource bar (1 line) + SQL input (5 lines) + results
(rest)
+ int inputH = 5;
+ int dsBarH = dsNames.size() > 1 ? 1 : 0;
+ int topH = dsBarH + inputH;
+
+ List<Rect> parts = Layout.vertical()
+ .constraints(Constraint.length(topH), Constraint.min(3))
+ .split(area);
+
+ renderInputArea(frame, parts.get(0), dsBarH);
+ renderResults(frame, parts.get(1));
+
+ sqlHistory.renderPopup(frame, area, "Query History");
+
+ if (editMode) {
+ renderEditPopup(frame, area);
+ }
+ }
+
+ private void renderInputArea(Frame frame, Rect area, int dsBarH) {
+ if (dsBarH > 0 && area.height() > 0) {
+ Rect dsBar = new Rect(area.x(), area.y(), area.width(), 1);
+ StringBuilder sb = new StringBuilder(" DataSource: ");
+ for (int i = 0; i < dsNames.size(); i++) {
+ if (i == selectedDs) {
+ sb.append("[").append(dsNames.get(i)).append("]");
+ } else {
+ sb.append(" ").append(dsNames.get(i)).append(" ");
+ }
+ if (i < dsNames.size() - 1) {
+ sb.append(" ");
+ }
+ }
+ Style style = focusOnInput ? Style.EMPTY.fg(Color.CYAN) :
Style.EMPTY.fg(Color.DARK_GRAY);
+ Paragraph dsLabel = Paragraph.builder()
+ .text(Text.from(Line.from(Span.styled(sb.toString(),
style))))
+ .build();
+ frame.renderWidget(dsLabel, dsBar);
+ }
+
+ Rect inputRect = new Rect(area.x(), area.y() + dsBarH, area.width(),
area.height() - dsBarH);
+
+ String title;
+ if (executing.get()) {
+ title = " Executing... ";
+ } else if (errorMessage != null) {
+ title = " SQL Query (error) ";
+ } else if (updateCount != null) {
+ title = String.format(" SQL Query (%d updated, %dms) ",
updateCount, elapsed);
+ } else if (resultRows != null) {
+ title = String.format(" SQL Query (%d row(s), %dms) ", rowCount,
elapsed);
+ } else {
+ title = " SQL Query (F5 to execute) ";
+ }
+ Style borderStyle = focusOnInput ? Style.EMPTY.fg(Color.CYAN) :
Style.EMPTY.fg(Color.DARK_GRAY);
+ Block inputBlock = Block.builder()
+ .title(Title.from(title))
+ .borderType(BorderType.ROUNDED)
+ .borderStyle(borderStyle)
+ .build();
+ Rect inner = inputBlock.inner(inputRect);
+ frame.renderWidget(inputBlock, inputRect);
+
+ TextArea textArea = TextArea.builder()
+ .cursorStyle(Style.EMPTY.reversed())
+ .placeholder("Type SQL query or file:query.sql ...")
+ .build();
+ if (focusOnInput) {
+ textArea.renderWithCursor(inner, frame.buffer(), sqlInput, frame);
+ } else {
+ textArea.render(inner, frame.buffer(), sqlInput);
+ }
+ }
+
+ private void renderResults(Frame frame, Rect area) {
+ if (errorMessage != null) {
+ Block errBlock = Block.builder()
+ .title(Title.from(" Error "))
+ .borderType(BorderType.ROUNDED)
+ .borderStyle(Style.EMPTY.fg(Color.RED))
+ .build();
+ Rect inner = errBlock.inner(area);
+ frame.renderWidget(errBlock, area);
+ Paragraph errText = Paragraph.builder()
+ .text(Text.from(Line.from(Span.styled(errorMessage,
Style.EMPTY.fg(Color.RED)))))
+ .build();
+ frame.renderWidget(errText, inner);
+ return;
+ }
+
+ if (updateCount != null) {
+ Block ucBlock = Block.builder()
+ .title(Title.from(" Result "))
+ .borderType(BorderType.ROUNDED)
+ .borderStyle(Style.EMPTY.fg(Color.GREEN))
+ .build();
+ Rect inner = ucBlock.inner(area);
+ frame.renderWidget(ucBlock, area);
+ String msg = String.format("Update count: %d (%dms)",
updateCount, elapsed);
+ Paragraph ucText = Paragraph.builder()
+ .text(Text.from(Line.from(Span.styled(msg,
Style.EMPTY.fg(Color.GREEN)))))
+ .build();
+ frame.renderWidget(ucText, inner);
+ return;
+ }
+
+ if (columnNames == null || resultRows == null) {
+ Block emptyBlock = Block.builder()
+ .title(Title.from(" Results "))
+ .borderType(BorderType.ROUNDED)
+ .borderStyle(Style.EMPTY.fg(Color.DARK_GRAY))
+ .build();
+ Rect inner = emptyBlock.inner(area);
+ frame.renderWidget(emptyBlock, area);
+
+ String hint = dsNames.isEmpty()
+ ? "No DataSource available"
+ : "Type a SQL query and press F5 to execute";
+ Paragraph hintText = Paragraph.builder()
+ .text(Text.from(Line.from(Span.styled(hint,
Style.EMPTY.fg(Color.DARK_GRAY)))))
+ .build();
+ frame.renderWidget(hintText, inner);
+ return;
+ }
+
+ // build result table
+ String resultTitle = String.format(" %d row(s)%s %dms ",
+ rowCount, truncated ? " (truncated)" : "", elapsed);
+ Style tableBorderStyle = !focusOnInput ? Style.EMPTY.fg(Color.CYAN) :
Style.EMPTY.fg(Color.DARK_GRAY);
+ Block tableBlock = Block.builder()
+ .title(Title.from(resultTitle))
+ .borderType(BorderType.ROUNDED)
+ .borderStyle(tableBorderStyle)
+ .build();
+
+ int[] widths = computeColumnWidths(area.width() - 2);
+
+ Row header = Row.from(buildHeaderCells());
+ header.style(Style.EMPTY.fg(Color.YELLOW));
+
+ List<Row> dataRows = new ArrayList<>();
+ for (JsonObject row : resultRows) {
+ List<Cell> cells = new ArrayList<>();
+ for (String col : columnNames) {
+ Object val = row.get(col);
+ String s = val != null ? String.valueOf(val) : "null";
+ Style style = val == null ? Style.EMPTY.fg(Color.DARK_GRAY) :
Style.EMPTY.fg(Color.WHITE);
+ cells.add(Cell.from(Span.styled(s, style)));
+ }
+ dataRows.add(Row.from(cells));
+ }
+
+ Constraint[] colConstraints = new Constraint[widths.length];
+ for (int i = 0; i < widths.length; i++) {
+ colConstraints[i] = Constraint.length(widths[i]);
+ }
+
+ Table table = Table.builder()
+ .header(header)
+ .rows(dataRows)
+ .widths(colConstraints)
+ .block(tableBlock)
+ .highlightStyle(Style.EMPTY.bg(Color.DARK_GRAY))
+ .build();
+ frame.renderStatefulWidget(table, area, tableState);
+ }
+
+ private Cell[] buildHeaderCells() {
+ Cell[] cells = new Cell[columnNames.length];
+ for (int i = 0; i < columnNames.length; i++) {
+ String label = columnNames[i];
+ if (columnIsPk != null && columnIsPk[i]) {
+ label = label + " 🔑";
+ }
+ cells[i] = Cell.from(Span.styled(label,
Style.EMPTY.fg(Color.YELLOW)));
+ }
+ return cells;
+ }
+
+ private int[] computeColumnWidths(int availableWidth) {
+ int colCount = columnNames.length;
+ int[] widths = new int[colCount];
+
+ for (int i = 0; i < colCount; i++) {
+ widths[i] = columnNames[i].length();
+ if (columnIsPk != null && columnIsPk[i]) {
+ widths[i] += 3;
+ }
+ }
+ if (resultRows != null) {
+ for (JsonObject row : resultRows) {
+ for (int i = 0; i < colCount; i++) {
+ Object val = row.get(columnNames[i]);
+ int len = val != null ? String.valueOf(val).length() : 4;
+ widths[i] = Math.max(widths[i], len);
+ }
+ }
+ }
+
+ // cap each column to reasonable max
+ int maxColWidth = Math.max(10, availableWidth / Math.max(1, colCount));
+ for (int i = 0; i < colCount; i++) {
+ widths[i] = Math.min(widths[i] + 2, maxColWidth);
+ }
+ return widths;
+ }
+
+ // ---- Edit row popup ----
+
+ private boolean isEditable() {
+ return tableName != null && primaryKeys != null && primaryKeys.length
> 0
+ && resultRows != null && !resultRows.isEmpty();
+ }
+
+ private void openEditMode() {
+ Integer sel = tableState.selected();
+ if (sel == null || sel < 0 || sel >= resultRows.size()) {
+ return;
+ }
+ editRowIndex = sel;
+ JsonObject row = resultRows.get(sel);
+
+ editInputs = new TextInputState[columnNames.length];
+ editOriginalValues = new String[columnNames.length];
+ for (int i = 0; i < columnNames.length; i++) {
+ Object val = row.get(columnNames[i]);
+ String strVal = val != null ? String.valueOf(val) : "";
+ editOriginalValues[i] = strVal;
+ editInputs[i] = new TextInputState(strVal);
+ }
+
+ // focus on first non-PK field
+ editField = 0;
+ for (int i = 0; i < columnNames.length; i++) {
+ if (!columnIsPk[i]) {
+ editField = i;
+ break;
+ }
+ }
+ editScrollOffset = 0;
+ editUpdateMessage = null;
+ editMode = true;
+ }
+
+ private void closeEditMode() {
+ editMode = false;
+ editInputs = null;
+ editOriginalValues = null;
+ editUpdateMessage = null;
+ }
+
+ private void saveEditedRow() {
+ if (!editMode || editInputs == null || executing.get()) {
+ return;
+ }
+
+ // build changed columns
+ JsonObject colValues = new JsonObject();
+ for (int i = 0; i < columnNames.length; i++) {
+ if (columnIsPk[i]) {
+ continue;
+ }
+ String newVal = editInputs[i].text();
+ if (!newVal.equals(editOriginalValues[i])) {
+ if (newVal.isEmpty()) {
+ colValues.put(columnNames[i], null);
+ } else {
+ colValues.put(columnNames[i], newVal);
+ }
+ }
+ }
+
+ if (colValues.isEmpty()) {
+ editUpdateMessage = "No changes";
+ return;
+ }
+
+ // build PK values from the original row
+ JsonObject pkValues = new JsonObject();
+ JsonObject row = resultRows.get(editRowIndex);
+ for (String pk : primaryKeys) {
+ Object val = row.get(pk);
+ if (val != null) {
+ pkValues.put(pk, val);
+ } else {
+ pkValues.put(pk, null);
+ }
+ }
+
+ if (!executing.compareAndSet(false, true)) {
+ return;
+ }
+
+ String dsName = dsNames.isEmpty() ? null : dsNames.get(selectedDs);
+ String pkJson = pkValues.toJson();
+ String colJson = colValues.toJson();
+ String savedSql = lastSql;
+ String savedDs = lastDsName;
+
+ ctx.runner.scheduler().execute(() -> {
+ try {
+ Path outputFile = ctx.getOutputFile(ctx.selectedPid);
+ PathUtils.deleteFile(outputFile);
+
+ JsonObject action = new JsonObject();
+ action.put("action", "sql-update-row");
+ action.put("table", tableName);
+ if (dsName != null) {
+ action.put("datasource", dsName);
+ }
+ action.put("primaryKeyValues", pkJson);
+ action.put("columnValues", colJson);
+
+ Path actionFile = ctx.getActionFile(ctx.selectedPid);
+ PathUtils.writeTextSafely(action.toJson(), actionFile);
+
+ JsonObject jo = pollJsonResponse(outputFile, 15000);
+ PathUtils.deleteFile(outputFile);
+
+ if (jo == null) {
+ if (ctx.runner != null) {
+ ctx.runner.runOnRenderThread(() -> editUpdateMessage =
"Timeout");
+ }
+ return;
+ }
+
+ String status = jo.getString("status");
+ if ("error".equals(status)) {
+ String msg = jo.getString("message");
+ if (ctx.runner != null) {
+ ctx.runner.runOnRenderThread(() -> editUpdateMessage =
"Error: " + msg);
+ }
+ return;
+ }
+
+ int uc = jo.getIntegerOrDefault("updateCount", 0);
+ if (ctx.runner != null) {
+ ctx.runner.runOnRenderThread(() -> {
+ editUpdateMessage = uc + " row(s) updated";
+ closeEditMode();
+ });
+ }
+
+ // re-execute the original query to refresh results
+ if (savedSql != null) {
+ executeInBackground(ctx.selectedPid, savedSql, savedDs);
+ }
+ } finally {
+ executing.set(false);
+ }
+ });
+ }
+
+ private void renderEditPopup(Frame frame, Rect area) {
+ int colCount = columnNames.length;
+ int labelW = 0;
+ for (String col : columnNames) {
+ labelW = Math.max(labelW, col.length());
+ }
+ labelW += 4;
+
+ int popupW = Math.min(area.width() - 4, Math.max(50, labelW + 30));
+ int visibleRows = Math.min(colCount, area.height() - 6);
+ int popupH = visibleRows + 4;
+ int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
+ int y = area.top() + Math.max(1, (area.height() - popupH) / 2);
+ Rect popup = new Rect(x, y, popupW, popupH);
+
+ frame.renderWidget(Clear.INSTANCE, popup);
+
+ String title = " Edit Row — " + tableName + " ";
+ if (editUpdateMessage != null) {
+ title = " " + editUpdateMessage + " ";
+ }
+
+ Block block = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(Title.from(Line.from(Span.styled(title,
Style.EMPTY.fg(Color.YELLOW).bold()))))
+ .build();
+ Rect inner = block.inner(popup);
+ frame.renderWidget(block, popup);
+
+ // adjust scroll to keep focused field visible
+ if (editField < editScrollOffset) {
+ editScrollOffset = editField;
+ } else if (editField >= editScrollOffset + visibleRows) {
+ editScrollOffset = editField - visibleRows + 1;
+ }
+
+ int fieldW = inner.width() - labelW - 1;
+ int end = Math.min(editScrollOffset + visibleRows, colCount);
+ for (int i = editScrollOffset; i < end; i++) {
+ int row = inner.top() + (i - editScrollOffset);
+ boolean isPk = columnIsPk[i];
+ boolean isFocused = (i == editField);
+
+ // column label
+ String label = columnNames[i] + (isPk ? " *" : " ");
+ Style labelStyle;
+ if (isPk) {
+ labelStyle = Style.EMPTY.fg(Color.DARK_GRAY);
+ } else if (isFocused) {
+ labelStyle = Style.EMPTY.fg(Color.CYAN).bold();
+ } else {
+ labelStyle = Style.EMPTY;
+ }
+ Rect labelArea = new Rect(inner.left(), row, labelW, 1);
+ frame.renderWidget(Paragraph.from(Line.from(
+ Span.styled(String.format("%" + (labelW - 1) + "s", label)
+ " ", labelStyle))), labelArea);
+
+ // value field
+ Rect valArea = new Rect(inner.left() + labelW, row, fieldW, 1);
+ if (isPk) {
+ String val = editOriginalValues[i];
+ frame.renderWidget(Paragraph.from(Line.from(
+ Span.styled(val.isEmpty() ? "null" : val,
Style.EMPTY.fg(Color.DARK_GRAY)))), valArea);
+ } else if (isFocused) {
+ boolean changed =
!editInputs[i].text().equals(editOriginalValues[i]);
+ Style cursorStyle = changed ?
Style.EMPTY.reversed().fg(Color.GREEN) : Style.EMPTY.reversed();
+ TextInput input = TextInput.builder()
+ .cursorStyle(cursorStyle)
+ .build();
+ frame.renderStatefulWidget(input, valArea, editInputs[i]);
+ } else {
+ String val = editInputs[i].text();
+ boolean changed = !val.equals(editOriginalValues[i]);
+ Style valStyle = changed ? Style.EMPTY.fg(Color.GREEN) :
Style.EMPTY;
+ frame.renderWidget(Paragraph.from(Line.from(
+ Span.styled(val.isEmpty() ? "null" : val, valStyle))),
valArea);
+ }
+ }
+
+ // footer hint inside popup
+ int footerY = inner.top() + visibleRows + 1;
+ if (footerY < popup.bottom() - 1) {
+ Rect footerArea = new Rect(inner.left(), footerY, inner.width(),
1);
+ frame.renderWidget(Paragraph.from(Line.from(
+ Span.styled(" F5", Style.EMPTY.fg(Color.YELLOW)),
+ Span.styled("=Save ", Style.EMPTY.fg(Color.DARK_GRAY)),
+ Span.styled("Esc", Style.EMPTY.fg(Color.YELLOW)),
+ Span.styled("=Cancel ", Style.EMPTY.fg(Color.DARK_GRAY)),
+ Span.styled("*", Style.EMPTY.fg(Color.DARK_GRAY)),
+ Span.styled("=Primary Key",
Style.EMPTY.fg(Color.DARK_GRAY)))), footerArea);
+ }
+ }
+
+ @Override
+ public void renderFooter(List<Span> spans) {
+ if (editMode) {
+ hint(spans, "F5", "save");
+ hint(spans, "Esc", "cancel");
+ } else if (focusOnInput) {
+ hint(spans, "F5", "execute");
+ if (!sqlHistory.isEmpty()) {
+ hint(spans, "C-e", "history");
+ }
+ if (dsNames.size() > 1) {
+ hint(spans, "C-←→", "datasource");
+ }
+ if (resultRows != null && !resultRows.isEmpty()) {
+ hint(spans, "Tab", "results");
+ }
+ } else {
+ hint(spans, "Tab", "input");
+ hint(spans, "↑↓", "navigate");
+ if (isEditable()) {
+ hint(spans, "Enter", "edit");
+ }
+ }
+ }
+
+ @Override
+ public String getHelpText() {
+ return """
+ # SQL Query
+
+ Execute SQL queries against DataSource beans registered in the
Camel application.
+
+ ## Usage
+ - Type a SQL query in the input field and press **F5** to
execute
+ - Type **file:query.sql** to load SQL from a file
+ - Use **Enter** for new lines in the query
+ - Use **Up/Down** arrows to move cursor within the query
+ - Paste multi-line queries from clipboard
+ - Use **Ctrl+E** to open query history (select with Enter,
dismiss with Esc)
+ - Use **Tab** to toggle focus between input and results table
+ - Use **Esc** to return focus to the input field from results
+ - Use **Ctrl+Left/Right** to switch between DataSources (when
multiple exist)
+
+ ## Inline Editing
+ - For simple single-table SELECT queries, press **Enter** on a
result row to edit
+ - Primary key columns (marked with *) are read-only
+ - Changed values are highlighted in green
+ - Press **F5** to save changes (executes an UPDATE statement)
+ - Press **Esc** to cancel editing
+ - The query is automatically re-executed after a successful
update
+
+ ## Supported Queries
+ - SELECT queries return a result table
+ - INSERT, UPDATE, DELETE return an update count
+ - Any valid SQL supported by the underlying database
+
+ ## Safety
+ - Results are limited to 100 rows by default
+ - Query timeout is 30 seconds by default
+ - This feature is only available when dev console is enabled
(dev profile)
+ """;
+ }
+
+ @Override
+ public boolean setInputValue(String field, String value) {
+ if ("sql".equals(field)) {
+ sqlInput.setText(value != null ? value : "");
+ focusOnInput = true;
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public JsonObject getTableDataAsJson() {
+ JsonObject root = new JsonObject();
+
+ // current input state
+ String sql = sqlInput.text().trim();
+ if (!sql.isEmpty()) {
+ root.put("sql", sql);
+ }
+ if (!dsNames.isEmpty()) {
+ root.put("datasource", dsNames.get(selectedDs));
+ }
+
+ if (columnNames != null && resultRows != null) {
+ JsonArray cols = new JsonArray();
+ for (String col : columnNames) {
+ cols.add(col);
+ }
+ root.put("columns", cols);
+ root.put("rows", new JsonArray(resultRows));
+ root.put("rowCount", rowCount);
+ root.put("truncated", truncated);
+ root.put("elapsed", elapsed);
+ Integer sel = tableState.selected();
+ if (sel != null && sel >= 0 && sel < resultRows.size()) {
+ root.put("selectedIndex", sel);
+ }
+ if (tableName != null) {
+ root.put("tableName", tableName);
+ root.put("editable", true);
+ if (primaryKeys != null) {
+ JsonArray pkArr = new JsonArray();
+ for (String pk : primaryKeys) {
+ pkArr.add(pk);
+ }
+ root.put("primaryKeys", pkArr);
+ }
+ }
+ }
+ if (errorMessage != null) {
+ root.put("error", errorMessage);
+ }
+ if (updateCount != null) {
+ root.put("updateCount", updateCount);
+ }
+ if (executing.get()) {
+ root.put("executing", true);
+ }
+ return root;
+ }
+
+ private void executeQuery() {
+ String sql = sqlInput.text().trim();
+ if (sql.isEmpty() || ctx.selectedPid == null || ctx.runner == null) {
+ return;
+ }
+
+ if (!executing.compareAndSet(false, true)) {
+ return;
+ }
+
+ clearResults();
+
+ sqlHistory.add(sql);
+ String pid = ctx.selectedPid;
+ String dsName = dsNames.isEmpty() ? null : dsNames.get(selectedDs);
+ lastSql = sql;
+ lastDsName = dsName;
+
+ ctx.runner.scheduler().execute(() -> {
+ try {
+ executeInBackground(pid, sql, dsName);
+ } finally {
+ executing.set(false);
+ }
+ });
+ }
+
+ private void executeInBackground(String pid, String sql, String dsName) {
+ Path outputFile = ctx.getOutputFile(pid);
+ PathUtils.deleteFile(outputFile);
+
+ JsonObject root = new JsonObject();
+ root.put("action", "sql-query");
+ root.put("sql", sql);
+ if (dsName != null) {
+ root.put("datasource", dsName);
+ }
+ root.put("maxRows", 100);
+ root.put("queryTimeout", 30);
+
+ Path actionFile = ctx.getActionFile(pid);
+ PathUtils.writeTextSafely(root.toJson(), actionFile);
+
+ JsonObject jo = pollJsonResponse(outputFile, 35000);
+ PathUtils.deleteFile(outputFile);
+
+ if (jo == null) {
+ if (ctx.runner != null) {
+ ctx.runner.runOnRenderThread(() -> {
+ errorMessage = "Timeout waiting for query response";
+ });
+ }
+ return;
+ }
+
+ String status = jo.getString("status");
+ if ("error".equals(status)) {
+ String msg = jo.getString("message");
+ if (ctx.runner != null) {
+ ctx.runner.runOnRenderThread(() -> {
+ errorMessage = msg;
+ elapsed = jo.getLongOrDefault("elapsed", 0);
+ });
+ }
+ return;
+ }
+
+ // update count result
+ if (jo.containsKey("updateCount")) {
+ int uc = jo.getInteger("updateCount");
+ long el = jo.getLongOrDefault("elapsed", 0);
+ if (ctx.runner != null) {
+ ctx.runner.runOnRenderThread(() -> {
+ updateCount = uc;
+ elapsed = el;
+ focusOnInput = true;
+ });
+ }
+ return;
+ }
+
+ // SELECT result
+ JsonArray columns = jo.getCollection("columns");
+ JsonArray rows = jo.getCollection("rows");
+ if (columns == null || rows == null) {
+ return;
+ }
+
+ String[] cols = new String[columns.size()];
+ boolean[] isPk = new boolean[columns.size()];
+ for (int i = 0; i < columns.size(); i++) {
+ JsonObject col = (JsonObject) columns.get(i);
+ cols[i] = col.getString("name");
+ isPk[i] = col.getBooleanOrDefault("primaryKey", false);
+ }
+
+ List<JsonObject> parsedRows = new ArrayList<>();
+ for (Object rowObj : rows) {
+ parsedRows.add((JsonObject) rowObj);
+ }
+
+ int rc = jo.getIntegerOrDefault("rowCount", parsedRows.size());
+ boolean trunc = jo.getBooleanOrDefault("truncated", false);
+ long el = jo.getLongOrDefault("elapsed", 0);
+
+ // editability metadata
+ String tblName = jo.getString("tableName");
+ String[] pks = null;
+ if (tblName != null) {
+ JsonArray pkArr = jo.getCollection("primaryKeys");
+ if (pkArr != null && !pkArr.isEmpty()) {
+ pks = new String[pkArr.size()];
+ for (int i = 0; i < pkArr.size(); i++) {
+ pks[i] = String.valueOf(pkArr.get(i));
+ }
+ }
+ }
+
+ String[] finalPks = pks;
+ if (ctx.runner != null) {
+ ctx.runner.runOnRenderThread(() -> {
+ columnNames = cols;
+ columnIsPk = isPk;
+ resultRows = parsedRows;
+ rowCount = rc;
+ truncated = trunc;
+ elapsed = el;
+ tableName = tblName;
+ primaryKeys = finalPks;
+ tableState.select(0);
+ focusOnInput = true;
+ });
+ }
+ }
+
+ private void clearResults() {
+ columnNames = null;
+ columnIsPk = null;
+ resultRows = null;
+ rowCount = 0;
+ truncated = false;
+ elapsed = 0;
+ errorMessage = null;
+ updateCount = null;
+ tableName = null;
+ primaryKeys = null;
+ tableState.select(0);
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
index fb871183452f..73a522768b11 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
@@ -443,6 +443,33 @@ class TuiMcpServer {
"body", propDef("string", "Message body to send"),
"headers", propDef("string", "Message headers as
key=value pairs separated by newlines")),
List.of("endpoint")));
+ toolList.add(toolDef(
+ "tui_execute_sql",
+ "Executes a SQL query against a DataSource in the selected
integration. "
+ + "Returns structured JSON with columns,
rows, and metadata for SELECT queries, "
+ + "or an update count for
INSERT/UPDATE/DELETE. "
+ + "Requires dev console to be enabled in
the running application.",
+ Map.of("query", propDef("string", "The SQL query to execute"),
+ "datasource", propDef("string",
+ "Name of the DataSource bean (auto-detected if
only one exists)"),
+ "maxRows", propDef("integer",
+ "Maximum number of rows to return (default
100)"),
+ "queryTimeout", propDef("integer",
+ "Query timeout in seconds (default 30)")),
+ List.of("query")));
+ toolList.add(toolDef(
+ "tui_update_row",
+ "Updates a single row in a database table. Use after
tui_execute_sql returns "
+ + "editable=true with tableName and
primaryKeys. Builds and executes "
+ + "an UPDATE statement using
PreparedStatement for safety.",
+ Map.of("table", propDef("string", "The table name to update"),
+ "primaryKeyValues", propDef("string",
+ "JSON object of primary key column-value
pairs, e.g. {\"id\": 1}"),
+ "columnValues", propDef("string",
+ "JSON object of column-value pairs to update,
e.g. {\"name\": \"new\"}"),
+ "datasource", propDef("string",
+ "Name of the DataSource bean (auto-detected if
only one exists)")),
+ List.of("table", "primaryKeyValues", "columnValues")));
toolList.add(toolDef(
"tui_set_log_level",
"Changes the runtime log level of the selected integration. "
@@ -461,6 +488,20 @@ class TuiMcpServer {
"tab", propDef("string",
"Tab name to filter (e.g. 'Classpath'). If
omitted, uses the active tab.")),
List.of("filter")));
+ toolList.add(toolDef(
+ "tui_set_input",
+ "Sets the value of a text input field on a TUI tab directly,
without simulating keystrokes. "
+ + "The text appears in the TUI input widget
so the user can see it. "
+ + "Supported fields by tab: SQL Query
(field='sql'), "
+ + "HTTP probe (field='path' or 'body'), "
+ + "Spans (field='filter'), Classpath
(field='filter').",
+ Map.of("field", propDef("string",
+ "Field name to set: 'sql', 'path', 'body', or
'filter'"),
+ "value", propDef("string",
+ "The text value to set in the input field"),
+ "tab", propDef("string",
+ "Tab name (e.g. 'SQL Query', 'HTTP'). If
omitted, uses the active tab.")),
+ List.of("field", "value")));
toolList.add(toolDef(
"tui_toggle_trace_display",
"Toggles which sections are visible in the History tab's
detail view. "
@@ -565,8 +606,11 @@ class TuiMcpServer {
case "tui_get_history" -> callGetHistory(args);
case "tui_get_topology" -> callGetTopology();
case "tui_send_message" -> callSendMessage(args);
+ case "tui_execute_sql" -> callExecuteSql(args);
+ case "tui_update_row" -> callUpdateRow(args);
case "tui_set_log_level" -> callSetLogLevel(args);
case "tui_filter" -> callFilter(args);
+ case "tui_set_input" -> callSetInput(args);
case "tui_toggle_trace_display" ->
callToggleTraceDisplay(args);
case "tui_get_readme" -> callGetReadme(args);
case "tui_control" -> callControl(args);
@@ -1186,6 +1230,42 @@ class TuiMcpServer {
return Jsoner.serialize(response);
}
+ private String callExecuteSql(Map<String, Object> args) {
+ String query = (String) args.get("query");
+ if (query == null || query.isBlank()) {
+ return "Error: query is required";
+ }
+ String datasource = args.get("datasource") instanceof String s ? s :
null;
+ int maxRows = args.get("maxRows") instanceof Number n ? n.intValue() :
100;
+ int queryTimeout = args.get("queryTimeout") instanceof Number n ?
n.intValue() : 30;
+ JsonObject response = monitor.executeSql(query, datasource, maxRows,
queryTimeout);
+ if (response == null) {
+ return "Error: no integration selected or PID unavailable";
+ }
+ return Jsoner.serialize(response);
+ }
+
+ private String callUpdateRow(Map<String, Object> args) {
+ String table = (String) args.get("table");
+ if (table == null || table.isBlank()) {
+ return "Error: table is required";
+ }
+ String pkValues = (String) args.get("primaryKeyValues");
+ if (pkValues == null || pkValues.isBlank()) {
+ return "Error: primaryKeyValues is required (JSON object)";
+ }
+ String colValues = (String) args.get("columnValues");
+ if (colValues == null || colValues.isBlank()) {
+ return "Error: columnValues is required (JSON object)";
+ }
+ String datasource = args.get("datasource") instanceof String s ? s :
null;
+ JsonObject response = monitor.updateRow(table, datasource, pkValues,
colValues);
+ if (response == null) {
+ return "Error: no integration selected or PID unavailable";
+ }
+ return Jsoner.serialize(response);
+ }
+
private String callSetLogLevel(Map<String, Object> args) {
String level = (String) args.get("level");
if (level == null || level.isBlank()) {
@@ -1210,6 +1290,20 @@ class TuiMcpServer {
return filter.isEmpty() ? "Filter cleared" : "Filter set to: " +
filter;
}
+ private String callSetInput(Map<String, Object> args) {
+ String field = (String) args.get("field");
+ if (field == null || field.isBlank()) {
+ return "Error: field is required";
+ }
+ String value = args.get("value") instanceof String s ? s : "";
+ String tab = args.get("tab") instanceof String s ? s : null;
+ boolean applied = monitor.setTabInputValue(tab, field, value);
+ if (!applied) {
+ return "Error: field '" + field + "' not found on " + (tab != null
? tab : "active") + " tab";
+ }
+ return "Input set: " + field + " = " + (value.length() > 80 ?
value.substring(0, 80) + "..." : value);
+ }
+
private String callToggleTraceDisplay(Map<String, Object> args) {
String section = (String) args.get("section");
if (section == null || section.isBlank()) {