This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch sql-query-tui in repository https://gitbox.apache.org/repos/asf/camel.git
commit 02518a3dbcf5afab76a6a4ffa6e700ef42496c1e Author: Claus Ibsen <[email protected]> AuthorDate: Sun Jun 28 11:56:18 2026 +0200 chore: camel-jbang - Add inline row editing to SQL query tab Add the ability to edit result rows directly in the TUI SQL query tab. Press Enter on a result row to open an edit form, change values, and press F5 to auto-generate and execute an UPDATE statement. Includes single-table detection via JDBC metadata, primary key discovery, PreparedStatement-based updates with proper type coercion, and an MCP tui_update_row tool for AI agents. Also fixes: ResultSet conflict crash (PK lookup moved after row iteration), number keys switching tabs during edit mode, and integer column type handling in updates. Co-Authored-By: Claude <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../camel/impl/console/SqlQueryDevConsole.java | 303 +++++++++++++--- .../camel/cli/connector/LocalCliConnector.java | 23 ++ .../camel/dsl/jbang/core/common/RuntimeHelper.java | 19 + .../dsl/jbang/core/commands/tui/CamelMonitor.java | 13 + .../dsl/jbang/core/commands/tui/SqlQueryTab.java | 386 ++++++++++++++++++++- .../dsl/jbang/core/commands/tui/TuiMcpServer.java | 35 ++ 6 files changed, 732 insertions(+), 47 deletions(-) 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 index 81ecc1b3c460..4bd24bba64d2 100644 --- 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 @@ -17,10 +17,16 @@ 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; @@ -32,6 +38,7 @@ 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) @@ -57,6 +64,26 @@ public class SqlQueryDevConsole extends AbstractDevConsole { */ 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; @@ -185,6 +212,14 @@ public class SqlQueryDevConsole extends AbstractDevConsole { @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); @@ -198,36 +233,11 @@ public class SqlQueryDevConsole extends AbstractDevConsole { 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) { - root.put("status", "error"); - root.put("message", String.format("DataSource '%s' not found in registry", dsName)); - return root; - } - } else { - 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 root; - } - if (all.size() > 1) { - root.put("status", "error"); - root.put("message", "Multiple DataSources found, specify one: " + String.join(", ", all.keySet())); - // include available names for the caller - JsonArray names = new JsonArray(); - all.keySet().forEach(names::add); - root.put("availableDataSources", names); - return root; - } - Map.Entry<String, DataSource> single = all.entrySet().iterator().next(); - ds = single.getValue(); - resolvedName = single.getKey(); + DataSource ds = resolveDataSource(dsName, root); + if (ds == null) { + return root; } + String resolvedName = root.getString("datasource"); StopWatch watch = new StopWatch(); try (Connection conn = ds.getConnection(); @@ -241,46 +251,104 @@ public class SqlQueryDevConsole extends AbstractDevConsole { root.put("status", "success"); root.put("elapsed", elapsed); - root.put("datasource", resolvedName); 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; + } - JsonArray columns = new JsonArray(); 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); } - root.put("columns", columns); - JsonArray rows = new JsonArray(); - int rowCount = 0; while (rs.next()) { JsonObject row = new JsonObject(); for (int i = 1; i <= colCount; i++) { - String colName = meta.getColumnLabel(i); Object val = rs.getObject(i); if (val == null) { - row.put(colName, null); + row.put(colLabels[i - 1], null); } else if (val instanceof Number n) { - row.put(colName, n); + row.put(colLabels[i - 1], n); } else if (val instanceof Boolean b) { - row.put(colName, b); + row.put(colLabels[i - 1], b); } else { - row.put(colName, String.valueOf(val)); + row.put(colLabels[i - 1], String.valueOf(val)); } } rows.add(row); rowCount++; } - root.put("rows", rows); - root.put("rowCount", rowCount); - root.put("truncated", rowCount >= maxRows); } + + // 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); @@ -295,6 +363,157 @@ public class SqlQueryDevConsole extends AbstractDevConsole { 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) { 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 ae14b3906620..731df87d90f5 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 @@ -373,6 +373,8 @@ public class LocalCliConnector extends ServiceSupport implements CliConnector, C 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); } @@ -1037,6 +1039,27 @@ public class LocalCliConnector extends ServiceSupport implements CliConnector, C } } + 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/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 91caf955c9f4..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 @@ -276,6 +276,25 @@ 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 -> { 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 bd4b5c97243d..4650772bea9c 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 @@ -3017,6 +3017,19 @@ public class CamelMonitor extends CamelCommand { 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/SqlQueryTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlQueryTab.java index a6e9d06591c7..7a9faa2e5ef1 100644 --- 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 @@ -32,11 +32,14 @@ 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; @@ -63,6 +66,7 @@ class SqlQueryTab implements MonitorTab { // results private String[] columnNames; + private boolean[] columnIsPk; private List<JsonObject> resultRows; private int rowCount; private boolean truncated; @@ -70,16 +74,38 @@ class SqlQueryTab implements MonitorTab { 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 focusOnInput; + return editMode || focusOnInput; } void handlePaste(String text) { - if (focusOnInput) { + if (editMode) { + if (editInputs != null && editField >= 0 && editField < editInputs.length + && !columnIsPk[editField]) { + FormHelper.handlePaste(text, editInputs[editField]); + } + } else if (focusOnInput) { sqlInput.insert(text); } } @@ -95,6 +121,7 @@ class SqlQueryTab implements MonitorTab { } } clearResults(); + closeEditMode(); } @Override @@ -109,6 +136,11 @@ class SqlQueryTab implements MonitorTab { return true; } + // edit mode takes priority + if (editMode) { + return handleEditKeyEvent(ke); + } + // history popup takes priority if (sqlHistory.isPopupVisible()) { sqlHistory.handleKeyEvent(ke); @@ -146,12 +178,16 @@ class SqlQueryTab implements MonitorTab { return true; } - // Enter adds newline in input mode + // 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; } @@ -222,8 +258,53 @@ class SqlQueryTab implements MonitorTab { 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; @@ -268,6 +349,10 @@ class SqlQueryTab implements MonitorTab { 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) { @@ -421,7 +506,11 @@ class SqlQueryTab implements MonitorTab { private Cell[] buildHeaderCells() { Cell[] cells = new Cell[columnNames.length]; for (int i = 0; i < columnNames.length; i++) { - cells[i] = Cell.from(Span.styled(columnNames[i], Style.EMPTY.fg(Color.YELLOW))); + 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; } @@ -432,6 +521,9 @@ class SqlQueryTab implements MonitorTab { 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) { @@ -451,9 +543,253 @@ class SqlQueryTab implements MonitorTab { 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 (focusOnInput) { + if (editMode) { + spans.add(Span.styled(" F5", Style.EMPTY.fg(Color.YELLOW))); + spans.add(Span.styled("=Save ", Style.EMPTY.fg(Color.DARK_GRAY))); + spans.add(Span.styled(" Esc", Style.EMPTY.fg(Color.YELLOW))); + spans.add(Span.styled("=Cancel ", Style.EMPTY.fg(Color.DARK_GRAY))); + } else if (focusOnInput) { spans.add(Span.styled(" F5", Style.EMPTY.fg(Color.YELLOW))); spans.add(Span.styled("=Execute ", Style.EMPTY.fg(Color.DARK_GRAY))); if (!sqlHistory.isEmpty()) { @@ -473,6 +809,10 @@ class SqlQueryTab implements MonitorTab { spans.add(Span.styled("=Input ", Style.EMPTY.fg(Color.DARK_GRAY))); spans.add(Span.styled(" ↑↓", Style.EMPTY.fg(Color.YELLOW))); spans.add(Span.styled("=Navigate ", Style.EMPTY.fg(Color.DARK_GRAY))); + if (isEditable()) { + spans.add(Span.styled(" Enter", Style.EMPTY.fg(Color.YELLOW))); + spans.add(Span.styled("=Edit ", Style.EMPTY.fg(Color.DARK_GRAY))); + } } } @@ -493,6 +833,14 @@ class SqlQueryTab implements MonitorTab { - 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 @@ -518,6 +866,10 @@ class SqlQueryTab implements MonitorTab { root.put("rowCount", rowCount); root.put("truncated", truncated); root.put("elapsed", elapsed); + if (tableName != null) { + root.put("tableName", tableName); + root.put("editable", true); + } } if (errorMessage != null) { root.put("error", errorMessage); @@ -542,6 +894,8 @@ class SqlQueryTab implements MonitorTab { sqlHistory.add(sql); String pid = ctx.selectedPid; String dsName = dsNames.isEmpty() ? null : dsNames.get(selectedDs); + lastSql = sql; + lastDsName = dsName; ctx.runner.scheduler().execute(() -> { try { @@ -614,9 +968,11 @@ class SqlQueryTab implements MonitorTab { } 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<>(); @@ -628,13 +984,30 @@ class SqlQueryTab implements MonitorTab { 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; }); @@ -643,12 +1016,15 @@ class SqlQueryTab implements MonitorTab { 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 d3921228b7e6..912fabb772f5 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 @@ -457,6 +457,19 @@ class TuiMcpServer { "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. " @@ -580,6 +593,7 @@ class TuiMcpServer { 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_toggle_trace_display" -> callToggleTraceDisplay(args); @@ -1216,6 +1230,27 @@ class TuiMcpServer { 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()) {
