From c68bb54b5c359d60a08358433d9f2a6b4f933b25 Mon Sep 17 00:00:00 2001 From: ibuler Date: Wed, 8 Jul 2026 09:51:56 +0800 Subject: [PATCH 01/44] perf: update for new client --- .dockerignore | 12 ++++++++++ .vscode/launch.json | 12 ++++++++++ .vscode/settings.json | 15 ++++++++++++ Dockerfile | 2 -- .../web/interceptor/SessionInterceptor.java | 4 ++-- config/application-dev.yml.example | 9 +++++++ docker-compose.yml | 24 +++++++++++++++++++ entrypoint.sh | 3 ++- 8 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 config/application-dev.yml.example create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c3a8dc2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.idea +.vscode +**/.DS_Store +**/*.iml +**/target +data +node_modules +frontend/node_modules +frontend/dist +backend/web/src/main/resources/static diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..7dca259 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node-terminal", + "name": "Docker: Chen Backend", + "request": "launch", + "command": "docker compose up --build", + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..aa0bb87 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + "java.configuration.runtimes": [ + { + "name": "JavaSE-21", + "path": "/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home", + "default": true + }, + { + "name": "JavaSE-17", + "path": "/Users/guang/Library/Java/JavaVirtualMachines/jbr-17.0.9/Contents/Home" + } + ], + "java.jdt.ls.java.home": "/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home", + "java.compile.nullAnalysis.mode": "disabled" +} diff --git a/Dockerfile b/Dockerfile index 2518fb9..25cf4d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,8 +4,6 @@ ENV LANG=en_US.UTF-8 WORKDIR /opt/chen/ COPY . . -RUN cd frontend \ - && npm run build RUN mvn clean package -Dmaven.test.skip=true diff --git a/backend/web/src/main/java/org/jumpserver/chen/web/interceptor/SessionInterceptor.java b/backend/web/src/main/java/org/jumpserver/chen/web/interceptor/SessionInterceptor.java index 92defe4..5383921 100644 --- a/backend/web/src/main/java/org/jumpserver/chen/web/interceptor/SessionInterceptor.java +++ b/backend/web/src/main/java/org/jumpserver/chen/web/interceptor/SessionInterceptor.java @@ -16,8 +16,8 @@ public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Objec } //1. 从header 中获取 token String token = req.getHeader("token"); - //2. 判断 token 是否认证 - if (token == null || token.isEmpty() || SessionManager.getSession(token) == null || !SessionManager.getSession(token).isActive()) { + //2. /api/profile 等请求发生在 websocket 建立前,此时 session 已创建但尚未 active。 + if (token == null || token.isEmpty() || SessionManager.getSession(token) == null) { //2.1 认证失败,返回错误信息 resp.setStatus(401); resp.getWriter().write("Unauthorized"); diff --git a/config/application-dev.yml.example b/config/application-dev.yml.example new file mode 100644 index 0000000..1cda5cd --- /dev/null +++ b/config/application-dev.yml.example @@ -0,0 +1,9 @@ +mock: + enable: true + mysql: + db-type: mysql + host: host.docker.internal + port: 3306 + user: root + password: your_password + db: test diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d3041b0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + chen-backend: + build: + context: . + dockerfile: Dockerfile + container_name: chen-backend-dev + volumes: + - ./config:/opt/chen/config + - chen-data:/opt/chen/data + ports: + - "8082:8082" + environment: + LOG_LEVEL: INFO + BOOTSTRAP_TOKEN: PleaseChangeMe + CORE_HOST: http://192.168.1.80:8080 + EXECUTE_PROGRAM: >- + java -Dfile.encoding=utf-8 -XX:+ExitOnOutOfMemoryError + -jar /opt/chen/chen.jar + --spring.config.additional-location=optional:file:/opt/chen/config/ + --spring.profiles.active=dev + restart: unless-stopped + +volumes: + chen-data: diff --git a/entrypoint.sh b/entrypoint.sh index 722db90..68ca079 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -12,7 +12,8 @@ export GIN_MODE=release export WORK_DIR=/opt/chen export COMPONENT_NAME=chen export WISP_TRACE_PROCESS=1 -export EXECUTE_PROGRAM="java -Dfile.encoding=utf-8 -XX:+ExitOnOutOfMemoryError -jar /opt/chen/chen.jar --mock.enable=false" +: ${EXECUTE_PROGRAM:="java -Dfile.encoding=utf-8 -XX:+ExitOnOutOfMemoryError -jar /opt/chen/chen.jar --mock.enable=false"} +export EXECUTE_PROGRAM : ${LOG_LEVEL:='ERROR'} From 5ebe341e3c2d6eee45a37cb8cd6c378f6c1d1b74 Mon Sep 17 00:00:00 2001 From: "Crane.z" <1481445951@qq.com> Date: Wed, 8 Jul 2026 17:48:34 +0800 Subject: [PATCH 02/44] Merge remote-tracking branch 'origin/dev' into new_terminal --- .../chen/framework/console/QueryConsole.java | 37 ++++++++++- .../chen/framework/datasource/sql/SQL.java | 30 ++++++++- .../datasource/sql/SQLIdentifier.java | 30 +++++++++ .../framework/ws/ConsoleWebSocketHandler.java | 66 +++++++++++++++---- .../clickhouse/ClickhouseActuator.java | 4 +- .../dameng/DMActuator.java | 4 +- .../db2/DB2Actuator.java | 4 +- .../mysql/MysqlActuator.java | 3 +- .../oracle/OracleActuator.java | 4 +- .../postgresql/PostgresqlActuator.java | 4 +- .../sqlserver/SQLServerActuator.java | 4 +- 11 files changed, 167 insertions(+), 23 deletions(-) create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/sql/SQLIdentifier.java diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/QueryConsole.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/QueryConsole.java index ca25f62..a3fab59 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/QueryConsole.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/QueryConsole.java @@ -32,7 +32,10 @@ import java.sql.Connection; import java.sql.SQLException; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; @@ -47,6 +50,7 @@ public class QueryConsole extends AbstractConsole { private volatile SQLExecutePlan currentPlan; private StateManager stateManager; private final Map dataViews = new HashMap<>(); + private volatile Map allowedContexts = Map.of(); private static final Gson GSON = new Gson(); @@ -99,6 +103,7 @@ public void onConnect(Connect connect) { } var schemas = this.getSqlActuator().getSchemas(); + this.replaceAllowedContexts(schemas); this.getState().setContexts(schemas); } catch (SQLException e) { @@ -285,15 +290,19 @@ public void onCancel() { } public void onManualChangeContext(String context) { - if (StringUtils.equals(this.getState().getCurrentContext(), context)) { + if (!this.isAllowedContext(context)) { + return; + } + var allowedContext = this.allowedContexts.get(context); + if (StringUtils.equals(this.getState().getCurrentContext(), allowedContext)) { return; } try { this.getState().setEditorLoading(true); this.stateManager.commit(); - this.getSqlActuator().changeSchema(context); - this.getState().setCurrentContext(context); + this.getSqlActuator().changeSchema(allowedContext); + this.getState().setCurrentContext(allowedContext); } catch (SQLException e) { this.getConsoleLogger().error(MessageUtils.get("ChangeContextError") + ": %s", e.getMessage()); @@ -304,8 +313,30 @@ public void onManualChangeContext(String context) { } + void replaceAllowedContexts(List contexts) { + var canonicalContexts = new LinkedHashMap(); + if (contexts != null) { + for (String context : contexts) { + if (StringUtils.isNotBlank(context)) { + canonicalContexts.putIfAbsent(context, context); + } + } + } + this.allowedContexts = Collections.unmodifiableMap(canonicalContexts); + } + + boolean isAllowedContext(String context) { + return StringUtils.isNotBlank(context) && this.allowedContexts.containsKey(context); + } public void onSQLFile(String filename) { + if (StringUtils.isBlank(filename) + || filename.contains("/") + || filename.contains("\\") + || filename.contains("..")) { + log.warn("Rejected invalid SQL file name"); + return; + } var filePath = SessionManager.getCurrentSession().getTempPath().resolve(filename); var file = filePath.toFile(); diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/sql/SQL.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/sql/SQL.java index ef64c84..d719c0a 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/sql/SQL.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/sql/SQL.java @@ -19,7 +19,11 @@ public SQL(String sql) { this.sql = sql; } - public static SQL of(String sql, Map params) { + /** + * Performs raw string interpolation. This is not a PreparedStatement. + * Never pass user-controlled values or SQL identifiers. + */ + public static SQL unsafeInterpolate(String sql, Map params) { List paramNames = new ArrayList<>(); Matcher matcher = Pattern.compile(":(\\w+)").matcher(sql); while (matcher.find()) { @@ -35,7 +39,11 @@ public static SQL of(String sql, Map params) { return new SQL(sql); } - public static SQL of(String sql, Object... params) { + /** + * Performs raw string interpolation. This is not a PreparedStatement. + * Never pass user-controlled values or SQL identifiers. + */ + public static SQL unsafeInterpolate(String sql, Object... params) { StringBuilder sb = new StringBuilder(); int lastPos = 0; for (Object param : params) { @@ -49,6 +57,24 @@ public static SQL of(String sql, Object... params) { return new SQL(sb.toString()); } + /** + * @deprecated This method performs unsafe raw string interpolation. Use a + * PreparedStatement for values and dialect-specific quoting for identifiers. + */ + @Deprecated + public static SQL of(String sql, Map params) { + return unsafeInterpolate(sql, params); + } + + /** + * @deprecated This method performs unsafe raw string interpolation. Use a + * PreparedStatement for values and dialect-specific quoting for identifiers. + */ + @Deprecated + public static SQL of(String sql, Object... params) { + return unsafeInterpolate(sql, params); + } + public static SQL of(String sql) { return new SQL(sql); } diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/sql/SQLIdentifier.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/sql/SQLIdentifier.java new file mode 100644 index 0000000..a73299d --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/sql/SQLIdentifier.java @@ -0,0 +1,30 @@ +package org.jumpserver.chen.framework.datasource.sql; + +import com.alibaba.druid.DbType; +import org.apache.commons.lang3.StringUtils; + +public final class SQLIdentifier { + private SQLIdentifier() { + } + + public static String quote(DbType dbType, String identifier) { + if (StringUtils.isBlank(identifier)) { + throw new IllegalArgumentException("SQL identifier must not be blank"); + } + for (int i = 0; i < identifier.length(); i++) { + if (Character.isISOControl(identifier.charAt(i))) { + throw new IllegalArgumentException("SQL identifier must not contain control characters"); + } + } + + return switch (dbType) { + case mysql, mariadb, clickhouse -> + "`" + identifier.replace("`", "``") + "`"; + case sqlserver -> + "[" + identifier.replace("]", "]]") + "]"; + case postgresql, oracle, db2, dm -> + "\"" + identifier.replace("\"", "\"\"") + "\""; + default -> throw new IllegalArgumentException("Unsupported database type: " + dbType); + }; + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/ws/ConsoleWebSocketHandler.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/ws/ConsoleWebSocketHandler.java index a76c992..e03911f 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/ws/ConsoleWebSocketHandler.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/ws/ConsoleWebSocketHandler.java @@ -7,15 +7,22 @@ import org.jumpserver.chen.framework.console.DataViewConsole; import org.jumpserver.chen.framework.console.QueryConsole; import org.jumpserver.chen.framework.console.entity.request.Connect; +import org.jumpserver.chen.framework.datasource.ResourceBrowser; +import org.jumpserver.chen.framework.datasource.entity.resource.TreeNode; import org.jumpserver.chen.framework.session.SessionManager; -import org.jumpserver.chen.framework.utils.TreeUtils; +import org.jumpserver.chen.framework.session.controller.message.Message; +import org.jumpserver.chen.framework.session.controller.message.MessageLevel; import org.jumpserver.chen.framework.ws.io.Packet; +import org.jumpserver.chen.framework.ws.io.PacketIO; +import org.jumpserver.chen.framework.utils.TreeUtils; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.WebSocketMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.adapter.NativeWebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; +import java.sql.SQLException; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -23,6 +30,9 @@ public class ConsoleWebSocketHandler extends TextWebSocketHandler { private static final Gson GSON = new Gson(); + private static final Set QUERY_NODE_TYPES = + Set.of("datasource", "database", "schema", "table"); + private static final Set DATA_VIEW_NODE_TYPES = Set.of("table", "view"); @Override @@ -71,17 +81,28 @@ public void handleMessage(WebSocketSession session, WebSocketMessage message) private void onConnectPacket(WebSocketSession session, Packet packet) { Connect connect = GSON.fromJson(GSON.toJson(packet.getData()), Connect.class); - Console console = null; var webSess = SessionManager.getCurrentSession(); - - switch (connect.getType()) { - case Connect.CONSOLE_TYPE_QUERY -> { - console = new QueryConsole(webSess.getDatasource(), session, connect.getNodeKey()); - } - case Connect.CONSOLE_TYPE_DATA_VIEW -> { - console = new DataViewConsole(webSess.getDatasource(), session, connect.getNodeKey()); - } + var node = this.resolveNode( + webSess.getDatasource().getResourceBrowser(), + connect.getNodeKey(), + connect.getType() + ); + if (node == null) { + new PacketIO(session).sendPacket( + "show_message", + new Message(MessageLevel.ERROR, "Invalid console context") + ); + return; } + + connect.setNodeKey(node.getKey()); + Console console = switch (connect.getType()) { + case Connect.CONSOLE_TYPE_QUERY -> + new QueryConsole(webSess.getDatasource(), session, node.getKey()); + case Connect.CONSOLE_TYPE_DATA_VIEW -> + new DataViewConsole(webSess.getDatasource(), session, node.getKey()); + default -> null; + }; if (console != null) { webSess.getConsoles().put(session.getId(), console); @@ -93,6 +114,27 @@ private void onConnectPacket(WebSocketSession session, Packet packet) { } } + TreeNode resolveNode(ResourceBrowser resourceBrowser, String nodeKey, String consoleType) { + if (resourceBrowser == null || StringUtils.isBlank(nodeKey) || StringUtils.isBlank(consoleType)) { + return null; + } + TreeNode node; + try { + var root = resourceBrowser.getTree(); + node = root == null ? null : TreeUtils.getNode(root, nodeKey); + } catch (SQLException e) { + return null; + } + if (node == null) { + return null; + } + var allowedTypes = switch (consoleType) { + case Connect.CONSOLE_TYPE_QUERY -> QUERY_NODE_TYPES; + case Connect.CONSOLE_TYPE_DATA_VIEW -> DATA_VIEW_NODE_TYPES; + default -> Set.of(); + }; + return allowedTypes.contains(node.getType()) ? node : null; + } @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { @@ -111,7 +153,9 @@ public void afterConnectionClosed(WebSocketSession session, CloseStatus closeSta .getCurrentSession() .getConsoles() .get(session.getId()); - console.close(); + if (console != null) { + console.close(); + } SessionManager.getCurrentSession().getConsoles().remove(session.getId()); } } diff --git a/backend/modules/src/main/java/org.jumpserver.chen.modules/clickhouse/ClickhouseActuator.java b/backend/modules/src/main/java/org.jumpserver.chen.modules/clickhouse/ClickhouseActuator.java index 408e169..777b2c9 100644 --- a/backend/modules/src/main/java/org.jumpserver.chen.modules/clickhouse/ClickhouseActuator.java +++ b/backend/modules/src/main/java/org.jumpserver.chen.modules/clickhouse/ClickhouseActuator.java @@ -4,6 +4,7 @@ import org.jumpserver.chen.framework.datasource.base.BaseSQLActuator; import org.jumpserver.chen.framework.datasource.sql.SQL; import org.jumpserver.chen.framework.datasource.sql.SQLExecutePlan; +import org.jumpserver.chen.framework.datasource.sql.SQLIdentifier; import org.jumpserver.chen.framework.datasource.sql.SQLQueryParams; import java.sql.Connection; @@ -33,7 +34,8 @@ public List getSchemas() throws SQLException { @Override public void changeSchema(String schema) throws SQLException { - this.execute(SQL.of("use `?`", schema)); + var identifier = SQLIdentifier.quote(this.getDbType(), schema); + this.execute(SQL.of("use " + identifier)); } diff --git a/backend/modules/src/main/java/org.jumpserver.chen.modules/dameng/DMActuator.java b/backend/modules/src/main/java/org.jumpserver.chen.modules/dameng/DMActuator.java index 150da0e..2cfcf28 100644 --- a/backend/modules/src/main/java/org.jumpserver.chen.modules/dameng/DMActuator.java +++ b/backend/modules/src/main/java/org.jumpserver.chen.modules/dameng/DMActuator.java @@ -4,6 +4,7 @@ import org.jumpserver.chen.framework.datasource.base.BaseSQLActuator; import org.jumpserver.chen.framework.datasource.sql.SQL; import org.jumpserver.chen.framework.datasource.sql.SQLExecutePlan; +import org.jumpserver.chen.framework.datasource.sql.SQLIdentifier; import org.jumpserver.chen.framework.datasource.sql.SQLQueryParams; import java.sql.Connection; @@ -33,7 +34,8 @@ public List getSchemas() throws SQLException { @Override public void changeSchema(String schema) throws SQLException { - this.execute(SQL.of("set schema ?", schema)); + var identifier = SQLIdentifier.quote(this.getDbType(), schema); + this.execute(SQL.of("set schema " + identifier)); } diff --git a/backend/modules/src/main/java/org.jumpserver.chen.modules/db2/DB2Actuator.java b/backend/modules/src/main/java/org.jumpserver.chen.modules/db2/DB2Actuator.java index 8224473..e99e4fa 100644 --- a/backend/modules/src/main/java/org.jumpserver.chen.modules/db2/DB2Actuator.java +++ b/backend/modules/src/main/java/org.jumpserver.chen.modules/db2/DB2Actuator.java @@ -4,6 +4,7 @@ import org.jumpserver.chen.framework.datasource.base.BaseSQLActuator; import org.jumpserver.chen.framework.datasource.sql.SQL; import org.jumpserver.chen.framework.datasource.sql.SQLExecutePlan; +import org.jumpserver.chen.framework.datasource.sql.SQLIdentifier; import org.jumpserver.chen.framework.datasource.sql.SQLQueryParams; import java.sql.Connection; @@ -33,7 +34,8 @@ public List getSchemas() throws SQLException { @Override public void changeSchema(String schema) throws SQLException { - this.execute(SQL.of("set current schema ?", schema)); + var identifier = SQLIdentifier.quote(this.getDbType(), schema); + this.execute(SQL.of("set current schema " + identifier)); } diff --git a/backend/modules/src/main/java/org.jumpserver.chen.modules/mysql/MysqlActuator.java b/backend/modules/src/main/java/org.jumpserver.chen.modules/mysql/MysqlActuator.java index 8c3e6d0..ef3440c 100644 --- a/backend/modules/src/main/java/org.jumpserver.chen.modules/mysql/MysqlActuator.java +++ b/backend/modules/src/main/java/org.jumpserver.chen.modules/mysql/MysqlActuator.java @@ -31,7 +31,8 @@ public List getSchemas() throws SQLException { @Override public void changeSchema(String schema) throws SQLException { - this.execute(SQL.of("use `?`", schema)); + var identifier = SQLIdentifier.quote(this.getDbType(), schema); + this.execute(SQL.of("use " + identifier)); } diff --git a/backend/modules/src/main/java/org.jumpserver.chen.modules/oracle/OracleActuator.java b/backend/modules/src/main/java/org.jumpserver.chen.modules/oracle/OracleActuator.java index 42680e3..7912445 100644 --- a/backend/modules/src/main/java/org.jumpserver.chen.modules/oracle/OracleActuator.java +++ b/backend/modules/src/main/java/org.jumpserver.chen.modules/oracle/OracleActuator.java @@ -6,6 +6,7 @@ import org.jumpserver.chen.framework.datasource.base.BaseSQLActuator; import org.jumpserver.chen.framework.datasource.sql.SQL; import org.jumpserver.chen.framework.datasource.sql.SQLExecutePlan; +import org.jumpserver.chen.framework.datasource.sql.SQLIdentifier; import org.jumpserver.chen.framework.datasource.sql.SQLQueryParams; import java.sql.Connection; @@ -35,7 +36,8 @@ public List getSchemas() throws SQLException { @Override public void changeSchema(String schema) throws SQLException { - this.execute(SQL.of("ALTER SESSION SET CURRENT_SCHEMA = ?", schema)); + var identifier = SQLIdentifier.quote(this.getDbType(), schema); + this.execute(SQL.of("ALTER SESSION SET CURRENT_SCHEMA = " + identifier)); } @Override diff --git a/backend/modules/src/main/java/org.jumpserver.chen.modules/postgresql/PostgresqlActuator.java b/backend/modules/src/main/java/org.jumpserver.chen.modules/postgresql/PostgresqlActuator.java index e54a541..2300483 100644 --- a/backend/modules/src/main/java/org.jumpserver.chen.modules/postgresql/PostgresqlActuator.java +++ b/backend/modules/src/main/java/org.jumpserver.chen.modules/postgresql/PostgresqlActuator.java @@ -5,6 +5,7 @@ import org.jumpserver.chen.framework.datasource.base.BaseSQLActuator; import org.jumpserver.chen.framework.datasource.sql.SQL; import org.jumpserver.chen.framework.datasource.sql.SQLExecutePlan; +import org.jumpserver.chen.framework.datasource.sql.SQLIdentifier; import org.jumpserver.chen.framework.datasource.sql.SQLQueryParams; import java.sql.Connection; @@ -38,7 +39,8 @@ public List getSchemas() throws SQLException { public void changeSchema(String schema) throws SQLException { var ss = schema.split("\\."); var schemaName = ss.length > 1 ? ss[1] : ss[0]; - this.execute(SQL.of("SET SEARCH_PATH TO '?';", schemaName)); + var identifier = SQLIdentifier.quote(this.getDbType(), schemaName); + this.execute(SQL.of("SET SEARCH_PATH TO " + identifier)); } @Override diff --git a/backend/modules/src/main/java/org.jumpserver.chen.modules/sqlserver/SQLServerActuator.java b/backend/modules/src/main/java/org.jumpserver.chen.modules/sqlserver/SQLServerActuator.java index 6eb1d2a..65c58af 100644 --- a/backend/modules/src/main/java/org.jumpserver.chen.modules/sqlserver/SQLServerActuator.java +++ b/backend/modules/src/main/java/org.jumpserver.chen.modules/sqlserver/SQLServerActuator.java @@ -4,6 +4,7 @@ import org.jumpserver.chen.framework.datasource.base.BaseSQLActuator; import org.jumpserver.chen.framework.datasource.sql.SQL; import org.jumpserver.chen.framework.datasource.sql.SQLExecutePlan; +import org.jumpserver.chen.framework.datasource.sql.SQLIdentifier; import org.jumpserver.chen.framework.datasource.sql.SQLQueryParams; import java.sql.Connection; @@ -33,7 +34,8 @@ public List getSchemas() throws SQLException { @Override public void changeSchema(String schema) throws SQLException { - this.execute(SQL.of("USE ?;", schema)); + var identifier = SQLIdentifier.quote(this.getDbType(), schema); + this.execute(SQL.of("USE " + identifier)); } @Override From 386ef732f2c15c5a641fc8915580ad6de062b5ee Mon Sep 17 00:00:00 2001 From: "Crane.z" <1481445951@qq.com> Date: Thu, 16 Jul 2026 15:25:30 +0800 Subject: [PATCH 03/44] fix: validate DataView change_limit values --- .../framework/console/dataview/DataView.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/DataView.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/DataView.java index 5e24175..b9f4af3 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/DataView.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/DataView.java @@ -65,7 +65,7 @@ public void doAction(DataViewAction action) throws SQLException { this.getStateManager().getState().setPinned(!this.getStateManager().getState().isPinned()); } case DataViewAction.ACTION_CHANGE_LIMIT -> { - this.changeLimit((int) action.getData()); + this.changeLimit(this.parseLimit(action.getData())); } case DataViewAction.ACTION_EXPORT -> { var data = (Map) action.getData(); @@ -76,6 +76,20 @@ public void doAction(DataViewAction action) throws SQLException { } } + private int parseLimit(Object data) throws SQLException { + if (!(data instanceof Number number)) { + throw new SQLException("Invalid data view limit: " + data); + } + + double value = number.doubleValue(); + int limit = number.intValue(); + if (!Double.isFinite(value) || value != Math.rint(value) || value != limit || + (limit != 50 && limit != 100 && limit != 200 && limit != 500)) { + throw new SQLException("Invalid data view limit: " + data); + } + return limit; + } + public void loadData() throws SQLException { SQLQueryParams queryParams = new SQLQueryParams(); queryParams.setLimit(this.state.getLimit()); From b7450fdfe5774805da9fa5276b8cc6ae2eca15f2 Mon Sep 17 00:00:00 2001 From: "Crane.z" <1481445951@qq.com> Date: Mon, 27 Jul 2026 10:23:37 +0800 Subject: [PATCH 04/44] =?UTF-8?q?feat=EF=BC=9Asupport=20dataview=20edit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../framework/console/AbstractConsole.java | 11 +- .../chen/framework/console/Console.java | 2 + .../framework/console/DataViewConsole.java | 63 +- .../chen/framework/console/QueryConsole.java | 185 ++++-- .../console/action/DataViewAction.java | 2 + .../console/context/ConsoleContext.java | 10 + .../ConsoleContextResolutionException.java | 7 + .../context/ConsoleContextResolver.java | 45 ++ .../framework/console/dataview/DataView.java | 16 +- .../console/dataview/DataViewData.java | 3 + .../QueryDataViewTableEditContextFactory.java | 148 +++++ .../entity/request/SaveChangesRequest.java | 45 ++ .../response/SaveChangesPreviewResult.java | 35 ++ .../entity/response/SaveChangesResult.java | 35 ++ .../datasource/ConnectionManager.java | 4 + .../datasource/DatasourceFactory.java | 33 +- .../framework/datasource/ResourceBrowser.java | 2 + .../base/BaseConnectionManager.java | 6 + .../datasource/base/BaseResourceBrowser.java | 70 ++- .../datasource/base/BaseSQLActuator.java | 166 +++++- .../datasource/edit/TableChangesPlan.java | 21 + .../edit/TableChangesPlanBuildResult.java | 35 ++ .../edit/TableChangesPlanBuilder.java | 521 ++++++++++++++++ .../edit/TableChangesPreviewService.java | 110 ++++ .../edit/TableChangesSaveService.java | 425 +++++++++++++ .../datasource/edit/TableEditContext.java | 37 ++ .../datasource/edit/TableInsertability.java | 31 + .../edit/analyzer/EditabilityReason.java | 33 ++ .../QueryResultEditabilityAnalyzer.java | 507 ++++++++++++++++ .../edit/bind/PreparedStatementBinder.java | 68 +++ .../edit/bind/TableEditTypeCodecs.java | 523 ++++++++++++++++ .../edit/bind/TableEditValueConverter.java | 19 + .../command/PreparedTableChangeCommand.java | 60 ++ .../dialect/AbstractTableEditDialect.java | 149 +++++ .../edit/dialect/DamengTableEditDialect.java | 24 + .../edit/dialect/Db2TableEditDialect.java | 24 + .../edit/dialect/MysqlTableEditDialect.java | 19 + .../edit/dialect/OracleTableEditDialect.java | 24 + .../dialect/PostgresqlTableEditDialect.java | 19 + .../dialect/SqlServerTableEditDialect.java | 24 + .../edit/dialect/TableEditDialect.java | 53 ++ .../edit/dialect/TableEditDialects.java | 39 ++ .../edit/exception/CommitFailedException.java | 11 + .../OptimisticLockConflictException.java | 16 + .../RowNotFoundOrNotUniqueException.java | 18 + .../edit/exception/TableEditException.java | 13 + .../UnexpectedAffectedRowsException.java | 22 + .../edit/pk/JdbcPrimaryKeyResolver.java | 252 ++++++++ .../edit/pk/PrimaryKeyResolution.java | 24 + .../edit/pk/PrimaryKeyResolver.java | 8 + .../datasource/entity/resource/Field.java | 13 + .../entity/resource/ResourceNodeSnapshot.java | 11 + .../chen/framework/utils/PageUtils.java | 7 +- .../framework/ws/ConsoleWebSocketHandler.java | 76 +-- .../mariadb/MariaDBConnectionManager.java | 12 +- .../mysql/MysqlConnectionManager.java | 12 +- .../Main/Explore/DataView/DataView.vue | 561 +++++++++++++++++- .../Main/Explore/DataView/ResultGrid.vue | 25 + .../Main/Explore/DataView/index.vue | 89 ++- .../Main/Explore/QueryConsole/ResultBar.vue | 82 ++- .../Main/Explore/QueryConsole/index.vue | 13 + .../src/components/Main/Explore/index.vue | 23 +- 62 files changed, 4796 insertions(+), 145 deletions(-) create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/console/context/ConsoleContext.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/console/context/ConsoleContextResolutionException.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/console/context/ConsoleContextResolver.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/QueryDataViewTableEditContextFactory.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/console/entity/request/SaveChangesRequest.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/console/entity/response/SaveChangesPreviewResult.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/console/entity/response/SaveChangesResult.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesPlan.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesPlanBuildResult.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesPlanBuilder.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesPreviewService.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesSaveService.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableEditContext.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableInsertability.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/analyzer/EditabilityReason.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/analyzer/QueryResultEditabilityAnalyzer.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/bind/PreparedStatementBinder.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/bind/TableEditTypeCodecs.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/bind/TableEditValueConverter.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/command/PreparedTableChangeCommand.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/AbstractTableEditDialect.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/DamengTableEditDialect.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/Db2TableEditDialect.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/MysqlTableEditDialect.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/OracleTableEditDialect.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/PostgresqlTableEditDialect.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/SqlServerTableEditDialect.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/TableEditDialect.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/TableEditDialects.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/CommitFailedException.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/OptimisticLockConflictException.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/RowNotFoundOrNotUniqueException.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/TableEditException.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/UnexpectedAffectedRowsException.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/pk/JdbcPrimaryKeyResolver.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/pk/PrimaryKeyResolution.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/pk/PrimaryKeyResolver.java create mode 100644 backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/entity/resource/ResourceNodeSnapshot.java diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/AbstractConsole.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/AbstractConsole.java index 1791a86..5a5e576 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/AbstractConsole.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/AbstractConsole.java @@ -5,6 +5,7 @@ import org.jumpserver.chen.framework.datasource.Datasource; import org.jumpserver.chen.framework.console.component.Logger; import org.jumpserver.chen.framework.console.component.Messager; +import org.jumpserver.chen.framework.console.context.ConsoleContext; import org.jumpserver.chen.framework.ws.io.PacketIO; import org.jumpserver.chen.framework.console.entity.request.Connect; import org.springframework.web.socket.WebSocketSession; @@ -20,7 +21,7 @@ public abstract class AbstractConsole implements Console { private final PacketIO packetIO; private final Messager messager; private String title; - private String nodeKey; + private final ConsoleContext context; public String getTitle() { return this.title; @@ -30,8 +31,12 @@ public void onInit(Connect connect) { this.packetIO.sendPacket("init", Map.of("title", this.title)); } - protected AbstractConsole(Datasource datasource, WebSocketSession ws, String nodeKey) { - this.nodeKey = nodeKey; + public String getNodeKey() { + return this.context.nodeKey(); + } + + protected AbstractConsole(Datasource datasource, WebSocketSession ws, ConsoleContext context) { + this.context = context; this.datasource = datasource; this.packetIO = new PacketIO(ws); this.consoleLogger = new Logger(this.packetIO); diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/Console.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/Console.java index 9ea44e6..c7e6a19 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/Console.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/Console.java @@ -3,10 +3,12 @@ import org.jumpserver.chen.framework.ws.io.Packet; import org.jumpserver.chen.framework.console.entity.request.Connect; +import org.jumpserver.chen.framework.console.context.ConsoleContext; public interface Console { String getTitle(); String getNodeKey(); + ConsoleContext getContext(); void onInit(Connect connect); void handle(Packet packet); diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/DataViewConsole.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/DataViewConsole.java index e808d64..0840714 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/DataViewConsole.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/DataViewConsole.java @@ -1,21 +1,25 @@ package org.jumpserver.chen.framework.console; import com.google.gson.Gson; -import org.apache.commons.lang3.StringUtils; +import lombok.Getter; import org.jumpserver.chen.framework.console.action.DataViewAction; +import org.jumpserver.chen.framework.console.context.ConsoleContext; import org.jumpserver.chen.framework.console.dataview.DataView; import org.jumpserver.chen.framework.console.dataview.UpdateDataView; import org.jumpserver.chen.framework.console.entity.request.Connect; +import org.jumpserver.chen.framework.console.entity.request.SaveChangesRequest; import org.jumpserver.chen.framework.console.entity.response.Message; import org.jumpserver.chen.framework.console.state.State; import org.jumpserver.chen.framework.console.state.StateManager; import org.jumpserver.chen.framework.datasource.Datasource; +import org.jumpserver.chen.framework.datasource.edit.TableChangesPreviewService; +import org.jumpserver.chen.framework.datasource.edit.TableChangesSaveService; +import org.jumpserver.chen.framework.datasource.edit.TableEditContext; import org.jumpserver.chen.framework.i18n.MessageUtils; import org.jumpserver.chen.framework.jms.entity.CommandRecord; import org.jumpserver.chen.framework.session.SessionManager; import org.jumpserver.chen.framework.session.controller.dialog.Button; import org.jumpserver.chen.framework.session.controller.dialog.Dialog; -import org.jumpserver.chen.framework.utils.TreeUtils; import org.jumpserver.chen.framework.ws.io.Packet; import org.jumpserver.wisp.Common; import org.springframework.web.socket.WebSocketSession; @@ -26,26 +30,30 @@ import java.util.concurrent.atomic.AtomicBoolean; public class DataViewConsole extends AbstractConsole { + private static final String PACKET_SAVE_CHANGES_PREVIEW_RESULT = "save_changes_preview_result"; + private static final String PACKET_SAVE_CHANGES_RESULT = "save_changes_result"; + private final TableChangesPreviewService tableChangesPreviewService = new TableChangesPreviewService(); + private final TableChangesSaveService tableChangesSaveService = new TableChangesSaveService(); private DataView tableDataView; private StateManager stateManager; - private String schema; - private String table; + @Getter + private final String schema; + @Getter + private final String table; private static final Gson GSON = new Gson(); - public DataViewConsole(Datasource datasource, WebSocketSession ws, String nodeKey) { - super(datasource, ws, nodeKey); + public DataViewConsole(Datasource datasource, WebSocketSession ws, ConsoleContext context) { + super(datasource, ws, context); + this.schema = context.schema(); + this.table = context.table(); } @Override public void onInit(Connect connect) { - this.schema = TreeUtils.getValue(connect.getNodeKey(), "schema"); - this.table = StringUtils.isEmpty(TreeUtils.getValue(connect.getNodeKey(), "table")) ? - TreeUtils.getValue(connect.getNodeKey(), "view") : TreeUtils.getValue(connect.getNodeKey(), "table"); - var title = ""; try { title = this.generateConsoleName(); @@ -181,6 +189,41 @@ public void createDataView(String schemaName, String tableName) { public void onDataViewAction(DataViewAction action) { + if (DataViewAction.ACTION_SAVE_CHANGES_PREVIEW.equals(action.getAction())) { + var request = GSON.fromJson(GSON.toJson(action.getData()), SaveChangesRequest.class); + var context = new TableEditContext( + this.tableDataView.getTitle(), + this.schema, + this.table, + this.tableDataView.getData().getFields(), + this.getDatasource().getDruidDbType(), + true + ); + var result = this.tableChangesPreviewService.preview(context, action.getDataView(), request); + this.getPacketIO().sendPacket(PACKET_SAVE_CHANGES_PREVIEW_RESULT, result); + return; + } + if (DataViewAction.ACTION_SAVE_CHANGES.equals(action.getAction())) { + var request = GSON.fromJson(GSON.toJson(action.getData()), SaveChangesRequest.class); + var context = new TableEditContext( + this.tableDataView.getTitle(), + this.schema, + this.table, + this.tableDataView.getData().getFields(), + this.getDatasource().getDruidDbType(), + true + ); + var result = this.tableChangesSaveService.save( + context, + action.getDataView(), + request, + this.getDatasource().getConnectionManager(), + SessionManager.getCurrentSession() + ); + this.getPacketIO().sendPacket(PACKET_SAVE_CHANGES_RESULT, result); + return; + } + try { this.tableDataView.getStateManager().getState().setLoading(true); this.tableDataView.getStateManager().commit(); diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/QueryConsole.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/QueryConsole.java index a3fab59..182fd3d 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/QueryConsole.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/QueryConsole.java @@ -7,12 +7,19 @@ import org.jumpserver.chen.framework.console.action.DataViewAction; import org.jumpserver.chen.framework.console.action.QueryConsoleAction; import org.jumpserver.chen.framework.console.dataview.DataView; +import org.jumpserver.chen.framework.console.dataview.QueryDataViewTableEditContextFactory; import org.jumpserver.chen.framework.console.dataview.UpdateDataView; +import org.jumpserver.chen.framework.console.context.ConsoleContext; import org.jumpserver.chen.framework.console.entity.request.Connect; +import org.jumpserver.chen.framework.console.entity.request.SaveChangesRequest; import org.jumpserver.chen.framework.console.entity.response.Message; +import org.jumpserver.chen.framework.console.entity.response.SaveChangesPreviewResult; +import org.jumpserver.chen.framework.console.entity.response.SaveChangesResult; import org.jumpserver.chen.framework.console.state.QueryConsoleState; import org.jumpserver.chen.framework.console.state.StateManager; import org.jumpserver.chen.framework.datasource.Datasource; +import org.jumpserver.chen.framework.datasource.edit.TableChangesPreviewService; +import org.jumpserver.chen.framework.datasource.edit.TableChangesSaveService; import org.jumpserver.chen.framework.datasource.sql.SQL; import org.jumpserver.chen.framework.datasource.sql.SQLActuator; import org.jumpserver.chen.framework.datasource.sql.SQLExecutePlan; @@ -22,13 +29,17 @@ import org.jumpserver.chen.framework.session.SessionManager; import org.jumpserver.chen.framework.session.controller.dialog.Button; import org.jumpserver.chen.framework.session.controller.dialog.Dialog; -import org.jumpserver.chen.framework.utils.TreeUtils; import org.jumpserver.chen.framework.ws.io.Packet; import org.jumpserver.wisp.Common; import org.springframework.web.socket.WebSocketSession; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.sql.Connection; import java.sql.SQLException; import java.util.ArrayList; @@ -44,18 +55,24 @@ @Slf4j public class QueryConsole extends AbstractConsole { + private static final String PACKET_SAVE_CHANGES_PREVIEW_RESULT = "save_changes_preview_result"; + private static final String PACKET_SAVE_CHANGES_RESULT = "save_changes_result"; private final Datasource datasource; + private final TableChangesPreviewService tableChangesPreviewService = new TableChangesPreviewService(); + private final TableChangesSaveService tableChangesSaveService = new TableChangesSaveService(); + private final QueryDataViewTableEditContextFactory tableEditContextFactory = new QueryDataViewTableEditContextFactory(); private Connection conn; private volatile SQLExecutePlan currentPlan; private StateManager stateManager; private final Map dataViews = new HashMap<>(); + // Manual context changes remain restricted to values returned by the current server-side actuator. private volatile Map allowedContexts = Map.of(); private static final Gson GSON = new Gson(); - public QueryConsole(Datasource datasource, WebSocketSession ws, String nodeKey) { - super(datasource, ws, nodeKey); + public QueryConsole(Datasource datasource, WebSocketSession ws, ConsoleContext context) { + super(datasource, ws, context); this.setTitle(String.format(MessageUtils.get("Query") + "-%d", generateConsoleName())); this.datasource = datasource; } @@ -88,7 +105,7 @@ public void onConnect(Connect connect) { this.getState().setLoading(true); this.stateManager.commit(); - var context = TreeUtils.getValue(connect.getNodeKey(), this.getDatasource().getConnectionManager().getContextKey()); + var context = this.getInitialContext(); try { var currentContext = this.getSqlActuator().getCurrentSchema(); @@ -115,6 +132,13 @@ public void onConnect(Connect connect) { } + String getInitialContext() { + var contextKey = this.getDatasource().getConnectionManager().getContextKey(); + return StringUtils.equals(contextKey, "database") + ? this.getContext().database() + : this.getContext().schema(); + } + private Connection getConnection() { if (this.conn == null) { try { @@ -262,6 +286,35 @@ private void onDataViewAction(DataViewAction action) { log.error("data view {} not found", action.getDataView()); return; } + if (DataViewAction.ACTION_SAVE_CHANGES_PREVIEW.equals(action.getAction())) { + var request = GSON.fromJson(GSON.toJson(action.getData()), SaveChangesRequest.class); + try { + var context = this.tableEditContextFactory.create(dataView, this.getDatasource().getDruidDbType()); + var result = this.tableChangesPreviewService.preview(context, action.getDataView(), request); + this.getPacketIO().sendPacket(PACKET_SAVE_CHANGES_PREVIEW_RESULT, result); + } catch (IllegalArgumentException e) { + this.getPacketIO().sendPacket(PACKET_SAVE_CHANGES_PREVIEW_RESULT, this.rejectedPreview(dataView, e.getMessage())); + } + return; + } + if (DataViewAction.ACTION_SAVE_CHANGES.equals(action.getAction())) { + var request = GSON.fromJson(GSON.toJson(action.getData()), SaveChangesRequest.class); + SaveChangesResult result; + try { + var context = this.tableEditContextFactory.create(dataView, this.getDatasource().getDruidDbType()); + result = this.tableChangesSaveService.save( + context, + action.getDataView(), + request, + this.getDatasource().getConnectionManager(), + SessionManager.getCurrentSession() + ); + } catch (IllegalArgumentException e) { + result = this.rejectedSave(dataView, e.getMessage()); + } + this.getPacketIO().sendPacket(PACKET_SAVE_CHANGES_RESULT, result); + return; + } try { dataView.getStateManager().getState().setLoading(true); dataView.getStateManager().commit(); @@ -278,6 +331,24 @@ private void onDataViewAction(DataViewAction action) { } } + private SaveChangesPreviewResult rejectedPreview(DataView dataView, String reason) { + SaveChangesPreviewResult result = new SaveChangesPreviewResult(); + result.setSuccess(false); + result.setAllowed(false); + result.setReason(reason); + result.setDataView(dataView.getTitle()); + return result; + } + + private SaveChangesResult rejectedSave(DataView dataView, String reason) { + SaveChangesResult result = new SaveChangesResult(); + result.setSuccess(false); + result.setAllowed(false); + result.setReason(reason); + result.setDataView(dataView.getTitle()); + return result; + } + public void onCancel() { try { if (this.currentPlan != null) { @@ -302,6 +373,12 @@ public void onManualChangeContext(String context) { this.stateManager.commit(); this.getSqlActuator().changeSchema(allowedContext); + var connectionManager = this.getDatasource().getConnectionManager(); + // 只有当前 UI 上下文本身就是 JDBC database 时,才同步连接池上下文,避免 PostgreSQL schema 被当 database。 + if (StringUtils.isNotBlank(allowedContext) && + StringUtils.equals(connectionManager.getContextKey(), connectionManager.getDatabaseContextKey())) { + connectionManager.setDatabaseContext(allowedContext); + } this.getState().setCurrentContext(allowedContext); } catch (SQLException e) { @@ -330,36 +407,73 @@ boolean isAllowedContext(String context) { } public void onSQLFile(String filename) { - if (StringUtils.isBlank(filename) - || filename.contains("/") - || filename.contains("\\") - || filename.contains("..")) { + var filePath = this.resolveSQLFileInSessionTemp(filename); + if (filePath == null) { log.warn("Rejected invalid SQL file name"); return; } - var filePath = SessionManager.getCurrentSession().getTempPath().resolve(filename); - var file = filePath.toFile(); - - if (!file.exists()) { + if (!Files.exists(filePath, LinkOption.NOFOLLOW_LINKS)) { this.getConsoleLogger().error("%s: %s", MessageUtils.get("msg.error.file_not_found"), filename); return; } - if (!file.isFile()) { + if (!Files.isRegularFile(filePath, LinkOption.NOFOLLOW_LINKS)) { this.getConsoleLogger().error("%s: %s", MessageUtils.get("msg.error.file_not_file"), filename); return; } - if (!file.canRead()) { + if (!Files.isReadable(filePath)) { this.getConsoleLogger().error("%s: %s", MessageUtils.get("msg.error.file_not_readable"), filename); return; } + var shouldDelete = false; try { - var sql = Files.readString(file.toPath()); + shouldDelete = true; + String sql; + try { + try (var inputStream = Files.newInputStream( + filePath, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { + sql = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + } catch (IOException | SecurityException e) { + this.getConsoleLogger().error("%s: %s", MessageUtils.get("msg.error.file_read_error"), e.getMessage()); + return; + } this.onSQL(sql); - } catch (IOException e) { - this.getConsoleLogger().error("%s: %s", MessageUtils.get("msg.error.file_read_error"), e.getMessage()); } finally { - file.delete(); + if (shouldDelete) { + try { + Files.deleteIfExists(filePath); + } catch (IOException | SecurityException e) { + log.warn("Failed to delete session SQL file", e); + } + } + } + } + + private Path resolveSQLFileInSessionTemp(String filename) { + if (StringUtils.isBlank(filename) + || StringUtils.equalsAny(filename, ".", "..") + || filename.contains("..") + || filename.contains("/") + || filename.contains("\\")) { + return null; + } + + try { + var requested = Path.of(filename); + if (requested.isAbsolute() + || requested.getNameCount() != 1 + || !requested.equals(requested.getFileName())) { + return null; + } + + var basePath = SessionManager.getCurrentSession().getTempPath() + .toAbsolutePath() + .normalize(); + var resolvedPath = basePath.resolve(requested.getFileName()).normalize(); + return resolvedPath.startsWith(basePath) ? resolvedPath : null; + } catch (InvalidPathException | SecurityException e) { + return null; } } @@ -474,19 +588,19 @@ private void ensureCurrentSchema() { private DataView runSingleSQL(String sql, ACLResult aclResult) throws SQLException { - SQLExecutePlan plan = this.datasource - .getConnectionManager() - .getSqlActuator() - .withConnection(this.getConnection()) - .createPlan(SQL.of(sql)); - - plan.setAclResult(aclResult); - DataView dataView = new DataView(plan.getSourceSQL(), this.getPacketIO(), this.getConsoleLogger()); - dataView.setSql(plan.getSourceSQL()); + String sourceSQL = sql; + DataView dataView = new DataView(sourceSQL, this.getPacketIO(), this.getConsoleLogger()); + dataView.setSql(sourceSQL); dataView.setLoadDataInterface((sqlQueryParams) -> { sqlQueryParams.setTimeout(this.getState().getTimeout()); + SQLExecutePlan plan = this.datasource + .getConnectionManager() + .getSqlActuator() + .withConnection(this.getConnection()) + .createPlan(SQL.of(sourceSQL)); + plan.setAclResult(aclResult); plan.setSqlQueryParams(sqlQueryParams); plan.generateTargetSQL(); @@ -497,19 +611,20 @@ private DataView runSingleSQL(String sql, ACLResult aclResult) throws SQLExcepti this.getState().setCanCancel(true); this.stateManager.commit(); - var result = plan.executeWithAudit(); - this.currentPlan = null; - - this.getConsoleLogger().success(result); - return result; + try { + var result = plan.executeWithAudit(); + this.getConsoleLogger().success(result); + return result; + } finally { + this.currentPlan = null; + this.getState().setCanCancel(false); + this.stateManager.commit(); + } }); dataView.loadData(); - this.getState().setCanCancel(false); - this.stateManager.commit(); - return dataView; } diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/action/DataViewAction.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/action/DataViewAction.java index 06d82e2..8ddf58c 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/action/DataViewAction.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/action/DataViewAction.java @@ -15,5 +15,7 @@ public class DataViewAction extends Action { public static final String ACTION_TOGGLE_PINNED = "toggle_pinned"; public static final String ACTION_CHANGE_LIMIT = "change_limit"; public static final String ACTION_EXPORT = "export"; + public static final String ACTION_SAVE_CHANGES_PREVIEW = "save_changes_preview"; + public static final String ACTION_SAVE_CHANGES = "save_changes"; } diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/context/ConsoleContext.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/context/ConsoleContext.java new file mode 100644 index 0000000..18c20a1 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/context/ConsoleContext.java @@ -0,0 +1,10 @@ +package org.jumpserver.chen.framework.console.context; + +public record ConsoleContext( + String nodeKey, + String nodeType, + String database, + String schema, + String table +) { +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/context/ConsoleContextResolutionException.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/context/ConsoleContextResolutionException.java new file mode 100644 index 0000000..187936c --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/context/ConsoleContextResolutionException.java @@ -0,0 +1,7 @@ +package org.jumpserver.chen.framework.console.context; + +public class ConsoleContextResolutionException extends IllegalArgumentException { + public ConsoleContextResolutionException(String message) { + super(message); + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/context/ConsoleContextResolver.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/context/ConsoleContextResolver.java new file mode 100644 index 0000000..acdb09a --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/context/ConsoleContextResolver.java @@ -0,0 +1,45 @@ +package org.jumpserver.chen.framework.console.context; + +import org.apache.commons.lang3.StringUtils; +import org.jumpserver.chen.framework.console.entity.request.Connect; +import org.jumpserver.chen.framework.datasource.ResourceBrowser; + +import java.util.Set; + +public class ConsoleContextResolver { + private static final Set QUERY_NODE_TYPES = Set.of("datasource", "database", "schema", "table"); + private static final Set DATA_VIEW_NODE_TYPES = Set.of("table", "view"); + + private final ResourceBrowser resourceBrowser; + + public ConsoleContextResolver(ResourceBrowser resourceBrowser) { + this.resourceBrowser = resourceBrowser; + } + + public ConsoleContext resolve(String submittedNodeKey, String consoleType) { + if (StringUtils.isBlank(submittedNodeKey) || StringUtils.isBlank(consoleType)) { + throw new ConsoleContextResolutionException("Invalid console context"); + } + + var node = this.resourceBrowser.getIndexedNode(submittedNodeKey); + if (node == null || !isAllowedNodeType(consoleType, node.type())) { + throw new ConsoleContextResolutionException("Invalid console context"); + } + + return new ConsoleContext( + node.key(), + node.type(), + node.database(), + node.schema(), + node.table() + ); + } + + private static boolean isAllowedNodeType(String consoleType, String nodeType) { + return switch (consoleType) { + case Connect.CONSOLE_TYPE_QUERY -> QUERY_NODE_TYPES.contains(nodeType); + case Connect.CONSOLE_TYPE_DATA_VIEW -> DATA_VIEW_NODE_TYPES.contains(nodeType); + default -> false; + }; + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/DataView.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/DataView.java index b9f4af3..ae18750 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/DataView.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/DataView.java @@ -8,6 +8,8 @@ import org.jumpserver.chen.framework.console.entity.response.SQLResult; import org.jumpserver.chen.framework.console.state.DataViewState; import org.jumpserver.chen.framework.console.state.StateManager; +import org.jumpserver.chen.framework.datasource.edit.TableInsertability; +import org.jumpserver.chen.framework.datasource.entity.resource.Field; import org.jumpserver.chen.framework.datasource.sql.SQLQueryParams; import org.jumpserver.chen.framework.datasource.sql.SQLQueryResult; import org.jumpserver.chen.framework.i18n.MessageUtils; @@ -120,7 +122,19 @@ private void fullData(SQLQueryResult result) { private void fullDataViewData(DataViewData viewData, SQLQueryResult result) { - viewData.setFields(result.getFields()); + List fields = result.getFields(); + boolean editable = fields.stream() + .anyMatch(Field::isEditable); + viewData.setEditable(editable); + viewData.setInsertable(TableInsertability.isInsertable(fields)); + viewData.setEditReason(editable + ? null + : fields.stream() + .map(Field::getEditReason) + .filter(reason -> reason != null) + .findFirst() + .orElse(null)); + viewData.setFields(fields); Map fieldNumMap = new HashMap<>(); diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/DataViewData.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/DataViewData.java index 36f69ac..4d98e4c 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/DataViewData.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/DataViewData.java @@ -10,6 +10,9 @@ @Data public class DataViewData { + private boolean editable; + private boolean insertable; + private String editReason; private List> data = new ArrayList<>(); private List fields = new ArrayList<>(); } diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/QueryDataViewTableEditContextFactory.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/QueryDataViewTableEditContextFactory.java new file mode 100644 index 0000000..c874f05 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/QueryDataViewTableEditContextFactory.java @@ -0,0 +1,148 @@ +package org.jumpserver.chen.framework.console.dataview; + +import com.alibaba.druid.DbType; +import org.apache.commons.lang3.StringUtils; +import org.jumpserver.chen.framework.datasource.edit.TableEditContext; +import org.jumpserver.chen.framework.datasource.entity.resource.Field; + +import java.util.List; +import java.util.Objects; + +public class QueryDataViewTableEditContextFactory { + public static final String FIELDS_REQUIRED = "FIELDS_REQUIRED"; + public static final String PRIMARY_KEY_REQUIRED = "PRIMARY_KEY_REQUIRED"; + public static final String PRIMARY_KEY_SOURCE_COLUMN_REQUIRED = "PRIMARY_KEY_SOURCE_COLUMN_REQUIRED"; + public static final String EDITABLE_FIELD_REQUIRED = "EDITABLE_FIELD_REQUIRED"; + public static final String EDITABLE_FIELD_SOURCE_REQUIRED = "EDITABLE_FIELD_SOURCE_REQUIRED"; + public static final String MIXED_SOURCE_TABLE_NOT_SUPPORTED = "MIXED_SOURCE_TABLE_NOT_SUPPORTED"; + public static final String MIXED_SOURCE_SCHEMA_NOT_SUPPORTED = "MIXED_SOURCE_SCHEMA_NOT_SUPPORTED"; + public static final String SOURCE_TABLE_REQUIRED = "SOURCE_TABLE_REQUIRED"; + + public TableEditContext create(DataView dataView, DbType dbType) { + List fields = getRequiredFields(dataView); + + Field primaryKey = getRequiredPrimaryKey(fields); + validatePrimaryKey(primaryKey); + + List editableFields = getRequiredEditableFields(fields); + validateEditableFields(editableFields); + + ensureSingleSource(fields); + + String sourceTable = resolveSourceTable(primaryKey, editableFields); + String sourceSchema = resolveSourceSchema(primaryKey); + + ensureEditableFieldsMatchSource(editableFields, sourceSchema, sourceTable); + + return new TableEditContext(dataView.getTitle(), sourceSchema, sourceTable, fields, dbType); + } + + private List getRequiredFields(DataView dataView) { + List fields = dataView.getData().getFields(); + if (fields == null || fields.isEmpty()) { + throw new IllegalArgumentException(FIELDS_REQUIRED); + } + return fields; + } + + private Field getRequiredPrimaryKey(List fields) { + return fields.stream() + .filter(Objects::nonNull) + .filter(Field::isPrimaryKey) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException(PRIMARY_KEY_REQUIRED)); + } + + private void validatePrimaryKey(Field primaryKey) { + if (StringUtils.isBlank(primaryKey.getSourceColumn())) { + throw new IllegalArgumentException(PRIMARY_KEY_SOURCE_COLUMN_REQUIRED); + } + } + + private List getRequiredEditableFields(List fields) { + List editableFields = fields.stream() + .filter(Objects::nonNull) + .filter(Field::isEditable) + .toList(); + + if (editableFields.isEmpty()) { + throw new IllegalArgumentException(EDITABLE_FIELD_REQUIRED); + } + + return editableFields; + } + + private void validateEditableFields(List editableFields) { + for (Field field : editableFields) { + if (StringUtils.isBlank(field.getSourceTable()) || StringUtils.isBlank(field.getSourceColumn())) { + throw new IllegalArgumentException(EDITABLE_FIELD_SOURCE_REQUIRED); + } + } + } + + private void ensureSingleSource(List fields) { + ensureSingleSourceTable(fields); + ensureSingleSourceSchema(fields); + } + + private void ensureSingleSourceTable(List fields) { + long sourceTableCount = fields.stream() + .filter(Objects::nonNull) + .map(Field::getSourceTable) + .filter(StringUtils::isNotBlank) + .distinct() + .count(); + + if (sourceTableCount > 1) { + throw new IllegalArgumentException(MIXED_SOURCE_TABLE_NOT_SUPPORTED); + } + } + + private void ensureSingleSourceSchema(List fields) { + long sourceSchemaCount = fields.stream() + .filter(Objects::nonNull) + .map(Field::getSourceSchema) + .filter(StringUtils::isNotBlank) + .distinct() + .count(); + + if (sourceSchemaCount > 1) { + throw new IllegalArgumentException(MIXED_SOURCE_SCHEMA_NOT_SUPPORTED); + } + } + + private String resolveSourceTable(Field primaryKey, List editableFields) { + String sourceTable = primaryKey.getSourceTable(); + + if (StringUtils.isBlank(sourceTable)) { + sourceTable = editableFields.get(0).getSourceTable(); + } + + if (StringUtils.isBlank(sourceTable)) { + throw new IllegalArgumentException(SOURCE_TABLE_REQUIRED); + } + + return sourceTable; + } + + private String resolveSourceSchema(Field primaryKey) { + // 保持原逻辑:sourceSchema 以主键字段为准,不从 editableFields fallback。 + return primaryKey.getSourceSchema(); + } + + private void ensureEditableFieldsMatchSource( + List editableFields, + String sourceSchema, + String sourceTable + ) { + for (Field editableField : editableFields) { + if (!StringUtils.equals(sourceTable, editableField.getSourceTable())) { + throw new IllegalArgumentException(MIXED_SOURCE_TABLE_NOT_SUPPORTED); + } + + if (!StringUtils.equals(sourceSchema, editableField.getSourceSchema())) { + throw new IllegalArgumentException(MIXED_SOURCE_SCHEMA_NOT_SUPPORTED); + } + } + } +} \ No newline at end of file diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/entity/request/SaveChangesRequest.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/entity/request/SaveChangesRequest.java new file mode 100644 index 0000000..5b39fea --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/entity/request/SaveChangesRequest.java @@ -0,0 +1,45 @@ +package org.jumpserver.chen.framework.console.entity.request; + +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class SaveChangesRequest { + private String schema; + private String table; + private List changes; + private List insertRows; + private List deleteRows; + + @Data + public static class ChangeItem { + private String pkColumn; + private Object pkValue; + private boolean pkValueIsNull; + private String sourceColumn; + private Object oldValue; + private boolean oldValueIsNull; + private Object newValue; + private boolean newValueIsNull; + } + + @Data + public static class InsertRow { + private Map values; + } + + @Data + public static class DeleteRow { + private String pkColumn; + private Object pkValue; + private boolean pkValueIsNull; + } + + @Data + public static class CellValue { + private Object value; + private boolean valueIsNull; + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/entity/response/SaveChangesPreviewResult.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/entity/response/SaveChangesPreviewResult.java new file mode 100644 index 0000000..4be0c96 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/entity/response/SaveChangesPreviewResult.java @@ -0,0 +1,35 @@ +package org.jumpserver.chen.framework.console.entity.response; + +import lombok.Data; +import org.jumpserver.chen.framework.console.entity.request.SaveChangesRequest; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class SaveChangesPreviewResult { + private boolean success; + private boolean allowed; + private String reason; + private Integer failedChangeIndex; + private SaveChangesRequest.ChangeItem failedChange; + private Object failedOperation; + private String dataView; + private String schema; + private String table; + private int changeCount; + private int updateCount; + private int insertCount; + private int deleteCount; + private List preparedStatements = new ArrayList<>(); + private String auditSql; + + @Data + public static class PreviewItem { + private String operation; + private String sourceColumn; + private String pkColumn; + private String preparedSql; + private List paramsPreview; + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/entity/response/SaveChangesResult.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/entity/response/SaveChangesResult.java new file mode 100644 index 0000000..a849e87 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/entity/response/SaveChangesResult.java @@ -0,0 +1,35 @@ +package org.jumpserver.chen.framework.console.entity.response; + +import lombok.Data; +import org.jumpserver.chen.framework.console.entity.request.SaveChangesRequest; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class SaveChangesResult { + private boolean success; + private boolean allowed; + private String reason; + private Integer failedChangeIndex; + private SaveChangesRequest.ChangeItem failedChange; + private Object failedOperation; + private String dataView; + private String schema; + private String table; + private int changeCount; + private int updateCount; + private int insertCount; + private int deleteCount; + private List statements = new ArrayList<>(); + private String auditSql; + + @Data + public static class ResultItem { + private String operation; + private String sourceColumn; + private String pkColumn; + private String preparedSql; + private int affectedRows; + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/ConnectionManager.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/ConnectionManager.java index 1978374..6ac3d2f 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/ConnectionManager.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/ConnectionManager.java @@ -30,7 +30,11 @@ public interface ConnectionManager { SQLActuator getSqlActuator(); + // 对象树/编辑器当前上下文使用的 key,例如 schema。 String getContextKey(); + // JDBC URL/连接池默认库使用的 key,不能和对象树上下文混用。 + String getDatabaseContextKey(); + void close(); } diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/DatasourceFactory.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/DatasourceFactory.java index e3e512f..5082cd7 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/DatasourceFactory.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/DatasourceFactory.java @@ -3,6 +3,7 @@ import lombok.extern.slf4j.Slf4j; import org.jumpserver.chen.framework.datasource.entity.DBConnectInfo; +import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -11,10 +12,26 @@ public class DatasourceFactory { private static final Map> DATASOURCE_MAP = new ConcurrentHashMap<>(); + private static final Map DATASOURCE_CLASS_NAMES = new HashMap<>(); + + static { + DATASOURCE_CLASS_NAMES.put("clickhouse", "org.jumpserver.chen.modules.clickhouse.ClickHouseDatasource"); + DATASOURCE_CLASS_NAMES.put("postgresql", "org.jumpserver.chen.modules.postgresql.PostgresqlDatasource"); + DATASOURCE_CLASS_NAMES.put("mariadb", "org.jumpserver.chen.modules.mariadb.MariaDBDatasource"); + DATASOURCE_CLASS_NAMES.put("oracle", "org.jumpserver.chen.modules.oracle.OracleDatasource"); + DATASOURCE_CLASS_NAMES.put("dameng", "org.jumpserver.chen.modules.dameng.DMDatasource"); + DATASOURCE_CLASS_NAMES.put("mysql", "org.jumpserver.chen.modules.mysql.MysqlDatasource"); + DATASOURCE_CLASS_NAMES.put("sqlserver", "org.jumpserver.chen.modules.sqlserver.SQLServerDatasource"); + DATASOURCE_CLASS_NAMES.put("db2", "org.jumpserver.chen.modules.db2.DB2Datasource"); + } public static Datasource fromConnectInfo(DBConnectInfo info) { var dbType = info.getDbType(); - var datasource = DATASOURCE_MAP.get(info.getDbType()); + var datasource = DATASOURCE_MAP.get(dbType); + if (datasource == null) { + lazyRegister(dbType); + datasource = DATASOURCE_MAP.get(dbType); + } if (datasource == null) { throw new RuntimeException("Unsupported dbType: " + dbType); } @@ -31,4 +48,18 @@ public static void Register(Class datasource) { log.info("Register datasource for dbType: {}", datasource.getName()); DATASOURCE_MAP.put(dbType, datasource); } + + @SuppressWarnings("unchecked") + private static void lazyRegister(String dbType) { + var className = DATASOURCE_CLASS_NAMES.get(dbType); + if (className == null) { + return; + } + try { + var datasourceClass = (Class) Class.forName(className); + Register(datasourceClass); + } catch (ClassNotFoundException e) { + log.warn("Datasource class not found for dbType {}: {}", dbType, className); + } + } } diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/ResourceBrowser.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/ResourceBrowser.java index 6e2c05d..de4eff9 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/ResourceBrowser.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/ResourceBrowser.java @@ -25,6 +25,8 @@ public interface ResourceBrowser { List getFields(SQL sql) throws SQLException; + ResourceNodeSnapshot getIndexedNode(String key); + SQLActuator getSQLActuator(); SQLHintsHandler getSQLHintsHandler(); } diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseConnectionManager.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseConnectionManager.java index b3d1dfc..f9c0b22 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseConnectionManager.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseConnectionManager.java @@ -84,6 +84,12 @@ public String getContextKey() { return "schema"; } + @Override + public String getDatabaseContextKey() { + // 大多数数据库的连接上下文是 database;MySQL/MariaDB 会覆盖为 schema。 + return "database"; + } + @Override public DBConnectInfo getConnectInfo() { return this.connectInfo; diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseResourceBrowser.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseResourceBrowser.java index 7bc4832..2f2f0ff 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseResourceBrowser.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseResourceBrowser.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; @Slf4j @@ -27,6 +28,7 @@ public abstract class BaseResourceBrowser implements ResourceBrowser { private final ConnectionManager connectionManager; private final SQLHintsHandler sqlHintsHandler; + private final ConcurrentHashMap nodeIndex = new ConcurrentHashMap<>(); @Override @@ -44,7 +46,7 @@ public void buildTree() throws SQLException { var root = new Root(); root.setName(SessionManager.getCurrentSession().getDatasourceName()); - this.root = root.toResourceNode(null); + this.resetNodeIndex(root.toResourceNode(null)); var parents = List.of(this.root); while (!parents.isEmpty()) { @@ -86,8 +88,9 @@ public List getChildren(TreeNode node, boolean fromCache) throws SQLEx return n.getChildren(); } } + var cachedNode = TreeUtils.getNode(this.root, node.getKey()); var children = this.getChildNodes(node); - if (!children.isEmpty()) { + if (!children.isEmpty() || cachedNode != null && cachedNode.getChildren() != null) { this.saveTreeNode(node, children); } return children; @@ -142,13 +145,74 @@ public List getFolderChildNodes(TreeNode parent) throws SQLException { }; } - public void saveTreeNode(TreeNode node, List children) { + public synchronized void saveTreeNode(TreeNode node, List children) { var n = TreeUtils.getNode(this.root, node.getKey()); if (n != null) { n.setChildren(children); + this.removeIndexedDescendants(n.getKey()); + var parent = this.nodeIndex.get(n.getKey()); + for (var child : children) { + this.registerNode(child, parent); + } } } + protected synchronized void resetNodeIndex(TreeNode root) { + this.root = root; + this.nodeIndex.clear(); + this.registerNode(root, null); + } + + @Override + public synchronized ResourceNodeSnapshot getIndexedNode(String key) { + return key == null ? null : this.nodeIndex.get(key); + } + + private void registerNode(TreeNode node, ResourceNodeSnapshot parent) { + String database = parent == null ? null : parent.database(); + String schema = parent == null ? null : parent.schema(); + String table = parent == null ? null : parent.table(); + + switch (node.getType()) { + case "database" -> { + database = node.getLabel(); + schema = null; + table = null; + } + case "schema" -> { + schema = node.getLabel(); + if (this.connectionManager != null && + Objects.equals(this.connectionManager.getDatabaseContextKey(), "schema")) { + database = node.getLabel(); + } + table = null; + } + case "table", "view" -> table = node.getLabel(); + default -> { + } + } + + this.nodeIndex.put(node.getKey(), new ResourceNodeSnapshot( + node.getKey(), + node.getType(), + database, + schema, + table, + node.getLabel() + )); + if (node.getChildren() != null) { + var snapshot = this.nodeIndex.get(node.getKey()); + for (var child : node.getChildren()) { + this.registerNode(child, snapshot); + } + } + } + + private void removeIndexedDescendants(String parentKey) { + String prefix = parentKey + ","; + this.nodeIndex.keySet().removeIf(key -> key.startsWith(prefix)); + } + public abstract List getSchemas() throws SQLException; @Override diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseSQLActuator.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseSQLActuator.java index 757aa54..dbe23db 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseSQLActuator.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseSQLActuator.java @@ -13,6 +13,9 @@ import org.jumpserver.chen.framework.datasource.ConnectionManager; import org.jumpserver.chen.framework.datasource.entity.resource.Field; import org.jumpserver.chen.framework.datasource.sql.*; +import org.jumpserver.chen.framework.datasource.edit.analyzer.EditabilityReason; +import org.jumpserver.chen.framework.datasource.edit.pk.JdbcPrimaryKeyResolver; +import org.jumpserver.chen.framework.datasource.edit.analyzer.QueryResultEditabilityAnalyzer; import org.jumpserver.chen.framework.jms.exception.CommandRejectException; import org.jumpserver.chen.framework.session.SessionManager; import org.jumpserver.chen.framework.utils.HexUtils; @@ -25,6 +28,7 @@ import java.math.BigDecimal; import java.math.BigInteger; import java.sql.*; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -158,14 +162,7 @@ private void executeStatement(SQLExecutePlan plan, Statement statement, SQLQuery var columnCount = metaData.getColumnCount(); for (int i = 1; i <= columnCount; i++) { - Field field = new Field(); - - var fieldName = StringUtils.isNotEmpty(metaData.getColumnLabel(i)) ? - metaData.getColumnLabel(i) : metaData.getColumnName(i); - field.setName(fieldName); - field.setColumnName(metaData.getColumnName(i)); - field.setLabel(metaData.getColumnLabel(i)); - result.getFields().add(field); + result.getFields().add(buildField(metaData, i)); } while (resultSet.next()) { @@ -180,7 +177,9 @@ private void executeStatement(SQLExecutePlan plan, Statement statement, SQLQuery result.getData().add(fs); } resultSet.close(); + markGeneratedColumns(plan.getConnection(), this.getDruidDbType(), result.getFields()); result.setFetchFinishedTime(new Time(System.currentTimeMillis())); + this.analyzeResultEditability(plan, result); // 数据脱敏 this.handleDataMasking(result); @@ -202,16 +201,88 @@ private void executeStatement(SQLExecutePlan plan, Statement statement, SQLQuery } } + private void analyzeResultEditability(SQLExecutePlan plan, SQLQueryResult result) { + var analyzer = new QueryResultEditabilityAnalyzer( + this.getDruidDbType(), + new JdbcPrimaryKeyResolver(plan.getConnection(), this.getDruidDbType()) + ); + analyzer.analyze(plan.getSourceSQL(), result.getFields()); + } + + static Field buildField(ResultSetMetaData metaData, int columnIndex) throws SQLException { + Field field = new Field(); + + String columnLabel = metaData.getColumnLabel(columnIndex); + String columnName = metaData.getColumnName(columnIndex); + String fieldName = StringUtils.isNotEmpty(columnLabel) ? columnLabel : columnName; + field.setName(fieldName); + field.setColumnName(columnName); + field.setLabel(columnLabel); + fillOptionalFieldMetadata(field, metaData, columnIndex); + return field; + } + + private static void fillOptionalFieldMetadata(Field field, ResultSetMetaData metaData, int columnIndex) { + //后续还要靠 SQL AST 和主键解析再判断 + field.setSchema(getNullableMetadataValue(() -> metaData.getSchemaName(columnIndex), "schema", columnIndex)); + field.setTable(getNullableMetadataValue(() -> metaData.getTableName(columnIndex), "table", columnIndex)); + field.setType(getNullableMetadataValue(() -> metaData.getColumnTypeName(columnIndex), "type", columnIndex)); + try { + field.setJdbcType(metaData.getColumnType(columnIndex)); + } catch (SQLException e) { + log.debug("read result set jdbc type metadata failed for column {}", columnIndex, e); + } + + try { + // Field.nullable is boolean, so failed/unknown nullable metadata remains false. + field.setNullable(metaData.isNullable(columnIndex) == ResultSetMetaData.columnNullable); + } catch (SQLException e) { + log.debug("read result set nullable metadata failed for column {}", columnIndex, e); + } + try { + field.setAutoIncrement(metaData.isAutoIncrement(columnIndex)); + } catch (Exception e) { + log.debug("read result set auto increment metadata failed for column {}", columnIndex, e); + } + try { + field.setReadOnly(metaData.isReadOnly(columnIndex)); + } catch (Exception e) { + log.debug("read result set read only metadata failed for column {}", columnIndex, e); + } + try { + if (!metaData.isWritable(columnIndex)) { + field.setReadOnly(true); + } + } catch (Exception e) { + log.debug("read result set writable metadata failed for column {}", columnIndex, e); + } + } + + private static String getNullableMetadataValue(MetadataValueReader reader, String name, int columnIndex) { + try { + String value = reader.read(); + return StringUtils.isNotBlank(value) ? value : null; + } catch (SQLException e) { + log.debug("read result set {} metadata failed for column {}", name, columnIndex, e); + return null; + } + } + + @FunctionalInterface + private interface MetadataValueReader { + String read() throws SQLException; + } + // Normalize JDBC driver objects before FastJSON sees them in update_data_view packets. protected Object normalizeJdbcValue(Object value) throws SQLException { + Object normalized = normalizeJdbcDisplayValue(value); + if (normalized != value) { + return normalized; + } if (value == null) { return null; } - if (value instanceof Timestamp timestamp) { - return new Date(timestamp.getTime()); - } - if (value instanceof Long || value instanceof BigDecimal || value instanceof BigInteger) { return value.toString(); } @@ -243,6 +314,69 @@ protected Object normalizeJdbcValue(Object value) throws SQLException { return value; } + static Object normalizeJdbcDisplayValue(Object value) { + if (value == null) { + return null; + } + if (value instanceof Timestamp timestamp) { + var localDateTime = timestamp.toLocalDateTime(); + if (timestamp.getNanos() == 0) { + return localDateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + } + return localDateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.n")); + } + if (value instanceof Time time) { + return time.toLocalTime().toString(); + } + if (value instanceof Date date) { + return date.toLocalDate().toString(); + } + return value; + } + + static void markGeneratedColumns(Connection connection, DbType dbType, List fields) { + if (dbType != DbType.postgresql || connection == null || fields == null || fields.isEmpty()) { + return; + } + String sql = """ + SELECT is_generated, generation_expression + FROM information_schema.columns + WHERE table_schema = ? + AND table_name = ? + AND column_name = ? + """; + for (Field field : fields) { + if (field == null || + StringUtils.isBlank(field.getSchema()) || + StringUtils.isBlank(field.getTable()) || + StringUtils.isBlank(field.getColumnName())) { + continue; + } + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, field.getSchema()); + statement.setString(2, field.getTable()); + statement.setString(3, field.getColumnName()); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + continue; + } + String isGenerated = resultSet.getString("is_generated"); + String generationExpression = resultSet.getString("generation_expression"); + if ("ALWAYS".equalsIgnoreCase(isGenerated) || StringUtils.isNotBlank(generationExpression)) { + field.setGenerated(true); + field.setReadOnly(true); + } + } + } catch (SQLException e) { + log.debug("read postgresql generated column metadata failed for {}.{}.{}", + field.getSchema(), + field.getTable(), + field.getColumnName(), + e); + } + } + } + private String toDisplayArray(java.sql.Array jdbcArray) throws SQLException { try { var text = jdbcArray.toString(); @@ -362,8 +496,14 @@ private void handleDataMasking(SQLQueryResult result) { var maskIndexes = new ArrayList<>(); var maskRules = new HashMap(); for (var i = 0; i < result.getFields().size(); i++) { + var field = result.getFields().get(i); for (Common.DataMaskingRule rule : rules) { - if (this.matchField(result.getFields().get(i), rule.getFieldsPattern())) { + if (this.matchField(field, rule.getFieldsPattern())) { + field.setMasked(true); + field.setEditable(false); + field.setEditReason(EditabilityReason.DATA_MASKED); + field.setInsertable(false); + field.setInsertReason(EditabilityReason.DATA_MASKED); maskIndexes.add(i); maskRules.put(i, rule); } diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesPlan.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesPlan.java new file mode 100644 index 0000000..e0428c5 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesPlan.java @@ -0,0 +1,21 @@ +package org.jumpserver.chen.framework.datasource.edit; + +import lombok.Data; +import org.jumpserver.chen.framework.datasource.edit.command.PreparedTableChangeCommand; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class TableChangesPlan { + private String dataView; + private String schema; + private String table; + private int changeCount; + private int updateCount; + private int insertCount; + private int deleteCount; + private List commands = new ArrayList<>(); + private String aclSql; + private String auditSql; +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesPlanBuildResult.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesPlanBuildResult.java new file mode 100644 index 0000000..45329aa --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesPlanBuildResult.java @@ -0,0 +1,35 @@ +package org.jumpserver.chen.framework.datasource.edit; + +import lombok.Data; +import org.jumpserver.chen.framework.console.entity.request.SaveChangesRequest; + +@Data +public class TableChangesPlanBuildResult { + private boolean success; + private String reason; + private Integer failedChangeIndex; + private SaveChangesRequest.ChangeItem failedChange; + private Object failedOperation; + private TableChangesPlan plan; + + public static TableChangesPlanBuildResult success(TableChangesPlan plan) { + TableChangesPlanBuildResult result = new TableChangesPlanBuildResult(); + result.setSuccess(true); + result.setPlan(plan); + return result; + } + + public static TableChangesPlanBuildResult failure(String reason, Integer failedChangeIndex, SaveChangesRequest.ChangeItem failedChange) { + return failure(reason, failedChangeIndex, failedChange, failedChange); + } + + public static TableChangesPlanBuildResult failure(String reason, Integer failedChangeIndex, SaveChangesRequest.ChangeItem failedChange, Object failedOperation) { + TableChangesPlanBuildResult result = new TableChangesPlanBuildResult(); + result.setSuccess(false); + result.setReason(reason); + result.setFailedChangeIndex(failedChangeIndex); + result.setFailedChange(failedChange); + result.setFailedOperation(failedOperation); + return result; + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesPlanBuilder.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesPlanBuilder.java new file mode 100644 index 0000000..4dadcce --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesPlanBuilder.java @@ -0,0 +1,521 @@ +package org.jumpserver.chen.framework.datasource.edit; + +import org.apache.commons.lang3.StringUtils; +import org.jumpserver.chen.framework.console.entity.request.SaveChangesRequest; +import org.jumpserver.chen.framework.datasource.edit.analyzer.EditabilityReason; +import org.jumpserver.chen.framework.datasource.edit.bind.TableEditTypeCodecs; +import org.jumpserver.chen.framework.datasource.edit.bind.TableEditValueConverter; +import org.jumpserver.chen.framework.datasource.edit.command.PreparedTableChangeCommand; +import org.jumpserver.chen.framework.datasource.edit.dialect.TableEditDialect; +import org.jumpserver.chen.framework.datasource.edit.dialect.TableEditDialects; +import org.jumpserver.chen.framework.datasource.entity.resource.Field; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class TableChangesPlanBuilder { + public static final int MAX_CHANGES = 500; + public static final String DATABASE_NOT_SUPPORTED_FOR_EDIT = "DATABASE_NOT_SUPPORTED_FOR_EDIT"; + public static final String DATA_VIEW_MISMATCH = "DATA_VIEW_MISMATCH"; + public static final String SCHEMA_TABLE_MISMATCH = "SCHEMA_TABLE_MISMATCH"; + public static final String EMPTY_CHANGES = "EMPTY_CHANGES"; + public static final String TOO_MANY_CHANGES = "TOO_MANY_CHANGES"; + public static final String NO_PRIMARY_KEY = "NO_PRIMARY_KEY"; + public static final String COMPOSITE_PRIMARY_KEY_NOT_SUPPORTED = "COMPOSITE_PRIMARY_KEY_NOT_SUPPORTED"; + public static final String PK_COLUMN_MISMATCH = "PK_COLUMN_MISMATCH"; + public static final String PRIMARY_KEY_VALUE_REQUIRED = "PRIMARY_KEY_VALUE_REQUIRED"; + public static final String PRIMARY_KEY_COLUMN_NOT_EDITABLE = "PRIMARY_KEY_COLUMN_NOT_EDITABLE"; + public static final String SOURCE_COLUMN_NOT_FOUND = "SOURCE_COLUMN_NOT_FOUND"; + public static final String SOURCE_COLUMN_AMBIGUOUS = "SOURCE_COLUMN_AMBIGUOUS"; + public static final String PRIMARY_KEY_SOURCE_COLUMN_MISSING = "PRIMARY_KEY_SOURCE_COLUMN_MISSING"; + public static final String SOURCE_SCHEMA_MISMATCH = "SOURCE_SCHEMA_MISMATCH"; + public static final String SOURCE_TABLE_MISMATCH = "SOURCE_TABLE_MISMATCH"; + public static final String SOURCE_COLUMN_NOT_EDITABLE = "SOURCE_COLUMN_NOT_EDITABLE"; + public static final String NO_OP_CHANGE = "NO_OP_CHANGE"; + public static final String TYPE_CONVERSION_FAILED = "TYPE_CONVERSION_FAILED"; + public static final String ROW_OPERATIONS_TABLE_BROWSE_ONLY = "ROW_OPERATIONS_TABLE_BROWSE_ONLY"; + public static final String INSERT_VALUES_REQUIRED = "INSERT_VALUES_REQUIRED"; + public static final String INSERT_COLUMN_NOT_WRITABLE = "INSERT_COLUMN_NOT_WRITABLE"; + + public TableChangesPlanBuildResult build(TableEditContext context, String actionDataView, SaveChangesRequest request) { + TableEditDialect dialect = TableEditDialects.find(context.getDbType()).orElse(null); + if (dialect == null) { + return TableChangesPlanBuildResult.failure(DATABASE_NOT_SUPPORTED_FOR_EDIT, null, null); + } + if (!StringUtils.equals(actionDataView, context.getDataViewTitle())) { + return TableChangesPlanBuildResult.failure(DATA_VIEW_MISMATCH, null, null); + } + if (request == null || + !StringUtils.equals(request.getSchema(), context.getSchema()) || + !StringUtils.equals(request.getTable(), context.getTable())) { + return TableChangesPlanBuildResult.failure(SCHEMA_TABLE_MISMATCH, null, null); + } + + int updateCount = size(request.getChanges()); + int insertCount = size(request.getInsertRows()); + int deleteCount = size(request.getDeleteRows()); + int totalCount = updateCount + insertCount + deleteCount; + if (totalCount == 0) { + return TableChangesPlanBuildResult.failure(EMPTY_CHANGES, null, null); + } + if (totalCount > MAX_CHANGES) { + return TableChangesPlanBuildResult.failure(TOO_MANY_CHANGES, null, null); + } + if ((insertCount > 0 || deleteCount > 0) && !context.isTableBrowse()) { + return TableChangesPlanBuildResult.failure(ROW_OPERATIONS_TABLE_BROWSE_ONLY, null, null); + } + if (insertCount > 0 && !TableInsertability.isInsertable(context.getFields())) { + return TableChangesPlanBuildResult.failure( + INSERT_COLUMN_NOT_WRITABLE, + deleteCount + updateCount, + null, + request.getInsertRows().get(0) + ); + } + + Field primaryKey = resolveSinglePrimaryKey(context.getFields()); + if (primaryKey == null) { + return TableChangesPlanBuildResult.failure(NO_PRIMARY_KEY, null, null); + } + if (primaryKey == MultiplePrimaryKeys.FIELD) { + return TableChangesPlanBuildResult.failure(COMPOSITE_PRIMARY_KEY_NOT_SUPPORTED, null, null); + } + + String pkColumn = primaryKey.getSourceColumn(); + if (StringUtils.isBlank(pkColumn)) { + return TableChangesPlanBuildResult.failure(PRIMARY_KEY_SOURCE_COLUMN_MISSING, null, null); + } + if (!TableEditTypeCodecs.supports(primaryKey, context.getDbType())) { + return TableChangesPlanBuildResult.failure(EditabilityReason.TYPE_NOT_SUPPORTED_FOR_EDIT, null, null); + } + + TableChangesPlan plan = new TableChangesPlan(); + plan.setDataView(context.getDataViewTitle()); + plan.setSchema(context.getSchema()); + plan.setTable(context.getTable()); + plan.setChangeCount(totalCount); + plan.setUpdateCount(updateCount); + plan.setInsertCount(insertCount); + plan.setDeleteCount(deleteCount); + List renderedSqlList = new ArrayList<>(); + + int commandIndex = 0; + BuildCommandsResult deleteResult = buildDeleteCommands(context, request, primaryKey, dialect, renderedSqlList, commandIndex, plan); + if (!deleteResult.success()) { + return deleteResult.failure(); + } + commandIndex += deleteCount; + + BuildCommandsResult updateResult = buildUpdateCommands(context, request, primaryKey, dialect, renderedSqlList, commandIndex, plan); + if (!updateResult.success()) { + return updateResult.failure(); + } + commandIndex += updateCount; + + BuildCommandsResult insertResult = buildInsertCommands(context, request, dialect, renderedSqlList, commandIndex, plan); + if (!insertResult.success()) { + return insertResult.failure(); + } + + String aclSql = String.join("\n", renderedSqlList); + plan.setAclSql(aclSql); + plan.setAuditSql(aclSql); + return TableChangesPlanBuildResult.success(plan); + } + + private BuildCommandsResult buildDeleteCommands( + TableEditContext context, + SaveChangesRequest request, + Field primaryKey, + TableEditDialect dialect, + List renderedSqlList, + int commandIndexOffset, + TableChangesPlan plan + ) { + if (request.getDeleteRows() == null) { + return BuildCommandsResult.ok(); + } + String pkColumn = primaryKey.getSourceColumn(); + for (int i = 0; i < request.getDeleteRows().size(); i++) { + SaveChangesRequest.DeleteRow deleteRow = request.getDeleteRows().get(i); + if (deleteRow == null) { + return BuildCommandsResult.failure(SOURCE_COLUMN_NOT_FOUND, commandIndexOffset + i, null); + } + if (!StringUtils.equals(deleteRow.getPkColumn(), pkColumn)) { + return BuildCommandsResult.failure(PK_COLUMN_MISMATCH, commandIndexOffset + i, deleteRow); + } + if (deleteRow.isPkValueIsNull() || deleteRow.getPkValue() == null) { + return BuildCommandsResult.failure(PRIMARY_KEY_VALUE_REQUIRED, commandIndexOffset + i, deleteRow); + } + + PreparedTableChangeCommand command = new PreparedTableChangeCommand(); + command.setOperation(PreparedTableChangeCommand.Operation.DELETE); + command.setPkColumn(pkColumn); + command.setDeleteRow(deleteRow); + command.setPreparedSql(dialect.buildPreparedDeleteSql(context.getSchema(), context.getTable(), pkColumn)); + try { + PreparedTableChangeCommand.Parameter pkParameter = convertedParameter( + "pkValue", pkColumn, primaryKey, deleteRow.getPkValue(), deleteRow.isPkValueIsNull(), context.getDbType() + ); + command.getParameters().add(pkParameter); + renderedSqlList.add(dialect.buildAuditDeleteSql( + context.getSchema(), + context.getTable(), + pkColumn, + primaryKey, + pkParameter.getValue(), + deleteRow.isPkValueIsNull() + )); + plan.getCommands().add(command); + } catch (SQLException e) { + return BuildCommandsResult.failure(TYPE_CONVERSION_FAILED, commandIndexOffset + i, deleteRow); + } + } + return BuildCommandsResult.ok(); + } + + private BuildCommandsResult buildUpdateCommands( + TableEditContext context, + SaveChangesRequest request, + Field primaryKey, + TableEditDialect dialect, + List renderedSqlList, + int commandIndexOffset, + TableChangesPlan plan + ) { + if (request.getChanges() == null) { + return BuildCommandsResult.ok(); + } + String pkColumn = primaryKey.getSourceColumn(); + for (int i = 0; i < request.getChanges().size(); i++) { + SaveChangesRequest.ChangeItem change = request.getChanges().get(i); + ChangeValidation validation = validateChange(context, change, primaryKey); + if (validation.reason() != null) { + return BuildCommandsResult.failure(validation.reason(), commandIndexOffset + i, change); + } + + Field targetField = validation.field(); + String sourceColumn = targetField.getSourceColumn(); + PreparedTableChangeCommand command = new PreparedTableChangeCommand(); + command.setOperation(PreparedTableChangeCommand.Operation.UPDATE); + command.setSourceColumn(sourceColumn); + command.setPkColumn(pkColumn); + command.setChange(change); + command.setPreparedSql(dialect.buildPreparedUpdateSql( + context.getSchema(), + context.getTable(), + sourceColumn, + pkColumn + )); + try { + PreparedTableChangeCommand.Parameter newParameter = convertedParameter( + "newValue", sourceColumn, targetField, change.getNewValue(), change.isNewValueIsNull(), context.getDbType() + ); + PreparedTableChangeCommand.Parameter pkParameter = convertedParameter( + "pkValue", pkColumn, primaryKey, change.getPkValue(), change.isPkValueIsNull(), context.getDbType() + ); + PreparedTableChangeCommand.Parameter oldParameter = convertedParameter( + "oldValue", sourceColumn, targetField, change.getOldValue(), change.isOldValueIsNull(), context.getDbType() + ); + command.getParameters().add(newParameter); + command.getParameters().add(pkParameter); + command.getParameters().add(oldParameter); + if (dialect.oldValueParameterCount() > 1) { + command.getParameters().add(oldParameter); + } + renderedSqlList.add(dialect.buildAuditUpdateSql( + context.getSchema(), + context.getTable(), + sourceColumn, + targetField, + newParameter.getValue(), + change.isNewValueIsNull(), + pkColumn, + primaryKey, + pkParameter.getValue(), + change.isPkValueIsNull(), + oldParameter.getValue(), + change.isOldValueIsNull() + )); + plan.getCommands().add(command); + } catch (SQLException e) { + return BuildCommandsResult.failure(TYPE_CONVERSION_FAILED, commandIndexOffset + i, change); + } + } + return BuildCommandsResult.ok(); + } + + private BuildCommandsResult buildInsertCommands( + TableEditContext context, + SaveChangesRequest request, + TableEditDialect dialect, + List renderedSqlList, + int commandIndexOffset, + TableChangesPlan plan + ) { + if (request.getInsertRows() == null) { + return BuildCommandsResult.ok(); + } + for (int i = 0; i < request.getInsertRows().size(); i++) { + SaveChangesRequest.InsertRow insertRow = request.getInsertRows().get(i); + if (insertRow == null || insertRow.getValues() == null || insertRow.getValues().isEmpty()) { + return BuildCommandsResult.failure(INSERT_VALUES_REQUIRED, commandIndexOffset + i, insertRow); + } + + List sourceColumns = new ArrayList<>(); + List fields = new ArrayList<>(); + List values = new ArrayList<>(); + List valueIsNulls = new ArrayList<>(); + Map requestedValues = insertRow.getValues(); + for (Field field : context.getFields()) { + if (field == null || StringUtils.isBlank(field.getSourceColumn())) { + continue; + } + if (!requestedValues.containsKey(field.getSourceColumn())) { + continue; + } + SaveChangesRequest.CellValue cellValue = requestedValues.get(field.getSourceColumn()); + if (cellValue == null) { + return BuildCommandsResult.failure(INSERT_VALUES_REQUIRED, commandIndexOffset + i, insertRow); + } + InsertValidation validation = validateInsertField(context, field); + if (validation.reason() != null) { + return BuildCommandsResult.failure(validation.reason(), commandIndexOffset + i, insertRow); + } + sourceColumns.add(field.getSourceColumn()); + fields.add(field); + try { + values.add(convertValue(cellValue.getValue(), cellValue.isValueIsNull(), field, context.getDbType())); + } catch (SQLException e) { + return BuildCommandsResult.failure(TYPE_CONVERSION_FAILED, commandIndexOffset + i, insertRow); + } + valueIsNulls.add(cellValue.isValueIsNull()); + } + + if (sourceColumns.size() != requestedValues.size()) { + return BuildCommandsResult.failure(SOURCE_COLUMN_NOT_FOUND, commandIndexOffset + i, insertRow); + } + if (sourceColumns.isEmpty()) { + return BuildCommandsResult.failure(INSERT_VALUES_REQUIRED, commandIndexOffset + i, insertRow); + } + + PreparedTableChangeCommand command = new PreparedTableChangeCommand(); + command.setOperation(PreparedTableChangeCommand.Operation.INSERT); + command.setInsertRow(insertRow); + command.setPreparedSql(dialect.buildPreparedInsertSql(context.getSchema(), context.getTable(), sourceColumns)); + for (int paramIndex = 0; paramIndex < sourceColumns.size(); paramIndex++) { + command.getParameters().add(new PreparedTableChangeCommand.Parameter( + "insertValue", + sourceColumns.get(paramIndex), + fields.get(paramIndex), + values.get(paramIndex), + valueIsNulls.get(paramIndex), + context.getDbType() + )); + } + plan.getCommands().add(command); + try { + renderedSqlList.add(dialect.buildAuditInsertSql( + context.getSchema(), + context.getTable(), + sourceColumns, + fields, + values, + valueIsNulls + )); + } catch (SQLException e) { + return BuildCommandsResult.failure(TYPE_CONVERSION_FAILED, commandIndexOffset + i, insertRow); + } + } + return BuildCommandsResult.ok(); + } + + private ChangeValidation validateChange(TableEditContext context, SaveChangesRequest.ChangeItem change, Field primaryKey) { + if (change == null) { + return ChangeValidation.failure(SOURCE_COLUMN_NOT_FOUND); + } + if (!StringUtils.equals(change.getPkColumn(), primaryKey.getSourceColumn())) { + return ChangeValidation.failure(PK_COLUMN_MISMATCH); + } + if (change.isPkValueIsNull() || change.getPkValue() == null) { + return ChangeValidation.failure(PRIMARY_KEY_VALUE_REQUIRED); + } + if (change.isOldValueIsNull() == change.isNewValueIsNull() && + Objects.equals(change.getOldValue(), change.getNewValue())) { + return ChangeValidation.failure(NO_OP_CHANGE); + } + if (StringUtils.equals(change.getSourceColumn(), primaryKey.getSourceColumn())) { + return ChangeValidation.failure(PRIMARY_KEY_COLUMN_NOT_EDITABLE); + } + + Field field = resolveUniqueSourceField(context.getFields(), change.getSourceColumn()); + if (field == null) { + return ChangeValidation.failure(SOURCE_COLUMN_NOT_FOUND); + } + if (field == MultiplePrimaryKeys.FIELD) { + return ChangeValidation.failure(SOURCE_COLUMN_AMBIGUOUS); + } + + String sourceReason = validateSourceField(context, field); + if (sourceReason != null) { + return ChangeValidation.failure(sourceReason); + } + if (field.isMasked()) { + return ChangeValidation.failure(EditabilityReason.DATA_MASKED); + } + if (field.isAutoIncrement() || field.isReadOnly() || field.isGenerated()) { + return ChangeValidation.failure(SOURCE_COLUMN_NOT_EDITABLE); + } + if (field.isPrimaryKey()) { + return ChangeValidation.failure(PRIMARY_KEY_COLUMN_NOT_EDITABLE); + } + if (!field.isEditable()) { + return ChangeValidation.failure(StringUtils.defaultIfBlank(field.getEditReason(), SOURCE_COLUMN_NOT_EDITABLE)); + } + if (!TableEditTypeCodecs.supports(field, context.getDbType())) { + return ChangeValidation.failure(EditabilityReason.TYPE_NOT_SUPPORTED_FOR_EDIT); + } + return ChangeValidation.success(field); + } + + private InsertValidation validateInsertField(TableEditContext context, Field field) { + String sourceReason = validateSourceField(context, field); + if (sourceReason != null) { + return InsertValidation.failure(sourceReason); + } + if (field.isMasked()) { + return InsertValidation.failure(EditabilityReason.DATA_MASKED); + } + if (!field.isInsertable()) { + return InsertValidation.failure(StringUtils.defaultIfBlank(field.getInsertReason(), INSERT_COLUMN_NOT_WRITABLE)); + } + if (field.isAutoIncrement() || field.isReadOnly() || field.isGenerated()) { + return InsertValidation.failure(INSERT_COLUMN_NOT_WRITABLE); + } + if (!TableEditTypeCodecs.supports(field, context.getDbType())) { + return InsertValidation.failure(EditabilityReason.TYPE_NOT_SUPPORTED_FOR_EDIT); + } + return InsertValidation.success(); + } + + private PreparedTableChangeCommand.Parameter convertedParameter( + String name, + String column, + Field field, + Object value, + boolean valueIsNull, + com.alibaba.druid.DbType dbType + ) throws SQLException { + return new PreparedTableChangeCommand.Parameter( + name, + column, + field, + convertValue(value, valueIsNull, field, dbType), + valueIsNull, + field != null ? field.getJdbcType() : null, + dbType + ); + } + + private Object convertValue(Object value, boolean valueIsNull, Field field, com.alibaba.druid.DbType dbType) throws SQLException { + if (valueIsNull || value == null) { + return null; + } + return TableEditValueConverter.coerce(value, field, dbType); + } + + private String validateSourceField(TableEditContext context, Field field) { + if (StringUtils.isNotBlank(field.getSourceSchema()) && !StringUtils.equals(field.getSourceSchema(), context.getSchema())) { + return SOURCE_SCHEMA_MISMATCH; + } + if (StringUtils.isNotBlank(field.getSourceTable()) && !StringUtils.equals(field.getSourceTable(), context.getTable())) { + return SOURCE_TABLE_MISMATCH; + } + return null; + } + + private Field resolveSinglePrimaryKey(List fields) { + if (fields == null) { + return null; + } + List primaryKeys = fields.stream() + .filter(Objects::nonNull) + .filter(Field::isPrimaryKey) + .toList(); + if (primaryKeys.isEmpty()) { + return null; + } + if (primaryKeys.size() > 1) { + return MultiplePrimaryKeys.FIELD; + } + return primaryKeys.get(0); + } + + private Field resolveUniqueSourceField(List fields, String sourceColumn) { + if (fields == null) { + return null; + } + List matchedFields = fields.stream() + .filter(Objects::nonNull) + .filter(field -> StringUtils.equals(field.getSourceColumn(), sourceColumn)) + .toList(); + if (matchedFields.isEmpty()) { + return null; + } + if (matchedFields.size() > 1) { + return MultiplePrimaryKeys.FIELD; + } + return matchedFields.get(0); + } + + private int size(List list) { + return list == null ? 0 : list.size(); + } + + private record ChangeValidation(String reason, Field field) { + static ChangeValidation success(Field field) { + return new ChangeValidation(null, field); + } + + static ChangeValidation failure(String reason) { + return new ChangeValidation(reason, null); + } + } + + private record InsertValidation(String reason) { + static InsertValidation success() { + return new InsertValidation(null); + } + + static InsertValidation failure(String reason) { + return new InsertValidation(reason); + } + } + + private record BuildCommandsResult(boolean successful, TableChangesPlanBuildResult failureResult) { + boolean success() { + return successful; + } + + TableChangesPlanBuildResult failure() { + return failureResult; + } + + static BuildCommandsResult ok() { + return new BuildCommandsResult(true, null); + } + + static BuildCommandsResult failure(String reason, Integer failedChangeIndex, Object failedOperation) { + SaveChangesRequest.ChangeItem failedChange = failedOperation instanceof SaveChangesRequest.ChangeItem change ? change : null; + return new BuildCommandsResult(false, + TableChangesPlanBuildResult.failure(reason, failedChangeIndex, failedChange, failedOperation)); + } + } + + private static final class MultiplePrimaryKeys { + private static final Field FIELD = new Field(); + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesPreviewService.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesPreviewService.java new file mode 100644 index 0000000..1ba07f2 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesPreviewService.java @@ -0,0 +1,110 @@ +package org.jumpserver.chen.framework.datasource.edit; + +import org.jumpserver.chen.framework.console.entity.request.SaveChangesRequest; +import org.jumpserver.chen.framework.console.entity.response.SaveChangesPreviewResult; +import org.jumpserver.chen.framework.datasource.edit.command.PreparedTableChangeCommand; + +import java.math.BigDecimal; +import java.sql.Date; +import java.sql.Time; +import java.sql.Timestamp; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.UUID; + +public class TableChangesPreviewService { + public static final int MAX_CHANGES = TableChangesPlanBuilder.MAX_CHANGES; + public static final String DATABASE_NOT_SUPPORTED_FOR_EDIT = TableChangesPlanBuilder.DATABASE_NOT_SUPPORTED_FOR_EDIT; + public static final String DATA_VIEW_MISMATCH = TableChangesPlanBuilder.DATA_VIEW_MISMATCH; + public static final String SCHEMA_TABLE_MISMATCH = TableChangesPlanBuilder.SCHEMA_TABLE_MISMATCH; + public static final String EMPTY_CHANGES = TableChangesPlanBuilder.EMPTY_CHANGES; + public static final String TOO_MANY_CHANGES = TableChangesPlanBuilder.TOO_MANY_CHANGES; + public static final String NO_PRIMARY_KEY = TableChangesPlanBuilder.NO_PRIMARY_KEY; + public static final String COMPOSITE_PRIMARY_KEY_NOT_SUPPORTED = TableChangesPlanBuilder.COMPOSITE_PRIMARY_KEY_NOT_SUPPORTED; + public static final String PK_COLUMN_MISMATCH = TableChangesPlanBuilder.PK_COLUMN_MISMATCH; + public static final String PRIMARY_KEY_VALUE_REQUIRED = TableChangesPlanBuilder.PRIMARY_KEY_VALUE_REQUIRED; + public static final String PRIMARY_KEY_COLUMN_NOT_EDITABLE = TableChangesPlanBuilder.PRIMARY_KEY_COLUMN_NOT_EDITABLE; + public static final String SOURCE_COLUMN_NOT_FOUND = TableChangesPlanBuilder.SOURCE_COLUMN_NOT_FOUND; + public static final String SOURCE_COLUMN_AMBIGUOUS = TableChangesPlanBuilder.SOURCE_COLUMN_AMBIGUOUS; + public static final String PRIMARY_KEY_SOURCE_COLUMN_MISSING = TableChangesPlanBuilder.PRIMARY_KEY_SOURCE_COLUMN_MISSING; + public static final String SOURCE_SCHEMA_MISMATCH = TableChangesPlanBuilder.SOURCE_SCHEMA_MISMATCH; + public static final String SOURCE_TABLE_MISMATCH = TableChangesPlanBuilder.SOURCE_TABLE_MISMATCH; + public static final String SOURCE_COLUMN_NOT_EDITABLE = TableChangesPlanBuilder.SOURCE_COLUMN_NOT_EDITABLE; + public static final String NO_OP_CHANGE = TableChangesPlanBuilder.NO_OP_CHANGE; + public static final String TYPE_CONVERSION_FAILED = TableChangesPlanBuilder.TYPE_CONVERSION_FAILED; + public static final String ROW_OPERATIONS_TABLE_BROWSE_ONLY = TableChangesPlanBuilder.ROW_OPERATIONS_TABLE_BROWSE_ONLY; + public static final String INSERT_VALUES_REQUIRED = TableChangesPlanBuilder.INSERT_VALUES_REQUIRED; + public static final String INSERT_COLUMN_NOT_WRITABLE = TableChangesPlanBuilder.INSERT_COLUMN_NOT_WRITABLE; + + private final TableChangesPlanBuilder planBuilder = new TableChangesPlanBuilder(); + + public SaveChangesPreviewResult preview(TableEditContext context, String actionDataView, SaveChangesRequest request) { + SaveChangesPreviewResult result = baseResult(context); + TableChangesPlanBuildResult buildResult = this.planBuilder.build(context, actionDataView, request); + if (!buildResult.isSuccess()) { + result.setReason(buildResult.getReason()); + result.setFailedChangeIndex(buildResult.getFailedChangeIndex()); + result.setFailedChange(buildResult.getFailedChange()); + result.setFailedOperation(buildResult.getFailedOperation()); + return result; + } + + TableChangesPlan plan = buildResult.getPlan(); + result.setSuccess(true); + result.setAllowed(true); + result.setChangeCount(plan.getChangeCount()); + result.setUpdateCount(plan.getUpdateCount()); + result.setInsertCount(plan.getInsertCount()); + result.setDeleteCount(plan.getDeleteCount()); + result.setAuditSql(plan.getAuditSql()); + for (PreparedTableChangeCommand command : plan.getCommands()) { + SaveChangesPreviewResult.PreviewItem previewItem = new SaveChangesPreviewResult.PreviewItem(); + previewItem.setOperation(command.getOperation().name()); + previewItem.setSourceColumn(command.getSourceColumn()); + previewItem.setPkColumn(command.getPkColumn()); + previewItem.setPreparedSql(command.getPreparedSql()); + ArrayList paramsPreview = new ArrayList<>(); + for (PreparedTableChangeCommand.Parameter parameter : command.getParameters()) { + paramsPreview.add(paramPreviewValue(parameter.getValue(), parameter.isValueIsNull())); + } + previewItem.setParamsPreview(paramsPreview); + result.getPreparedStatements().add(previewItem); + } + return result; + } + + private SaveChangesPreviewResult baseResult(TableEditContext context) { + SaveChangesPreviewResult result = new SaveChangesPreviewResult(); + result.setSuccess(false); + result.setAllowed(false); + result.setDataView(context.getDataViewTitle()); + result.setSchema(context.getSchema()); + result.setTable(context.getTable()); + return result; + } + + private Object paramPreviewValue(Object value, boolean isNull) { + if (isNull) { + return null; + } + if (value instanceof BigDecimal decimal) { + return decimal.toPlainString(); + } + if (value instanceof Date date) { + return date.toLocalDate().toString(); + } + if (value instanceof Time time) { + return time.toLocalTime().toString(); + } + if (value instanceof Timestamp timestamp) { + return timestamp.toLocalDateTime().toString(); + } + if (value instanceof OffsetDateTime offsetDateTime) { + return offsetDateTime.toString(); + } + if (value instanceof UUID uuid) { + return uuid.toString(); + } + return value; + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesSaveService.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesSaveService.java new file mode 100644 index 0000000..7b1d172 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableChangesSaveService.java @@ -0,0 +1,425 @@ +package org.jumpserver.chen.framework.datasource.edit; + +import lombok.extern.slf4j.Slf4j; +import org.jumpserver.chen.framework.console.entity.request.SaveChangesRequest; +import org.jumpserver.chen.framework.console.entity.response.SaveChangesResult; +import org.jumpserver.chen.framework.datasource.ConnectionManager; +import org.jumpserver.chen.framework.datasource.edit.bind.PreparedStatementBinder; +import org.jumpserver.chen.framework.datasource.edit.command.PreparedTableChangeCommand; +import org.jumpserver.chen.framework.datasource.edit.exception.CommitFailedException; +import org.jumpserver.chen.framework.datasource.edit.exception.OptimisticLockConflictException; +import org.jumpserver.chen.framework.datasource.edit.exception.RowNotFoundOrNotUniqueException; +import org.jumpserver.chen.framework.datasource.edit.exception.UnexpectedAffectedRowsException; +import org.jumpserver.chen.framework.datasource.sql.SQLQueryResult; +import org.jumpserver.chen.framework.jms.acl.ACLResult; +import org.jumpserver.chen.framework.jms.exception.CommandRejectException; +import org.jumpserver.chen.framework.session.Session; +import org.jumpserver.wisp.Common; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +@Slf4j +public class TableChangesSaveService { + public static final String ACL_REJECTED = "ACL_REJECTED"; + public static final String OPTIMISTIC_LOCK_CONFLICT = "OPTIMISTIC_LOCK_CONFLICT"; + public static final String ROW_NOT_FOUND_OR_NOT_UNIQUE = "ROW_NOT_FOUND_OR_NOT_UNIQUE"; + public static final String AFFECTED_ROWS_UNEXPECTED = "AFFECTED_ROWS_UNEXPECTED"; + public static final String SAVE_CHANGES_EXECUTE_FAILED = "SAVE_CHANGES_EXECUTE_FAILED"; + public static final String SAVE_CHANGES_COMMIT_FAILED = "SAVE_CHANGES_COMMIT_FAILED"; + public static final String SAVE_CHANGES_AUDIT_REJECTED = "SAVE_CHANGES_AUDIT_REJECTED"; + + private final TableChangesPlanBuilder planBuilder; + private final PreparedStatementBinder binder; + + public TableChangesSaveService() { + this(new TableChangesPlanBuilder(), new PreparedStatementBinder()); + } + + TableChangesSaveService(TableChangesPlanBuilder planBuilder, PreparedStatementBinder binder) { + this.planBuilder = planBuilder; + this.binder = binder; + } + + public SaveChangesResult save( + TableEditContext context, + String actionDataView, + SaveChangesRequest request, + ConnectionManager connectionManager, + Session session + ) { + SaveChangesResult result = baseResult(context); + TableChangesPlanBuildResult buildResult = this.planBuilder.build(context, actionDataView, request); + if (!buildResult.isSuccess()) { + log.warn( + "save changes plan rejected, reason={}, dataView={}, actionDataView={}, table={}.{}, failedChangeIndex={}", + buildResult.getReason(), + context.getDataViewTitle(), + actionDataView, + context.getSchema(), + context.getTable(), + buildResult.getFailedChangeIndex() + ); + return reject(result, buildResult.getReason(), buildResult.getFailedChangeIndex(), + buildResult.getFailedChange(), buildResult.getFailedOperation()); + } + + TableChangesPlan plan = buildResult.getPlan(); + fillPlanResult(result, plan); + + log.info("save_changes auditSql:\n{}", plan.getAuditSql()); + + try (Connection connection = connectionManager.getConnection()) { + log.debug( + "save changes plan accepted, dataView={}, table={}.{}, changeCount={}", + plan.getDataView(), + plan.getSchema(), + plan.getTable(), + plan.getChangeCount() + ); + + ACLResult aclResult = session.checkACL(plan.getAclSql(), connection); + if (isRejected(aclResult)) { + log.warn( + "save changes acl rejected, dataView={}, table={}.{}, riskLevel={}", + plan.getDataView(), + plan.getSchema(), + plan.getTable(), + aclResult.getRiskLevel() + ); + return reject(result, ACL_REJECTED, null, null); + } + if (aclResult == null) { + aclResult = new ACLResult(); + aclResult.setRiskLevel(Common.RiskLevel.Normal); + } + + ACLResult finalAclResult = aclResult; + SQLQueryResult queryResult = session.withAudit( + plan.getAuditSql(), + () -> executeTransaction(connection, plan, finalAclResult) + ); + result.setSuccess(true); + result.setAllowed(true); + result.setChangeCount(queryResult.getUpdateCount()); + // executeTransaction has already verified every command affected exactly one row. + result.getStatements().forEach(item -> item.setAffectedRows(1)); + + log.info( + "save changes succeeded, dataView={}, table={}.{}, changeCount={}", + plan.getDataView(), + plan.getSchema(), + plan.getTable(), + plan.getChangeCount() + ); + return result; + } catch (OptimisticLockConflictException e) { + PreparedTableChangeCommand command = commandAt(plan, e.getChangeIndex()); + log.warn( + "save changes optimistic lock conflict, dataView={}, table={}.{}, changeIndex={}, sourceColumn={}, pkColumn={}", + plan.getDataView(), + plan.getSchema(), + plan.getTable(), + e.getChangeIndex(), + command != null ? command.getSourceColumn() : null, + command != null ? command.getPkColumn() : null + ); + return reject(result, OPTIMISTIC_LOCK_CONFLICT, e.getChangeIndex(), + command != null ? command.getChange() : null, command); + } catch (UnexpectedAffectedRowsException e) { + PreparedTableChangeCommand command = commandAt(plan, e.getChangeIndex()); + log.error( + "save changes affected rows unexpected, dataView={}, table={}.{}, changeIndex={}, sourceColumn={}, pkColumn={}, affectedRows={}", + plan.getDataView(), + plan.getSchema(), + plan.getTable(), + e.getChangeIndex(), + command != null ? command.getSourceColumn() : null, + command != null ? command.getPkColumn() : null, + e.getAffectedRows() + ); + return reject(result, AFFECTED_ROWS_UNEXPECTED, e.getChangeIndex(), + command != null ? command.getChange() : null, command); + } catch (RowNotFoundOrNotUniqueException e) { + PreparedTableChangeCommand command = commandAt(plan, e.getChangeIndex()); + log.warn( + "save changes row not found or not unique, dataView={}, table={}.{}, changeIndex={}, operation={}, pkColumn={}", + plan.getDataView(), + plan.getSchema(), + plan.getTable(), + e.getChangeIndex(), + command != null ? command.getOperation() : null, + command != null ? command.getPkColumn() : null + ); + return reject(result, ROW_NOT_FOUND_OR_NOT_UNIQUE, e.getChangeIndex(), + command != null ? command.getChange() : null, command); + } catch (CommitFailedException e) { + log.error( + "save changes commit failed, dataView={}, table={}.{}, message={}", + plan.getDataView(), + plan.getSchema(), + plan.getTable(), + e.getMessage(), + e + ); + return reject(result, SAVE_CHANGES_COMMIT_FAILED, null, null); + } catch (CommandRejectException e) { + log.warn( + "save changes audit rejected, dataView={}, table={}.{}, message={}", + plan.getDataView(), + plan.getSchema(), + plan.getTable(), + e.getMessage(), + e + ); + return reject(result, SAVE_CHANGES_AUDIT_REJECTED, null, null); + } catch (SQLException e) { + log.warn( + "save changes failed, reason={}, dataView={}, table={}.{}, sqlState={}, vendorCode={}, message={}", + SAVE_CHANGES_EXECUTE_FAILED, + plan.getDataView(), + plan.getSchema(), + plan.getTable(), + e.getSQLState(), + e.getErrorCode(), + e.getMessage(), + e + ); + return reject(result, SAVE_CHANGES_EXECUTE_FAILED, null, null); + } + } + + SQLQueryResult executeTransaction(Connection connection, TableChangesPlan plan, ACLResult aclResult) throws SQLException { + boolean originalAutoCommit = connection.getAutoCommit(); + try { + connection.setAutoCommit(false); + for (int i = 0; i < plan.getCommands().size(); i++) { + executeCommand(connection, plan, plan.getCommands().get(i), i); + } + commitTransaction(connection, plan); + return successQueryResult(plan, aclResult); + } catch (SQLException e) { + if (!isExpectedSaveException(e)) { + log.warn( + "save changes transaction failed, table={}.{}, sqlState={}, vendorCode={}, message={}", + plan.getSchema(), + plan.getTable(), + e.getSQLState(), + e.getErrorCode(), + e.getMessage(), + e + ); + rollbackQuietly(connection, plan); + } + throw e; + } finally { + try { + connection.setAutoCommit(originalAutoCommit); + } catch (SQLException e) { + log.warn( + "restore save changes connection autoCommit failed, table={}.{}, originalAutoCommit={}, sqlState={}, vendorCode={}, message={}", + plan.getSchema(), + plan.getTable(), + originalAutoCommit, + e.getSQLState(), + e.getErrorCode(), + e.getMessage(), + e + ); + throw e; + } + } + } + + private void executeCommand( + Connection connection, + TableChangesPlan plan, + PreparedTableChangeCommand command, + int changeIndex + ) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(command.getPreparedSql())) { + this.binder.bind(statement, command); + + int affectedRows; + try { + affectedRows = statement.executeUpdate(); + } catch (SQLException e) { + log.warn( + "save changes statement execute failed, table={}.{}, changeIndex={}, operation={}, sourceColumn={}, pkColumn={}, sqlState={}, vendorCode={}, message={}", + plan.getSchema(), + plan.getTable(), + changeIndex, + command.getOperation(), + command.getSourceColumn(), + command.getPkColumn(), + e.getSQLState(), + e.getErrorCode(), + e.getMessage(), + e + ); + throw e; + } + + handleAffectedRows(connection, plan, command, changeIndex, affectedRows); + } + } + + private void handleAffectedRows( + Connection connection, + TableChangesPlan plan, + PreparedTableChangeCommand command, + int changeIndex, + int affectedRows + ) throws SQLException { + if (affectedRows == 0) { + log.warn( + "save changes affected zero rows, table={}.{}, changeIndex={}, operation={}, sourceColumn={}, pkColumn={}", + plan.getSchema(), + plan.getTable(), + changeIndex, + command.getOperation(), + command.getSourceColumn(), + command.getPkColumn() + ); + rollbackQuietly(connection, plan); + if (command.getOperation() == PreparedTableChangeCommand.Operation.DELETE) { + throw new RowNotFoundOrNotUniqueException(changeIndex); + } + if (command.getOperation() == PreparedTableChangeCommand.Operation.INSERT) { + throw new UnexpectedAffectedRowsException(changeIndex, affectedRows); + } + throw new OptimisticLockConflictException(changeIndex); + } + if (affectedRows > 1) { + log.error( + "save changes affected unexpected rows, table={}.{}, changeIndex={}, operation={}, sourceColumn={}, pkColumn={}, affectedRows={}", + plan.getSchema(), + plan.getTable(), + changeIndex, + command.getOperation(), + command.getSourceColumn(), + command.getPkColumn(), + affectedRows + ); + rollbackQuietly(connection, plan); + throw new UnexpectedAffectedRowsException(changeIndex, affectedRows); + } + } + + private void commitTransaction(Connection connection, TableChangesPlan plan) throws SQLException { + try { + connection.commit(); + } catch (SQLException e) { + log.error( + "save changes commit failed, table={}.{}, sqlState={}, vendorCode={}, message={}", + plan.getSchema(), + plan.getTable(), + e.getSQLState(), + e.getErrorCode(), + e.getMessage(), + e + ); + rollbackQuietly(connection, plan); + throw new CommitFailedException(e); + } + } + + private SQLQueryResult successQueryResult(TableChangesPlan plan, ACLResult aclResult) { + SQLQueryResult result = new SQLQueryResult(plan.getAuditSql()); + result.setHasResultSet(false); + result.setUpdateCount(plan.getChangeCount()); + result.setAclResult(aclResult); + return result; + } + + private boolean isExpectedSaveException(SQLException e) { + return e instanceof OptimisticLockConflictException || + e instanceof RowNotFoundOrNotUniqueException || + e instanceof UnexpectedAffectedRowsException || + e instanceof CommitFailedException; + } + + private boolean isRejected(ACLResult aclResult) { + return aclResult != null && + (aclResult.getRiskLevel() == Common.RiskLevel.Reject || + aclResult.getRiskLevel() == Common.RiskLevel.ReviewReject); + } + + private void rollbackQuietly(Connection connection, TableChangesPlan plan) { + try { + connection.rollback(); + } catch (SQLException e) { + log.warn( + "rollback save changes transaction failed, table={}.{}, sqlState={}, vendorCode={}, message={}", + plan != null ? plan.getSchema() : null, + plan != null ? plan.getTable() : null, + e.getSQLState(), + e.getErrorCode(), + e.getMessage(), + e + ); + } + } + + private SaveChangesResult baseResult(TableEditContext context) { + SaveChangesResult result = new SaveChangesResult(); + result.setSuccess(false); + result.setAllowed(false); + result.setDataView(context.getDataViewTitle()); + result.setSchema(context.getSchema()); + result.setTable(context.getTable()); + return result; + } + + private void fillPlanResult(SaveChangesResult result, TableChangesPlan plan) { + result.setDataView(plan.getDataView()); + result.setSchema(plan.getSchema()); + result.setTable(plan.getTable()); + result.setChangeCount(plan.getChangeCount()); + result.setUpdateCount(plan.getUpdateCount()); + result.setInsertCount(plan.getInsertCount()); + result.setDeleteCount(plan.getDeleteCount()); + result.setAuditSql(plan.getAuditSql()); + for (PreparedTableChangeCommand command : plan.getCommands()) { + SaveChangesResult.ResultItem item = new SaveChangesResult.ResultItem(); + item.setOperation(command.getOperation().name()); + item.setSourceColumn(command.getSourceColumn()); + item.setPkColumn(command.getPkColumn()); + item.setPreparedSql(command.getPreparedSql()); + result.getStatements().add(item); + } + } + + private PreparedTableChangeCommand commandAt(TableChangesPlan plan, int index) { + if (plan == null || plan.getCommands() == null || index < 0 || index >= plan.getCommands().size()) { + return null; + } + return plan.getCommands().get(index); + } + + private SaveChangesResult reject( + SaveChangesResult result, + String reason, + Integer failedChangeIndex, + SaveChangesRequest.ChangeItem failedChange + ) { + return reject(result, reason, failedChangeIndex, failedChange, failedChange); + } + + private SaveChangesResult reject( + SaveChangesResult result, + String reason, + Integer failedChangeIndex, + SaveChangesRequest.ChangeItem failedChange, + Object failedOperation + ) { + result.setSuccess(false); + result.setAllowed(false); + result.setReason(reason); + result.setFailedChangeIndex(failedChangeIndex); + result.setFailedChange(failedChange); + result.setFailedOperation(failedOperation); + return result; + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableEditContext.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableEditContext.java new file mode 100644 index 0000000..6db2132 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableEditContext.java @@ -0,0 +1,37 @@ +package org.jumpserver.chen.framework.datasource.edit; + +import com.alibaba.druid.DbType; +import lombok.Data; +import org.jumpserver.chen.framework.datasource.entity.resource.Field; + +import java.util.List; + +@Data +public class TableEditContext { + private String dataViewTitle; + private String schema; + private String table; + private List fields; + private DbType dbType; + private boolean tableBrowse; + + public TableEditContext(String dataViewTitle, String schema, String table, List fields, DbType dbType) { + this(dataViewTitle, schema, table, fields, dbType, false); + } + + public TableEditContext( + String dataViewTitle, + String schema, + String table, + List fields, + DbType dbType, + boolean tableBrowse + ) { + this.dataViewTitle = dataViewTitle; + this.schema = schema; + this.table = table; + this.fields = fields; + this.dbType = dbType; + this.tableBrowse = tableBrowse; + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableInsertability.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableInsertability.java new file mode 100644 index 0000000..24ff75e --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/TableInsertability.java @@ -0,0 +1,31 @@ +package org.jumpserver.chen.framework.datasource.edit; + +import org.jumpserver.chen.framework.datasource.entity.resource.Field; + +import java.util.List; + +public final class TableInsertability { + private TableInsertability() { + } + + public static boolean isInsertable(List fields) { + if (fields == null || fields.isEmpty()) { + return false; + } + + boolean hasOrdinaryField = false; + for (Field field : fields) { + if (field == null) { + return false; + } + if (field.isAutoIncrement() || field.isGenerated()) { + continue; + } + hasOrdinaryField = true; + if (!field.isInsertable()) { + return false; + } + } + return hasOrdinaryField; + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/analyzer/EditabilityReason.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/analyzer/EditabilityReason.java new file mode 100644 index 0000000..544cd4b --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/analyzer/EditabilityReason.java @@ -0,0 +1,33 @@ +package org.jumpserver.chen.framework.datasource.edit.analyzer; + +public final class EditabilityReason { + public static final String NOT_SELECT = "NOT_SELECT"; + public static final String MULTIPLE_STATEMENTS_NOT_SUPPORTED = "MULTIPLE_STATEMENTS_NOT_SUPPORTED"; + public static final String QUERY_BLOCK_NOT_SUPPORTED = "QUERY_BLOCK_NOT_SUPPORTED"; + public static final String CTE_NOT_SUPPORTED = "CTE_NOT_SUPPORTED"; + public static final String SET_OPERATION_NOT_SUPPORTED = "SET_OPERATION_NOT_SUPPORTED"; + public static final String JOIN_NOT_SUPPORTED = "JOIN_NOT_SUPPORTED"; + public static final String SUBQUERY_NOT_SUPPORTED = "SUBQUERY_NOT_SUPPORTED"; + public static final String TABLE_SOURCE_NOT_SUPPORTED = "TABLE_SOURCE_NOT_SUPPORTED"; + public static final String VIEW_NOT_SUPPORTED = "VIEW_NOT_SUPPORTED"; + public static final String DISTINCT_NOT_SUPPORTED = "DISTINCT_NOT_SUPPORTED"; + public static final String GROUP_BY_NOT_SUPPORTED = "GROUP_BY_NOT_SUPPORTED"; + public static final String HAVING_NOT_SUPPORTED = "HAVING_NOT_SUPPORTED"; + public static final String EXPRESSION_COLUMN = "EXPRESSION_COLUMN"; + public static final String AGGREGATE_OR_FUNCTION_COLUMN = "AGGREGATE_OR_FUNCTION_COLUMN"; + public static final String ALL_COLUMNS_NOT_SUPPORTED = "ALL_COLUMNS_NOT_SUPPORTED"; + public static final String COLUMN_OWNER_NOT_SUPPORTED = "COLUMN_OWNER_NOT_SUPPORTED"; + public static final String UNKNOWN_COLUMN_SOURCE = "UNKNOWN_COLUMN_SOURCE"; + public static final String SELECT_LIST_MISMATCH = "SELECT_LIST_MISMATCH"; + public static final String PRIMARY_KEY_RESOLUTION_NOT_IMPLEMENTED = "PRIMARY_KEY_RESOLUTION_NOT_IMPLEMENTED"; + public static final String PRIMARY_KEY_RESOLUTION_FAILED = "PRIMARY_KEY_RESOLUTION_FAILED"; + public static final String NO_PRIMARY_KEY = "NO_PRIMARY_KEY"; + public static final String COMPOSITE_PRIMARY_KEY_NOT_SUPPORTED = "COMPOSITE_PRIMARY_KEY_NOT_SUPPORTED"; + public static final String PRIMARY_KEY_NOT_IN_RESULT = "PRIMARY_KEY_NOT_IN_RESULT"; + public static final String PRIMARY_KEY_COLUMN_NOT_EDITABLE = "PRIMARY_KEY_COLUMN_NOT_EDITABLE"; + public static final String DATA_MASKED = "DATA_MASKED"; + public static final String TYPE_NOT_SUPPORTED_FOR_EDIT = "TYPE_NOT_SUPPORTED_FOR_EDIT"; + + private EditabilityReason() { + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/analyzer/QueryResultEditabilityAnalyzer.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/analyzer/QueryResultEditabilityAnalyzer.java new file mode 100644 index 0000000..af9cb91 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/analyzer/QueryResultEditabilityAnalyzer.java @@ -0,0 +1,507 @@ +package org.jumpserver.chen.framework.datasource.edit.analyzer; + +import com.alibaba.druid.DbType; +import com.alibaba.druid.sql.SQLUtils; +import com.alibaba.druid.sql.ast.SQLExpr; +import com.alibaba.druid.sql.ast.SQLStatement; +import com.alibaba.druid.sql.ast.expr.SQLAggregateExpr; +import com.alibaba.druid.sql.ast.expr.SQLAllColumnExpr; +import com.alibaba.druid.sql.ast.expr.SQLIdentifierExpr; +import com.alibaba.druid.sql.ast.expr.SQLMethodInvokeExpr; +import com.alibaba.druid.sql.ast.expr.SQLPropertyExpr; +import com.alibaba.druid.sql.ast.statement.SQLExprTableSource; +import com.alibaba.druid.sql.ast.statement.SQLJoinTableSource; +import com.alibaba.druid.sql.ast.statement.SQLSelectGroupByClause; +import com.alibaba.druid.sql.ast.statement.SQLSelectItem; +import com.alibaba.druid.sql.ast.statement.SQLSelectQueryBlock; +import com.alibaba.druid.sql.ast.statement.SQLSelectStatement; +import com.alibaba.druid.sql.ast.statement.SQLSubqueryTableSource; +import com.alibaba.druid.sql.ast.statement.SQLTableSource; +import com.alibaba.druid.sql.ast.statement.SQLUnionQuery; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.jumpserver.chen.framework.datasource.edit.TableChangesPlanBuilder; +import org.jumpserver.chen.framework.datasource.edit.bind.TableEditTypeCodecs; +import org.jumpserver.chen.framework.datasource.entity.resource.Field; +import org.jumpserver.chen.framework.datasource.edit.pk.PrimaryKeyResolution; +import org.jumpserver.chen.framework.datasource.edit.pk.PrimaryKeyResolver; + +import java.sql.SQLException; +import java.util.List; +import java.util.Objects; + +@Slf4j +public class QueryResultEditabilityAnalyzer { + private final DbType dbType; + private final PrimaryKeyResolver primaryKeyResolver; + + public QueryResultEditabilityAnalyzer(DbType dbType, PrimaryKeyResolver primaryKeyResolver) { + this.dbType = dbType; + this.primaryKeyResolver = primaryKeyResolver; + } + + public void analyze(String sourceSQL, List fields) { + if (fields == null || fields.isEmpty()) { + return; + } + + try { + List statements = SQLUtils.parseStatements(sourceSQL, this.dbType); + if (statements.size() != 1) { + this.markAllReadOnly(fields, EditabilityReason.MULTIPLE_STATEMENTS_NOT_SUPPORTED); + return; + } + if (!(statements.get(0) instanceof SQLSelectStatement selectStatement)) { + this.markAllReadOnly(fields, EditabilityReason.NOT_SELECT); + return; + } + this.analyzeSelect(selectStatement, fields); + } catch (Exception e) { + log.debug("analyze query result editability failed", e); + this.markAllReadOnly(fields, EditabilityReason.UNKNOWN_COLUMN_SOURCE); + } + } + + private void analyzeSelect(SQLSelectStatement selectStatement, List fields) { + SQLSelectQueryBlock queryBlock = this.resolveSupportedQueryBlock(selectStatement, fields); + if (queryBlock == null) { + return; + } + + SourceTable source = this.resolveSourceTable(queryBlock, fields); + + if (!this.mapSelectItems( + queryBlock.getSelectList(), + fields, + source.schema(), + source.table(), + source.alias() + )) { + return; + } + + this.applyPrimaryKeyEditability(fields, source.schema(), source.table()); + } + + private SQLSelectQueryBlock resolveSupportedQueryBlock(SQLSelectStatement selectStatement, List fields) { + var select = selectStatement.getSelect(); + if (select.getWithSubQuery() != null) { + this.markAllReadOnly(fields, EditabilityReason.CTE_NOT_SUPPORTED); + return null; + } + if (select.getQuery() instanceof SQLUnionQuery) { + this.markAllReadOnly(fields, EditabilityReason.SET_OPERATION_NOT_SUPPORTED); + return null; + } + if (!(select.getQuery() instanceof SQLSelectQueryBlock queryBlock)) { + this.markAllReadOnly(fields, EditabilityReason.QUERY_BLOCK_NOT_SUPPORTED); + return null; + } + + if (!this.validateQueryBlock(queryBlock, fields)) { + return null; + } + return queryBlock; + } + + private SourceTable resolveSourceTable(SQLSelectQueryBlock queryBlock, List fields) { + var tableSource = (SQLExprTableSource) queryBlock.getFrom(); + String rawSchema = tableSource.getSchema(); + String rawTable = tableSource.getTableName(); + String schema = normalizeIdentifier(rawSchema); + String table = normalizeIdentifier(rawTable); + String tableAlias = normalizeIdentifier(tableSource.getAlias()); + schema = this.canonicalizeSourceSchema(schema, fields, isQuotedIdentifier(rawSchema)); + table = this.canonicalizeSourceTable(table, fields, isQuotedIdentifier(rawTable)); + + return new SourceTable(schema, table, tableAlias); + } + + private boolean validateQueryBlock(SQLSelectQueryBlock queryBlock, List fields) { + if (queryBlock.isDistinct()) { + this.markAllReadOnly(fields, EditabilityReason.DISTINCT_NOT_SUPPORTED); + return false; + } + + SQLSelectGroupByClause groupBy = queryBlock.getGroupBy(); + if (groupBy != null && !groupBy.getItems().isEmpty()) { + this.markAllReadOnly(fields, EditabilityReason.GROUP_BY_NOT_SUPPORTED); + return false; + } + if (groupBy != null && groupBy.getHaving() != null) { + this.markAllReadOnly(fields, EditabilityReason.HAVING_NOT_SUPPORTED); + return false; + } + + SQLTableSource from = queryBlock.getFrom(); + if (from instanceof SQLJoinTableSource) { + this.markAllReadOnly(fields, EditabilityReason.JOIN_NOT_SUPPORTED); + return false; + } + if (from instanceof SQLSubqueryTableSource) { + this.markAllReadOnly(fields, EditabilityReason.SUBQUERY_NOT_SUPPORTED); + return false; + } + if (!(from instanceof SQLExprTableSource)) { + this.markAllReadOnly(fields, EditabilityReason.TABLE_SOURCE_NOT_SUPPORTED); + return false; + } + if (!containsAllColumn(queryBlock.getSelectList()) && queryBlock.getSelectList().size() != fields.size()) { + this.markAllReadOnly(fields, EditabilityReason.SELECT_LIST_MISMATCH); + return false; + } + return true; + } + + private boolean mapSelectItems(List selectItems, + List fields, + String schema, + String table, + String tableAlias) { + if (containsAllColumn(selectItems)) { + return this.mapAllColumns(selectItems, fields, schema, table, tableAlias); + } + + for (int i = 0; i < selectItems.size(); i++) { + var field = fields.get(i); + var column = resolveColumn(selectItems.get(i).getExpr(), table, tableAlias); + if (column.reason() != null) { + this.markAllReadOnly(fields, column.reason()); + return false; + } + + field.setSourceSchema(schema); + field.setSourceTable(table); + field.setSourceColumn(this.canonicalizeSourceColumn(field, column.name(), column.quoted())); + } + return true; + } + + private boolean mapAllColumns(List selectItems, + List fields, + String schema, + String table, + String tableAlias) { + if (selectItems.size() != 1) { + this.markAllReadOnly(fields, EditabilityReason.ALL_COLUMNS_NOT_SUPPORTED); + return false; + } + + var star = selectItems.get(0).getExpr(); + String owner = starOwner(star); + if (owner == null) { + this.markAllReadOnly(fields, EditabilityReason.ALL_COLUMNS_NOT_SUPPORTED); + return false; + } + if (StringUtils.isNotBlank(owner) && !matchesTableOwner(owner, table, tableAlias)) { + this.markAllReadOnly(fields, EditabilityReason.COLUMN_OWNER_NOT_SUPPORTED); + return false; + } + + for (Field field : fields) { + String sourceColumn = this.canonicalizeSourceColumn(field, normalizeIdentifier(field.getColumnName()), false); + if (StringUtils.isBlank(sourceColumn)) { + this.markAllReadOnly(fields, EditabilityReason.UNKNOWN_COLUMN_SOURCE); + return false; + } + field.setSourceSchema(schema); + field.setSourceTable(table); + field.setSourceColumn(sourceColumn); + } + return true; + } + + private ColumnResolution resolveColumn(SQLExpr expr, String table, String tableAlias) { + if (expr instanceof SQLIdentifierExpr identifierExpr) { + String rawName = identifierExpr.getName(); + return ColumnResolution.column(normalizeIdentifier(rawName), isQuotedIdentifier(rawName)); + } + if (expr instanceof SQLPropertyExpr propertyExpr) { + String owner = normalizeIdentifier(propertyExpr.getOwnerName()); + if (!matchesTableOwner(owner, table, tableAlias)) { + return ColumnResolution.reason(EditabilityReason.COLUMN_OWNER_NOT_SUPPORTED); + } + String rawName = propertyExpr.getName(); + return ColumnResolution.column(normalizeIdentifier(rawName), isQuotedIdentifier(rawName)); + } + if (expr instanceof SQLAggregateExpr || expr instanceof SQLMethodInvokeExpr) { + return ColumnResolution.reason(EditabilityReason.AGGREGATE_OR_FUNCTION_COLUMN); + } + return ColumnResolution.reason(EditabilityReason.EXPRESSION_COLUMN); + } + + private static boolean containsAllColumn(List selectItems) { + return selectItems.stream() + .map(SQLSelectItem::getExpr) + .anyMatch(QueryResultEditabilityAnalyzer::isAllColumn); + } + + private static boolean isAllColumn(SQLExpr expr) { + if (expr instanceof SQLAllColumnExpr) { + return true; + } + if (expr instanceof SQLPropertyExpr propertyExpr) { + return Objects.equals(normalizeIdentifier(propertyExpr.getName()), "*"); + } + return false; + } + + private static String starOwner(SQLExpr expr) { + if (expr instanceof SQLAllColumnExpr allColumnExpr) { + SQLExpr owner = allColumnExpr.getOwner(); + return owner == null ? "" : normalizeIdentifier(owner.toString()); + } + if (expr instanceof SQLPropertyExpr propertyExpr && Objects.equals(normalizeIdentifier(propertyExpr.getName()), "*")) { + return normalizeIdentifier(propertyExpr.getOwnerName()); + } + return null; + } + + private void applyPrimaryKeyEditability(List fields, String schema, String table) { + PrimaryKeyResolution resolution; + try { + resolution = this.primaryKeyResolver.resolvePrimaryKeys(schema, table); + } catch (SQLException e) { + log.debug("resolve primary key failed for {}.{}", schema, table, e); + this.markAllReadOnly(fields, EditabilityReason.PRIMARY_KEY_RESOLUTION_FAILED); + return; + } + + if (resolution == null) { + this.markAllReadOnly(fields, EditabilityReason.PRIMARY_KEY_RESOLUTION_NOT_IMPLEMENTED); + return; + } + if (StringUtils.isNotBlank(resolution.getReadOnlyReason())) { + this.markAllReadOnly(fields, resolution.getReadOnlyReason()); + return; + } + + List primaryKeys = resolution.getPrimaryKeys(); + if (primaryKeys.isEmpty()) { + this.markAllReadOnly(fields, EditabilityReason.NO_PRIMARY_KEY); + return; + } + if (primaryKeys.size() > 1) { + this.markAllReadOnly(fields, EditabilityReason.COMPOSITE_PRIMARY_KEY_NOT_SUPPORTED); + return; + } + + String primaryKey = normalizeIdentifier(primaryKeys.get(0)); + var primaryKeyField = fields.stream() + .filter(field -> identifierEquals(field.getSourceColumn(), primaryKey)) + .findFirst(); + if (primaryKeyField.isEmpty()) { + this.markAllReadOnly(fields, EditabilityReason.PRIMARY_KEY_NOT_IN_RESULT); + return; + } + + fields.forEach(field -> { + this.applyColumnEditability(field, primaryKey); + this.applyInsertability(field); + }); + } + + private void applyColumnEditability(Field field, String primaryKey) { + if (identifierEquals(field.getSourceColumn(), primaryKey)) { + field.setPrimaryKey(true); + field.setEditable(false); + field.setEditReason(EditabilityReason.PRIMARY_KEY_COLUMN_NOT_EDITABLE); + return; + } + if (field.isMasked()) { + field.setEditable(false); + field.setEditReason(EditabilityReason.DATA_MASKED); + return; + } + if (field.isAutoIncrement() || field.isReadOnly() || field.isGenerated()) { + field.setEditable(false); + field.setEditReason(TableChangesPlanBuilder.SOURCE_COLUMN_NOT_EDITABLE); + return; + } + if (!TableEditTypeCodecs.supports(field, this.dbType)) { + field.setEditable(false); + field.setEditReason(EditabilityReason.TYPE_NOT_SUPPORTED_FOR_EDIT); + return; + } + field.setEditable(true); + field.setEditReason(null); + } + + private void markAllReadOnly(List fields, String reason) { + fields.forEach(field -> { + field.setEditable(false); + field.setEditReason(reason); + field.setInsertable(false); + field.setInsertReason(reason); + field.setRequiredOnInsert(false); + }); + } + + private void applyInsertability(Field field) { + if (field.isMasked()) { + this.markNotInsertable(field, EditabilityReason.DATA_MASKED); + return; + } + if (!TableEditTypeCodecs.supports(field, this.dbType)) { + this.markNotInsertable(field, EditabilityReason.TYPE_NOT_SUPPORTED_FOR_EDIT); + return; + } + if (field.isAutoIncrement() || field.isReadOnly() || field.isGenerated()) { + this.markNotInsertable(field, TableChangesPlanBuilder.INSERT_COLUMN_NOT_WRITABLE); + return; + } + if (StringUtils.isBlank(field.getSourceColumn())) { + this.markNotInsertable(field, EditabilityReason.UNKNOWN_COLUMN_SOURCE); + return; + } + field.setInsertable(true); + field.setInsertReason(null); + field.setRequiredOnInsert(!field.isNullable()); + } + + private void markNotInsertable(Field field, String reason) { + field.setInsertable(false); + field.setInsertReason(reason); + field.setRequiredOnInsert(false); + } + + private static boolean matchesTableOwner(String owner, String table, String tableAlias) { + if (StringUtils.isBlank(owner)) { + return true; + } + return identifierEquals(owner, tableAlias) || identifierEquals(owner, table); + } + + private static boolean identifierEquals(String left, String right) { + return StringUtils.equalsIgnoreCase(normalizeIdentifier(left), normalizeIdentifier(right)); + } + + private static String normalizeIdentifier(String identifier) { + if (identifier == null) { + return null; + } + String value = identifier.trim(); + if ((value.startsWith("\"") && value.endsWith("\"")) || + (value.startsWith("`") && value.endsWith("`")) || + (value.startsWith("[") && value.endsWith("]"))) { + value = value.substring(1, value.length() - 1); + } + return value; + } + + private String canonicalizeSourceSchema(String schema, List fields, boolean quoted) { + if (!this.shouldCanonicalizeFromMetadata()) { + return schema; + } + return this.canonicalizeFromFieldMetadata(schema, fields, MetadataKind.SCHEMA, quoted); + } + + private String canonicalizeSourceTable(String table, List fields, boolean quoted) { + if (!this.shouldCanonicalizeFromMetadata()) { + return table; + } + return this.canonicalizeFromFieldMetadata(table, fields, MetadataKind.TABLE, quoted); + } + + private String canonicalizeSourceColumn(Field field, String column, boolean quoted) { + if (!this.shouldCanonicalizeFromMetadata() || field == null) { + return column; + } + if (quoted && this.isDamengOrDb2()) { + return column; + } + String metadataColumn = normalizeIdentifier(field.getColumnName()); + if (StringUtils.isBlank(metadataColumn)) { + return column; + } + if (StringUtils.isBlank(column) || identifierEquals(metadataColumn, column)) { + return metadataColumn; + } + return this.canonicalizeUnquotedIdentifier(column, quoted); + } + + private String canonicalizeFromFieldMetadata(String identifier, List fields, MetadataKind kind, boolean quoted) { + if (quoted && this.isDamengOrDb2()) { + return identifier; + } + if (fields == null || fields.isEmpty()) { + return this.canonicalizeUnquotedIdentifier(identifier, quoted); + } + String normalizedIdentifier = normalizeIdentifier(identifier); + String fallback = null; + for (Field field : fields) { + if (field == null) { + continue; + } + String metadataIdentifier = switch (kind) { + case SCHEMA -> normalizeIdentifier(field.getSchema()); + case TABLE -> normalizeIdentifier(field.getTable()); + }; + if (StringUtils.isBlank(metadataIdentifier)) { + continue; + } + if (fallback == null) { + fallback = metadataIdentifier; + } + if (StringUtils.isBlank(normalizedIdentifier) || identifierEquals(metadataIdentifier, normalizedIdentifier)) { + return metadataIdentifier; + } + } + if (StringUtils.isNotBlank(fallback) && StringUtils.isBlank(normalizedIdentifier)) { + return fallback; + } + return this.canonicalizeUnquotedIdentifier(normalizedIdentifier, quoted); + } + + private String canonicalizeUnquotedIdentifier(String identifier, boolean quoted) { + if (quoted || StringUtils.isBlank(identifier)) { + return identifier; + } + if (this.dbType != DbType.oracle && this.dbType != DbType.dm && this.dbType != DbType.db2) { + return identifier; + } + return identifier.toUpperCase(java.util.Locale.ROOT); + } + + private static boolean isQuotedIdentifier(String identifier) { + if (identifier == null) { + return false; + } + String value = identifier.trim(); + return (value.startsWith("\"") && value.endsWith("\"")) || + (value.startsWith("`") && value.endsWith("`")) || + (value.startsWith("[") && value.endsWith("]")); + } + + private boolean shouldCanonicalizeFromMetadata() { + return this.dbType == DbType.oracle || + this.dbType == DbType.sqlserver || + this.dbType == DbType.dm || + this.dbType == DbType.db2; + } + + private boolean isDamengOrDb2() { + return this.dbType == DbType.dm || this.dbType == DbType.db2; + } + + private enum MetadataKind { + SCHEMA, + TABLE + } + + private record ColumnResolution(String name, boolean quoted, String reason) { + static ColumnResolution column(String name, boolean quoted) { + if (StringUtils.isBlank(name) || Objects.equals(name, "*")) { + return reason(EditabilityReason.UNKNOWN_COLUMN_SOURCE); + } + return new ColumnResolution(name, quoted, null); + } + + static ColumnResolution reason(String reason) { + return new ColumnResolution(null, false, reason); + } + } + + private record SourceTable(String schema, String table, String alias) { + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/bind/PreparedStatementBinder.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/bind/PreparedStatementBinder.java new file mode 100644 index 0000000..8cafbaf --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/bind/PreparedStatementBinder.java @@ -0,0 +1,68 @@ +package org.jumpserver.chen.framework.datasource.edit.bind; + +import com.alibaba.druid.DbType; +import lombok.extern.slf4j.Slf4j; +import org.jumpserver.chen.framework.datasource.edit.command.PreparedTableChangeCommand; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Types; + +@Slf4j +public class PreparedStatementBinder { + + public void bind(PreparedStatement statement, PreparedTableChangeCommand command) throws SQLException { + for (int i = 0; i < command.getParameters().size(); i++) { + PreparedTableChangeCommand.Parameter parameter = command.getParameters().get(i); + bindConvertedValue( + statement, + i + 1, + parameter + ); + } + } + + private void bindConvertedValue( + PreparedStatement statement, + int index, + PreparedTableChangeCommand.Parameter parameter + ) throws SQLException { + try { + if (parameter.isValueIsNull() || parameter.getValue() == null) { + statement.setNull(index, parameter.getJdbcType() != null ? parameter.getJdbcType() : Types.NULL); + return; + } + if (isJsonOther(parameter)) { + statement.setObject(index, parameter.getValue(), Types.OTHER); + return; + } + statement.setObject(index, parameter.getValue()); + } catch (SQLException e) { + log.warn( + "bind save changes parameter failed, index={}, paramName={}, column={}, isNull={}, jdbcType={}, dbType={}, valueClass={}, sqlState={}, vendorCode={}, message={}", + index, + parameter.getName(), + parameter.getColumn(), + parameter.isValueIsNull(), + parameter.getJdbcType(), + parameter.getDbType(), + parameter.getValue() != null ? parameter.getValue().getClass().getName() : null, + e.getSQLState(), + e.getErrorCode(), + e.getMessage(), + e + ); + throw e; + } + } + + private boolean isJsonOther(PreparedTableChangeCommand.Parameter parameter) { + if (parameter.getDbType() != DbType.postgresql || + parameter.getJdbcType() == null || + parameter.getJdbcType() != Types.OTHER) { + return false; + } + String type = TableEditTypeCodecs.normalizeType(parameter.getField()); + return "json".equals(type) || "jsonb".equals(type); + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/bind/TableEditTypeCodecs.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/bind/TableEditTypeCodecs.java new file mode 100644 index 0000000..6b4c3cb --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/bind/TableEditTypeCodecs.java @@ -0,0 +1,523 @@ +package org.jumpserver.chen.framework.datasource.edit.bind; + +import com.alibaba.druid.DbType; +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import org.apache.commons.lang3.StringUtils; +import org.jumpserver.chen.framework.datasource.entity.resource.Field; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.sql.Date; +import java.sql.SQLException; +import java.sql.Time; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.util.UUID; + +public final class TableEditTypeCodecs { + private static final Gson GSON = new Gson(); + private static final int SQLSERVER_GUID = -145; + private static final int SQLSERVER_SMALLDATETIME = -150; + private static final int SQLSERVER_DATETIME = -151; + private static final int SQLSERVER_DATETIMEOFFSET = -155; + private static final int ORACLE_TIMESTAMPNS = -100; + private static final int ORACLE_TIMESTAMPTZ = -101; + private static final int ORACLE_TIMESTAMPLTZ = -102; + private static final int ORACLE_JSON = 2016; + + private TableEditTypeCodecs() { + } + + public static boolean supports(Field field) { + return supports(field, null); + } + + public static boolean supports(Field field, DbType dbType) { + return kind(field, dbType) != Kind.UNSUPPORTED; + } + + public static Object coerce(Object value, Field field) throws SQLException { + return coerce(value, field, null); + } + + public static Object coerce(Object value, Field field, DbType dbType) throws SQLException { + Kind kind = kind(field, dbType); + String type = normalizeType(field); + if (kind == Kind.UNSUPPORTED) { + throw new SQLException("unsupported table edit type, column=" + + (field != null ? field.getSourceColumn() : null) + ", type=" + type); + } + if (value == null) { + return null; + } + + try { + return switch (kind) { + case TINYINT -> toByte(value); + case SMALLINT -> toShort(value); + case INTEGER -> toInteger(value); + case BIGINT -> toLong(value); + case DECIMAL -> toBigDecimal(value); + case FLOATING -> toFiniteDouble(value); + case BOOLEAN -> toBoolean(value); + case STRING -> value.toString(); + case DATE -> toDate(value); + case TIME -> toTime(value); + case TIMESTAMP -> toTimestamp(value); + case OFFSET_TIMESTAMP -> toOffsetDateTime(value); + case UUID -> toUuid(value); + case JSON -> toJsonString(value); + case UNSUPPORTED -> throw new IllegalArgumentException("unsupported type"); + }; + } catch (RuntimeException e) { + String column = field != null ? field.getSourceColumn() : null; + throw new SQLException("convert value failed, column=" + column + ", type=" + type, e); + } + } + + public static String renderLiteral(Object value, boolean isNull, Field field) throws SQLException { + return renderLiteral(value, isNull, field, null); + } + + public static String renderLiteral(Object value, boolean isNull, Field field, DbType dbType) throws SQLException { + if (isNull || value == null) { + return "NULL"; + } + Object coercedValue = coerce(value, field, dbType); + if (coercedValue == null) { + return "NULL"; + } + if (coercedValue instanceof Number) { + return coercedValue.toString(); + } + if (coercedValue instanceof Boolean bool) { + return bool ? "TRUE" : "FALSE"; + } + return "'" + coercedValue.toString().replace("'", "''") + "'"; + } + + public static String normalizeType(Field field) { + if (field == null || StringUtils.isBlank(field.getType())) { + return null; + } + String type = unwrapKnownTypeWrappers(field.getType().trim().toLowerCase()); + return type.replaceAll("\\([^)]*\\)", "").trim(); + } + + private static Kind kind(Field field, DbType dbType) { + if (field == null) { + return Kind.UNSUPPORTED; + } + String type = normalizeType(field); + Integer jdbcType = field.getJdbcType(); + if (isUnsignedType(type)) { + return Kind.UNSUPPORTED; + } + if (jdbcType != null) { + if (jdbcType == SQLSERVER_GUID) { + return dbType == DbType.sqlserver ? Kind.UUID : Kind.UNSUPPORTED; + } + if (jdbcType == SQLSERVER_DATETIMEOFFSET) { + return dbType == DbType.sqlserver ? Kind.OFFSET_TIMESTAMP : Kind.UNSUPPORTED; + } + if (jdbcType == ORACLE_TIMESTAMPTZ) { + return dbType == DbType.oracle ? Kind.OFFSET_TIMESTAMP : Kind.UNSUPPORTED; + } + if (jdbcType == ORACLE_TIMESTAMPLTZ) { + return Kind.UNSUPPORTED; + } + if (jdbcType == ORACLE_JSON) { + return dbType == DbType.oracle ? Kind.JSON : Kind.UNSUPPORTED; + } + if (jdbcType == Types.TIMESTAMP_WITH_TIMEZONE) { + return timestampWithTimezoneKind(type, dbType); + } + if (jdbcType == Types.BOOLEAN) { + return booleanKind(type, dbType); + } + if (jdbcType == Types.BIT) { + return bitKind(type, dbType); + } + if (jdbcType == Types.OTHER && StringUtils.isNotBlank(type)) { + if (isTimestampWithTimezone(type)) { + return timestampWithTimezoneKind(type, dbType); + } + if (isUuid(type)) { + return uuidKind(type, dbType); + } + if (isJson(type)) { + return jsonKind(type, dbType); + } + if (isBoolean(type)) { + return booleanKind(type, dbType); + } + } + if (jdbcType == SQLSERVER_DATETIME) { + return dbType == DbType.sqlserver ? Kind.TIMESTAMP : Kind.UNSUPPORTED; + } + if (jdbcType == SQLSERVER_SMALLDATETIME) { + return dbType == DbType.sqlserver ? Kind.TIMESTAMP : Kind.UNSUPPORTED; + } + if (jdbcType == ORACLE_TIMESTAMPNS) { + return dbType == DbType.oracle ? Kind.TIMESTAMP : Kind.UNSUPPORTED; + } + } + if (jdbcType != null && jdbcType != Types.OTHER) { + return switch (jdbcType) { + case Types.TINYINT -> Kind.TINYINT; + case Types.SMALLINT -> Kind.SMALLINT; + case Types.INTEGER -> Kind.INTEGER; + case Types.BIGINT -> Kind.BIGINT; + case Types.NUMERIC, Types.DECIMAL -> Kind.DECIMAL; + case Types.REAL, Types.FLOAT, Types.DOUBLE -> Kind.FLOATING; + case Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, + Types.NCHAR, Types.NVARCHAR, Types.LONGNVARCHAR -> Kind.STRING; + case Types.DATE -> Kind.DATE; + case Types.TIME -> Kind.TIME; + case Types.TIMESTAMP -> Kind.TIMESTAMP; + default -> Kind.UNSUPPORTED; + }; + } + if (StringUtils.isBlank(type)) { + return Kind.UNSUPPORTED; + } + if (isBitString(type)) { + return Kind.UNSUPPORTED; + } + if (isBigInt(type)) { + return Kind.BIGINT; + } + if (isInteger(type)) { + return Kind.INTEGER; + } + if (isTinyInt(type)) { + return Kind.TINYINT; + } + if (isSmallInt(type)) { + return Kind.SMALLINT; + } + if (isDecimal(type)) { + return Kind.DECIMAL; + } + if (isFloating(type)) { + return Kind.FLOATING; + } + if (isBoolean(type)) { + return booleanKind(type, dbType); + } + if (isString(type)) { + return Kind.STRING; + } + if (isDate(type)) { + return Kind.DATE; + } + if (isTime(type)) { + return Kind.TIME; + } + if (isTimestamp(type)) { + return Kind.TIMESTAMP; + } + if (isTimestampWithTimezone(type)) { + return timestampWithTimezoneKind(type, dbType); + } + if (isUuid(type)) { + return uuidKind(type, dbType); + } + if (isJson(type)) { + return jsonKind(type, dbType); + } + return Kind.UNSUPPORTED; + } + + private static Kind booleanKind(String type, DbType dbType) { + if (dbType == DbType.postgresql || dbType == DbType.mysql || dbType == DbType.mariadb) { + return StringUtils.isBlank(type) || isBoolean(type) ? Kind.BOOLEAN : Kind.UNSUPPORTED; + } + return Kind.UNSUPPORTED; + } + + private static Kind bitKind(String type, DbType dbType) { + if (dbType == DbType.postgresql && isBoolean(type)) { + return Kind.BOOLEAN; + } + if (dbType == DbType.sqlserver && (StringUtils.isBlank(type) || StringUtils.equals(type, "bit"))) { + return Kind.BOOLEAN; + } + return Kind.UNSUPPORTED; + } + + private static Kind uuidKind(String type, DbType dbType) { + if (dbType == DbType.postgresql && StringUtils.equals(type, "uuid")) { + return Kind.UUID; + } + if (dbType == DbType.sqlserver && StringUtils.equals(type, "uniqueidentifier")) { + return Kind.UUID; + } + return Kind.UNSUPPORTED; + } + + private static Kind jsonKind(String type, DbType dbType) { + if (dbType == DbType.postgresql && StringUtils.equalsAny(type, "json", "jsonb")) { + return Kind.JSON; + } + if ((dbType == DbType.mysql || dbType == DbType.mariadb || dbType == DbType.oracle) && + StringUtils.equals(type, "json")) { + return Kind.JSON; + } + return Kind.UNSUPPORTED; + } + + private static Kind timestampWithTimezoneKind(String type, DbType dbType) { + if (dbType == DbType.postgresql && (StringUtils.isBlank(type) || StringUtils.equals(type, "timestamptz") || + StringUtils.equals(type, "timestamp with time zone"))) { + return Kind.OFFSET_TIMESTAMP; + } + if (dbType == DbType.sqlserver && (StringUtils.isBlank(type) || StringUtils.equals(type, "datetimeoffset"))) { + return Kind.OFFSET_TIMESTAMP; + } + if (dbType == DbType.oracle && (StringUtils.isBlank(type) || + StringUtils.equals(type, "timestamp with time zone"))) { + return Kind.OFFSET_TIMESTAMP; + } + return Kind.UNSUPPORTED; + } + + private static String unwrapKnownTypeWrappers(String type) { + String unwrapped = type; + boolean changed; + do { + changed = false; + if (unwrapped.startsWith("nullable(") && unwrapped.endsWith(")")) { + unwrapped = unwrapped.substring("nullable(".length(), unwrapped.length() - 1).trim(); + changed = true; + } + if (unwrapped.startsWith("lowcardinality(") && unwrapped.endsWith(")")) { + unwrapped = unwrapped.substring("lowcardinality(".length(), unwrapped.length() - 1).trim(); + changed = true; + } + } while (changed); + return unwrapped; + } + + private static boolean isBigInt(String type) { + return StringUtils.equalsAny(type, "bigserial", "bigint", "int8"); + } + + private static boolean isInteger(String type) { + return StringUtils.equalsAny(type, "serial", "integer", "int", "int4", "mediumint"); + } + + private static boolean isTinyInt(String type) { + return StringUtils.equalsAny(type, "tinyint", "int1"); + } + + private static boolean isSmallInt(String type) { + return StringUtils.equalsAny(type, "smallint", "int2"); + } + + private static boolean isDecimal(String type) { + return StringUtils.equalsAny(type, "numeric", "decimal", "number"); + } + + private static boolean isFloating(String type) { + return StringUtils.equalsAny(type, "real", "float", "float4", "float8", "double", "double precision"); + } + + private static boolean isUnsignedType(String type) { + return StringUtils.endsWith(type, " unsigned") || StringUtils.startsWith(type, "uint"); + } + + private static boolean isBoolean(String type) { + return StringUtils.equalsAny(type, "bool", "boolean"); + } + + private static boolean isBitString(String type) { + return StringUtils.equalsAny(type, "bit", "bit varying", "varbit"); + } + + private static boolean isString(String type) { + return StringUtils.equalsAny(type, "varchar", "varchar2", "character varying", "text", "tinytext", + "mediumtext", "longtext", "char", "character", "bpchar", "nvarchar", "nvarchar2", "nchar", + "longnvarchar"); + } + + private static boolean isDate(String type) { + return StringUtils.equals(type, "date"); + } + + private static boolean isTime(String type) { + return StringUtils.equalsAny(type, "time", "time without time zone"); + } + + private static boolean isTimestamp(String type) { + return StringUtils.equalsAny(type, "timestamp", "timestamp without time zone", "datetime", "datetime2", + "smalldatetime"); + } + + private static boolean isTimestampWithTimezone(String type) { + return StringUtils.equalsAny(type, "timestamptz", "timestamp with time zone", "datetimeoffset"); + } + + private static boolean isUuid(String type) { + return StringUtils.equalsAny(type, "uuid", "uniqueidentifier"); + } + + private static boolean isJson(String type) { + return StringUtils.equalsAny(type, "json", "jsonb"); + } + + private static Byte toByte(Object value) { + return exactInteger(value, BigInteger.valueOf(Byte.MIN_VALUE), BigInteger.valueOf(Byte.MAX_VALUE)).byteValueExact(); + } + + private static Short toShort(Object value) { + return exactInteger(value, BigInteger.valueOf(Short.MIN_VALUE), BigInteger.valueOf(Short.MAX_VALUE)).shortValueExact(); + } + + private static Integer toInteger(Object value) { + return exactInteger(value, BigInteger.valueOf(Integer.MIN_VALUE), BigInteger.valueOf(Integer.MAX_VALUE)).intValueExact(); + } + + private static Long toLong(Object value) { + return exactInteger(value, BigInteger.valueOf(Long.MIN_VALUE), BigInteger.valueOf(Long.MAX_VALUE)).longValueExact(); + } + + private static BigInteger exactInteger(Object value, BigInteger minimum, BigInteger maximum) { + BigInteger integer = toBigDecimal(value).toBigIntegerExact(); + if (integer.compareTo(minimum) < 0 || integer.compareTo(maximum) > 0) { + throw new ArithmeticException("integer out of range"); + } + return integer; + } + + private static BigDecimal toBigDecimal(Object value) { + if (value instanceof BigDecimal bigDecimal) { + return bigDecimal; + } + if (value instanceof Number || value instanceof CharSequence) { + return new BigDecimal(value.toString()); + } + throw new IllegalArgumentException("value is not numeric"); + } + + private static Double toFiniteDouble(Object value) { + double converted; + if (value instanceof Number number) { + converted = number.doubleValue(); + } else { + converted = Double.parseDouble(value.toString().trim()); + } + if (!Double.isFinite(converted)) { + throw new IllegalArgumentException("value is not finite"); + } + return converted; + } + + private static Boolean toBoolean(Object value) { + if (value instanceof Boolean bool) { + return bool; + } + String text = value.toString().trim(); + if (StringUtils.equalsAnyIgnoreCase(text, "true", "t", "1", "yes", "y")) { + return true; + } + if (StringUtils.equalsAnyIgnoreCase(text, "false", "f", "0", "no", "n")) { + return false; + } + throw new IllegalArgumentException("value is not boolean"); + } + + private static Date toDate(Object value) { + if (value instanceof Date date) { + return date; + } + if (value instanceof java.util.Date date) { + return new Date(date.getTime()); + } + String text = value.toString().trim(); + int timeSeparator = Math.max(text.indexOf('T'), text.indexOf(' ')); + if (timeSeparator > -1) { + text = text.substring(0, timeSeparator); + } + return Date.valueOf(LocalDate.parse(text)); + } + + private static Time toTime(Object value) { + if (value instanceof Time time) { + return time; + } + if (value instanceof java.util.Date date) { + return new Time(date.getTime()); + } + String text = value.toString().trim(); + if (text.endsWith("Z") || text.matches(".*[+-]\\d{2}:\\d{2}$")) { + return Time.valueOf(OffsetDateTime.parse(text).toLocalTime()); + } + if (text.contains("T") || text.contains(" ")) { + return Time.valueOf(LocalDateTime.parse(text.replace(' ', 'T')).toLocalTime()); + } + return Time.valueOf(LocalTime.parse(text)); + } + + private static Timestamp toTimestamp(Object value) { + if (value instanceof Timestamp timestamp) { + return timestamp; + } + if (value instanceof java.util.Date date) { + return new Timestamp(date.getTime()); + } + String text = value.toString().trim(); + if (text.endsWith("Z") || text.matches(".*[+-]\\d{2}:\\d{2}$")) { + return Timestamp.from(OffsetDateTime.parse(text).toInstant()); + } + return Timestamp.valueOf(LocalDateTime.parse(text.replace(' ', 'T'))); + } + + private static OffsetDateTime toOffsetDateTime(Object value) { + if (value instanceof OffsetDateTime offsetDateTime) { + return offsetDateTime; + } + return OffsetDateTime.parse(value.toString().trim()); + } + + private static UUID toUuid(Object value) { + if (value instanceof UUID uuid) { + return uuid; + } + return UUID.fromString(value.toString().trim()); + } + + private static String toJsonString(Object value) { + String text = value instanceof CharSequence ? value.toString() : GSON.toJson(value); + try { + JsonParser.parseString(text); + } catch (JsonParseException e) { + throw new IllegalArgumentException("value is not valid json", e); + } + return text; + } + + private enum Kind { + TINYINT, + SMALLINT, + INTEGER, + BIGINT, + DECIMAL, + FLOATING, + BOOLEAN, + STRING, + DATE, + TIME, + TIMESTAMP, + OFFSET_TIMESTAMP, + UUID, + JSON, + UNSUPPORTED + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/bind/TableEditValueConverter.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/bind/TableEditValueConverter.java new file mode 100644 index 0000000..c74bc6c --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/bind/TableEditValueConverter.java @@ -0,0 +1,19 @@ +package org.jumpserver.chen.framework.datasource.edit.bind; + +import com.alibaba.druid.DbType; +import org.jumpserver.chen.framework.datasource.entity.resource.Field; + +import java.sql.SQLException; + +public final class TableEditValueConverter { + private TableEditValueConverter() { + } + + public static Object coerce(Object value, Field field) throws SQLException { + return TableEditTypeCodecs.coerce(value, field); + } + + public static Object coerce(Object value, Field field, DbType dbType) throws SQLException { + return TableEditTypeCodecs.coerce(value, field, dbType); + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/command/PreparedTableChangeCommand.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/command/PreparedTableChangeCommand.java new file mode 100644 index 0000000..be493e6 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/command/PreparedTableChangeCommand.java @@ -0,0 +1,60 @@ +package org.jumpserver.chen.framework.datasource.edit.command; + +import com.alibaba.druid.DbType; +import lombok.Data; +import org.jumpserver.chen.framework.console.entity.request.SaveChangesRequest; +import org.jumpserver.chen.framework.datasource.entity.resource.Field; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class PreparedTableChangeCommand { + private Operation operation; + private String preparedSql; + private String sourceColumn; + private String pkColumn; + private SaveChangesRequest.ChangeItem change; + private SaveChangesRequest.InsertRow insertRow; + private SaveChangesRequest.DeleteRow deleteRow; + private List parameters = new ArrayList<>(); + + public enum Operation { + UPDATE, + DELETE, + INSERT + } + + @Data + public static class Parameter { + private final String name; + private final String column; + private final Field field; + private final Object value; + private final boolean valueIsNull; + private final Integer jdbcType; + private final DbType dbType; + + public Parameter(String name, String column, Field field, Object value, boolean valueIsNull) { + this(name, column, field, value, valueIsNull, field != null ? field.getJdbcType() : null, null); + } + + public Parameter(String name, String column, Field field, Object value, boolean valueIsNull, DbType dbType) { + this(name, column, field, value, valueIsNull, field != null ? field.getJdbcType() : null, dbType); + } + + public Parameter(String name, String column, Field field, Object value, boolean valueIsNull, Integer jdbcType) { + this(name, column, field, value, valueIsNull, jdbcType, null); + } + + public Parameter(String name, String column, Field field, Object value, boolean valueIsNull, Integer jdbcType, DbType dbType) { + this.name = name; + this.column = column; + this.field = field; + this.value = value; + this.valueIsNull = valueIsNull; + this.jdbcType = jdbcType; + this.dbType = dbType; + } + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/AbstractTableEditDialect.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/AbstractTableEditDialect.java new file mode 100644 index 0000000..9412846 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/AbstractTableEditDialect.java @@ -0,0 +1,149 @@ +package org.jumpserver.chen.framework.datasource.edit.dialect; + +import com.alibaba.druid.DbType; +import org.apache.commons.lang3.StringUtils; +import org.jumpserver.chen.framework.datasource.entity.resource.Field; +import org.jumpserver.chen.framework.datasource.edit.bind.TableEditTypeCodecs; +import org.jumpserver.chen.framework.datasource.sql.SQLIdentifier; + +import java.sql.SQLException; +import java.util.List; +import java.util.stream.Collectors; + +abstract class AbstractTableEditDialect implements TableEditDialect { + private final DbType dbType; + + AbstractTableEditDialect(DbType dbType) { + this.dbType = dbType; + } + + @Override + public String quoteIdentifier(String identifier) { + return SQLIdentifier.quote(this.dbType, identifier); + } + + @Override + public String buildPreparedUpdateSql(String schema, String table, String sourceColumn, String pkColumn) { + String qualifiedTable = this.qualifiedTable(schema, table); + String quotedSourceColumn = this.quoteIdentifier(sourceColumn); + String quotedPkColumn = this.quoteIdentifier(pkColumn); + return "UPDATE " + qualifiedTable + "\n" + + "SET " + quotedSourceColumn + " = ?\n" + + "WHERE " + quotedPkColumn + " = ?\n" + + " AND " + this.buildPreparedOldValueCondition(quotedSourceColumn); + } + + @Override + public String buildPreparedDeleteSql(String schema, String table, String pkColumn) { + return "DELETE FROM " + this.qualifiedTable(schema, table) + "\n" + + "WHERE " + this.quoteIdentifier(pkColumn) + " = ?"; + } + + @Override + public String buildPreparedInsertSql(String schema, String table, List sourceColumns) { + String columns = sourceColumns.stream() + .map(this::quoteIdentifier) + .collect(Collectors.joining(", ")); + String placeholders = sourceColumns.stream() + .map(column -> "?") + .collect(Collectors.joining(", ")); + return "INSERT INTO " + this.qualifiedTable(schema, table) + " (" + columns + ")\n" + + "VALUES (" + placeholders + ")"; + } + + @Override + public int oldValueParameterCount() { + return 1; + } + + @Override + public String renderLiteral(Object value, boolean isNull, Field field) throws SQLException { + return TableEditTypeCodecs.renderLiteral(value, isNull, field, this.dbType); + } + + @Override + public String buildAuditUpdateSql( + String schema, + String table, + String sourceColumn, + Field targetField, + Object newValue, + boolean newValueIsNull, + String pkColumn, + Field pkField, + Object pkValue, + boolean pkValueIsNull, + Object oldValue, + boolean oldValueIsNull + ) throws SQLException { + if (targetField != null && StringUtils.isNotBlank(targetField.getSourceColumn()) && + !StringUtils.equals(targetField.getSourceColumn(), sourceColumn)) { + throw new SQLException("sourceColumn does not match targetField.sourceColumn"); + } + String qualifiedTable = this.qualifiedTable(schema, table); + String quotedSourceColumn = this.quoteIdentifier(sourceColumn); + String quotedPkColumn = this.quoteIdentifier(pkColumn); + String renderedOldValue = this.renderLiteral(oldValue, oldValueIsNull, targetField); + return "UPDATE " + qualifiedTable + "\n" + + "SET " + quotedSourceColumn + " = " + this.renderLiteral(newValue, newValueIsNull, targetField) + "\n" + + "WHERE " + quotedPkColumn + " = " + this.renderLiteral(pkValue, pkValueIsNull, pkField) + "\n" + + " AND " + this.buildAuditOldValueCondition(quotedSourceColumn, renderedOldValue) + ";"; + } + + @Override + public String buildAuditDeleteSql( + String schema, + String table, + String pkColumn, + Field pkField, + Object pkValue, + boolean pkValueIsNull + ) throws SQLException { + return "DELETE FROM " + this.qualifiedTable(schema, table) + "\n" + + "WHERE " + this.quoteIdentifier(pkColumn) + " = " + + this.renderLiteral(pkValue, pkValueIsNull, pkField) + ";"; + } + + @Override + public String buildAuditInsertSql( + String schema, + String table, + List sourceColumns, + List fields, + List values, + List valueIsNulls + ) throws SQLException { + String columns = sourceColumns.stream() + .map(this::quoteIdentifier) + .collect(Collectors.joining(", ")); + StringBuilder renderedValues = new StringBuilder(); + for (int i = 0; i < sourceColumns.size(); i++) { + if (i > 0) { + renderedValues.append(", "); + } + renderedValues.append(this.renderLiteral(values.get(i), valueIsNulls.get(i), fields.get(i))); + } + return "INSERT INTO " + this.qualifiedTable(schema, table) + " (" + columns + ")\n" + + "VALUES (" + renderedValues + ");"; + } + + protected abstract String buildPreparedOldValueCondition(String quotedSourceColumn); + + protected abstract String buildAuditOldValueCondition(String quotedSourceColumn, String renderedOldValue); + + protected String buildPreparedNullableEqualityCondition(String quotedSourceColumn) { + return "(" + quotedSourceColumn + " = ? OR (" + quotedSourceColumn + " IS NULL AND ? IS NULL))"; + } + + protected String buildAuditNullableEqualityCondition(String quotedSourceColumn, String renderedOldValue) { + return "(" + quotedSourceColumn + " = " + renderedOldValue + " OR (" + + quotedSourceColumn + " IS NULL AND " + renderedOldValue + " IS NULL))"; + } + + protected String qualifiedTable(String schema, String table) { + if (StringUtils.isBlank(schema)) { + return this.quoteIdentifier(table); + } + return this.quoteIdentifier(schema) + "." + this.quoteIdentifier(table); + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/DamengTableEditDialect.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/DamengTableEditDialect.java new file mode 100644 index 0000000..e3e2180 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/DamengTableEditDialect.java @@ -0,0 +1,24 @@ +package org.jumpserver.chen.framework.datasource.edit.dialect; + +import com.alibaba.druid.DbType; + +class DamengTableEditDialect extends AbstractTableEditDialect { + DamengTableEditDialect() { + super(DbType.dm); + } + + @Override + public int oldValueParameterCount() { + return 2; + } + + @Override + protected String buildPreparedOldValueCondition(String quotedSourceColumn) { + return this.buildPreparedNullableEqualityCondition(quotedSourceColumn); + } + + @Override + protected String buildAuditOldValueCondition(String quotedSourceColumn, String renderedOldValue) { + return this.buildAuditNullableEqualityCondition(quotedSourceColumn, renderedOldValue); + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/Db2TableEditDialect.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/Db2TableEditDialect.java new file mode 100644 index 0000000..71d070c --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/Db2TableEditDialect.java @@ -0,0 +1,24 @@ +package org.jumpserver.chen.framework.datasource.edit.dialect; + +import com.alibaba.druid.DbType; + +class Db2TableEditDialect extends AbstractTableEditDialect { + Db2TableEditDialect() { + super(DbType.db2); + } + + @Override + public int oldValueParameterCount() { + return 2; + } + + @Override + protected String buildPreparedOldValueCondition(String quotedSourceColumn) { + return this.buildPreparedNullableEqualityCondition(quotedSourceColumn); + } + + @Override + protected String buildAuditOldValueCondition(String quotedSourceColumn, String renderedOldValue) { + return this.buildAuditNullableEqualityCondition(quotedSourceColumn, renderedOldValue); + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/MysqlTableEditDialect.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/MysqlTableEditDialect.java new file mode 100644 index 0000000..2bf8503 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/MysqlTableEditDialect.java @@ -0,0 +1,19 @@ +package org.jumpserver.chen.framework.datasource.edit.dialect; + +import com.alibaba.druid.DbType; + +class MysqlTableEditDialect extends AbstractTableEditDialect { + MysqlTableEditDialect() { + super(DbType.mysql); + } + + @Override + protected String buildPreparedOldValueCondition(String quotedSourceColumn) { + return quotedSourceColumn + " <=> ?"; + } + + @Override + protected String buildAuditOldValueCondition(String quotedSourceColumn, String renderedOldValue) { + return quotedSourceColumn + " <=> " + renderedOldValue; + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/OracleTableEditDialect.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/OracleTableEditDialect.java new file mode 100644 index 0000000..d6da7a9 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/OracleTableEditDialect.java @@ -0,0 +1,24 @@ +package org.jumpserver.chen.framework.datasource.edit.dialect; + +import com.alibaba.druid.DbType; + +class OracleTableEditDialect extends AbstractTableEditDialect { + OracleTableEditDialect() { + super(DbType.oracle); + } + + @Override + public int oldValueParameterCount() { + return 2; + } + + @Override + protected String buildPreparedOldValueCondition(String quotedSourceColumn) { + return this.buildPreparedNullableEqualityCondition(quotedSourceColumn); + } + + @Override + protected String buildAuditOldValueCondition(String quotedSourceColumn, String renderedOldValue) { + return this.buildAuditNullableEqualityCondition(quotedSourceColumn, renderedOldValue); + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/PostgresqlTableEditDialect.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/PostgresqlTableEditDialect.java new file mode 100644 index 0000000..158769f --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/PostgresqlTableEditDialect.java @@ -0,0 +1,19 @@ +package org.jumpserver.chen.framework.datasource.edit.dialect; + +import com.alibaba.druid.DbType; + +class PostgresqlTableEditDialect extends AbstractTableEditDialect { + PostgresqlTableEditDialect() { + super(DbType.postgresql); + } + + @Override + protected String buildPreparedOldValueCondition(String quotedSourceColumn) { + return quotedSourceColumn + " IS NOT DISTINCT FROM ?"; + } + + @Override + protected String buildAuditOldValueCondition(String quotedSourceColumn, String renderedOldValue) { + return quotedSourceColumn + " IS NOT DISTINCT FROM " + renderedOldValue; + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/SqlServerTableEditDialect.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/SqlServerTableEditDialect.java new file mode 100644 index 0000000..5b922f5 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/SqlServerTableEditDialect.java @@ -0,0 +1,24 @@ +package org.jumpserver.chen.framework.datasource.edit.dialect; + +import com.alibaba.druid.DbType; + +class SqlServerTableEditDialect extends AbstractTableEditDialect { + SqlServerTableEditDialect() { + super(DbType.sqlserver); + } + + @Override + public int oldValueParameterCount() { + return 2; + } + + @Override + protected String buildPreparedOldValueCondition(String quotedSourceColumn) { + return this.buildPreparedNullableEqualityCondition(quotedSourceColumn); + } + + @Override + protected String buildAuditOldValueCondition(String quotedSourceColumn, String renderedOldValue) { + return this.buildAuditNullableEqualityCondition(quotedSourceColumn, renderedOldValue); + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/TableEditDialect.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/TableEditDialect.java new file mode 100644 index 0000000..aeb25a8 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/TableEditDialect.java @@ -0,0 +1,53 @@ +package org.jumpserver.chen.framework.datasource.edit.dialect; + +import org.jumpserver.chen.framework.datasource.entity.resource.Field; + +import java.sql.SQLException; + +public interface TableEditDialect { + String quoteIdentifier(String identifier); + + String buildPreparedUpdateSql(String schema, String table, String sourceColumn, String pkColumn); + + String buildPreparedDeleteSql(String schema, String table, String pkColumn); + + String buildPreparedInsertSql(String schema, String table, java.util.List sourceColumns); + + int oldValueParameterCount(); + + // For preview/audit SQL only. Real writes must use PreparedStatement binding. + String renderLiteral(Object value, boolean isNull, Field field) throws SQLException; + + String buildAuditUpdateSql( + String schema, + String table, + String sourceColumn, + Field targetField, + Object newValue, + boolean newValueIsNull, + String pkColumn, + Field pkField, + Object pkValue, + boolean pkValueIsNull, + Object oldValue, + boolean oldValueIsNull + ) throws SQLException; + + String buildAuditDeleteSql( + String schema, + String table, + String pkColumn, + Field pkField, + Object pkValue, + boolean pkValueIsNull + ) throws SQLException; + + String buildAuditInsertSql( + String schema, + String table, + java.util.List sourceColumns, + java.util.List fields, + java.util.List values, + java.util.List valueIsNulls + ) throws SQLException; +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/TableEditDialects.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/TableEditDialects.java new file mode 100644 index 0000000..34f2fee --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/dialect/TableEditDialects.java @@ -0,0 +1,39 @@ +package org.jumpserver.chen.framework.datasource.edit.dialect; + +import com.alibaba.druid.DbType; + +import java.util.Optional; + +public final class TableEditDialects { + private static final TableEditDialect POSTGRESQL = new PostgresqlTableEditDialect(); + private static final TableEditDialect MYSQL = new MysqlTableEditDialect(); + private static final TableEditDialect ORACLE = new OracleTableEditDialect(); + private static final TableEditDialect SQLSERVER = new SqlServerTableEditDialect(); + private static final TableEditDialect DAMENG = new DamengTableEditDialect(); + private static final TableEditDialect DB2 = new Db2TableEditDialect(); + + private TableEditDialects() { + } + + public static Optional find(DbType dbType) { + if (dbType == DbType.postgresql) { + return Optional.of(POSTGRESQL); + } + if (dbType == DbType.mysql || dbType == DbType.mariadb) { + return Optional.of(MYSQL); + } + if (dbType == DbType.oracle) { + return Optional.of(ORACLE); + } + if (dbType == DbType.sqlserver) { + return Optional.of(SQLSERVER); + } + if (dbType == DbType.dm) { + return Optional.of(DAMENG); + } + if (dbType == DbType.db2) { + return Optional.of(DB2); + } + return Optional.empty(); + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/CommitFailedException.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/CommitFailedException.java new file mode 100644 index 0000000..a33013e --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/CommitFailedException.java @@ -0,0 +1,11 @@ +package org.jumpserver.chen.framework.datasource.edit.exception; + +import org.jumpserver.chen.framework.datasource.edit.TableChangesSaveService; + +import java.sql.SQLException; + +public class CommitFailedException extends TableEditException { + public CommitFailedException(SQLException cause) { + super(TableChangesSaveService.SAVE_CHANGES_COMMIT_FAILED, cause); + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/OptimisticLockConflictException.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/OptimisticLockConflictException.java new file mode 100644 index 0000000..51a4a25 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/OptimisticLockConflictException.java @@ -0,0 +1,16 @@ +package org.jumpserver.chen.framework.datasource.edit.exception; + +import org.jumpserver.chen.framework.datasource.edit.TableChangesSaveService; + +public class OptimisticLockConflictException extends TableEditException { + private final int changeIndex; + + public OptimisticLockConflictException(int changeIndex) { + super(TableChangesSaveService.OPTIMISTIC_LOCK_CONFLICT); + this.changeIndex = changeIndex; + } + + public int getChangeIndex() { + return changeIndex; + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/RowNotFoundOrNotUniqueException.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/RowNotFoundOrNotUniqueException.java new file mode 100644 index 0000000..022dc8b --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/RowNotFoundOrNotUniqueException.java @@ -0,0 +1,18 @@ +package org.jumpserver.chen.framework.datasource.edit.exception; + +import org.jumpserver.chen.framework.datasource.edit.TableChangesSaveService; + +import java.sql.SQLException; + +public class RowNotFoundOrNotUniqueException extends SQLException { + private final int changeIndex; + + public RowNotFoundOrNotUniqueException(int changeIndex) { + super(TableChangesSaveService.ROW_NOT_FOUND_OR_NOT_UNIQUE); + this.changeIndex = changeIndex; + } + + public int getChangeIndex() { + return changeIndex; + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/TableEditException.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/TableEditException.java new file mode 100644 index 0000000..1288e9e --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/TableEditException.java @@ -0,0 +1,13 @@ +package org.jumpserver.chen.framework.datasource.edit.exception; + +import java.sql.SQLException; + +public class TableEditException extends SQLException { + public TableEditException(String reason) { + super(reason); + } + + public TableEditException(String reason, Throwable cause) { + super(reason, cause); + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/UnexpectedAffectedRowsException.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/UnexpectedAffectedRowsException.java new file mode 100644 index 0000000..a099ae3 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/exception/UnexpectedAffectedRowsException.java @@ -0,0 +1,22 @@ +package org.jumpserver.chen.framework.datasource.edit.exception; + +import org.jumpserver.chen.framework.datasource.edit.TableChangesSaveService; + +public class UnexpectedAffectedRowsException extends TableEditException { + private final int changeIndex; + private final int affectedRows; + + public UnexpectedAffectedRowsException(int changeIndex, int affectedRows) { + super(TableChangesSaveService.AFFECTED_ROWS_UNEXPECTED); + this.changeIndex = changeIndex; + this.affectedRows = affectedRows; + } + + public int getChangeIndex() { + return changeIndex; + } + + public int getAffectedRows() { + return affectedRows; + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/pk/JdbcPrimaryKeyResolver.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/pk/JdbcPrimaryKeyResolver.java new file mode 100644 index 0000000..83d3be5 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/pk/JdbcPrimaryKeyResolver.java @@ -0,0 +1,252 @@ +package org.jumpserver.chen.framework.datasource.edit.pk; + +import com.alibaba.druid.DbType; +import org.apache.commons.lang3.StringUtils; +import org.jumpserver.chen.framework.datasource.edit.analyzer.EditabilityReason; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +public class JdbcPrimaryKeyResolver implements PrimaryKeyResolver { + private final Connection connection; + private final DbType dbType; + + public JdbcPrimaryKeyResolver(Connection connection, DbType dbType) { + this.connection = connection; + this.dbType = dbType; + } + + @Override + public PrimaryKeyResolution resolvePrimaryKeys(String schema, String table) throws SQLException { + return switch (this.dbType) { + case postgresql -> this.resolvePostgresqlPrimaryKeysWithTableCheck(schema, table); + case mysql, mariadb -> this.resolveMysqlPrimaryKeysWithTableCheck(schema, table); + case oracle -> this.resolveOraclePrimaryKeysWithTableCheck(schema, table); + case sqlserver -> this.resolveSqlServerPrimaryKeysWithTableCheck(schema, table); + case dm -> this.resolveDamengPrimaryKeysWithTableCheck(schema, table); + case db2 -> this.resolveDb2PrimaryKeysWithTableCheck(schema, table); + default -> null; + }; + } + + private PrimaryKeyResolution resolvePostgresqlPrimaryKeysWithTableCheck(String schema, String table) throws SQLException { + if (this.isPostgresqlView(schema, table)) { + return PrimaryKeyResolution.readOnly(EditabilityReason.VIEW_NOT_SUPPORTED); + } + return PrimaryKeyResolution.primaryKeys(this.resolvePostgresqlPrimaryKeys(schema, table)); + } + + private PrimaryKeyResolution resolveMysqlPrimaryKeysWithTableCheck(String schema, String table) throws SQLException { + if (this.isMysqlView(schema, table)) { + return PrimaryKeyResolution.readOnly(EditabilityReason.VIEW_NOT_SUPPORTED); + } + return PrimaryKeyResolution.primaryKeys(this.resolveMysqlPrimaryKeys(schema, table)); + } + + private PrimaryKeyResolution resolveOraclePrimaryKeysWithTableCheck(String schema, String table) throws SQLException { + if (this.isOracleView(schema, table)) { + return PrimaryKeyResolution.readOnly(EditabilityReason.VIEW_NOT_SUPPORTED); + } + return PrimaryKeyResolution.primaryKeys(this.resolveOraclePrimaryKeys(schema, table)); + } + + private PrimaryKeyResolution resolveSqlServerPrimaryKeysWithTableCheck(String schema, String table) throws SQLException { + if (this.isSqlServerView(schema, table)) { + return PrimaryKeyResolution.readOnly(EditabilityReason.VIEW_NOT_SUPPORTED); + } + return PrimaryKeyResolution.primaryKeys(this.resolveSqlServerPrimaryKeys(schema, table)); + } + + private PrimaryKeyResolution resolveDamengPrimaryKeysWithTableCheck(String schema, String table) throws SQLException { + var metadata = this.connection.getMetaData(); + String schemaPattern = StringUtils.trimToNull(schema); + try (var resultSet = metadata.getTables(null, schemaPattern, table, new String[]{"VIEW"})) { + if (resultSet.next()) { + return PrimaryKeyResolution.readOnly(EditabilityReason.VIEW_NOT_SUPPORTED); + } + } + + List primaryKeys = new ArrayList<>(); + try (var resultSet = metadata.getPrimaryKeys(null, schemaPattern, table)) { + while (resultSet.next()) { + primaryKeys.add(resultSet.getString("COLUMN_NAME")); + } + } + return PrimaryKeyResolution.primaryKeys(primaryKeys); + } + + private PrimaryKeyResolution resolveDb2PrimaryKeysWithTableCheck(String schema, String table) throws SQLException { + if (this.isDb2View(schema, table)) { + return PrimaryKeyResolution.readOnly(EditabilityReason.VIEW_NOT_SUPPORTED); + } + return PrimaryKeyResolution.primaryKeys(this.resolveDb2PrimaryKeys(schema, table)); + } + + private boolean isPostgresqlView(String schema, String table) throws SQLException { + String sql = """ + SELECT table_type + FROM information_schema.tables + WHERE table_schema = COALESCE(?, current_schema()) + AND table_name = ? + """; + return this.isView(sql, schema, table); + } + + private boolean isMysqlView(String schema, String table) throws SQLException { + String sql = """ + SELECT table_type + FROM information_schema.tables + WHERE table_schema = COALESCE(?, DATABASE()) + AND table_name = ? + """; + return this.isView(sql, schema, table); + } + + private boolean isOracleView(String schema, String table) throws SQLException { + String sql = """ + SELECT 'VIEW' + FROM all_views + WHERE owner = COALESCE(?, SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA')) + AND view_name = ? + """; + return this.isView(sql, schema, table); + } + + private boolean isSqlServerView(String schema, String table) throws SQLException { + String sql = """ + SELECT 'VIEW' + FROM sys.views v + JOIN sys.schemas s + ON v.schema_id = s.schema_id + WHERE s.name = COALESCE(?, 'dbo') + AND v.name = ? + """; + return this.isView(sql, schema, table); + } + + private boolean isDb2View(String schema, String table) throws SQLException { + String sql = """ + SELECT 'VIEW' + FROM syscat.tables + WHERE tabschema = COALESCE(?, CURRENT SCHEMA) + AND tabname = ? + AND type = 'V' + """; + return this.isView(sql, schema, table); + } + + private boolean isView(String sql, String schema, String table) throws SQLException { + try (var statement = this.connection.prepareStatement(sql)) { + bindSchemaAndTable(statement, schema, table); + try (var resultSet = statement.executeQuery()) { + return resultSet.next() && StringUtils.equalsIgnoreCase(resultSet.getString(1), "VIEW"); + } + } + } + + private List resolvePostgresqlPrimaryKeys(String schema, String table) throws SQLException { + String sql = """ + SELECT kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + AND tc.table_name = kcu.table_name + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = COALESCE(?, current_schema()) + AND tc.table_name = ? + ORDER BY kcu.ordinal_position + """; + return this.queryPrimaryKeys(sql, schema, table); + } + + private List resolveMysqlPrimaryKeys(String schema, String table) throws SQLException { + String sql = """ + SELECT column_name + FROM information_schema.key_column_usage + WHERE constraint_name = 'PRIMARY' + AND table_schema = COALESCE(?, DATABASE()) + AND table_name = ? + ORDER BY ordinal_position + """; + return this.queryPrimaryKeys(sql, schema, table); + } + + private List resolveOraclePrimaryKeys(String schema, String table) throws SQLException { + String sql = """ + SELECT acc.column_name + FROM all_constraints ac + JOIN all_cons_columns acc + ON ac.owner = acc.owner + AND ac.constraint_name = acc.constraint_name + AND ac.table_name = acc.table_name + WHERE ac.constraint_type = 'P' + AND ac.owner = COALESCE(?, SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA')) + AND ac.table_name = ? + ORDER BY acc.position + """; + return this.queryPrimaryKeys(sql, schema, table); + } + + private List resolveSqlServerPrimaryKeys(String schema, String table) throws SQLException { + String sql = """ + SELECT c.name + FROM sys.key_constraints kc + JOIN sys.index_columns ic + ON kc.parent_object_id = ic.object_id + AND kc.unique_index_id = ic.index_id + JOIN sys.columns c + ON ic.object_id = c.object_id + AND ic.column_id = c.column_id + JOIN sys.tables t + ON kc.parent_object_id = t.object_id + JOIN sys.schemas s + ON t.schema_id = s.schema_id + WHERE kc.type = 'PK' + AND s.name = COALESCE(?, 'dbo') + AND t.name = ? + ORDER BY ic.key_ordinal + """; + return this.queryPrimaryKeys(sql, schema, table); + } + + private List resolveDb2PrimaryKeys(String schema, String table) throws SQLException { + String sql = """ + SELECT kcu.colname + FROM syscat.tabconst tc + JOIN syscat.keycoluse kcu + ON tc.constname = kcu.constname + AND tc.tabschema = kcu.tabschema + AND tc.tabname = kcu.tabname + WHERE tc.type = 'P' + AND tc.tabschema = COALESCE(?, CURRENT SCHEMA) + AND tc.tabname = ? + ORDER BY kcu.colseq + """; + return this.queryPrimaryKeys(sql, schema, table); + } + + private List queryPrimaryKeys(String sql, String schema, String table) throws SQLException { + List primaryKeys = new ArrayList<>(); + try (var statement = this.connection.prepareStatement(sql)) { + bindSchemaAndTable(statement, schema, table); + try (var resultSet = statement.executeQuery()) { + while (resultSet.next()) { + primaryKeys.add(resultSet.getString(1)); + } + } + } + return primaryKeys; + } + + private static void bindSchemaAndTable(java.sql.PreparedStatement statement, String schema, String table) throws SQLException { + if (StringUtils.isBlank(schema)) { + statement.setNull(1, java.sql.Types.VARCHAR); + } else { + statement.setString(1, schema); + } + statement.setString(2, table); + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/pk/PrimaryKeyResolution.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/pk/PrimaryKeyResolution.java new file mode 100644 index 0000000..13e3e14 --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/pk/PrimaryKeyResolution.java @@ -0,0 +1,24 @@ +package org.jumpserver.chen.framework.datasource.edit.pk; + +import lombok.Getter; + +import java.util.List; + +@Getter +public class PrimaryKeyResolution { + private final List primaryKeys; + private final String readOnlyReason; + + private PrimaryKeyResolution(List primaryKeys, String readOnlyReason) { + this.primaryKeys = primaryKeys; + this.readOnlyReason = readOnlyReason; + } + + public static PrimaryKeyResolution primaryKeys(List primaryKeys) { + return new PrimaryKeyResolution(primaryKeys, null); + } + + public static PrimaryKeyResolution readOnly(String reason) { + return new PrimaryKeyResolution(List.of(), reason); + } +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/pk/PrimaryKeyResolver.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/pk/PrimaryKeyResolver.java new file mode 100644 index 0000000..f36634c --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/edit/pk/PrimaryKeyResolver.java @@ -0,0 +1,8 @@ +package org.jumpserver.chen.framework.datasource.edit.pk; + +import java.sql.SQLException; + +@FunctionalInterface +public interface PrimaryKeyResolver { + PrimaryKeyResolution resolvePrimaryKeys(String schema, String table) throws SQLException; +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/entity/resource/Field.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/entity/resource/Field.java index 34d3ba4..6d93f0b 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/entity/resource/Field.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/entity/resource/Field.java @@ -12,8 +12,21 @@ public class Field implements ResourceNode { private String schema; private String table; private String type; + private transient Integer jdbcType; + private String sourceSchema; + private String sourceTable; + private String sourceColumn; private boolean nullable; + private boolean editable; + private String editReason; private boolean isPrimaryKey; + private boolean masked; + private boolean autoIncrement; + private boolean readOnly; + private transient boolean generated; + private boolean insertable; + private boolean requiredOnInsert; + private String insertReason; public void setNullable(String nullable) { String[] trueAlias = {"YES", "Y"}; diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/entity/resource/ResourceNodeSnapshot.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/entity/resource/ResourceNodeSnapshot.java new file mode 100644 index 0000000..0e57f4a --- /dev/null +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/entity/resource/ResourceNodeSnapshot.java @@ -0,0 +1,11 @@ +package org.jumpserver.chen.framework.datasource.entity.resource; + +public record ResourceNodeSnapshot( + String key, + String type, + String database, + String schema, + String table, + String name +) { +} diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/utils/PageUtils.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/utils/PageUtils.java index ad24e8b..b6cb963 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/utils/PageUtils.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/utils/PageUtils.java @@ -15,7 +15,6 @@ import com.alibaba.druid.sql.dialect.oracle.visitor.OracleASTVisitorAdapter; import com.alibaba.druid.sql.dialect.postgresql.ast.stmt.PGSelectQueryBlock; import com.alibaba.druid.sql.dialect.sqlserver.ast.SQLServerSelectQueryBlock; -import com.alibaba.druid.sql.dialect.sqlserver.ast.SQLServerTop; import com.alibaba.druid.util.JdbcUtils; import java.util.Iterator; @@ -264,14 +263,14 @@ private static boolean limitSQLServer(SQLSelect select, DbType dbType, int offse if (query instanceof SQLSelectQueryBlock) { queryBlock = (SQLServerSelectQueryBlock) query; if (offset <= 0) { - SQLServerTop top = queryBlock.getTop(); + SQLTop top = queryBlock.getTop(); if (check && top != null && !top.isPercent() && top.getExpr() instanceof SQLNumericLiteralExpr) { int rowCount = ((SQLNumericLiteralExpr) top.getExpr()).getNumber().intValue(); if (rowCount <= count) { return false; } } - queryBlock.setTop(new SQLServerTop(new SQLNumberExpr(count))); + queryBlock.setTop(new SQLTop(new SQLNumberExpr(count))); return true; } else { // 创建 SELECT NULL 的子查询 @@ -303,7 +302,7 @@ private static boolean limitSQLServer(SQLSelect select, DbType dbType, int offse } else { queryBlock = new SQLServerSelectQueryBlock(); if (offset <= 0) { - queryBlock.setTop(new SQLServerTop(new SQLNumberExpr(count))); + queryBlock.setTop(new SQLTop(new SQLNumberExpr(count))); select.setQuery(queryBlock); return true; } else { diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/ws/ConsoleWebSocketHandler.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/ws/ConsoleWebSocketHandler.java index e03911f..b897c1e 100644 --- a/backend/framework/src/main/java/org/jumpserver/chen/framework/ws/ConsoleWebSocketHandler.java +++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/ws/ConsoleWebSocketHandler.java @@ -6,23 +6,21 @@ import org.jumpserver.chen.framework.console.Console; import org.jumpserver.chen.framework.console.DataViewConsole; import org.jumpserver.chen.framework.console.QueryConsole; +import org.jumpserver.chen.framework.console.context.ConsoleContext; +import org.jumpserver.chen.framework.console.context.ConsoleContextResolutionException; +import org.jumpserver.chen.framework.console.context.ConsoleContextResolver; import org.jumpserver.chen.framework.console.entity.request.Connect; -import org.jumpserver.chen.framework.datasource.ResourceBrowser; -import org.jumpserver.chen.framework.datasource.entity.resource.TreeNode; import org.jumpserver.chen.framework.session.SessionManager; import org.jumpserver.chen.framework.session.controller.message.Message; import org.jumpserver.chen.framework.session.controller.message.MessageLevel; import org.jumpserver.chen.framework.ws.io.Packet; import org.jumpserver.chen.framework.ws.io.PacketIO; -import org.jumpserver.chen.framework.utils.TreeUtils; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.WebSocketMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.adapter.NativeWebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; -import java.sql.SQLException; -import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -30,11 +28,6 @@ public class ConsoleWebSocketHandler extends TextWebSocketHandler { private static final Gson GSON = new Gson(); - private static final Set QUERY_NODE_TYPES = - Set.of("datasource", "database", "schema", "table"); - private static final Set DATA_VIEW_NODE_TYPES = Set.of("table", "view"); - - @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { if (session instanceof NativeWebSocketSession ns) { @@ -66,8 +59,7 @@ public void handleMessage(WebSocketSession session, WebSocketMessage message) .get(session.getId()); if (console != null) { - var db = TreeUtils.getValue(console.getNodeKey(), "database"); - SessionManager.getCurrentSession().getDatasource().getConnectionManager().setDatabaseContext(db); + this.setDatabaseContext(console); var handler = SessionManager.getCurrentSession().getConsoles().get(session.getId()); handler.handle(packet); @@ -82,12 +74,11 @@ public void handleMessage(WebSocketSession session, WebSocketMessage message) private void onConnectPacket(WebSocketSession session, Packet packet) { Connect connect = GSON.fromJson(GSON.toJson(packet.getData()), Connect.class); var webSess = SessionManager.getCurrentSession(); - var node = this.resolveNode( - webSess.getDatasource().getResourceBrowser(), - connect.getNodeKey(), - connect.getType() - ); - if (node == null) { + ConsoleContext context; + try { + context = new ConsoleContextResolver(webSess.getDatasource().getResourceBrowser()) + .resolve(connect.getNodeKey(), connect.getType()); + } catch (ConsoleContextResolutionException e) { new PacketIO(session).sendPacket( "show_message", new Message(MessageLevel.ERROR, "Invalid console context") @@ -95,47 +86,34 @@ private void onConnectPacket(WebSocketSession session, Packet packet) { return; } - connect.setNodeKey(node.getKey()); - Console console = switch (connect.getType()) { - case Connect.CONSOLE_TYPE_QUERY -> - new QueryConsole(webSess.getDatasource(), session, node.getKey()); - case Connect.CONSOLE_TYPE_DATA_VIEW -> - new DataViewConsole(webSess.getDatasource(), session, node.getKey()); - default -> null; - }; + connect.setNodeKey(context.nodeKey()); + Console console = this.createConsole(connect.getType(), webSess.getDatasource(), session, context); if (console != null) { + this.setDatabaseContext(console); webSess.getConsoles().put(session.getId(), console); - - var db = TreeUtils.getValue(console.getNodeKey(), "database"); - SessionManager.getCurrentSession().getDatasource().getConnectionManager().setDatabaseContext(db); - console.onInit(connect); log.info("User {} open a console ", webSess.getUsername()); } } - TreeNode resolveNode(ResourceBrowser resourceBrowser, String nodeKey, String consoleType) { - if (resourceBrowser == null || StringUtils.isBlank(nodeKey) || StringUtils.isBlank(consoleType)) { - return null; - } - TreeNode node; - try { - var root = resourceBrowser.getTree(); - node = root == null ? null : TreeUtils.getNode(root, nodeKey); - } catch (SQLException e) { - return null; - } - if (node == null) { - return null; - } - var allowedTypes = switch (consoleType) { - case Connect.CONSOLE_TYPE_QUERY -> QUERY_NODE_TYPES; - case Connect.CONSOLE_TYPE_DATA_VIEW -> DATA_VIEW_NODE_TYPES; - default -> Set.of(); + protected Console createConsole(String type, org.jumpserver.chen.framework.datasource.Datasource datasource, + WebSocketSession session, ConsoleContext context) { + return switch (type) { + case Connect.CONSOLE_TYPE_QUERY -> new QueryConsole(datasource, session, context); + case Connect.CONSOLE_TYPE_DATA_VIEW -> new DataViewConsole(datasource, session, context); + default -> null; }; - return allowedTypes.contains(node.getType()) ? node : null; } + private void setDatabaseContext(Console console) { + var connectionManager = SessionManager.getCurrentSession().getDatasource().getConnectionManager(); + var db = console.getContext().database(); + if (StringUtils.isNotBlank(db)) { + connectionManager.setDatabaseContext(db); + } + } + + @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { log.error("websocket error", exception); diff --git a/backend/modules/src/main/java/org.jumpserver.chen.modules/mariadb/MariaDBConnectionManager.java b/backend/modules/src/main/java/org.jumpserver.chen.modules/mariadb/MariaDBConnectionManager.java index fa70990..382aa70 100644 --- a/backend/modules/src/main/java/org.jumpserver.chen.modules/mariadb/MariaDBConnectionManager.java +++ b/backend/modules/src/main/java/org.jumpserver.chen.modules/mariadb/MariaDBConnectionManager.java @@ -1,5 +1,6 @@ package org.jumpserver.chen.modules.mariadb; +import org.apache.commons.lang3.StringUtils; import org.jumpserver.chen.framework.datasource.Datasource; import org.jumpserver.chen.framework.datasource.base.BaseConnectionManager; import org.jumpserver.chen.framework.datasource.entity.DBConnectInfo; @@ -46,9 +47,18 @@ public String getDisplayJDBCUrl() { return this.getConnectInfo().toDisplayJDBCUrl(jdbcUrlTemplate); } + @Override + public String getDatabaseContextKey() { + // MariaDB 的对象树使用 schema 节点表示 database,连接池默认库也要取这个值。 + return "schema"; + } @Override public String getJDBCUrl(String database) { - return this.jdbcUrl; + if (StringUtils.isBlank(database)) { + return this.jdbcUrl; + } + // 连接池不能依赖上一条连接执行过 USE,这里显式把 database 放进 JDBC URL。 + return this.getConnectInfo().toJDBCUrl(jdbcUrlTemplate, database); } } diff --git a/backend/modules/src/main/java/org.jumpserver.chen.modules/mysql/MysqlConnectionManager.java b/backend/modules/src/main/java/org.jumpserver.chen.modules/mysql/MysqlConnectionManager.java index cc83f29..20c5e4d 100644 --- a/backend/modules/src/main/java/org.jumpserver.chen.modules/mysql/MysqlConnectionManager.java +++ b/backend/modules/src/main/java/org.jumpserver.chen.modules/mysql/MysqlConnectionManager.java @@ -108,8 +108,18 @@ public String getDisplayJDBCUrl() { return this.getConnectInfo().toDisplayJDBCUrl(jdbcUrlTemplate); } + @Override + public String getDatabaseContextKey() { + // MySQL 的对象树使用 schema 节点表示 database,连接池默认库也要取这个值。 + return "schema"; + } + @Override public String getJDBCUrl(String database) { - return this.jdbcUrl; + if (StringUtils.isBlank(database)) { + return this.jdbcUrl; + } + // 连接池不能依赖上一条连接执行过 USE,这里显式把 database 放进 JDBC URL。 + return this.getConnectInfo().toJDBCUrl(jdbcUrlTemplate, database); } } diff --git a/frontend/src/components/Main/Explore/DataView/DataView.vue b/frontend/src/components/Main/Explore/DataView/DataView.vue index dd4c27c..b9cfbfa 100644 --- a/frontend/src/components/Main/Explore/DataView/DataView.vue +++ b/frontend/src/components/Main/Explore/DataView/DataView.vue @@ -8,10 +8,14 @@ > - + @@ -55,12 +59,30 @@ export default { toolBarItems: { type: Object, default: () => ({}) + }, + editable: { + type: Boolean, + default: false + }, + rowEditActionsEnabled: { + type: Boolean, + default: false + }, + previewBeforeSave: { + type: Boolean, + default: false } }, data() { return { rowData: [], colDefs: [], + dirtyCells: {}, + insertRows: [], + deletedRows: {}, + dirtyVersion: 0, + nextInsertRowId: 1, + pendingSavePayload: null, exportDataDialogVisible: false, state: { @@ -154,6 +176,53 @@ export default { icon: 'iconfont icon-chen-reload1', onClick: this.onRefresh }, + addRow: { + split: true, + type: 'button', + icon: 'iconfont icon-chen-plus', + name: () => 'Add', + hidden: () => { + return !this.resultEditable || !this.rowEditActionsEnabled + }, + onClick: this.onAddRow + }, + deleteRow: { + type: 'button', + icon: 'iconfont icon-chen-minus', + name: () => 'Delete', + hidden: () => { + return !this.resultEditable || !this.rowEditActionsEnabled + }, + disabled: () => { + return !this.currentRow + }, + onClick: this.onDeleteRows + }, + saveChanges: { + split: true, + type: 'button', + icon: 'iconfont icon-chen-save', + name: () => 'Save', + hidden: () => { + return !this.resultEditable + }, + disabled: () => { + return !this.hasDirty() + }, + onClick: this.onSaveChanges + }, + cancelChanges: { + type: 'button', + icon: 'el-icon-close', + name: () => 'Cancel', + hidden: () => { + return !this.resultEditable + }, + disabled: () => { + return !this.hasDirty() + }, + onClick: this.onCancelChanges + }, export: { split: true, type: 'button', @@ -188,11 +257,21 @@ export default { } }, computed: { + resultEditable() { + const fields = this.data && Array.isArray(this.data.fields) ? this.data.fields : [] + return this.editable && + this.data && + this.data.editable === true && + fields.some((field) => field && field.editable === true) + }, isStatePaged() { return this.state.paged }, iToolBarItems() { return Object.assign(this.defaultToolBarItems, this.toolBarItems) + }, + toolbarKey() { + return `toolbar-${this.dirtyVersion}` } }, watch: { @@ -216,12 +295,26 @@ export default { return this.state }, reloadTable() { - this.rowData = this.data.data + const rows = this.data && this.data.data ? this.data.data.map((row) => this.normalizeRowData(row)) : [] + rows.forEach((row) => { + if (this.isExistingRowDeleted(row)) { + row.__chenDeleted = true + } + }) + this.rowData = rows.concat(this.insertRows.map((row) => row.data)) }, initTable() { const headers = this.data.fields.map((item) => { return { - field: item.name + field: item.name, + fieldMeta: item, + editable: (params) => this.isCellEditable(params, item), + valueParser: (params) => this.parseCellValue(params.newValue, item), + cellClassRules: { + 'chen-dirty-cell': (params) => this.isDirtyCell(params), + 'chen-insert-row': (params) => this.isInsertRow(params.data), + 'chen-delete-row': (params) => this.isDeleteRow(params.data) + } } }) @@ -230,6 +323,450 @@ export default { this.reloadTable() this.init = true }, + getPrimaryKeyField() { + if (!this.data || !this.data.fields) { + return null + } + return this.data.fields.find((field) => field.primaryKey === true || field.isPrimaryKey === true) + }, + getValueIsNull(value) { + return value === null || value === undefined + }, + valuesEqual(left, right) { + return left === right + }, + buildDirtyKey(pkValue, sourceColumn) { + return `${JSON.stringify(pkValue)}::${sourceColumn}` + }, + buildDeleteKey(pkValue) { + return JSON.stringify(pkValue) + }, + buildInsertKey(id, sourceColumn) { + return `insert:${id}::${sourceColumn}` + }, + hasDirty() { + return Object.keys(this.dirtyCells).length > 0 || + this.insertRows.length > 0 || + Object.keys(this.deletedRows).length > 0 + }, + clearDirty() { + this.dirtyCells = {} + this.insertRows = [] + this.deletedRows = {} + this.pendingSavePayload = null + this.dirtyVersion += 1 + this.reloadTable() + this.refreshDirtyCells() + }, + setObjectValue(targetName, key, value) { + this[targetName] = { + ...this[targetName], + [key]: value + } + }, + deleteObjectValue(targetName, key) { + const nextValue = { ...this[targetName] } + delete nextValue[key] + this[targetName] = nextValue + }, + isDirtyCell(params) { + if (!params || !params.data || !params.colDef || !params.colDef.fieldMeta) { + return false + } + if (this.isInsertRow(params.data)) { + const sourceColumn = params.colDef.fieldMeta.sourceColumn + return !!(sourceColumn && params.data.__chenValues && params.data.__chenValues[sourceColumn]) + } + const primaryKeyField = this.getPrimaryKeyField() + const sourceColumn = params.colDef.fieldMeta.sourceColumn + if (!primaryKeyField || !sourceColumn) { + return false + } + const pkValue = params.data[primaryKeyField.name] + if (this.getValueIsNull(pkValue)) { + return false + } + return !!this.dirtyCells[this.buildDirtyKey(pkValue, sourceColumn)] + }, + isInsertRow(row) { + return !!(row && row.__chenInsertId) + }, + isDeleteRow(row) { + return !!(row && row.__chenDeleted) + }, + isExistingRowDeleted(row) { + const primaryKeyField = this.getPrimaryKeyField() + if (!row || !primaryKeyField) { + return false + } + const pkValue = row[primaryKeyField.name] + return !!this.deletedRows[this.buildDeleteKey(pkValue)] + }, + isCellEditable(params, fieldMeta) { + if (!this.resultEditable || !fieldMeta || !fieldMeta.sourceColumn) { + return false + } + const row = params ? params.data : null + if (this.isDeleteRow(row)) { + return false + } + if (this.isInsertRow(row)) { + return fieldMeta.insertable === true + } + return fieldMeta.editable === true + }, + refreshDirtyCells() { + const grid = this.$refs.resultGrid + if (grid && grid.gridApi && typeof grid.gridApi.refreshCells === 'function') { + grid.gridApi.refreshCells({ force: true }) + } + }, + onCellValueChanged(params) { + if (!this.resultEditable || !params || !params.colDef || !params.colDef.fieldMeta || !params.data) { + return + } + + const fieldMeta = params.colDef.fieldMeta + if (!this.isCellEditable(params, fieldMeta)) { + return + } + if (this.isInsertRow(params.data)) { + this.onInsertCellValueChanged(params, fieldMeta) + return + } + + const primaryKeyField = this.getPrimaryKeyField() + if (!primaryKeyField || !primaryKeyField.sourceColumn || !fieldMeta.sourceColumn) { + return + } + + const pkValue = params.data[primaryKeyField.name] + if (this.getValueIsNull(pkValue)) { + this.$message.warning('Primary key value is empty, cannot edit this row') + return + } + + const sourceColumn = fieldMeta.sourceColumn + const key = this.buildDirtyKey(pkValue, sourceColumn) + const fallbackOldValue = Object.prototype.hasOwnProperty.call(params, 'oldValue') + ? params.oldValue + : params.data[params.colDef.field] + const oldValue = this.dirtyCells[key] + ? this.dirtyCells[key].oldValue + : this.normalizeCellValue(fallbackOldValue, fieldMeta) + const oldValueIsNull = this.dirtyCells[key] + ? this.dirtyCells[key].oldValueIsNull + : this.getValueIsNull(fallbackOldValue) + const newValue = this.normalizeCellValue(params.newValue, fieldMeta) + const newValueIsNull = this.getValueIsNull(newValue) + + if (oldValueIsNull === newValueIsNull && this.valuesEqual(oldValue, newValue)) { + this.deleteObjectValue('dirtyCells', key) + this.dirtyVersion += 1 + this.refreshDirtyCells() + return + } + + this.setObjectValue('dirtyCells', key, { + pkColumn: primaryKeyField.sourceColumn, + pkValue, + pkValueIsNull: this.getValueIsNull(pkValue), + sourceColumn, + oldValue, + oldValueIsNull, + newValue, + newValueIsNull + }) + this.dirtyVersion += 1 + this.refreshDirtyCells() + }, + onInsertCellValueChanged(params, fieldMeta) { + const sourceColumn = fieldMeta.sourceColumn + const row = this.insertRows.find((item) => item.id === params.data.__chenInsertId) + if (!row || !sourceColumn) { + return + } + if (params.newValue === undefined) { + this.$delete(row.values, sourceColumn) + this.$delete(row.data.__chenValues, sourceColumn) + } else { + const newValue = this.normalizeCellValue(params.newValue, fieldMeta) + this.$set(row.values, sourceColumn, { + value: newValue, + valueIsNull: this.getValueIsNull(newValue) + }) + this.$set(row.data.__chenValues, sourceColumn, true) + } + this.dirtyVersion += 1 + this.refreshDirtyCells() + }, + normalizeRowData(row) { + const normalized = { ...row } + const fields = this.data && Array.isArray(this.data.fields) ? this.data.fields : [] + fields.forEach((field) => { + if (field && Object.prototype.hasOwnProperty.call(normalized, field.name)) { + normalized[field.name] = this.normalizeCellValue(normalized[field.name], field) + } + }) + return normalized + }, + parseCellValue(value, fieldMeta) { + return this.normalizeCellValue(value, fieldMeta) + }, + normalizeCellValue(value, fieldMeta) { + if (value === null || value === undefined || !fieldMeta) { + return value + } + const type = this.normalizeFieldType(fieldMeta.type) + if (this.isDateType(type)) { + return this.normalizeDateValue(value) + } + if (this.isTimeType(type)) { + return this.normalizeTimeValue(value) + } + if (this.isTimestampType(type)) { + return this.normalizeTimestampValue(value, this.isOffsetTimestampType(type)) + } + return value + }, + normalizeFieldType(type) { + if (!type) { + return '' + } + let normalized = String(type).trim().toLowerCase() + let changed = true + while (changed) { + changed = false + if (normalized.startsWith('nullable(') && normalized.endsWith(')')) { + normalized = normalized.substring('nullable('.length, normalized.length - 1).trim() + changed = true + } + if (normalized.startsWith('lowcardinality(') && normalized.endsWith(')')) { + normalized = normalized.substring('lowcardinality('.length, normalized.length - 1).trim() + changed = true + } + } + const bracketIndex = normalized.indexOf('(') + if (bracketIndex > -1) { + normalized = normalized.substring(0, bracketIndex).trim() + } + return normalized + }, + isDateType(type) { + return type === 'date' + }, + isTimeType(type) { + return type === 'time' || type === 'time without time zone' + }, + isTimestampType(type) { + return this.isOffsetTimestampType(type) || + ['timestamp', 'timestamp without time zone', 'datetime', 'datetime2', 'smalldatetime'].includes(type) + }, + isOffsetTimestampType(type) { + return ['timestamptz', 'timestamp with time zone', 'datetimeoffset'].includes(type) + }, + normalizeDateValue(value) { + if (value instanceof Date) { + return this.formatDateParts(value.getFullYear(), value.getMonth() + 1, value.getDate()) + } + const text = String(value).trim() + const match = text.match(/^(\d{4}-\d{2}-\d{2})/) + return match ? match[1] : value + }, + normalizeTimeValue(value) { + if (value instanceof Date) { + return this.formatTimeParts(value.getHours(), value.getMinutes(), value.getSeconds()) + } + const text = String(value).trim() + const localized = text.match(/^(\d{1,2}):(\d{2}):(\d{2})(\.\d{1,9})?\s*(上午|下午|AM|PM)$/i) + if (localized) { + let hour = Number(localized[1]) + const meridiem = localized[5].toUpperCase() + if ((meridiem === '下午' || meridiem === 'PM') && hour < 12) { + hour += 12 + } + if ((meridiem === '上午' || meridiem === 'AM') && hour === 12) { + hour = 0 + } + return `${this.pad2(hour)}:${localized[2]}:${localized[3]}${localized[4] || ''}` + } + const canonical = text.match(/^(\d{1,2}):(\d{2}):(\d{2})(\.\d{1,9})?$/) + if (canonical) { + return `${this.pad2(Number(canonical[1]))}:${canonical[2]}:${canonical[3]}${canonical[4] || ''}` + } + const isoTime = text.match(/[T\s](\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?)/) + return isoTime ? isoTime[1] : value + }, + normalizeTimestampValue(value, keepOffset) { + if (value instanceof Date) { + return `${this.formatDateParts(value.getFullYear(), value.getMonth() + 1, value.getDate())} ${this.formatTimeParts(value.getHours(), value.getMinutes(), value.getSeconds())}` + } + const text = String(value).trim() + if (keepOffset) { + return text + } + const match = text.match(/^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?)/) + return match ? `${match[1]} ${match[2]}` : value + }, + formatDateParts(year, month, day) { + return `${year}-${this.pad2(month)}-${this.pad2(day)}` + }, + formatTimeParts(hour, minute, second) { + return `${this.pad2(hour)}:${this.pad2(minute)}:${this.pad2(second)}` + }, + pad2(value) { + return String(value).padStart(2, '0') + }, + buildSaveChangesPayload() { + return { + schema: this.resolveSaveSchema(), + table: this.resolveSaveTable(), + changes: Object.values(this.dirtyCells), + insertRows: this.insertRows + .filter((row) => Object.keys(row.values).length > 0) + .map((row) => ({ values: row.values })), + deleteRows: Object.values(this.deletedRows) + } + }, + resolveSaveSchema() { + if (this.meta && Object.prototype.hasOwnProperty.call(this.meta, 'schema')) { + return this.meta.schema + } + const field = this.getEditableSourceField() + return field ? field.sourceSchema : undefined + }, + resolveSaveTable() { + if (this.meta && Object.prototype.hasOwnProperty.call(this.meta, 'table')) { + return this.meta.table + } + const field = this.getEditableSourceField() + return field ? field.sourceTable : undefined + }, + getEditableSourceField() { + if (!this.data || !this.data.fields) { + return null + } + return this.data.fields.find((field) => field && field.editable === true && field.sourceTable && field.sourceColumn) + }, + onSaveChanges() { + if (!this.hasDirty()) { + return + } + + const payload = this.buildSaveChangesPayload() + this.pendingSavePayload = payload + const action = { + action: this.previewBeforeSave ? 'save_changes_preview' : 'save_changes', + dataView: this.meta.title, + data: payload + } + + this.$emit('action', action) + }, + handleSaveChangesPreviewResult(result) { + if (!result || !result.success) { + const reason = result && result.reason ? result.reason : 'Preview failed' + const index = result && result.failedChangeIndex !== undefined && result.failedChangeIndex !== null + ? `, failedChangeIndex=${result.failedChangeIndex}` + : '' + this.$message.error(`${reason}${index}`) + return + } + const updateCount = result.updateCount || 0 + const insertCount = result.insertCount || 0 + const deleteCount = result.deleteCount || 0 + this.$confirm( + `Preview: ${updateCount} updates, ${insertCount} inserts, ${deleteCount} deletes. Continue?`, + 'Save changes', + { + confirmButtonText: 'Save', + cancelButtonText: 'Cancel', + type: 'warning' + } + ).then(() => { + this.$emit('action', { + action: 'save_changes', + dataView: this.meta.title, + data: this.pendingSavePayload || this.buildSaveChangesPayload() + }) + }).catch(() => {}) + }, + onCancelChanges() { + this.clearDirty() + this.onRefresh() + }, + onAddRow() { + const id = this.nextInsertRowId++ + const data = { + __chenInsertId: id, + __chenValues: {} + } + this.insertRows.push({ + id, + data, + values: {} + }) + this.reloadTable() + this.dirtyVersion += 1 + this.refreshDirtyCells() + }, + onDeleteRows() { + const rows = this.getRowsForDelete() + if (rows.length === 0) { + return + } + rows.forEach((row) => this.markRowDeleted(row)) + this.reloadTable() + this.dirtyVersion += 1 + this.refreshDirtyCells() + }, + getRowsForDelete() { + const grid = this.$refs.resultGrid + if (grid && typeof grid.getRangeRowData === 'function') { + const rows = grid.getRangeRowData() + if (rows.length > 0) { + return rows + } + } + return this.currentRow ? [this.currentRow] : [] + }, + markRowDeleted(row) { + if (!row) { + return + } + if (this.isInsertRow(row)) { + this.insertRows = this.insertRows.filter((item) => item.id !== row.__chenInsertId) + if (this.currentRow === row) { + this.currentRow = null + } + return + } + const primaryKeyField = this.getPrimaryKeyField() + if (!primaryKeyField || !primaryKeyField.sourceColumn) { + this.$message.warning('Primary key is missing, cannot delete this row') + return + } + const pkValue = row[primaryKeyField.name] + if (this.getValueIsNull(pkValue)) { + this.$message.warning('Primary key value is empty, cannot delete this row') + return + } + this.clearRowUpdates(pkValue) + this.setObjectValue('deletedRows', this.buildDeleteKey(pkValue), { + pkColumn: primaryKeyField.sourceColumn, + pkValue, + pkValueIsNull: false + }) + }, + clearRowUpdates(pkValue) { + const nextDirtyCells = { ...this.dirtyCells } + Object.keys(nextDirtyCells).forEach((key) => { + if (key.startsWith(`${JSON.stringify(pkValue)}::`)) { + delete nextDirtyCells[key] + } + }) + this.dirtyCells = nextDirtyCells + }, fallbackWriteClipboardText(text, originalError) { const textarea = document.createElement('textarea') textarea.value = text @@ -273,6 +810,10 @@ export default { this.exportDataDialogVisible = false this.$emit('action', { action: 'export', data: scope }) }, + onCellClicked(params) { + this.currentRow = params ? params.data : null + this.dirtyVersion += 1 + }, showContextMenu(params) { this.currentRow = params.data this.$refs.rightMenu.show(params.event) @@ -344,4 +885,18 @@ export default { padding-bottom: 26px; background: #2B2B2B; } + +.data-view ::v-deep .chen-dirty-cell { + background-color: rgba(3, 157, 0, 0.28) !important; +} + +.data-view ::v-deep .chen-insert-row { + background-color: rgba(47, 101, 202, 0.24) !important; +} + +.data-view ::v-deep .chen-delete-row { + background-color: rgba(190, 66, 66, 0.26) !important; + color: #b8b8b8; + text-decoration: line-through; +} diff --git a/frontend/src/components/Main/Explore/DataView/ResultGrid.vue b/frontend/src/components/Main/Explore/DataView/ResultGrid.vue index fe095a9..7c36e9a 100644 --- a/frontend/src/components/Main/Explore/DataView/ResultGrid.vue +++ b/frontend/src/components/Main/Explore/DataView/ResultGrid.vue @@ -7,6 +7,7 @@ :default-col-def="defaultColDef" :grid-options="gridOptions" @grid-ready="onGridReady" + @cell-value-changed="onCellValueChanged" @cell-mouse-down="onCellMouseDown" @cell-mouse-over="onCellMouseOver" @cell-clicked="onCellClicked" @@ -31,6 +32,10 @@ export default { columnDefs: { type: Array, default: () => [] + }, + editable: { + type: Boolean, + default: false } }, data() { @@ -81,6 +86,9 @@ export default { onCellContextMenu(params) { this.$emit('cell-context-menu', params) }, + onCellValueChanged(params) { + this.$emit('cell-value-changed', params) + }, getCellRef(params) { if ( !params || @@ -136,6 +144,7 @@ export default { document.removeEventListener('mouseup', this.onDocumentMouseUp) }, onCellClicked(params) { + this.$emit('cell-clicked', params) const cell = this.getCellRef(params) if (!cell) { return @@ -246,6 +255,22 @@ export default { } this.refreshRangeCells() }, + getRangeRowData() { + const bounds = this.getRangeBounds() + if (!bounds || !this.gridApi) { + return [] + } + const rows = [] + const seen = new Set() + for (let rowIndex = bounds.minRow; rowIndex <= bounds.maxRow; rowIndex++) { + const rowNode = this.gridApi.getDisplayedRowAtIndex(rowIndex) + if (rowNode && rowNode.data && !seen.has(rowNode.data)) { + seen.add(rowNode.data) + rows.push(rowNode.data) + } + } + return rows + }, onDocumentKeyDown(event) { if ((!event.ctrlKey && !event.metaKey) || event.key.toLowerCase() !== 'c') { return diff --git a/frontend/src/components/Main/Explore/DataView/index.vue b/frontend/src/components/Main/Explore/DataView/index.vue index c3ad4c6..587e46f 100644 --- a/frontend/src/components/Main/Explore/DataView/index.vue +++ b/frontend/src/components/Main/Explore/DataView/index.vue @@ -1,33 +1,36 @@