handler = DB_TYPE_TO_HANDLER.get(dbType);
46 | if (handler == null) {
47 | throw new RuntimeException("不支持的数据库类型: " + dbType);
48 | }
49 | return handler.apply(dbConfig, tableName);
50 | }
51 |
52 | /**
53 | * 获取 Oracle 数据库表字段元数据。
54 | *
55 | * @param dbConfig 数据库连接配置
56 | * @param tableName 表名
57 | * @return 字段元数据列表
58 | */
59 | private static TableMeta getTableColumnsFromOracle(DbConfig dbConfig, String tableName) {
60 | return JdbcTableInfoUtil.getTableColumnsFromOracle(dbConfig, tableName);
61 | }
62 |
63 | /**
64 | * 获取 MySQL 数据库表字段元数据。
65 | *
66 | * @param dbConfig 数据库连接配置
67 | * @param tableName 表名
68 | * @return 字段元数据列表
69 | */
70 | private static TableMeta getTableColumnsFromMySQL(DbConfig dbConfig, String tableName) {
71 | return JdbcTableInfoUtil.getTableMetaFromMySQL(dbConfig, tableName);
72 | }
73 |
74 | /**
75 | * 获取 SQL Server 数据库表字段元数据。
76 | *
77 | * @param dbConfig 数据库连接配置
78 | * @param tableName 表名
79 | * @return 字段元数据列表
80 | */
81 | private static TableMeta getTableColumnsFromSqlServer(DbConfig dbConfig, String tableName) {
82 | return JdbcTableInfoUtil.getTableColumnsFromSqlServer(dbConfig, tableName);
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/main/java/com/chen/action/PrettySqlAction.java:
--------------------------------------------------------------------------------
1 | package com.chen.action;
2 |
3 | import com.chen.entity.DbConfig;
4 | import com.chen.utils.SqlFormatUtil;
5 | import com.intellij.openapi.actionSystem.AnAction;
6 | import com.intellij.openapi.actionSystem.AnActionEvent;
7 | import com.intellij.openapi.actionSystem.CommonDataKeys;
8 | import com.intellij.openapi.command.WriteCommandAction;
9 | import com.intellij.openapi.editor.CaretModel;
10 | import com.intellij.openapi.editor.Editor;
11 | import com.intellij.openapi.editor.SelectionModel;
12 | import com.intellij.openapi.project.Project;
13 | import org.jetbrains.annotations.NotNull;
14 |
15 | import java.util.Arrays;
16 | import java.util.stream.Collectors;
17 |
18 | import static com.chen.utils.DbConfigUtil.loadFromCache;
19 |
20 |
21 | /**
22 | * SQL 格式化操作类
23 | * 当用户选中 SQL 文本时,自动进行美化格式化,并替换选中内容
24 | * 适用于 IntelliJ Platform 插件开发
25 | *
26 | * @author czh
27 | * @version 1.0
28 | * @date 2025/6/12 14:47
29 | */
30 | public class PrettySqlAction extends AnAction {
31 |
32 | /**
33 | * 执行格式化操作的入口方法
34 | *
35 | * @param e 当前触发动作的事件对象
36 | */
37 | @Override
38 | public void actionPerformed(@NotNull AnActionEvent e) {
39 | // 获取当前编辑器
40 | Editor editor = e.getData(CommonDataKeys.EDITOR);
41 | if (editor == null) return;
42 |
43 | // 获取当前选中内容
44 | SelectionModel selectionModel = editor.getSelectionModel();
45 | String selectedText = selectionModel.getSelectedText();
46 | if (selectedText == null || selectedText.isEmpty()) return;
47 | DbConfig dbConfig = loadFromCache(e.getProject());
48 | // 使用工具类格式化 SQL 文本
49 | String formattedSql = SqlFormatUtil.formatSql(selectedText, dbConfig.getUrl().replaceFirst("^jdbc:(\\w+):.*", "$1"));
50 |
51 | // 设置缩进(这里是 11 个空格)
52 | String indent = " ";
53 |
54 | // 为格式化后的每一行添加统一缩进
55 | String indentedSql = Arrays.stream(formattedSql.split("\n"))
56 | .map(line -> indent + line)
57 | .collect(Collectors.joining("\n"));
58 |
59 | // 获取选中文本的起止位置
60 | int start = selectionModel.getSelectionStart();
61 | int end = selectionModel.getSelectionEnd();
62 |
63 | Project project = e.getProject();
64 |
65 | // 执行替换操作,并支持撤销(IDE 操作必须在 WriteCommandAction 中执行)
66 | WriteCommandAction.runWriteCommandAction(project, () -> {
67 | editor.getDocument().replaceString(start, end, indentedSql);
68 |
69 | // 可选:将光标跳转到 SELECT 后的位置,便于继续编辑
70 | int newOffset = start + indentedSql.indexOf("SELECT");
71 | if (newOffset >= start) {
72 | CaretModel caretModel = editor.getCaretModel();
73 | caretModel.moveToOffset(newOffset + "SELECT".length() + 1);
74 | }
75 | });
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/main/java/com/chen/constant/MessageConstants.java:
--------------------------------------------------------------------------------
1 | package com.chen.constant;
2 |
3 | /**
4 | * @author czh
5 | * @version 1.0
6 | * @description: 消息常量类
7 | * @date 2025/6/14 8:39
8 | */
9 | public class MessageConstants {
10 | public static final String SQL_CHECK_TITLE = "SQL检查";
11 | public static final String SQL_WARNING_EDITOR_EMPTY = "请在编辑器中选中或输入SQL语句";
12 | public static final String SQL_WARNING_SQL_EMPTY = "未检测到SQL语句";
13 | public static final String SQL_ERROR_NO_DB_CONFIG = "未配置数据库,无法执行SQL检查";
14 | public static final String SQL_ERROR_CONNECTION_FAIL = "数据库连接失败,请检查配置是否正确。";
15 | public static final String SQL_ERROR_TITLE_CONNECTION_FAIL = "连接失败";
16 | public static final String SQL_ERROR_TITLE = "错误";
17 | public static final String SQL_ERROR_SYNTAX = "SQL语法检查";
18 | public static final String NOT_IN_SCOPE = "不在文件规定范围";
19 | public static final String SQL_WARNING_DANGER = "SQL危险检测";
20 | public static final String SQL_SUCCESS = "SQL语句语法正确";
21 | public static final String SQL_SUCCESS_TITLE = "SQL检查";
22 | public static final String CONFIG_SAVE_SUCCESS_MESSAGE_PREFIX = "数据库配置已保存到路径:\n";
23 | public static final String CONFIG_SWITCH_SUCCESS_MESSAGE = "数据源切换成功";
24 | public static final String CONFIG_SAVE_SUCCESS_MESSAGE_SUFFIX = "\n请重新将鼠标悬停以查看表结构。";
25 | public static final String CONFIG_SAVE_SUCCESS_TITLE = "配置成功";
26 | public static final String ERROR_PREFIX = "异常信息: ";
27 | public static final String DATASOURCE = "当前数据源:";
28 | public static final String ERROR_NO_COLUMNS = " 未在数据库中找到该表结构或表无字段。";
29 | public static final String CONFIG_SAVE_FAIL_PREFIX = "保存配置失败:";
30 | public static final String CONFIG_SAVE_FAIL_TITLE = "保存错误";
31 | public static final String DIALOG_TITLE = "执行计划分析";
32 | public static final String ERROR_NO_EDITOR = "未选中任何 SQL,且编辑器内容为空";
33 | public static final String ERROR_NO_DB_CONFIG = "未找到数据库配置,请先配置数据库连接";
34 | public static final String ERROR_CONNECTION_FAIL = "数据库连接失败,请检查配置";
35 | public static final String ERROR_CONNECTION_EXCEPTION_PREFIX = "数据库连接异常:";
36 | public static final String WARN_NOT_SELECT = "执行计划只支持 SELECT 语句";
37 | public static final String INFO_NO_RESULT = "执行计划无结果";
38 | public static final String ERROR_ANALYZE_FAIL_PREFIX = "执行计划分析失败:";
39 | public static final String DELETE_SUCCESS_MESSAGE = "数据源配置已删除";
40 | public static final String DELETE_SUCCESS_TITLE = "删除成功";
41 | public static final String NO_DB_CONFIG_HTML = "请先配置或者新增数据库连接! ";
42 | public static final String MESSAGE_SELECT_SQL = "请先选中一段SQL语句";
43 | public static final String PARAM_OPTIONAL_LABEL = "(选填):";
44 | public static final String BTN_SQL_CHECK = "SQL执行检查";
45 | public static final String BTN_COPY = "复制";
46 | public static final String DIALOG_RESULT = "执行计划分析";
47 | public static final String COPY_SUCCESS_MSG = "已复制到剪贴板!";
48 |
49 | }
50 |
51 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/src/main/java/com/chen/utils/SqlFormatUtil.java:
--------------------------------------------------------------------------------
1 | package com.chen.utils;
2 |
3 | import com.github.vertical_blank.sqlformatter.SqlFormatter;
4 |
5 | import java.util.Arrays;
6 | import java.util.LinkedHashMap;
7 | import java.util.Map;
8 | import java.util.regex.Matcher;
9 | import java.util.regex.Pattern;
10 | import java.util.stream.Collectors;
11 |
12 | /**
13 | * SQL 格式化工具类。
14 | *
15 | * 基于第三方库 {@code vertical-blank/sql-formatter},可根据指定方言对 SQL 字符串进行美化格式化,
16 | * 使 SQL 更易阅读,适用于开发工具、日志展示、SQL 编辑器等场景。
17 | * 如果传入方言无效或格式化失败,将原样返回 SQL 字符串。
18 | * @author czh
19 | * @version 1.0
20 | * @since 2025/6/12
21 | */
22 | public class SqlFormatUtil {
23 |
24 | /**
25 | * 根据指定 SQL 方言对 SQL 字符串进行格式化美化处理。
26 | *
27 | * @param sql 原始 SQL 字符串(非空)
28 | * @param dialect SQL 方言名称,如 "mysql"、"postgresql"、"sql" 等
29 | * @return 格式化后的 SQL 字符串;如果格式化失败,返回原始 SQL
30 | */
31 | public static String formatSql(String sql, String dialect) {
32 | try {
33 | // 1. 提取所有MyBatis标签(成对和自闭合)作为占位符,保存原始内容
34 | Map placeholderMap = new LinkedHashMap<>();
35 | String regex = "<(\\w+)(\\s[^>]*)?>[\\s\\S]*?\\1>|<\\w+[^>]*/?>";
36 | Pattern pattern = Pattern.compile(regex, Pattern.DOTALL);
37 | Matcher matcher = pattern.matcher(sql);
38 |
39 | int idx = 0;
40 | StringBuffer buffer = new StringBuffer();
41 | while (matcher.find()) {
42 | String tag = matcher.group();
43 | String key = "###PLACEHOLDER_" + idx++ + "###";
44 | placeholderMap.put(key, tag);
45 | matcher.appendReplacement(buffer, key);
46 | }
47 | matcher.appendTail(buffer);
48 |
49 | // 2. 格式化没有标签的SQL部分
50 | String formatted = SqlFormatter.of(dialect).format(buffer.toString());
51 |
52 | // 3. 逐个替换回原始标签
53 | for (Map.Entry entry : placeholderMap.entrySet()) {
54 | String key = entry.getKey();
55 | String originalTag = entry.getValue();
56 | formatted = formatted.replaceFirst(Pattern.quote(key), Matcher.quoteReplacement(originalTag));
57 | }
58 |
59 | // 4. 标签缩进后处理
60 | String[] lines = formatted.split("\n");
61 | StringBuilder result = new StringBuilder();
62 | for (String line : lines) {
63 | String trim = line.trim();
64 | if (trim.startsWith("<") && trim.endsWith(">")) {
65 | result.append(" ").append(trim).append("\n");
66 | } else if(trim.equals("")) {
67 | result.append("\n");
68 | } else {
69 | result.append(line).append("\n");
70 | }
71 | }
72 | return result.toString();
73 |
74 | } catch (Exception e) {
75 | e.printStackTrace();
76 | return sql;
77 | }
78 | }
79 |
80 |
81 |
82 |
83 |
84 |
85 | }
86 |
--------------------------------------------------------------------------------
/src/main/java/com/chen/entity/ColumnMeta.java:
--------------------------------------------------------------------------------
1 | package com.chen.entity;
2 |
3 | /**
4 | * 数据库字段元数据信息类
5 | * 用于描述表中的某一列,包括列名、类型、是否主键、是否索引、备注等信息
6 | * 可用于代码生成、数据库结构读取等场景
7 | *
8 | * @author czh
9 | * @version 2.0
10 | * @date 2025/6/19
11 | */
12 | public class ColumnMeta {
13 |
14 | // 字段名称
15 | private String name;
16 |
17 | // 字段类型(如 VARCHAR、INT、DATE 等)
18 | private String type;
19 |
20 | // 是否为主键
21 | private boolean primaryKey;
22 |
23 | // 是否为索引字段(主键字段一定是索引,其他索引字段不一定是主键)
24 | private boolean index;
25 |
26 | // 字段备注(注释)
27 | private String remark;
28 |
29 | public ColumnMeta() {
30 | }
31 |
32 | /**
33 | * 构造方法,初始化字段元数据
34 | *
35 | * @param name 字段名
36 | * @param type 字段类型
37 | * @param primaryKey 是否为主键
38 | * @param index 是否为索引字段
39 | * @param remark 字段备注
40 | */
41 | public ColumnMeta(String name, String type, boolean primaryKey, boolean index, String remark) {
42 | this.name = name;
43 | this.type = type;
44 | this.primaryKey = primaryKey;
45 | this.index = index;
46 | this.remark = remark;
47 | }
48 |
49 | /**
50 | * 获取字段名称
51 | * @return 字段名
52 | */
53 | public String getName() {
54 | return name;
55 | }
56 |
57 | /**
58 | * 设置字段名称
59 | * @param name 字段名
60 | */
61 | public void setName(String name) {
62 | this.name = name;
63 | }
64 |
65 | /**
66 | * 获取字段类型
67 | * @return 字段类型
68 | */
69 | public String getType() {
70 | return type;
71 | }
72 |
73 | /**
74 | * 设置字段类型
75 | * @param type 字段类型
76 | */
77 | public void setType(String type) {
78 | this.type = type;
79 | }
80 |
81 | /**
82 | * 是否为主键
83 | * @return true 是主键,false 不是
84 | */
85 | public boolean isPrimaryKey() {
86 | return primaryKey;
87 | }
88 |
89 | /**
90 | * 设置是否为主键
91 | * @param primaryKey 是否主键
92 | */
93 | public void setPrimaryKey(boolean primaryKey) {
94 | this.primaryKey = primaryKey;
95 | }
96 |
97 | /**
98 | * 是否为索引字段
99 | * @return true 是索引,false 不是
100 | */
101 | public boolean isIndex() {
102 | return index;
103 | }
104 |
105 | /**
106 | * 设置是否为索引字段
107 | * @param index 是否索引字段
108 | */
109 | public void setIndex(boolean index) {
110 | this.index = index;
111 | }
112 |
113 | /**
114 | * 获取字段备注
115 | * @return 字段备注
116 | */
117 | public String getRemark() {
118 | return remark;
119 | }
120 |
121 | /**
122 | * 设置字段备注
123 | * @param remark 字段备注
124 | */
125 | public void setRemark(String remark) {
126 | this.remark = remark;
127 | }
128 |
129 | @Override
130 | public String toString() {
131 | return "ColumnMeta{" +
132 | "name='" + name + '\'' +
133 | ", type='" + type + '\'' +
134 | ", primaryKey=" + primaryKey +
135 | ", index=" + index +
136 | ", remark='" + remark + '\'' +
137 | '}';
138 | }
139 | }
--------------------------------------------------------------------------------
/src/main/java/com/chen/utils/DataSourceManager.java:
--------------------------------------------------------------------------------
1 | package com.chen.utils;
2 |
3 | import com.chen.constant.DataSourceConstants;
4 | import com.chen.entity.DbConfig;
5 | import com.zaxxer.hikari.HikariConfig;
6 | import com.zaxxer.hikari.HikariDataSource;
7 |
8 | import javax.sql.DataSource;
9 | import java.util.Map;
10 | import java.util.concurrent.ConcurrentHashMap;
11 |
12 | import static com.chen.utils.DbConfigUtil.parseDbType;
13 |
14 | /**
15 | * 数据源管理类,用于统一管理数据库连接池实例。
16 | * 基于 HikariCP 实现数据源的创建、缓存与复用。
17 | * 支持 MySQL、Oracle、SQL Server 等主流数据库。
18 | */
19 | public class DataSourceManager {
20 |
21 | /**
22 | * 数据库类型与对应 JDBC 驱动类名的映射。
23 | */
24 | private static final Map DRIVER_MAP = Map.of(
25 | DataSourceConstants.DB_TYPE_MYSQL, DataSourceConstants.MYSQL_DRIVER,
26 | DataSourceConstants.DB_TYPE_ORACLE, DataSourceConstants.ORACLE_DRIVER,
27 | DataSourceConstants.DB_TYPE_SQLSERVER, DataSourceConstants.SQLSERVER_DRIVER,
28 | DataSourceConstants.DB_TYPE_POSTGRESQL, DataSourceConstants.POSTGRESQL_DRIVER
29 | );
30 |
31 | /**
32 | * 数据源缓存池,key 为 DbConfig,value 为 HikariDataSource。
33 | * 使用 ConcurrentHashMap 保证线程安全。
34 | */
35 | private static final Map cache = new ConcurrentHashMap<>();
36 |
37 | /**
38 | * 获取指定配置的 DataSource,如果缓存中不存在则创建新的。
39 | *
40 | * @param config 数据库连接配置
41 | * @return 对应的数据源对象(HikariDataSource 实现)
42 | */
43 | public static DataSource getDataSource(DbConfig config) {
44 | return cache.computeIfAbsent(config, cfg -> {
45 | loadJdbcDriver(cfg.getUrl());
46 | return createHikariDataSource(cfg);
47 | });
48 | }
49 |
50 | /**
51 | * 根据数据库 URL 加载对应的 JDBC 驱动类。
52 | *
53 | * @param url 数据库连接 URL
54 | * @throws RuntimeException 如果不支持该数据库或驱动加载失败
55 | */
56 | private static void loadJdbcDriver(String url) {
57 | String dbType = parseDbType(url);
58 | String driverClass = DRIVER_MAP.get(dbType);
59 | if (driverClass == null) {
60 | throw new RuntimeException("不支持的数据库类型: " + dbType);
61 | }
62 | try {
63 | Class.forName(driverClass);
64 | } catch (ClassNotFoundException e) {
65 | throw new RuntimeException("JDBC驱动加载失败: " + e.getMessage(), e);
66 | }
67 | }
68 |
69 | /**
70 | * 创建一个新的 HikariCP 数据源实例。
71 | *
72 | * @param cfg 数据库连接配置
73 | * @return 初始化后的 HikariDataSource 实例
74 | */
75 | private static HikariDataSource createHikariDataSource(DbConfig cfg) {
76 | HikariConfig hikariConfig = new HikariConfig();
77 | hikariConfig.setJdbcUrl(cfg.getUrl());
78 | hikariConfig.setUsername(cfg.getUsername());
79 | hikariConfig.setPassword(cfg.getPassword());
80 | hikariConfig.setMaximumPoolSize(DataSourceConstants.MAX_POOL_SIZE); // 最大连接数
81 | hikariConfig.setMinimumIdle(DataSourceConstants.MIN_IDLE); // 最小空闲连接数
82 | hikariConfig.setConnectionTimeout(DataSourceConstants.CONNECTION_TIMEOUT); // 获取连接超时时间
83 | hikariConfig.setIdleTimeout(DataSourceConstants.IDLE_TIMEOUT); // 空闲连接最大存活时间
84 | hikariConfig.setMaxLifetime(DataSourceConstants.MAX_LIFETIME); // 连接最大生命周期
85 | return new HikariDataSource(hikariConfig);
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/README_EN.md:
--------------------------------------------------------------------------------
1 | # PrettySQL - Powerful SQL Plugin for IntelliJ
2 |
3 | > PrettySQL is a lightweight IntelliJ plugin designed to enhance SQL development. It integrates features like SQL formatting, table structure tooltips, syntax checking, and execution plan analysis to provide a cleaner, more visual, and efficient SQL development experience.
4 |
5 | [📚 Documentation](http://czh.znunwm.top/) | [🐛 Report Issues](https://github.com/SiYuan-2002/PrettySQL/issues) | 🇨🇳 [简体中文](https://github.com/SiYuan-2002/PrettySQL/blob/master/README.md)
6 |
7 |
8 | ## 🔧 Features
9 |
10 | - Beautify SQL statements with a single click
11 | - Table structure tooltip when hovering over table names
12 | - Support for multiple data sources
13 | - SQL syntax checking
14 | - SQL execution plan analysis
15 | - SQL check report generation
16 | - ER diagram generation
17 |
18 | For more features, visit the [Documentation Center](http://czh.znunwm.top/).
19 |
20 | ## 🚀 Getting Started
21 |
22 | PrettySQL supports JetBrains IDEs like **IntelliJ IDEA**, **PyCharm**, **CLion**, **DataGrip**, etc. (Currently not compatible with versions earlier than IntelliJ IDEA 2022.2.2)
23 |
24 | ### Installation Steps
25 |
26 | 1. Open your JetBrains IDE and go to **File → Settings → Plugins** (or **Preferences** on macOS)
27 | 2. Search for **PrettySQL** in the plugin marketplace and click **Install**
28 | 3. Restart your IDE after installation
29 | 4. Right-click SQL inside XML files to format them (supports selected XML tags)
30 | 5. If a `yml` config file is present, it's used automatically. Otherwise, a popup window will guide you to add a data source
31 | 6. Hover over a table name to view its structure tooltip
32 | 7. Right-click SQL to check syntax and execution plan (DML will not be committed)
33 |
34 | ## 🖼️ Screenshots
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | ## 🤝 Join Us
46 |
47 | PrettySQL is continuously evolving. Contributions are welcome — whether it's feedback, feature suggestions, or pull requests.
48 |
49 | **This project is for learning and personal use only. Commercial use and re-publishing (including uploading to any marketplace or app store) are strictly prohibited without explicit permission from the author.**
50 |
51 | If you're interested in joining or helping out, feel free to reach out with your ideas!
52 |
53 | ---
54 |
55 | ## ☕ Support the Author
56 |
57 | If you find PrettySQL helpful, consider buying a coffee to support continued development ❤️
58 |
59 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/src/main/java/com/chen/action/CheckSqlAction.java:
--------------------------------------------------------------------------------
1 | package com.chen.action;
2 |
3 | import com.chen.constant.MessageConstants;
4 | import com.chen.entity.DbConfig;
5 | import com.chen.utils.SqlCheckUtil;
6 | import com.intellij.openapi.actionSystem.AnAction;
7 | import com.intellij.openapi.actionSystem.AnActionEvent;
8 | import com.intellij.openapi.editor.Editor;
9 | import com.intellij.openapi.editor.SelectionModel;
10 | import com.intellij.openapi.project.Project;
11 | import com.intellij.openapi.ui.Messages;
12 | import org.jetbrains.annotations.NotNull;
13 |
14 | import java.util.Optional;
15 |
16 | import static com.chen.utils.DbConfigUtil.*;
17 | import static com.chen.utils.JdbcTableInfoUtil.testConnection;
18 |
19 | /**
20 | * @author czh
21 | * @version 1.0
22 | * @description: SQL语法检查操作类
23 | * @date 2025/6/13 14:29
24 | */
25 |
26 | public class CheckSqlAction extends AnAction {
27 | @Override
28 | public void actionPerformed(@NotNull AnActionEvent e) {
29 | Project project = e.getProject();
30 | Editor editor = e.getData(com.intellij.openapi.actionSystem.CommonDataKeys.EDITOR);
31 |
32 | if (editor == null) {
33 | Messages.showWarningDialog(project,
34 | MessageConstants.SQL_WARNING_EDITOR_EMPTY,
35 | MessageConstants.SQL_CHECK_TITLE);
36 | return;
37 | }
38 |
39 | SelectionModel selectionModel = editor.getSelectionModel();
40 | String sql = selectionModel.getSelectedText();
41 | if (sql == null || sql.trim().isEmpty()) {
42 | sql = editor.getDocument().getText();
43 | }
44 |
45 | if (sql == null || sql.trim().isEmpty()) {
46 | Messages.showWarningDialog(project,
47 | MessageConstants.SQL_WARNING_SQL_EMPTY,
48 | MessageConstants.SQL_CHECK_TITLE);
49 | return;
50 | }
51 |
52 | Optional dbConfigOpt = Optional.ofNullable(loadFromCache(e.getProject()))
53 | .or(() -> Optional.ofNullable(tryLoadDbConfig(e.getProject())))
54 | .or(() -> Optional.ofNullable(promptUserInputSync(e.getProject())));
55 |
56 | if (!dbConfigOpt.isPresent()) {
57 | Messages.showErrorDialog(e.getProject(),
58 | MessageConstants.SQL_ERROR_NO_DB_CONFIG,
59 | MessageConstants.SQL_ERROR_TITLE);
60 | return;
61 | }
62 |
63 | DbConfig dbConfig = dbConfigOpt.get();
64 |
65 | try {
66 | if (!testConnection(dbConfig)) {
67 | Messages.showErrorDialog(e.getProject(),
68 | MessageConstants.SQL_ERROR_CONNECTION_FAIL,
69 | MessageConstants.SQL_ERROR_TITLE_CONNECTION_FAIL);
70 | return;
71 | }
72 | } catch (Exception ex) {
73 | Messages.showErrorDialog(e.getProject(),
74 | "数据库连接异常:" + ex.getMessage(),
75 | MessageConstants.SQL_ERROR_TITLE_CONNECTION_FAIL);
76 | return;
77 | }
78 |
79 |
80 | // 语法检查
81 | String syntaxResult = SqlCheckUtil.checkSQLWithRollback(dbConfig, sql);
82 | if (syntaxResult != null) {
83 | Messages.showErrorDialog(project,
84 | syntaxResult,
85 | MessageConstants.SQL_ERROR_SYNTAX);
86 | return;
87 | }
88 |
89 | // 危险SQL检查
90 | String dangerResult = SqlCheckUtil.checkDangerous(sql);
91 | if (dangerResult != null) {
92 | Messages.showWarningDialog(project,
93 | dangerResult,
94 | MessageConstants.SQL_WARNING_DANGER);
95 | return;
96 | }
97 |
98 | Messages.showInfoMessage(project,
99 | MessageConstants.SQL_SUCCESS,
100 | MessageConstants.SQL_SUCCESS_TITLE);
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/src/main/java/com/chen/utils/HtmlViewerUtil.java:
--------------------------------------------------------------------------------
1 | package com.chen.utils;
2 |
3 | import com.intellij.openapi.project.Project;
4 | import com.intellij.openapi.ui.Messages;
5 | import com.intellij.openapi.ui.popup.JBPopup;
6 | import com.intellij.openapi.ui.popup.JBPopupFactory;
7 | import groovyjarjarantlr4.v4.runtime.misc.Nullable;
8 |
9 | import javax.swing.*;
10 | import java.awt.*;
11 |
12 | public class HtmlViewerUtil {
13 |
14 | /**
15 | * 弹出一个简单的 HTML 查看窗口
16 | * @param html HTML 字符串内容
17 | * @param title 窗口标题
18 | */
19 | public static void showHtml(Project project, String html, String title, @Nullable String markdownReportHtml, boolean isReportPage) {
20 | JEditorPane editorPane = new JEditorPane("text/html", html);
21 | editorPane.setEditable(false);
22 | editorPane.setCaretPosition(0);
23 |
24 | JScrollPane scrollPane = new JScrollPane(editorPane);
25 | scrollPane.setPreferredSize(new Dimension(1500, 600));
26 | scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED);
27 | scrollPane.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED);
28 |
29 | JPanel panel = new JPanel(new BorderLayout());
30 | panel.add(scrollPane, BorderLayout.CENTER);
31 |
32 | JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT));
33 |
34 | // 如果是展示原始HTML的弹窗,才显示“查看分析报告和建议”按钮
35 | if (!isReportPage && markdownReportHtml != null) {
36 | JButton viewReportBtn = new JButton("查看分析报告和建议");
37 | viewReportBtn.addActionListener(e -> {
38 | showHtml(project, markdownReportHtml, "SQL分析报告", null, true);
39 | });
40 | buttonPanel.add(viewReportBtn);
41 | }
42 |
43 | if (isReportPage) {
44 | JButton rewriteSQLBtn = new JButton("SQL重写");
45 | rewriteSQLBtn.addActionListener(e -> {
46 | try {
47 | String rewriteSQL = SoarYamlUtil.rewriteSQL(project);
48 | Messages.showInfoMessage(project, rewriteSQL, "重写成功");
49 | } catch (Exception ex) {
50 | throw new RuntimeException(ex);
51 | }
52 | });
53 | buttonPanel.add(rewriteSQLBtn);
54 |
55 | JButton generateHtmlBtn = new JButton("生成 HTML");
56 | generateHtmlBtn.addActionListener(e -> {
57 | try {
58 | SoarYamlUtil.downHtml(project);
59 | } catch (Exception ex) {
60 | throw new RuntimeException(ex);
61 | }
62 | });
63 | buttonPanel.add(generateHtmlBtn);
64 |
65 | JButton generateMdBtn = new JButton("生成 MD");
66 | generateMdBtn.addActionListener(e -> {
67 | try {
68 | SoarYamlUtil.downMD(project);
69 | } catch (Exception ex) {
70 | throw new RuntimeException(ex);
71 | }
72 | });
73 | buttonPanel.add(generateMdBtn);
74 | }
75 |
76 | // 添加关闭按钮,必须点击它才能关闭
77 | JButton closeBtn = new JButton("关闭");
78 | buttonPanel.add(closeBtn);
79 |
80 | panel.add(buttonPanel, BorderLayout.SOUTH);
81 |
82 | JBPopup popup = JBPopupFactory.getInstance()
83 | .createComponentPopupBuilder(panel, null)
84 | .setTitle(title)
85 | .setResizable(true)
86 | .setMovable(true)
87 | .setRequestFocus(true)
88 | // 禁止点击弹窗外部关闭
89 | .setCancelOnClickOutside(false)
90 | // 禁止按ESC关闭
91 | .setCancelOnWindowDeactivation(false)
92 | .createPopup();
93 |
94 | // 点击关闭按钮时关闭弹窗
95 | closeBtn.addActionListener(e -> popup.cancel());
96 |
97 | popup.showInFocusCenter();
98 | }
99 |
100 |
101 |
102 |
103 | }
104 |
--------------------------------------------------------------------------------
/src/main/java/com/chen/dialog/SqlPreviewDialog.java:
--------------------------------------------------------------------------------
1 | package com.chen.dialog;
2 | import com.chen.constant.MessageConstants;
3 | import com.chen.entity.DbConfig;
4 | import com.chen.utils.SqlCheckUtil;
5 | import com.intellij.openapi.project.Project;
6 | import com.intellij.openapi.ui.DialogWrapper;
7 | import com.intellij.openapi.ui.Messages;
8 | import org.jetbrains.annotations.Nullable;
9 |
10 | import javax.swing.*;
11 | import java.awt.*;
12 | import java.awt.datatransfer.StringSelection;
13 | import java.util.Optional;
14 |
15 | import static com.chen.utils.DbConfigUtil.*;
16 | import static com.chen.utils.JdbcTableInfoUtil.testConnection;
17 | import static com.chen.constant.MessageConstants.*;
18 | /**
19 | * @author czh
20 | * @version 1.0
21 | * @description: SQL 预览窗口。含“SQL执行检查”“复制”“关闭”等功能按钮
22 | * @date 2025/6/25 14:35
23 | */
24 | public class SqlPreviewDialog extends DialogWrapper {
25 |
26 | private final Project project;
27 | private final String sql;
28 | private JTextArea textArea;
29 |
30 | /**
31 | * 构造方法
32 | */
33 | public SqlPreviewDialog(Project project, String sql) {
34 | super(project, true);
35 | this.project = project;
36 | this.sql = sql;
37 | setTitle("预览实际 SQL");
38 | init();
39 | }
40 |
41 | @Nullable
42 | @Override
43 | protected JComponent createCenterPanel() {
44 | textArea = new JTextArea(sql, Math.min(20, sql.split("\n").length + 1), 80);
45 | textArea.setLineWrap(true);
46 | textArea.setWrapStyleWord(true);
47 | textArea.setCaretPosition(0);
48 | textArea.setEditable(false);
49 | JScrollPane scrollPane = new JScrollPane(textArea);
50 | scrollPane.setPreferredSize(new Dimension(900, 400));
51 | return scrollPane;
52 | }
53 |
54 | @Override
55 | protected Action[] createActions() {
56 | return new Action[]{
57 | new AbstractAction(BTN_SQL_CHECK) {
58 | @Override
59 | public void actionPerformed(java.awt.event.ActionEvent e) {
60 | doSqlCheck();
61 | }
62 | },
63 | new AbstractAction(BTN_COPY) {
64 | @Override
65 | public void actionPerformed(java.awt.event.ActionEvent e) {
66 | StringSelection selection = new StringSelection(textArea.getText());
67 | Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection);
68 | Messages.showInfoMessage(project, COPY_SUCCESS_MSG, BTN_COPY);
69 | }
70 | },
71 | getOKAction()
72 | };
73 | }
74 |
75 | /**
76 | * SQL执行检查逻辑,可根据实际需求扩展
77 | */
78 | private void doSqlCheck() {
79 | if (sql == null || sql.trim().isEmpty()) {
80 | Messages.showWarningDialog(project,
81 | MessageConstants.SQL_WARNING_SQL_EMPTY,
82 | MessageConstants.SQL_CHECK_TITLE);
83 | return;
84 | }
85 |
86 | Optional dbConfigOpt = Optional.ofNullable(loadFromCache(project))
87 | .or(() -> Optional.ofNullable(tryLoadDbConfig(project)))
88 | .or(() -> Optional.ofNullable(promptUserInputSync(project)));
89 |
90 | if (!dbConfigOpt.isPresent()) {
91 | Messages.showErrorDialog(project,
92 | MessageConstants.SQL_ERROR_NO_DB_CONFIG,
93 | MessageConstants.SQL_ERROR_TITLE);
94 | return;
95 | }
96 |
97 | DbConfig dbConfig = dbConfigOpt.get();
98 |
99 | try {
100 | if (!testConnection(dbConfig)) {
101 | Messages.showErrorDialog(project,
102 | MessageConstants.SQL_ERROR_CONNECTION_FAIL,
103 | MessageConstants.SQL_ERROR_TITLE_CONNECTION_FAIL);
104 | return;
105 | }
106 | } catch (Exception ex) {
107 | Messages.showErrorDialog(project,
108 | "数据库连接异常:" + ex.getMessage(),
109 | MessageConstants.SQL_ERROR_TITLE_CONNECTION_FAIL);
110 | return;
111 | }
112 |
113 | // 语法检查
114 | String syntaxResult = SqlCheckUtil.checkSQLWithRollback(dbConfig, sql);
115 | if (syntaxResult != null) {
116 | Messages.showErrorDialog(project,
117 | syntaxResult,
118 | MessageConstants.SQL_ERROR_SYNTAX);
119 | return;
120 | }
121 |
122 | // 危险SQL检查
123 | String dangerResult = SqlCheckUtil.checkDangerous(sql);
124 | if (dangerResult != null) {
125 | Messages.showWarningDialog(project,
126 | dangerResult,
127 | MessageConstants.SQL_WARNING_DANGER);
128 | return;
129 | }
130 |
131 | Messages.showInfoMessage(project,
132 | MessageConstants.SQL_SUCCESS,
133 | MessageConstants.SQL_SUCCESS_TITLE);
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/main/resources/META-INF/plugin.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | com.chen.PrettySQL
5 |
6 |
8 | PrettySQL
9 |
10 |
11 | 陈思源
12 |
13 |
16 |
19 |
20 |
21 |
23 | com.intellij.modules.platform
24 |
25 |
27 |
28 |
29 |
30 |
31 |
32 |
35 |
36 |
37 |
42 |
43 |
44 |
45 |
46 |
50 |
51 |
52 |
53 |
54 |
58 |
59 |
60 |
61 |
62 |
68 |
69 |
70 |
71 |
72 |
76 |
77 |
78 |
79 |
80 |
84 |
85 |
86 |
87 |
88 |
89 |
93 |
94 |
95 |
96 |
97 |
101 |
102 |
103 |
104 |
105 |
106 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
--------------------------------------------------------------------------------
/src/main/java/com/chen/action/SqlTableDocumentationProvider.java:
--------------------------------------------------------------------------------
1 | package com.chen.action;
2 |
3 | import com.chen.constant.MessageConstants;
4 | import com.chen.entity.ColumnMeta;
5 | import com.chen.entity.DbConfig;
6 | import com.chen.entity.TableMeta;
7 | import com.chen.utils.JdbcTableInfoUtil;
8 | import com.intellij.lang.documentation.AbstractDocumentationProvider;
9 | import com.intellij.lang.documentation.DocumentationProvider;
10 | import com.intellij.openapi.ui.Messages;
11 | import com.intellij.openapi.vfs.VirtualFile;
12 | import com.intellij.psi.PsiElement;
13 | import com.intellij.psi.PsiFile;
14 | import com.intellij.psi.xml.XmlText;
15 | import com.intellij.psi.xml.XmlToken;
16 | import org.jetbrains.annotations.Nullable;
17 |
18 | import java.nio.file.Path;
19 | import java.nio.file.Paths;
20 |
21 | import java.util.List;
22 |
23 | import java.util.Optional;
24 | import java.util.regex.Matcher;
25 | import java.util.regex.Pattern;
26 |
27 | import static com.chen.constant.FileConstant.CONFIG_PATH;
28 | import static com.chen.constant.FileConstant.XML;
29 | import static com.chen.constant.MessageConstants.*;
30 | import static com.chen.utils.DbConfigUtil.*;
31 | import static com.chen.utils.JdbcTableInfoUtil.testConnection;
32 |
33 | /**
34 | * SQL 表结构文档展示提供器:支持在 IntelliJ 中对 SQL 中的表名悬停显示结构
35 | * 自动从 application.yml / application-dev.yml 中提取数据库连接配置并通过 JDBC 获取表字段元数据
36 | */
37 | public class SqlTableDocumentationProvider extends AbstractDocumentationProvider {
38 |
39 |
40 | /**
41 | * 悬停展示文档:从 PSI 中识别表名,读取数据库连接并生成 HTML 表格
42 | */
43 | @Override
44 | public @Nullable String generateDoc(PsiElement element, @Nullable PsiElement originalElement) {
45 | String extension = Optional.ofNullable(element.getContainingFile())
46 | .map(PsiFile::getVirtualFile)
47 | .map(VirtualFile::getExtension)
48 | .orElse("");
49 |
50 | if (!XML.equalsIgnoreCase(extension)) {
51 | return NOT_IN_SCOPE;
52 | }
53 |
54 |
55 | String tableName = extractTableName(element);
56 | if (tableName == null) {
57 | return null;
58 | }
59 |
60 | DbConfig dbConfig = loadFromCache(element.getProject());
61 |
62 | if (dbConfig == null) {
63 | dbConfig = tryLoadDbConfig(element.getProject());
64 | }
65 |
66 | if (dbConfig == null) {
67 | promptUserInput(element.getProject(), config -> {
68 | try {
69 | if (!testConnection(config)) {
70 | Messages.showErrorDialog(element.getProject(),
71 | MessageConstants.SQL_ERROR_CONNECTION_FAIL,
72 | MessageConstants.SQL_ERROR_TITLE_CONNECTION_FAIL);
73 | return;
74 | }
75 | } catch (Exception ex) {
76 | Messages.showErrorDialog(element.getProject(),
77 | "数据库连接异常:" + ex.getMessage(),
78 | MessageConstants.SQL_ERROR_TITLE_CONNECTION_FAIL);
79 | return;
80 | }
81 |
82 | if (saveToCache(element.getProject(), config)) {
83 | Path path = Paths.get(element.getProject().getBasePath(), CONFIG_PATH);
84 | Messages.showInfoMessage(
85 | CONFIG_SAVE_SUCCESS_MESSAGE_PREFIX + path.toString() + CONFIG_SAVE_SUCCESS_MESSAGE_SUFFIX,
86 | CONFIG_SAVE_SUCCESS_TITLE
87 | );
88 | }
89 | });
90 | }
91 |
92 | List columns;
93 | String tableRemark = null;
94 | try {
95 | TableMeta tableColumns = JdbcTableInfoUtil.getTableColumns(dbConfig, tableName);
96 | columns = tableColumns.getColumns();
97 | tableRemark = tableColumns.getTableComment();
98 | } catch (Exception e) {
99 | return ERROR_PREFIX + tableName + DATASOURCE + dbConfig.getUrl() + e.getMessage();
100 | }
101 |
102 | if (columns == null || columns.isEmpty()) {
103 | return ERROR_PREFIX + tableName + DATASOURCE + dbConfig.getUrl() + ERROR_NO_COLUMNS;
104 | }
105 |
106 | return buildHtmlTable(tableName,tableRemark, columns);
107 | }
108 |
109 |
110 | /**
111 | * 构建表结构的 HTML 表格展示
112 | */
113 | private String buildHtmlTable(String tableName, String tableRemark,List columns) {
114 | StringBuilder html = new StringBuilder();
115 | html.append("");
124 |
125 | html.append("表名: ")
126 | .append(tableName)
127 | .append(tableRemark != null && !tableRemark.isBlank() ? "(" + tableRemark + ")" : "")
128 | .append(" ");
129 | html.append("")
130 | .append("字段名称 ")
131 | .append("类型 ")
132 | .append("备注 ")
133 | .append("主键 ")
134 | .append("索引 ")
135 | .append(" ");
136 |
137 | for (ColumnMeta col : columns) {
138 | html.append("").append(col.getName()).append(" ")
139 | .append("").append(col.getType()).append(" ")
140 | .append("").append(col.getRemark() == null ? "" : col.getRemark()).append(" ")
141 | .append("").append(col.isPrimaryKey() ? "YES " : "").append(" ")
142 | .append("").append(col.isIndex() ? "YES " : "").append(" ");
143 | }
144 |
145 | html.append("
");
146 | return html.toString();
147 | }
148 |
149 | /**
150 | * 提取 SQL 中的表名,适配 MyBatis XML 或 SQL 字符串
151 | */
152 | private @Nullable String extractTableName(PsiElement element) {
153 | if (element == null) return null;
154 | String text = element.getText();
155 | if (text == null || !text.matches("[a-zA-Z_][a-zA-Z0-9_]*")) return null;
156 |
157 | PsiElement parent = element.getParent();
158 |
159 | // 支持 from/join 关键字场景
160 | if (parent != null && parent.getText().matches("(?i).*\\b(from|join)\\b.*" + text + ".*")) {
161 | return text;
162 | }
163 |
164 | // XML/SQL 中的 token 内容匹配
165 | if (element instanceof XmlToken || element instanceof XmlText) {
166 | Pattern pattern = Pattern.compile("\\b(from|join)\\s+([a-zA-Z_][a-zA-Z0-9_]*)", Pattern.CASE_INSENSITIVE);
167 | Matcher matcher = pattern.matcher(text);
168 | if (matcher.find()) {
169 | return matcher.group(2);
170 | }
171 | }
172 |
173 | return text;
174 | }
175 |
176 |
177 | }
178 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | #
21 | # Gradle start up script for POSIX generated by Gradle.
22 | #
23 | # Important for running:
24 | #
25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
26 | # noncompliant, but you have some other compliant shell such as ksh or
27 | # bash, then to run this script, type that shell name before the whole
28 | # command line, like:
29 | #
30 | # ksh Gradle
31 | #
32 | # Busybox and similar reduced shells will NOT work, because this script
33 | # requires all of these POSIX shell features:
34 | # * functions;
35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
37 | # * compound commands having a testable exit status, especially «case»;
38 | # * various built-in commands including «command», «set», and «ulimit».
39 | #
40 | # Important for patching:
41 | #
42 | # (2) This script targets any POSIX shell, so it avoids extensions provided
43 | # by Bash, Ksh, etc; in particular arrays are avoided.
44 | #
45 | # The "traditional" practice of packing multiple parameters into a
46 | # space-separated string is a well documented source of bugs and security
47 | # problems, so this is (mostly) avoided, by progressively accumulating
48 | # options in "$@", and eventually passing that to Java.
49 | #
50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
52 | # see the in-line comments for details.
53 | #
54 | # There are tweaks for specific operating systems such as AIX, CygWin,
55 | # Darwin, MinGW, and NonStop.
56 | #
57 | # (3) This script is generated from the Groovy template
58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
59 | # within the Gradle project.
60 | #
61 | # You can find Gradle at https://github.com/gradle/gradle/.
62 | #
63 | ##############################################################################
64 |
65 | # Attempt to set APP_HOME
66 |
67 | # Resolve links: $0 may be a link
68 | app_path=$0
69 |
70 | # Need this for daisy-chained symlinks.
71 | while
72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
73 | [ -h "$app_path" ]
74 | do
75 | ls=$( ls -ld "$app_path" )
76 | link=${ls#*' -> '}
77 | case $link in #(
78 | /*) app_path=$link ;; #(
79 | *) app_path=$APP_HOME$link ;;
80 | esac
81 | done
82 |
83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
84 |
85 | APP_NAME="Gradle"
86 | APP_BASE_NAME=${0##*/}
87 |
88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
90 |
91 | # Use the maximum available, or set MAX_FD != -1 to use that value.
92 | MAX_FD=maximum
93 |
94 | warn () {
95 | echo "$*"
96 | } >&2
97 |
98 | die () {
99 | echo
100 | echo "$*"
101 | echo
102 | exit 1
103 | } >&2
104 |
105 | # OS specific support (must be 'true' or 'false').
106 | cygwin=false
107 | msys=false
108 | darwin=false
109 | nonstop=false
110 | case "$( uname )" in #(
111 | CYGWIN* ) cygwin=true ;; #(
112 | Darwin* ) darwin=true ;; #(
113 | MSYS* | MINGW* ) msys=true ;; #(
114 | NONSTOP* ) nonstop=true ;;
115 | esac
116 |
117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
118 |
119 |
120 | # Determine the Java command to use to start the JVM.
121 | if [ -n "$JAVA_HOME" ] ; then
122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
123 | # IBM's JDK on AIX uses strange locations for the executables
124 | JAVACMD=$JAVA_HOME/jre/sh/java
125 | else
126 | JAVACMD=$JAVA_HOME/bin/java
127 | fi
128 | if [ ! -x "$JAVACMD" ] ; then
129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
130 |
131 | Please set the JAVA_HOME variable in your environment to match the
132 | location of your Java installation."
133 | fi
134 | else
135 | JAVACMD=java
136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
137 |
138 | Please set the JAVA_HOME variable in your environment to match the
139 | location of your Java installation."
140 | fi
141 |
142 | # Increase the maximum file descriptors if we can.
143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
144 | case $MAX_FD in #(
145 | max*)
146 | MAX_FD=$( ulimit -H -n ) ||
147 | warn "Could not query maximum file descriptor limit"
148 | esac
149 | case $MAX_FD in #(
150 | '' | soft) :;; #(
151 | *)
152 | ulimit -n "$MAX_FD" ||
153 | warn "Could not set maximum file descriptor limit to $MAX_FD"
154 | esac
155 | fi
156 |
157 | # Collect all arguments for the java command, stacking in reverse order:
158 | # * args from the command line
159 | # * the main class name
160 | # * -classpath
161 | # * -D...appname settings
162 | # * --module-path (only if needed)
163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
164 |
165 | # For Cygwin or MSYS, switch paths to Windows format before running java
166 | if "$cygwin" || "$msys" ; then
167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
169 |
170 | JAVACMD=$( cygpath --unix "$JAVACMD" )
171 |
172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
173 | for arg do
174 | if
175 | case $arg in #(
176 | -*) false ;; # don't mess with options #(
177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
178 | [ -e "$t" ] ;; #(
179 | *) false ;;
180 | esac
181 | then
182 | arg=$( cygpath --path --ignore --mixed "$arg" )
183 | fi
184 | # Roll the args list around exactly as many times as the number of
185 | # args, so each arg winds up back in the position where it started, but
186 | # possibly modified.
187 | #
188 | # NB: a `for` loop captures its iteration list before it begins, so
189 | # changing the positional parameters here affects neither the number of
190 | # iterations, nor the values presented in `arg`.
191 | shift # remove old arg
192 | set -- "$@" "$arg" # push replacement arg
193 | done
194 | fi
195 |
196 | # Collect all arguments for the java command;
197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
198 | # shell script including quotes and variable substitutions, so put them in
199 | # double quotes to make sure that they get re-expanded; and
200 | # * put everything else in single quotes, so that it's not re-expanded.
201 |
202 | set -- \
203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
204 | -classpath "$CLASSPATH" \
205 | org.gradle.wrapper.GradleWrapperMain \
206 | "$@"
207 |
208 | # Use "xargs" to parse quoted args.
209 | #
210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
211 | #
212 | # In Bash we could simply go:
213 | #
214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
215 | # set -- "${ARGS[@]}" "$@"
216 | #
217 | # but POSIX shell has neither arrays nor command substitution, so instead we
218 | # post-process each arg (as a line of input to sed) to backslash-escape any
219 | # character that might be a shell metacharacter, then use eval to reverse
220 | # that process (while maintaining the separation between arguments), and wrap
221 | # the whole thing up as a single "set" statement.
222 | #
223 | # This will of course break if any of these variables contains a newline or
224 | # an unmatched quote.
225 | #
226 |
227 | eval "set -- $(
228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
229 | xargs -n1 |
230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
231 | tr '\n' ' '
232 | )" '"$@"'
233 |
234 | exec "$JAVACMD" "$@"
235 |
--------------------------------------------------------------------------------
/src/main/java/com/chen/action/ExplainSqlAction.java:
--------------------------------------------------------------------------------
1 | package com.chen.action;
2 |
3 | import com.chen.dialog.ParamInputDialog;
4 | import com.chen.entity.DbConfig;
5 | import com.chen.utils.HtmlViewerUtil;
6 | import com.chen.utils.JdbcTableInfoUtil;
7 | import com.chen.utils.SoarYamlUtil;
8 | import com.chen.utils.SqlParamUtils;
9 | import com.intellij.openapi.actionSystem.AnAction;
10 | import com.intellij.openapi.actionSystem.AnActionEvent;
11 | import com.intellij.openapi.editor.Editor;
12 | import com.intellij.openapi.editor.SelectionModel;
13 | import com.intellij.openapi.project.Project;
14 | import com.intellij.openapi.ui.Messages;
15 | import org.jetbrains.annotations.NotNull;
16 | import java.util.List;
17 | import java.util.Map;
18 | import java.util.Optional;
19 |
20 | import java.util.*;
21 |
22 | import static com.chen.constant.MessageConstants.*;
23 | import static com.chen.utils.DbConfigUtil.*;
24 | import static com.chen.utils.JdbcTableInfoUtil.testConnection;
25 | import static com.chen.utils.SoarYamlUtil.runSoarFixed;
26 | import static com.chen.utils.SoarYamlUtil.writeSqlToIdea;
27 |
28 | /**
29 | * 执行计划分析操作类
30 | * 模仿 CheckSqlAction,实现 EXPLAIN 执行计划展示
31 | */
32 | public class ExplainSqlAction extends AnAction {
33 |
34 | @Override
35 | public void actionPerformed(@NotNull AnActionEvent e) {
36 | Project project = e.getProject();
37 | Editor editor = e.getData(com.intellij.openapi.actionSystem.CommonDataKeys.EDITOR);
38 |
39 | if (editor == null) {
40 | Messages.showWarningDialog(project,
41 | ERROR_NO_EDITOR,
42 | DIALOG_TITLE);
43 | return;
44 | }
45 |
46 | // 获取 SQL 文本
47 | SelectionModel selectionModel = editor.getSelectionModel();
48 | String sqlTmp = selectionModel.getSelectedText();
49 | if (sqlTmp == null || sqlTmp.trim().isEmpty()) {
50 | Messages.showWarningDialog(project, MESSAGE_SELECT_SQL, DIALOG_TITLE);
51 | return;
52 | }
53 |
54 | // 解析 SQL 中的参数
55 | Set params = SqlParamUtils.extractParams(sqlTmp);
56 |
57 | Map paramValues = Collections.emptyMap();
58 | if (!params.isEmpty()) {
59 | // 存在参数,弹参数输入框
60 | ParamInputDialog paramDialog = new ParamInputDialog(params, project);
61 | if (!paramDialog.showAndGet()) return;
62 | paramValues = paramDialog.getParamValues();
63 | }
64 |
65 | // 生成预览 SQL
66 | String sql = SqlParamUtils.buildFinalSql(sqlTmp, paramValues);
67 | if (sql == null || sql.trim().isEmpty()) {
68 | sql = editor.getDocument().getText();
69 | }
70 |
71 | if (sql == null || sql.trim().isEmpty()) {
72 | Messages.showWarningDialog(project,
73 | ERROR_NO_EDITOR,
74 | DIALOG_TITLE);
75 | return;
76 | }
77 |
78 | Optional dbConfigOpt = Optional.ofNullable(loadFromCache(project))
79 | .or(() -> Optional.ofNullable(tryLoadDbConfig(project)))
80 | .or(() -> Optional.ofNullable(promptUserInputSync(project)));
81 |
82 | if (!dbConfigOpt.isPresent()) {
83 | Messages.showErrorDialog(project,
84 | ERROR_NO_DB_CONFIG,
85 | DIALOG_TITLE);
86 | return;
87 | }
88 |
89 | DbConfig dbConfig = dbConfigOpt.get();
90 |
91 | try {
92 | if (!testConnection(dbConfig)) {
93 | Messages.showErrorDialog(project,
94 | ERROR_CONNECTION_FAIL,
95 | DIALOG_TITLE);
96 | return;
97 | }
98 | } catch (Exception ex) {
99 | Messages.showErrorDialog(project,
100 | ERROR_CONNECTION_EXCEPTION_PREFIX + ex.getMessage(),
101 | DIALOG_TITLE);
102 | return;
103 | }
104 |
105 |
106 |
107 |
108 |
109 | try {
110 | // 仅支持 SELECT 语句
111 | if (!sql.trim().toLowerCase().startsWith("select")) {
112 | Messages.showWarningDialog(project,
113 | WARN_NOT_SELECT,
114 | DIALOG_TITLE);
115 | return;
116 | }
117 |
118 | List> explainRows = JdbcTableInfoUtil.explainSql(dbConfig, sql);
119 |
120 | if (explainRows == null || explainRows.isEmpty()) {
121 | Messages.showInfoMessage(project,
122 | INFO_NO_RESULT,
123 | DIALOG_TITLE);
124 | return;
125 | }
126 | saveToCache(project, dbConfig);
127 | //缓存sql文件
128 | writeSqlToIdea(project, sql);
129 |
130 | String html = buildExplainHtmlWithChinese(sql, explainRows);
131 |
132 | HtmlViewerUtil.showHtml(project,html, DIALOG_TITLE,runSoarFixed(project),false);
133 |
134 | } catch (Exception ex) {
135 | Messages.showErrorDialog(project,
136 | ERROR_ANALYZE_FAIL_PREFIX + ex.getMessage(),
137 | DIALOG_TITLE);
138 | }
139 | }
140 |
141 |
142 | /**
143 | * 生成执行计划 HTML
144 | */
145 | private String buildExplainHtmlWithChinese(String sql, List> rows) {
146 | StringBuilder html = new StringBuilder();
147 | html.append("");
155 |
156 | html.append("SQL 执行计划分析: ");
157 | html.append("").append(sql).append("
");
158 |
159 | html.append("");
160 | // 中文列头映射
161 | Map columnTitleMap = new LinkedHashMap<>();
162 | columnTitleMap.put("id", "序号");
163 | columnTitleMap.put("select_type", "查询类型");
164 | columnTitleMap.put("table", "表名");
165 | columnTitleMap.put("type", "连接类型");
166 | columnTitleMap.put("possible_keys", "可能使用的索引");
167 | columnTitleMap.put("key", "实际使用的索引");
168 | columnTitleMap.put("key_len", "索引长度");
169 | columnTitleMap.put("ref", "关联字段");
170 | columnTitleMap.put("rows", "扫描行数");
171 | columnTitleMap.put("filtered", "过滤比例");
172 | columnTitleMap.put("Extra", "额外信息");
173 |
174 | // 获取第一个元素的 keySet(使用 List> 类型 rows)
175 | Set keys = rows.get(0).keySet();
176 |
177 | for (String col : keys) {
178 | html.append("").append(columnTitleMap.getOrDefault(col, col)).append(" ");
179 | }
180 | html.append(" ");
181 |
182 | for (Map row : rows) {
183 | html.append("");
184 | for (String key : keys) {
185 | Object value = row.get(key);
186 | String valStr = value != null ? value.toString() : "";
187 | if ("type".equalsIgnoreCase(key) && "ALL".equalsIgnoreCase(valStr)) {
188 | valStr = "ALL(全表扫描) ";
189 | }
190 | html.append("").append(valStr).append(" ");
191 | }
192 | html.append(" ");
193 | }
194 |
195 | html.append("
");
196 |
197 | html.append("")
198 | .append("字段解释: ")
199 | .append("➤ 序号(id) :执行顺序,数字越大优先执行。 ")
200 | .append("➤ 查询类型(select_type) :表示当前 SELECT 的类型,如 SIMPLE(简单查询)、PRIMARY(主查询)、UNION 等。 ")
201 | .append("➤ 表名(table) :当前正在访问的表名或临时表名。 ")
202 | .append("➤ 连接类型(type) :MySQL 选择的数据读取方式,对性能影响很大,按效率从高到低排列如下: ")
203 | .append(" - system :系统表(只有一行),极少见,效率最高。 ")
204 | .append(" - const :常量查找,例如主键查询,最多返回一行。 ")
205 | .append(" - eq_ref :主键或唯一索引等值连接,性能很高。 ")
206 | .append(" - ref :普通索引等值连接,效率略低于 eq_ref。 ")
207 | .append(" - range :范围查询,例如使用了 BETWEEN、<、>、IN 等。 ")
208 | .append(" - index :全索引扫描,不读取数据表,仅扫描索引。 ")
209 | .append(" - ALL :全表扫描,性能最差,应尽量避免。 ")
210 | .append("➤ 可能使用的索引(possible_keys) :查询可能会用到的索引。 ")
211 | .append("➤ 使用索引(key) :优化器实际使用的索引。 ")
212 | .append("➤ 索引长度(key_len) :使用索引的长度,越短越优。 ")
213 | .append("➤ 关联字段(ref) :显示索引的哪一列被用于查找数据。 ")
214 | .append("➤ 扫描行数(rows) :MySQL 预估要扫描的行数,值越小越优。 ")
215 | .append("➤ 过滤比例(filtered) :剩余记录占比,表示该表经过 WHERE 条件过滤后剩余的记录比例。 ")
216 | .append("➤ 额外信息(Extra) :包含执行过程中的额外信息,如是否使用临时表、文件排序、索引下推等。 ")
217 | .append("
");
218 |
219 |
220 | return html.toString();
221 | }
222 | }
223 |
--------------------------------------------------------------------------------
/src/main/java/com/chen/entity/SoarConfig.java:
--------------------------------------------------------------------------------
1 | package com.chen.entity;
2 |
3 | import java.util.Map;
4 |
5 | /**
6 | * @author czh
7 | * @version 1.0
8 | * @description:
9 | * @date 2025/6/20 8:16
10 | */
11 | public class SoarConfig {
12 | public Map online_dsn;
13 | public Map test_dsn;
14 | public boolean allow_online_as_test;
15 | public boolean drop_test_temporary;
16 | public boolean only_syntax_check;
17 | public int sampling_statistic_target;
18 | public boolean sampling;
19 | public int log_level;
20 | public String log_output;
21 | public String report_type;
22 | public String[] ignore_rules;
23 | public String blacklist;
24 | public int max_join_table_count;
25 | public int max_group_by_cols_count;
26 | public int max_distinct_count;
27 | public int max_index_cols_count;
28 | public int max_total_rows;
29 | public int spaghetti_query_length;
30 | public boolean allow_drop_index;
31 | public String explain_sql_report_type;
32 | public String explain_type;
33 | public String explain_format;
34 | public String[] explain_warn_select_type;
35 | public String[] explain_warn_access_type;
36 | public int explain_max_keys;
37 | public int explain_min_keys;
38 | public int explain_max_rows;
39 | public String[] explain_warn_extra;
40 | public int explain_max_filtered;
41 | public String[] explain_warn_scalability;
42 | public String query;
43 | public boolean list_heuristic_rules;
44 | public boolean list_test_sqls;
45 | public boolean verbose;
46 |
47 | public static class Dsn {
48 | public String addr;
49 | public String schema;
50 | public String user;
51 | public String password;
52 | public boolean disable;
53 | }
54 |
55 | public Map getOnline_dsn() {
56 | return online_dsn;
57 | }
58 |
59 | public void setOnline_dsn(Map online_dsn) {
60 | this.online_dsn = online_dsn;
61 | }
62 |
63 | public Map getTest_dsn() {
64 | return test_dsn;
65 | }
66 |
67 | public void setTest_dsn(Map test_dsn) {
68 | this.test_dsn = test_dsn;
69 | }
70 |
71 | public boolean isAllow_online_as_test() {
72 | return allow_online_as_test;
73 | }
74 |
75 | public void setAllow_online_as_test(boolean allow_online_as_test) {
76 | this.allow_online_as_test = allow_online_as_test;
77 | }
78 |
79 | public boolean isDrop_test_temporary() {
80 | return drop_test_temporary;
81 | }
82 |
83 | public void setDrop_test_temporary(boolean drop_test_temporary) {
84 | this.drop_test_temporary = drop_test_temporary;
85 | }
86 |
87 | public boolean isOnly_syntax_check() {
88 | return only_syntax_check;
89 | }
90 |
91 | public void setOnly_syntax_check(boolean only_syntax_check) {
92 | this.only_syntax_check = only_syntax_check;
93 | }
94 |
95 | public int getSampling_statistic_target() {
96 | return sampling_statistic_target;
97 | }
98 |
99 | public void setSampling_statistic_target(int sampling_statistic_target) {
100 | this.sampling_statistic_target = sampling_statistic_target;
101 | }
102 |
103 | public boolean isSampling() {
104 | return sampling;
105 | }
106 |
107 | public void setSampling(boolean sampling) {
108 | this.sampling = sampling;
109 | }
110 |
111 | public int getLog_level() {
112 | return log_level;
113 | }
114 |
115 | public void setLog_level(int log_level) {
116 | this.log_level = log_level;
117 | }
118 |
119 | public String getLog_output() {
120 | return log_output;
121 | }
122 |
123 | public void setLog_output(String log_output) {
124 | this.log_output = log_output;
125 | }
126 |
127 | public String getReport_type() {
128 | return report_type;
129 | }
130 |
131 | public void setReport_type(String report_type) {
132 | this.report_type = report_type;
133 | }
134 |
135 | public String[] getIgnore_rules() {
136 | return ignore_rules;
137 | }
138 |
139 | public void setIgnore_rules(String[] ignore_rules) {
140 | this.ignore_rules = ignore_rules;
141 | }
142 |
143 | public String getBlacklist() {
144 | return blacklist;
145 | }
146 |
147 | public void setBlacklist(String blacklist) {
148 | this.blacklist = blacklist;
149 | }
150 |
151 | public int getMax_join_table_count() {
152 | return max_join_table_count;
153 | }
154 |
155 | public void setMax_join_table_count(int max_join_table_count) {
156 | this.max_join_table_count = max_join_table_count;
157 | }
158 |
159 | public int getMax_group_by_cols_count() {
160 | return max_group_by_cols_count;
161 | }
162 |
163 | public void setMax_group_by_cols_count(int max_group_by_cols_count) {
164 | this.max_group_by_cols_count = max_group_by_cols_count;
165 | }
166 |
167 | public int getMax_distinct_count() {
168 | return max_distinct_count;
169 | }
170 |
171 | public void setMax_distinct_count(int max_distinct_count) {
172 | this.max_distinct_count = max_distinct_count;
173 | }
174 |
175 | public int getMax_index_cols_count() {
176 | return max_index_cols_count;
177 | }
178 |
179 | public void setMax_index_cols_count(int max_index_cols_count) {
180 | this.max_index_cols_count = max_index_cols_count;
181 | }
182 |
183 | public int getMax_total_rows() {
184 | return max_total_rows;
185 | }
186 |
187 | public void setMax_total_rows(int max_total_rows) {
188 | this.max_total_rows = max_total_rows;
189 | }
190 |
191 | public int getSpaghetti_query_length() {
192 | return spaghetti_query_length;
193 | }
194 |
195 | public void setSpaghetti_query_length(int spaghetti_query_length) {
196 | this.spaghetti_query_length = spaghetti_query_length;
197 | }
198 |
199 | public boolean isAllow_drop_index() {
200 | return allow_drop_index;
201 | }
202 |
203 | public void setAllow_drop_index(boolean allow_drop_index) {
204 | this.allow_drop_index = allow_drop_index;
205 | }
206 |
207 | public String getExplain_sql_report_type() {
208 | return explain_sql_report_type;
209 | }
210 |
211 | public void setExplain_sql_report_type(String explain_sql_report_type) {
212 | this.explain_sql_report_type = explain_sql_report_type;
213 | }
214 |
215 | public String getExplain_type() {
216 | return explain_type;
217 | }
218 |
219 | public void setExplain_type(String explain_type) {
220 | this.explain_type = explain_type;
221 | }
222 |
223 | public String getExplain_format() {
224 | return explain_format;
225 | }
226 |
227 | public void setExplain_format(String explain_format) {
228 | this.explain_format = explain_format;
229 | }
230 |
231 | public String[] getExplain_warn_select_type() {
232 | return explain_warn_select_type;
233 | }
234 |
235 | public void setExplain_warn_select_type(String[] explain_warn_select_type) {
236 | this.explain_warn_select_type = explain_warn_select_type;
237 | }
238 |
239 | public String[] getExplain_warn_access_type() {
240 | return explain_warn_access_type;
241 | }
242 |
243 | public void setExplain_warn_access_type(String[] explain_warn_access_type) {
244 | this.explain_warn_access_type = explain_warn_access_type;
245 | }
246 |
247 | public int getExplain_max_keys() {
248 | return explain_max_keys;
249 | }
250 |
251 | public void setExplain_max_keys(int explain_max_keys) {
252 | this.explain_max_keys = explain_max_keys;
253 | }
254 |
255 | public int getExplain_min_keys() {
256 | return explain_min_keys;
257 | }
258 |
259 | public void setExplain_min_keys(int explain_min_keys) {
260 | this.explain_min_keys = explain_min_keys;
261 | }
262 |
263 | public int getExplain_max_rows() {
264 | return explain_max_rows;
265 | }
266 |
267 | public void setExplain_max_rows(int explain_max_rows) {
268 | this.explain_max_rows = explain_max_rows;
269 | }
270 |
271 | public String[] getExplain_warn_extra() {
272 | return explain_warn_extra;
273 | }
274 |
275 | public void setExplain_warn_extra(String[] explain_warn_extra) {
276 | this.explain_warn_extra = explain_warn_extra;
277 | }
278 |
279 | public int getExplain_max_filtered() {
280 | return explain_max_filtered;
281 | }
282 |
283 | public void setExplain_max_filtered(int explain_max_filtered) {
284 | this.explain_max_filtered = explain_max_filtered;
285 | }
286 |
287 | public String[] getExplain_warn_scalability() {
288 | return explain_warn_scalability;
289 | }
290 |
291 | public void setExplain_warn_scalability(String[] explain_warn_scalability) {
292 | this.explain_warn_scalability = explain_warn_scalability;
293 | }
294 |
295 | public String getQuery() {
296 | return query;
297 | }
298 |
299 | public void setQuery(String query) {
300 | this.query = query;
301 | }
302 |
303 | public boolean isList_heuristic_rules() {
304 | return list_heuristic_rules;
305 | }
306 |
307 | public void setList_heuristic_rules(boolean list_heuristic_rules) {
308 | this.list_heuristic_rules = list_heuristic_rules;
309 | }
310 |
311 | public boolean isList_test_sqls() {
312 | return list_test_sqls;
313 | }
314 |
315 | public void setList_test_sqls(boolean list_test_sqls) {
316 | this.list_test_sqls = list_test_sqls;
317 | }
318 |
319 | public boolean isVerbose() {
320 | return verbose;
321 | }
322 |
323 | public void setVerbose(boolean verbose) {
324 | this.verbose = verbose;
325 | }
326 | }
327 |
--------------------------------------------------------------------------------
/src/main/java/com/chen/utils/SqlParamUtils.java:
--------------------------------------------------------------------------------
1 | package com.chen.utils;
2 |
3 | import java.util.*;
4 | import java.util.regex.Matcher;
5 | import java.util.regex.Pattern;
6 |
7 | /**
8 | * @author czh
9 | * @version 1.6
10 | * @description: SQL 参数工具类。包含参数提取、参数替换、if判断、foreach展开、set块修正、输入为空则跳过渲染(即便只判断 != null)
11 | * @date 2025/6/25
12 | */
13 | public class SqlParamUtils {
14 |
15 | public static final String PARAM_REGEX = "[#\\$]\\{(\\w+(?:\\.\\w+)*)}";
16 | public static final String IF_BLOCK_REGEX = "([\\s\\S]*?) ";
17 | public static final String FOREACH_REGEX = "]*collection=\"(\\w+)\"[^>]*>([\\s\\S]*?) ";
18 | public static final String SQL_WHITESPACE_REGEX = "[\\t ]+";
19 | public static final String SQL_MULTILINE_REGEX = "(\\r?\\n){2,}";
20 | public static final String SET_BLOCK_REGEX = "([\\s\\S]*?) ";
21 |
22 | /**
23 | * 提取 SQL 中的参数名
24 | *
25 | * @param sql SQL字符串
26 | * @return 参数名集合,按出现顺序
27 | */
28 | public static Set extractParams(String sql) {
29 | Set params = new LinkedHashSet<>();
30 | Matcher matcher = Pattern.compile(PARAM_REGEX).matcher(sql);
31 | while (matcher.find()) params.add(matcher.group(1));
32 | return params;
33 | }
34 |
35 | /**
36 | * 根据参数值渲染最终 SQL,支持 if/foreach/set 模板语法
37 | *
38 | * @param sql SQL模板
39 | * @param paramValues 参数值映射
40 | * @return 渲染后的 SQL
41 | */
42 | public static String buildFinalSql(String sql, Map paramValues) {
43 | sql = processIfBlocks(sql, paramValues);
44 | sql = processForeachBlocks(sql, paramValues);
45 | sql = processSetBlocks(sql);
46 | sql= processWhereTag(sql);
47 |
48 | for (Map.Entry entry : paramValues.entrySet()) {
49 | String key = entry.getKey();
50 | Object valObj = entry.getValue();
51 | if (valObj == null || valObj.toString().trim().isEmpty()) {
52 | sql = sql.replaceAll("[#\\$]\\{" + Pattern.quote(key) + "}", "");
53 | continue;
54 | }
55 | String value = String.valueOf(valObj);
56 | sql = sql.replaceAll("#\\{" + Pattern.quote(key) + "}", isNumber(value) ? value : ("'" + value + "'"));
57 | sql = sql.replaceAll("\\$\\{" + Pattern.quote(key) + "}", value);
58 | }
59 | return sql.replaceAll("?if.*?>", "")
60 | .replaceAll(SQL_WHITESPACE_REGEX, " ")
61 | .replaceAll(SQL_MULTILINE_REGEX, "\n")
62 | .trim();
63 | }
64 |
65 | /**
66 | * 判断字符串是否为数字
67 | *
68 | * @param s 输入字符串
69 | * @return 是否数字
70 | */
71 | public static boolean isNumber(String s) {
72 | return s != null && s.matches("^-?\\d+(\\.\\d+)?$");
73 | }
74 |
75 |
76 | /**
77 | * 处理 SQL 里的 块
78 | *
79 | * @param sql SQL模板
80 | * @param paramValues 参数值映射
81 | * @return 处理后的 SQL
82 | */
83 | public static String processIfBlocks(String sql, Map paramValues) {
84 | Pattern ifBlock = Pattern.compile(IF_BLOCK_REGEX, Pattern.DOTALL);
85 | Matcher matcher = ifBlock.matcher(sql);
86 | StringBuffer sb = new StringBuffer();
87 | while (matcher.find()) {
88 | String condition = matcher.group(1);
89 | String blockSql = matcher.group(2);
90 | boolean show = evalCondition(condition, paramValues);
91 | matcher.appendReplacement(sb, show ? Matcher.quoteReplacement(blockSql) : "");
92 | }
93 | matcher.appendTail(sb);
94 | return sb.toString();
95 | }
96 |
97 | /**
98 | * 模拟 MyBatis 的 标签逻辑:
99 | * - 去除无用的 标签
100 | * - 自动添加 WHERE
101 | * - 移除第一个 AND 或 OR
102 | */
103 | public static String processWhereTag(String sql) {
104 | Pattern pattern = Pattern.compile("(.*?) ", Pattern.DOTALL);
105 | Matcher matcher = pattern.matcher(sql);
106 | StringBuffer sb = new StringBuffer();
107 |
108 | while (matcher.find()) {
109 | String body = matcher.group(1).trim();
110 |
111 | // 去除最前面的 and / or
112 | body = body.replaceFirst("(?i)^\\s*(and|or)\\s+", "");
113 |
114 | if (!body.isEmpty()) {
115 | matcher.appendReplacement(sb, "WHERE " + Matcher.quoteReplacement(body));
116 | } else {
117 | matcher.appendReplacement(sb, "");
118 | }
119 | }
120 |
121 | matcher.appendTail(sb);
122 | return sb.toString();
123 | }
124 |
125 | /**
126 | * 判断 if test 内的条件是否成立
127 | * 只支持 xx != null, xx != '', xx != 0 的多 and 条件
128 | *
129 | * @param condition 条件表达式
130 | * @param paramValues 参数值映射
131 | * @return 是否成立
132 | */
133 | public static boolean evalCondition(String condition, Map paramValues) {
134 | String[] ands = condition.split("and");
135 | for (String cond : ands) {
136 | cond = cond.trim();
137 | Matcher keyMatch = Pattern.compile("(\\w+)").matcher(cond);
138 | if (keyMatch.find()) {
139 | String key = keyMatch.group(1);
140 | Object val = paramValues.get(key);
141 | if (cond.contains("!= null")) {
142 | if (val == null || val.toString().trim().isEmpty()) return false;
143 | } else if (cond.contains("!= ''")) {
144 | if (val == null || val.toString().trim().isEmpty()) return false;
145 | } else if (cond.contains("!= 0")) {
146 | if (val == null || "0".equals(val.toString().trim())) return false;
147 | }
148 | }
149 | }
150 | return true;
151 | }
152 |
153 | /**
154 | * 处理带的SQL模板,支持弹窗输入userId为单个或逗号分隔的字符串
155 | * @param sql SQL模板
156 | * @param paramValues 参数map(支持userIds传"1,2,3"或List)
157 | * @return 替换后的SQL
158 | */
159 | public static String processForeachBlocks(String sql, Map paramValues) {
160 | Pattern foreachPattern = Pattern.compile(
161 | "]*)>([\\s\\S]*?) ",
162 | Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
163 | Matcher matcher = foreachPattern.matcher(sql);
164 | StringBuffer sb = new StringBuffer();
165 |
166 | while (matcher.find()) {
167 | String attrStr = matcher.group(1);
168 | String content = matcher.group(2);
169 |
170 | // 只关心item,不判定collection
171 | String itemAlias = getAttr(attrStr, "item");
172 | String open = getAttr(attrStr, "open", "(");
173 | String separator = getAttr(attrStr, "separator", ",");
174 | String close = getAttr(attrStr, "close", ")");
175 |
176 | // 只从paramValues取itemAlias
177 | Object valueObj = paramValues.get(itemAlias);
178 | List list = parseList(valueObj);
179 |
180 | StringBuilder fragment = new StringBuilder();
181 | fragment.append(open);
182 | for (int i = 0; i < list.size(); i++) {
183 | String val = list.get(i);
184 | String itemSql = content;
185 | itemSql = itemSql.replace(
186 | "#{" + itemAlias + "}",
187 | isNumber(val) ? val : ("'" + val + "'")
188 | );
189 | itemSql = itemSql.replace(
190 | "${" + itemAlias + "}",
191 | val
192 | );
193 | fragment.append(itemSql.trim());
194 | if (i < list.size() - 1) fragment.append(separator);
195 | }
196 | fragment.append(close);
197 |
198 | matcher.appendReplacement(sb, Matcher.quoteReplacement(fragment.toString()));
199 | }
200 | matcher.appendTail(sb);
201 | return sb.toString();
202 | }
203 |
204 | private static List parseList(Object obj) {
205 | if (obj == null) return Collections.emptyList();
206 | if (obj instanceof List) {
207 | List> origin = (List>) obj;
208 | List ret = new ArrayList<>();
209 | for (Object o : origin) ret.add(o == null ? "" : o.toString().trim());
210 | return ret;
211 | }
212 | if (obj instanceof String) {
213 | String s = ((String)obj).trim();
214 | if (s.isEmpty()) return Collections.emptyList();
215 | if (s.contains(",")) {
216 | List ret = new ArrayList<>();
217 | for (String part : s.split(",")) ret.add(part.trim());
218 | return ret;
219 | } else {
220 | return Collections.singletonList(s);
221 | }
222 | }
223 | return Collections.singletonList(obj.toString().trim());
224 | }
225 |
226 | private static String getAttr(String attrStr, String attrName) {
227 | return getAttr(attrStr, attrName, "");
228 | }
229 | private static String getAttr(String attrStr, String attrName, String defaultVal) {
230 | Pattern p = Pattern.compile(attrName + "=\"([^\"]*)\"");
231 | Matcher m = p.matcher(attrStr);
232 | return m.find() ? m.group(1) : defaultVal;
233 | }
234 |
235 |
236 |
237 | /**
238 | * 处理 块,自动补全逗号,去除末尾多余逗号
239 | *
240 | * @param sql SQL模板
241 | * @return 处理后的 SQL
242 | */
243 | public static String processSetBlocks(String sql) {
244 | Pattern setBlock = Pattern.compile(SET_BLOCK_REGEX, Pattern.DOTALL);
245 | Matcher matcher = setBlock.matcher(sql);
246 | StringBuffer sb = new StringBuffer();
247 | while (matcher.find()) {
248 | String content = matcher.group(1).trim();
249 | String[] lines = content.split("\r?\n");
250 | StringBuilder cleaned = new StringBuilder();
251 | for (String line : lines) {
252 | String trimmed = line.trim();
253 | if (trimmed.isEmpty()) continue;
254 | cleaned.append(trimmed);
255 | if (!trimmed.endsWith(",")) cleaned.append(",");
256 | cleaned.append("\n");
257 | }
258 | if (cleaned.length() > 0 && cleaned.charAt(cleaned.length() - 2) == ',') {
259 | cleaned.deleteCharAt(cleaned.length() - 2);
260 | }
261 | matcher.appendReplacement(sb, "SET\n" + Matcher.quoteReplacement(cleaned.toString().trim()));
262 | }
263 | matcher.appendTail(sb);
264 | return sb.toString();
265 | }
266 | }
267 |
--------------------------------------------------------------------------------
/src/main/java/com/chen/utils/SoarYamlUtil.java:
--------------------------------------------------------------------------------
1 | package com.chen.utils;
2 |
3 | import com.chen.entity.DbConfig;
4 | import com.fasterxml.jackson.core.type.TypeReference;
5 | import com.intellij.openapi.project.Project;
6 | import com.intellij.openapi.ui.Messages;
7 |
8 | import java.io.*;
9 | import java.net.URL;
10 | import java.nio.charset.StandardCharsets;
11 | import java.nio.file.Files;
12 | import java.nio.file.Path;
13 | import java.nio.file.Paths;
14 | import java.nio.file.StandardCopyOption;
15 | import java.text.SimpleDateFormat;
16 | import java.time.format.DateTimeFormatter;
17 | import java.util.*;
18 |
19 | import static com.chen.constant.FileConstant.*;
20 |
21 | /**
22 | * @author czh
23 | * @version 1.0
24 | * @description:
25 | * @date 2025/6/20 8:23
26 | */
27 | public class SoarYamlUtil {
28 |
29 | public static String readYamlTemplate(String resourcePath) throws IOException {
30 | try (InputStream is = SoarYamlUtil.class.getClassLoader().getResourceAsStream(resourcePath)) {
31 | if (is == null) throw new IOException("资源文件未找到: " + resourcePath);
32 |
33 | try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
34 | StringBuilder sb = new StringBuilder();
35 | String line;
36 | while ((line = reader.readLine()) != null) {
37 | sb.append(line).append("\n");
38 | }
39 | return sb.toString();
40 | }
41 | }
42 | }
43 |
44 | public static String readResourceFile(String path) throws IOException {
45 | try (InputStream is = SoarYamlUtil.class.getClassLoader().getResourceAsStream(path)) {
46 | if (is == null) throw new IOException("资源文件未找到: " + path);
47 | return new String(is.readAllBytes(), StandardCharsets.UTF_8);
48 | }
49 | }
50 |
51 | /**
52 | * 复制resources目录下的soar.exe到目标目录
53 | *
54 | * @param targetDir 目标目录路径,例如 .idea目录的绝对路径
55 | * @throws IOException
56 | */
57 | public static void copySoarExeToDir(Path targetDir) throws IOException {
58 | if (!Files.exists(targetDir)) {
59 | Files.createDirectories(targetDir);
60 | }
61 | Path targetFile = targetDir.resolve("soar.exe");
62 | if (Files.exists(targetFile)) {
63 | return;
64 | }
65 | // 资源路径(resources目录下的soar.exe)
66 | try (InputStream in = SoarYamlUtil.class.getClassLoader().getResourceAsStream("soar.exe")) {
67 | if (in == null) {
68 | throw new FileNotFoundException("资源目录下找不到 soar.exe");
69 | }
70 |
71 | Files.copy(in, targetFile, StandardCopyOption.REPLACE_EXISTING);
72 | }
73 | }
74 |
75 | /**
76 | * 根据 json 配置替换 yaml 内容中的数据库连接信息
77 | *
78 | * @param yamlTemplate yaml 模板内容字符串
79 | */
80 | public static String replaceDbConfigInYaml(DbConfig config, String yamlTemplate) {
81 | String url = config.getUrl();
82 | String username = config.getUsername(); // 或者 getUser()
83 | String password = config.getPassword();
84 |
85 | String addr = url.substring(url.indexOf("//") + 2, url.indexOf("/", url.indexOf("//") + 2));
86 | String schema = url.substring(url.indexOf("/", url.indexOf("//") + 2) + 1,
87 | url.contains("?") ? url.indexOf("?") : url.length());
88 |
89 | String replaced = yamlTemplate;
90 | replaced = replaced.replaceAll("(?m)^([ \\t]*)addr:\\s*.*", "$1addr: " + addr);
91 | replaced = replaced.replaceAll("(?m)^([ \\t]*)schema:\\s*.*", "$1schema: " + schema);
92 | replaced = replaced.replaceAll("(?m)^([ \\t]*)user:\\s*.*", "$1user: " + username);
93 | replaced = replaced.replaceAll("(?m)^([ \\t]*)password:\\s*.*", "$1password: " + password);
94 |
95 | return replaced;
96 | }
97 |
98 |
99 | /**
100 | * 写内容到用户项目的.idea目录下的soar.yaml
101 | *
102 | * @param projectBasePath 用户项目根路径
103 | * @param content 替换好的 yaml 内容
104 | * @throws IOException
105 | */
106 | public static void writeYamlToProjectIdea(String projectBasePath, String content) throws IOException {
107 | Path yamlPath = Paths.get(projectBasePath, SOARYMAL_PATH);
108 | Files.createDirectories(yamlPath.getParent());
109 | Files.writeString(yamlPath, content, StandardCharsets.UTF_8);
110 | }
111 |
112 | /**
113 | * 把 sqlContent 写入到用户项目 .idea/idea.sql 文件
114 | *
115 | * @param sqlContent SQL 脚本内容
116 | * @throws IOException
117 | */
118 | public static void writeSqlToIdea(Project project, String sqlContent) throws IOException {
119 | String projectBasePath = project.getBasePath();
120 | Path sqlFilePath = Paths.get(projectBasePath, SQL_SCRIPT_FILE_NAME);
121 | Files.createDirectories(sqlFilePath.getParent()); // 确保.idea目录存在
122 | Files.writeString(sqlFilePath, sqlContent, StandardCharsets.UTF_8);
123 | }
124 |
125 | /**
126 | * 获取当前时间戳(用于生成报告文件名)
127 | */
128 | private static String getTimestamp() {
129 | return new SimpleDateFormat(DATE_FORMAT).format(new Date());
130 | }
131 |
132 | /**
133 | * 获取 .idea 目录下某个文件的完整路径
134 | */
135 | private static Path getIdeaPath(Project project, String fileName) {
136 | return Paths.get(project.getBasePath(), IDEA_DIR, fileName);
137 | }
138 |
139 | /**
140 | * 确保结果输出目录存在,若不存在则创建
141 | */
142 | private static File ensureResultDir(String file) throws IOException {
143 | File resultDir = new File(RESULT_DIR+file);
144 | if (!resultDir.exists() && !resultDir.mkdirs()) {
145 | throw new IOException("无法创建结果目录 " + RESULT_DIR);
146 | }
147 | return resultDir;
148 | }
149 |
150 | /**
151 | * 运行分析命令并返回控制台输出(不生成文件)
152 | */
153 | public static String runSoarFixed(Project project) throws Exception {
154 | Path soarExePath = getIdeaPath(project, SOAR_EXE_NAME);
155 | Path sqlFilePath = getIdeaPath(project, SQL_FILE_NAME);
156 |
157 | List command = List.of(
158 | soarExePath.toString(),
159 | "-query=" + sqlFilePath
160 | );
161 |
162 | ProcessBuilder builder = new ProcessBuilder(command);
163 | builder.redirectErrorStream(true);
164 |
165 | StringBuilder output = new StringBuilder();
166 | Process process = null;
167 |
168 | try {
169 | DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
170 | output.append("执行时间: ").append(dtf.format(java.time.LocalDateTime.now())).append(System.lineSeparator());
171 | process = builder.start();
172 | try (BufferedReader reader = new BufferedReader(
173 | new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
174 | String line;
175 | while ((line = reader.readLine()) != null) {
176 | if (line.contains("beelogger") || line.contains("jsonconfig must have filename") || line.contains("a:link")) {
177 | continue;
178 | }
179 | output.append(line).append(System.lineSeparator());
180 | }
181 | }
182 | int exitCode = process.waitFor();
183 | if (exitCode != 0) {
184 | output.append("进程异常退出,退出码: ").append(exitCode).append(System.lineSeparator());
185 | }
186 | } catch (Exception e) {
187 | e.printStackTrace();
188 | output.append("执行异常: ").append(e.getMessage()).append(System.lineSeparator());
189 | } finally {
190 | if (process != null) process.destroy();
191 | }
192 | return output.toString();
193 | }
194 |
195 | /**
196 | * 执行分析结果保存为 HTML 文件
197 | */
198 | public static void downHtml(Project project) throws Exception {
199 | File soarExe = getIdeaPath(project, SOAR_EXE_NAME).toFile();
200 | File sqlFile = getIdeaPath(project, SQL_FILE_NAME).toFile();
201 |
202 | if (!soarExe.exists() || !sqlFile.exists()) {
203 | throw new FileNotFoundException("soar.exe 或 SQL 文件不存在!");
204 | }
205 |
206 | File resultDir = ensureResultDir("\\html");
207 | File outputFile = new File(resultDir, REPORT_PREFIX + getTimestamp() + HTML_SUFFIX);
208 |
209 | ProcessBuilder pb = new ProcessBuilder(
210 | soarExe.getAbsolutePath(),
211 | "-query=" + sqlFile.getAbsolutePath(),
212 | "-log-output=stdout"
213 | );
214 | pb.redirectErrorStream(true);
215 |
216 | Process process = pb.start();
217 |
218 | try (BufferedReader reader = new BufferedReader(
219 | new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
220 | BufferedWriter writer = new BufferedWriter(
221 | new OutputStreamWriter(new FileOutputStream(outputFile), StandardCharsets.UTF_8))) {
222 |
223 | String line;
224 | while ((line = reader.readLine()) != null) {
225 | writer.write(line);
226 | writer.newLine();
227 | }
228 | }
229 |
230 | if (process.waitFor() != 0) {
231 | throw new RuntimeException("执行失败,退出码:" + process.exitValue());
232 | }
233 |
234 | Messages.showInfoMessage(project, "HTML 报告生成成功:\n" + outputFile.getAbsolutePath(), "生成成功");
235 | }
236 |
237 | /**
238 | * 执行分析结果保存为 Markdown 文件
239 | * 会修改 soar.yaml 报告类型配置,完成后再还原
240 | */
241 | public static void downMD(Project project) throws Exception {
242 | Path yamlPath = getIdeaPath(project, YAML_FILE_NAME);
243 | Path soarExePath = getIdeaPath(project, SOAR_EXE_NAME);
244 | Path sqlFilePath = getIdeaPath(project, SQL_FILE_NAME);
245 |
246 | if (!Files.exists(yamlPath) || !Files.exists(soarExePath) || !Files.exists(sqlFilePath)) {
247 | throw new FileNotFoundException("配置文件、soar.exe 或 SQL 文件不存在!");
248 | }
249 |
250 | String originalYaml = Files.readString(yamlPath, StandardCharsets.UTF_8);
251 | String modifiedYaml = originalYaml.replace("report-type: html", "report-type: markdown");
252 |
253 | Files.writeString(yamlPath, modifiedYaml, StandardCharsets.UTF_8);
254 |
255 | File outputMd = new File(ensureResultDir("\\md"), REPORT_PREFIX + getTimestamp() + MD_SUFFIX);
256 |
257 | ProcessBuilder pb = new ProcessBuilder(
258 | soarExePath.toAbsolutePath().toString(),
259 | "-query=" + sqlFilePath.toAbsolutePath(),
260 | "-log-output=stdout"
261 | );
262 | pb.redirectErrorStream(true);
263 |
264 | Process process = pb.start();
265 |
266 | try (BufferedReader reader = new BufferedReader(
267 | new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
268 | BufferedWriter writer = new BufferedWriter(
269 | new OutputStreamWriter(new FileOutputStream(outputMd), StandardCharsets.UTF_8))) {
270 |
271 | String line;
272 | while ((line = reader.readLine()) != null) {
273 | writer.write(line);
274 | writer.newLine();
275 | }
276 | }
277 |
278 | if (process.waitFor() != 0) {
279 | throw new RuntimeException("执行失败,退出码:" + process.exitValue());
280 | }
281 |
282 | // 恢复原 YAML
283 | Files.writeString(yamlPath, originalYaml, StandardCharsets.UTF_8);
284 |
285 | Messages.showInfoMessage(project, "Markdown 报告生成成功:\n" + outputMd.getAbsolutePath(), "生成成功");
286 | }
287 |
288 | /**
289 | * 重写 SQL 并直接在弹窗中展示(不写入文件)
290 | */
291 | public static String rewriteSQL(Project project) throws Exception {
292 |
293 | Path yamlPath = getIdeaPath(project, YAML_FILE_NAME);
294 | Path soarExePath = getIdeaPath(project, SOAR_EXE_NAME);
295 | Path sqlFilePath = getIdeaPath(project, SQL_FILE_NAME);
296 |
297 | if (!Files.exists(yamlPath) || !Files.exists(soarExePath) || !Files.exists(sqlFilePath)) {
298 | throw new FileNotFoundException("配置文件、soar.exe 或 SQL 文件不存在!");
299 | }
300 |
301 | String originalYaml = Files.readString(yamlPath, StandardCharsets.UTF_8);
302 | String modifiedYaml = originalYaml.replace("report-type: html", "report-type: rewrite");
303 | Files.writeString(yamlPath, modifiedYaml, StandardCharsets.UTF_8);
304 | List command = List.of(
305 | soarExePath.toString(),
306 | "-query=" + sqlFilePath,
307 | "-log-output=stdout"
308 | );
309 |
310 | ProcessBuilder builder = new ProcessBuilder(command);
311 | builder.redirectErrorStream(true);
312 |
313 | StringBuilder output = new StringBuilder();
314 | Process process = null;
315 |
316 | try {
317 | process = builder.start();
318 | try (BufferedReader reader = new BufferedReader(
319 | new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
320 | String line;
321 | while ((line = reader.readLine()) != null) {
322 | output.append(line).append(System.lineSeparator());
323 | }
324 | }
325 | int exitCode = process.waitFor();
326 | if (exitCode != 0) {
327 | output.append("进程异常退出,退出码: ").append(exitCode).append(System.lineSeparator());
328 | }
329 | } catch (Exception e) {
330 | e.printStackTrace();
331 | output.append("执行异常: ").append(e.getMessage()).append(System.lineSeparator());
332 | } finally {
333 | if (process != null) process.destroy();
334 | }
335 |
336 | return output.toString();
337 | }
338 |
339 |
340 | }
341 |
--------------------------------------------------------------------------------
/src/main/java/com/chen/utils/JdbcTableInfoUtil.java:
--------------------------------------------------------------------------------
1 | package com.chen.utils;
2 |
3 | import com.chen.constant.DataSourceConstants;
4 | import com.chen.entity.ColumnMeta;
5 | import com.chen.entity.DbConfig;
6 | import com.chen.entity.TableMeta;
7 |
8 | import java.sql.*;
9 | import java.util.*;
10 |
11 | import static com.chen.utils.DbConfigUtil.parseDbType;
12 | import static java.sql.DriverManager.getConnection;
13 |
14 | /**
15 | * JDBC 工具类,用于获取指定表的字段元数据信息
16 | * 包括字段名称、类型、是否主键和备注信息
17 | *
18 | * 依赖 MySQL 数据库驱动:com.mysql.cj.jdbc.Driver
19 | *
20 | * @author czh
21 | * @version 1.0
22 | * @date 2025/6/12 14:44
23 | */
24 | public class JdbcTableInfoUtil {
25 |
26 | private static final Map DRIVER_MAP = Map.of(
27 | DataSourceConstants.DB_TYPE_MYSQL, DataSourceConstants.MYSQL_DRIVER,
28 | DataSourceConstants.DB_TYPE_ORACLE, DataSourceConstants.ORACLE_DRIVER,
29 | DataSourceConstants.DB_TYPE_SQLSERVER, DataSourceConstants.SQLSERVER_DRIVER,
30 | DataSourceConstants.DB_TYPE_POSTGRESQL, DataSourceConstants.POSTGRESQL_DRIVER
31 | // 可继续扩展
32 | );
33 |
34 | /**
35 | * 获取指定表的字段元数据列表
36 | *
37 | * @param dbConfig 数据库连接配置
38 | * @param tableName 表名
39 | * @return 字段元数据列表(ColumnMeta)
40 | * @throws ClassNotFoundException 如果 JDBC 驱动未加载
41 | */
42 | public static TableMeta getTableMetaFromMySQL(DbConfig dbConfig, String tableName) {
43 | try (Connection conn = DataSourceManager.getDataSource(dbConfig).getConnection()) {
44 | DatabaseMetaData meta = conn.getMetaData();
45 |
46 | String catalog = null;
47 | String url = dbConfig.getUrl();
48 | if (url != null) {
49 | int idx1 = url.indexOf("/", "jdbc:mysql://".length());
50 | int idx2 = url.indexOf("?", idx1);
51 | if (idx1 != -1) {
52 | catalog = (idx2 != -1) ? url.substring(idx1 + 1, idx2) : url.substring(idx1 + 1);
53 | }
54 | }
55 |
56 | // ✅ 获取表注释(表备注)
57 | String tableComment = tableName;
58 | String commentSql = "SELECT TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?";
59 | try (PreparedStatement ps = conn.prepareStatement(commentSql)) {
60 | ps.setString(1, catalog);
61 | ps.setString(2, tableName);
62 | try (ResultSet rs = ps.executeQuery()) {
63 | if (rs.next()) {
64 | String comment = rs.getString("TABLE_COMMENT");
65 | if (comment != null && !comment.isBlank()) {
66 | tableComment = comment;
67 | }
68 | }
69 | }
70 | }
71 |
72 | // ✅ 获取主键字段
73 | Set pkSet = new HashSet<>();
74 | try (ResultSet pkRs = meta.getPrimaryKeys(catalog, null, tableName)) {
75 | while (pkRs.next()) {
76 | pkSet.add(pkRs.getString("COLUMN_NAME"));
77 | }
78 | }
79 |
80 | // ✅ 获取索引字段
81 | Set indexSet = new HashSet<>();
82 | try (ResultSet idxRs = meta.getIndexInfo(catalog, null, tableName, false, false)) {
83 | while (idxRs.next()) {
84 | String colName = idxRs.getString("COLUMN_NAME");
85 | if (colName != null) {
86 | indexSet.add(colName);
87 | }
88 | }
89 | }
90 |
91 | // ✅ 获取字段列表
92 | List columns = new ArrayList<>();
93 | try (ResultSet rs = meta.getColumns(catalog, null, tableName, null)) {
94 | while (rs.next()) {
95 | String name = rs.getString("COLUMN_NAME");
96 | String type = rs.getString("TYPE_NAME");
97 | String remark = rs.getString("REMARKS");
98 | boolean pk = pkSet.contains(name);
99 | boolean idx = indexSet.contains(name);
100 | columns.add(new ColumnMeta(name, type, pk, idx, remark));
101 | }
102 | }
103 |
104 | return new TableMeta(tableName, tableComment, columns);
105 |
106 | } catch (SQLException e) {
107 | throw new RuntimeException(e);
108 | }
109 | }
110 |
111 |
112 |
113 | /**
114 | * 获取 SQL Server 表字段元数据
115 | *
116 | * @param dbConfig 数据库配置
117 | * @param tableName 表名
118 | * @return 字段元数据列表
119 | */
120 | public static TableMeta getTableColumnsFromSqlServer(DbConfig dbConfig, String tableName) {
121 | try (Connection conn = DataSourceManager.getDataSource(dbConfig).getConnection()) {
122 | DatabaseMetaData meta = conn.getMetaData();
123 |
124 | // 获取当前数据库名(Catalog)
125 | String catalog = conn.getCatalog();
126 |
127 | // 表注释
128 | String tableComment = tableName;
129 | String commentSql = "SELECT TABLE_NAME, TABLE_SCHEMA, TABLE_CATALOG, TABLE_TYPE, REMARKS " +
130 | "FROM INFORMATION_SCHEMA.TABLES " +
131 | "WHERE TABLE_CATALOG = ? AND TABLE_NAME = ?";
132 | try (PreparedStatement ps = conn.prepareStatement(commentSql)) {
133 | ps.setString(1, catalog);
134 | ps.setString(2, tableName);
135 | try (ResultSet rs = ps.executeQuery()) {
136 | if (rs.next()) {
137 | String comment = rs.getString("REMARKS");
138 | if (comment != null && !comment.isBlank()) {
139 | tableComment = comment;
140 | }
141 | }
142 | }
143 | }
144 |
145 | // 主键集合
146 | Set pkSet = new HashSet<>();
147 | try (ResultSet pkRs = meta.getPrimaryKeys(catalog, null, tableName)) {
148 | while (pkRs.next()) {
149 | pkSet.add(pkRs.getString("COLUMN_NAME"));
150 | }
151 | }
152 |
153 | // 索引字段集合
154 | Set indexSet = new HashSet<>();
155 | try (ResultSet idxRs = meta.getIndexInfo(catalog, null, tableName, false, false)) {
156 | while (idxRs.next()) {
157 | String colName = idxRs.getString("COLUMN_NAME");
158 | if (colName != null) {
159 | indexSet.add(colName);
160 | }
161 | }
162 | }
163 |
164 | // 字段信息
165 | List columns = new ArrayList<>();
166 | try (ResultSet rs = meta.getColumns(catalog, null, tableName, null)) {
167 | while (rs.next()) {
168 | String name = rs.getString("COLUMN_NAME");
169 | String type = rs.getString("TYPE_NAME");
170 | String remark = rs.getString("REMARKS");
171 | boolean pk = pkSet.contains(name);
172 | boolean idx = indexSet.contains(name);
173 | columns.add(new ColumnMeta(name, type, pk, idx, remark));
174 | }
175 | }
176 |
177 | return new TableMeta(tableName, tableComment, columns);
178 | } catch (SQLException e) {
179 | throw new RuntimeException("获取 SQL Server 字段失败: " + e.getMessage(), e);
180 | }
181 | }
182 |
183 |
184 |
185 |
186 | /**
187 | * 获取 Oracle 表字段元数据
188 | *
189 | * @param dbConfig 数据库配置
190 | * @param tableName 表名
191 | * @return 字段元数据列表
192 | */
193 | public static TableMeta getTableColumnsFromOracle(DbConfig dbConfig, String tableName) {
194 | List columns = new ArrayList<>();
195 | Set pkSet = new HashSet<>();
196 | Set indexSet = new HashSet<>();
197 | String schema = dbConfig.getUsername().toUpperCase();
198 | String upperTableName = tableName.toUpperCase();
199 | String tableComment = tableName;
200 |
201 | try (Connection conn = DataSourceManager.getDataSource(dbConfig).getConnection()) {
202 | DatabaseMetaData meta = conn.getMetaData();
203 |
204 | // 表注释
205 | String commentSql = "SELECT COMMENTS FROM ALL_TAB_COMMENTS WHERE OWNER = ? AND TABLE_NAME = ?";
206 | try (PreparedStatement ps = conn.prepareStatement(commentSql)) {
207 | ps.setString(1, schema);
208 | ps.setString(2, upperTableName);
209 | try (ResultSet rs = ps.executeQuery()) {
210 | if (rs.next()) {
211 | String comment = rs.getString("COMMENTS");
212 | if (comment != null && !comment.isBlank()) {
213 | tableComment = comment;
214 | }
215 | }
216 | }
217 | }
218 |
219 | // 主键字段
220 | try (ResultSet pkRs = meta.getPrimaryKeys(null, schema, upperTableName)) {
221 | while (pkRs.next()) {
222 | pkSet.add(pkRs.getString("COLUMN_NAME"));
223 | }
224 | }
225 |
226 | // 索引字段
227 | try (ResultSet idxRs = meta.getIndexInfo(null, schema, upperTableName, false, false)) {
228 | while (idxRs.next()) {
229 | String colName = idxRs.getString("COLUMN_NAME");
230 | if (colName != null) {
231 | indexSet.add(colName);
232 | }
233 | }
234 | }
235 |
236 | // 字段信息(带注释)
237 | String columnSql = "SELECT col.COLUMN_NAME, col.DATA_TYPE, com.COMMENTS " +
238 | "FROM ALL_TAB_COLUMNS col " +
239 | "LEFT JOIN ALL_COL_COMMENTS com " +
240 | "ON col.OWNER = com.OWNER AND col.TABLE_NAME = com.TABLE_NAME AND col.COLUMN_NAME = com.COLUMN_NAME " +
241 | "WHERE col.OWNER = ? AND col.TABLE_NAME = ?";
242 | try (PreparedStatement ps = conn.prepareStatement(columnSql)) {
243 | ps.setString(1, schema);
244 | ps.setString(2, upperTableName);
245 | try (ResultSet rs = ps.executeQuery()) {
246 | while (rs.next()) {
247 | String name = rs.getString("COLUMN_NAME");
248 | String type = rs.getString("DATA_TYPE");
249 | String remark = rs.getString("COMMENTS");
250 | boolean pk = pkSet.contains(name);
251 | boolean idx = indexSet.contains(name);
252 | columns.add(new ColumnMeta(name, type, pk, idx, remark));
253 | }
254 | }
255 | }
256 | } catch (SQLException e) {
257 | throw new RuntimeException("获取 Oracle 字段失败: " + e.getMessage(), e);
258 | }
259 |
260 | return new TableMeta(tableName, tableComment, columns);
261 | }
262 |
263 |
264 |
265 | /**
266 | * 获取指定表的字段元数据
267 | *
268 | * @param dbConfig 数据库连接配置
269 | * @param tableName 表名
270 | * @return 字段元数据列表
271 | * @throws Exception 如果获取失败
272 | */
273 | public static TableMeta getTableColumns(DbConfig dbConfig, String tableName) throws Exception {
274 | return ColumnMetaUtils.getTableColumns(dbConfig, tableName);
275 | }
276 |
277 |
278 |
279 | /**
280 | * 检查数据库连接是否有效
281 | *
282 | * @param dbConfig 数据库连接配置
283 | * @return true 表示连接成功,false 表示连接失败
284 | */
285 | public static boolean testConnection(DbConfig dbConfig) throws Exception {
286 | String dbType = parseDbType(dbConfig.getUrl());
287 | String driverClass = Optional.ofNullable(DRIVER_MAP.get(dbType))
288 | .orElseThrow(() -> new RuntimeException("不支持的数据库类型: " + dbType));
289 | Class.forName(driverClass);
290 |
291 | try (Connection conn = getConnection(
292 | dbConfig.getUrl(),
293 | dbConfig.getUsername(),
294 | dbConfig.getPassword())) {
295 | return conn != null && !conn.isClosed();
296 | }
297 | }
298 |
299 | /**
300 | * 执行 EXPLAIN 查询,并返回每行结果映射列表
301 | * 只支持 MySQL 的 EXPLAIN
302 | *
303 | * @param config 数据库连接配置
304 | * @param sql 要执行 EXPLAIN 的 SQL 查询(必须是 SELECT)
305 | * @return 结果集每行对应的 Map 集合列表
306 | * @throws Exception 执行异常
307 | */
308 | public static List> explainSql(DbConfig config, String sql) throws Exception {
309 | if (sql == null || !sql.trim().toLowerCase().startsWith("select")) {
310 | throw new IllegalArgumentException("只支持 SELECT 语句的执行计划");
311 | }
312 |
313 | String explainSql = "EXPLAIN " + sql;
314 |
315 | try (Connection conn = DataSourceManager.getDataSource(config).getConnection()) {
316 | PreparedStatement ps = conn.prepareStatement(explainSql);
317 | ResultSet rs = ps.executeQuery();
318 |
319 | ResultSetMetaData metaData = rs.getMetaData();
320 | int colCount = metaData.getColumnCount();
321 | List> list = new ArrayList<>();
322 |
323 | while (rs.next()) {
324 | Map row = new LinkedHashMap<>();
325 | for (int i = 1; i <= colCount; i++) {
326 | String colName = metaData.getColumnLabel(i);
327 | Object value = rs.getObject(i);
328 | row.put(colName, value);
329 | }
330 | list.add(row);
331 | }
332 |
333 | return list;
334 | } catch (SQLException e) {
335 | throw new Exception("执行 EXPLAIN 失败:" + e.getMessage(), e);
336 | }
337 | }
338 |
339 |
340 |
341 | }
342 |
--------------------------------------------------------------------------------
/src/main/java/com/chen/action/ERDiagramToolWindowFactory.java:
--------------------------------------------------------------------------------
1 | package com.chen.action;
2 |
3 | import com.chen.entity.DbConfig;
4 | import com.chen.utils.DataSourceManager;
5 | import com.intellij.openapi.project.Project;
6 | import com.intellij.openapi.wm.ToolWindow;
7 | import com.intellij.openapi.wm.ToolWindowFactory;
8 | import com.intellij.ui.jcef.JBCefBrowser;
9 | import com.intellij.ui.content.Content;
10 | import com.intellij.ui.content.ContentFactory;
11 |
12 | import javax.swing.*;
13 | import java.awt.*;
14 | import java.io.*;
15 | import java.nio.charset.StandardCharsets;
16 | import java.sql.*;
17 | import java.util.*;
18 | import java.util.List;
19 |
20 | import static com.chen.constant.FileConstant.D3_JS_PATH;
21 | import static com.chen.constant.FileConstant.DAGRE_JS_PATH;
22 | import static com.chen.constant.MessageConstants.ERROR_NO_DB_CONFIG;
23 | import static com.chen.constant.MessageConstants.NO_DB_CONFIG_HTML;
24 | import static com.chen.utils.DbConfigUtil.loadFromCache;
25 |
26 | /**
27 | * IDEA 工具窗口:展示数据库ER图(GoJS已换为D3+dagre-d3)。
28 | * 支持一键全部刷新、批量获取表/字段/外键信息,展示全部表及外键关系。
29 | * 支持表名及备注、字段类型及备注的显示。
30 | */
31 | public class ERDiagramToolWindowFactory implements ToolWindowFactory {
32 |
33 |
34 | /** 刷新按钮文本 */
35 | private static final String REFRESH_BUTTON_TEXT = "刷新ER图";
36 | /** 刷新按钮宽度 */
37 | private static final int REFRESH_BUTTON_WIDTH = 90;
38 | /** 刷新按钮高度 */
39 | private static final int REFRESH_BUTTON_HEIGHT = 28;
40 | /** 刷新按钮左侧边距 */
41 | private static final int REFRESH_BUTTON_LEFT_MARGIN = 10;
42 | /** 刷新按钮上侧边距 */
43 | private static final int REFRESH_BUTTON_TOP_MARGIN = 10;
44 | /** ER图页签标题 */
45 | private static final String TAB_TITLE = "ER Diagram";
46 |
47 | @Override
48 | public void createToolWindowContent(Project project, ToolWindow toolWindow) {
49 | // 创建浏览器组件
50 | JBCefBrowser browser = new JBCefBrowser();
51 |
52 | // 主面板,包含浏览器和按钮
53 | JPanel panel = new JPanel(new BorderLayout());
54 | panel.add(browser.getComponent(), BorderLayout.CENTER);
55 |
56 | // —— 刷新ER图按钮 ——
57 | JButton refreshButton = new JButton(REFRESH_BUTTON_TEXT);
58 | refreshButton.setFocusable(false);
59 | refreshButton.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
60 | refreshButton.setPreferredSize(new Dimension(REFRESH_BUTTON_WIDTH, REFRESH_BUTTON_HEIGHT));
61 |
62 | JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, REFRESH_BUTTON_LEFT_MARGIN, REFRESH_BUTTON_TOP_MARGIN));
63 | buttonPanel.setOpaque(false);
64 | buttonPanel.add(refreshButton);
65 | panel.add(buttonPanel, BorderLayout.SOUTH);
66 |
67 | // 刷新按钮点击事件
68 | refreshButton.addActionListener(e -> {
69 | DbConfig dbConfigRefresh = loadFromCache(project);
70 | if (dbConfigRefresh == null) {
71 | showWarnDialog(panel, ERROR_NO_DB_CONFIG);
72 | return;
73 | }
74 | try (Connection conn = DataSourceManager.getDataSource(dbConfigRefresh).getConnection()) {
75 | String html = buildERHtml(conn);
76 | browser.loadHTML(html);
77 | showInfoDialog(panel, "ER图刷新成功!");
78 | } catch (Exception ex) {
79 | ex.printStackTrace();
80 | browser.loadHTML(buildErrorHtml("数据库连接失败,无法生成ER图。\n" + ex.getMessage()));
81 | }
82 | });
83 |
84 | // 初始加载(自动加载上次数据库配置)
85 | DbConfig dbConfig = loadFromCache(project);
86 | if (dbConfig == null) {
87 | browser.loadHTML(NO_DB_CONFIG_HTML);
88 | } else {
89 | try (Connection conn = DataSourceManager.getDataSource(dbConfig).getConnection()) {
90 | String html = buildERHtml(conn);
91 | browser.loadHTML(html);
92 | } catch (Exception e) {
93 | e.printStackTrace();
94 | browser.loadHTML(buildErrorHtml("数据库连接失败,无法生成ER图。\n" + e.getMessage()));
95 | }
96 | }
97 |
98 | // 添加到IntelliJ ToolWindow
99 | ContentFactory contentFactory = ContentFactory.getInstance();
100 | Content content = contentFactory.createContent(panel, TAB_TITLE, false);
101 | toolWindow.getContentManager().addContent(content);
102 | }
103 |
104 | /**
105 | * 构建ER图HTML内容(批量查询数据库表、字段、外键信息,生成D3+dagre-d3代码)。
106 | * @param conn 有效的MySQL数据库连接
107 | * @return HTML字符串
108 | * @throws SQLException SQL异常
109 | * @throws IOException IO异常(js文件读取)
110 | */
111 | public String buildERHtml(Connection conn) throws SQLException, IOException {
112 | StringBuilder nodesBuilder = new StringBuilder();
113 | StringBuilder edgesBuilder = new StringBuilder();
114 |
115 | String catalog = conn.getCatalog();
116 |
117 | // 1. 批量查询所有表备注
118 | Map tableRemarks = queryTableRemarks(conn, catalog);
119 |
120 | // 2. 批量查询所有表字段及字段备注
121 | Map> tableFields = queryTableFields(conn, catalog);
122 |
123 | // 3. 批量查询所有外键信息
124 | List> links = queryForeignKeys(conn, catalog);
125 |
126 | // 4. 生成节点(表+备注+字段)
127 | for (Map.Entry> entry : tableFields.entrySet()) {
128 | String table = entry.getKey();
129 | Map fields = entry.getValue();
130 | StringBuilder label = new StringBuilder();
131 | label.append(table);
132 | // 拼接表备注(如 user(用户表))
133 | if (tableRemarks.containsKey(table) && !tableRemarks.get(table).isEmpty()) {
134 | label.append("(").append(tableRemarks.get(table)).append(")");
135 | }
136 | label.append("\\n———\\n");
137 | // 拼接字段及备注
138 | for (Map.Entry field : fields.entrySet()) {
139 | label.append(field.getKey()).append(": ").append(field.getValue()).append("\\n");
140 | }
141 | nodesBuilder.append("g.setNode(\"").append(table).append("\", {\n")
142 | .append("label: \"").append(label.toString().replace("\"", "\\\"")).append("\",\n")
143 | .append("shape: \"rect\"\n")
144 | .append("});\n");
145 | }
146 |
147 | // 5. 生成边(外键关系)
148 | for (Map link : links) {
149 | edgesBuilder.append("g.setEdge(\"").append(link.get("from")).append("\", \"")
150 | .append(link.get("to")).append("\", {\n")
151 | .append("label: \"").append(link.get("label")).append("\",\n")
152 | .append("lineInterpolate: \"basis\",\n")
153 | .append("arrowhead: \"vee\"\n")
154 | .append("});\n");
155 | }
156 |
157 | // 6. 读取d3.js和dagre-d3.js资源
158 | String d3Js = readJsResource(D3_JS_PATH);
159 | String dagreJs = readJsResource(DAGRE_JS_PATH);
160 | return String.format(HTML_TEMPLATE, d3Js, dagreJs, nodesBuilder, edgesBuilder);
161 | }
162 |
163 | /**
164 | * 批量查询所有表备注
165 | * @param conn 数据库连接
166 | * @param catalog 数据库名
167 | * @return 表名 -> 表备注
168 | * @throws SQLException SQL异常
169 | */
170 | private Map queryTableRemarks(Connection conn, String catalog) throws SQLException {
171 | Map tableRemarks = new HashMap<>();
172 | final String tableRemarkSql = "SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?";
173 | try (PreparedStatement ps = conn.prepareStatement(tableRemarkSql)) {
174 | ps.setString(1, catalog);
175 | try (ResultSet rs = ps.executeQuery()) {
176 | while (rs.next()) {
177 | String tableName = rs.getString("TABLE_NAME");
178 | String comment = rs.getString("TABLE_COMMENT");
179 | tableRemarks.put(tableName, (comment != null && !comment.isBlank()) ? comment : "");
180 | }
181 | }
182 | }
183 | return tableRemarks;
184 | }
185 |
186 | /**
187 | * 批量查询所有表字段及字段备注
188 | * @param conn 数据库连接
189 | * @param catalog 数据库名
190 | * @return 表名 -> (字段名 -> 类型+备注)
191 | * @throws SQLException SQL异常
192 | */
193 | private Map> queryTableFields(Connection conn, String catalog) throws SQLException {
194 | Map> tableFields = new LinkedHashMap<>();
195 | final String columnSql = "SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ?";
196 | try (PreparedStatement ps = conn.prepareStatement(columnSql)) {
197 | ps.setString(1, catalog);
198 | try (ResultSet rs = ps.executeQuery()) {
199 | while (rs.next()) {
200 | String tableName = rs.getString("TABLE_NAME");
201 | String colName = rs.getString("COLUMN_NAME");
202 | String type = rs.getString("COLUMN_TYPE");
203 | String remark = rs.getString("COLUMN_COMMENT");
204 | tableFields
205 | .computeIfAbsent(tableName, k -> new LinkedHashMap<>())
206 | .put(colName, type + (remark != null && !remark.isEmpty() ? " (" + remark + ")" : ""));
207 | }
208 | }
209 | }
210 | return tableFields;
211 | }
212 |
213 | /**
214 | * 批量查询所有外键信息
215 | * @param conn 数据库连接
216 | * @param catalog 数据库名
217 | * @return 外键关系列表
218 | * @throws SQLException SQL异常
219 | */
220 | private List> queryForeignKeys(Connection conn, String catalog) throws SQLException {
221 | List> links = new ArrayList<>();
222 | final String fkSql = "SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME " +
223 | "FROM information_schema.KEY_COLUMN_USAGE " +
224 | "WHERE TABLE_SCHEMA = ? AND REFERENCED_TABLE_NAME IS NOT NULL";
225 | try (PreparedStatement ps = conn.prepareStatement(fkSql)) {
226 | ps.setString(1, catalog);
227 | try (ResultSet rs = ps.executeQuery()) {
228 | while (rs.next()) {
229 | String pkTable = rs.getString("REFERENCED_TABLE_NAME");
230 | String pkColumn = rs.getString("REFERENCED_COLUMN_NAME");
231 | String fkTable = rs.getString("TABLE_NAME");
232 | String fkColumn = rs.getString("COLUMN_NAME");
233 | links.add(Map.of(
234 | "from", pkTable,
235 | "to", fkTable,
236 | "label", pkTable + "." + pkColumn + " -> " + fkTable + "." + fkColumn
237 | ));
238 | }
239 | }
240 | }
241 | return links;
242 | }
243 |
244 | /**
245 | * 读取js资源文件内容
246 | * @param resourcePath 资源路径
247 | * @return 文件内容字符串
248 | * @throws IOException IO异常
249 | */
250 | private String readJsResource(String resourcePath) throws IOException {
251 | // 兼容IDEA插件环境的资源读取
252 | InputStream is = getClass().getClassLoader().getResourceAsStream(resourcePath);
253 | if (is == null) throw new FileNotFoundException("资源未找到: " + resourcePath);
254 | try (is) {
255 | return new String(is.readAllBytes(), StandardCharsets.UTF_8);
256 | }
257 | }
258 |
259 | /**
260 | * 构建错误信息的HTML片段
261 | * @param message 错误信息
262 | * @return HTML字符串
263 | */
264 | private String buildErrorHtml(String message) {
265 | return "" + message + " ";
266 | }
267 |
268 | /**
269 | * 弹出警告对话框
270 | * @param parent 父组件
271 | * @param message 提示信息
272 | */
273 | private void showWarnDialog(Component parent, String message) {
274 | JOptionPane.showMessageDialog(parent, message, "提示", JOptionPane.WARNING_MESSAGE);
275 | }
276 |
277 | /**
278 | * 弹出信息对话框
279 | * @param parent 父组件
280 | * @param message 信息
281 | */
282 | private void showInfoDialog(Component parent, String message) {
283 | JOptionPane.showMessageDialog(parent, message, "提示", JOptionPane.INFORMATION_MESSAGE);
284 | }
285 |
286 | /**
287 | * ER图HTML模板,内嵌D3与Dagre-D3绘图脚本和样式
288 | */
289 | private static final String HTML_TEMPLATE = """
290 |
291 |
292 |
293 |
294 | ER 图
295 |
298 |
301 |
309 |
310 |
311 |
312 |
313 |
314 |
361 |
362 |
363 | """;
364 | }
--------------------------------------------------------------------------------
/src/main/java/com/chen/utils/DbConfigUtil.java:
--------------------------------------------------------------------------------
1 | package com.chen.utils;
2 | import com.chen.entity.DbConfig;
3 | import com.fasterxml.jackson.core.type.TypeReference;
4 | import com.fasterxml.jackson.databind.ObjectMapper;
5 | import com.intellij.openapi.application.ApplicationManager;
6 | import com.intellij.openapi.project.Project;
7 | import com.intellij.openapi.ui.Messages;
8 | import org.yaml.snakeyaml.Yaml;
9 |
10 | import javax.swing.*;
11 | import java.awt.*;
12 | import java.io.File;
13 | import java.io.FileInputStream;
14 | import java.io.IOException;
15 | import java.io.InputStream;
16 | import java.nio.charset.StandardCharsets;
17 | import java.nio.file.Files;
18 | import java.nio.file.Path;
19 | import java.nio.file.Paths;
20 | import java.util.*;
21 | import java.util.List;
22 | import java.util.function.Consumer;
23 | import java.util.stream.Collectors;
24 |
25 | import static com.chen.constant.DataSourceConstants.DB_TYPE_MYSQL;
26 | import static com.chen.constant.DbConstant.*;
27 | import static com.chen.constant.FileConstant.CONFIG_PATH;
28 | import static com.chen.constant.FileConstant.CONFIG_PATH_ALL;
29 | import static com.chen.constant.MessageConstants.*;
30 | import static com.chen.utils.DataSourceUrlFixer.appendUrlFix;
31 |
32 | /**
33 | * @author czh
34 | * @version 1.0
35 | * @description:
36 | * @date 2025/6/14 7:25
37 | */
38 | public class DbConfigUtil {
39 |
40 | private static final ObjectMapper objectMapper = new ObjectMapper();
41 |
42 | /**
43 | * 从项目目录中查找 YAML 配置并解析数据库连接参数,支持 spring.datasource 与 druid.master 配置
44 | */
45 | public static DbConfig tryLoadDbConfig(Project project) {
46 | List ymlFiles = findAllYmlFiles(new File(project.getBasePath()));
47 | DbConfig dbConfig = null;
48 |
49 | for (File yml : ymlFiles) {
50 | try (InputStream input = new FileInputStream(yml)) {
51 | Yaml yaml = new Yaml();
52 | Map data = yaml.load(input);
53 | if (data == null) continue;
54 |
55 | @SuppressWarnings("unchecked")
56 | Map spring = (Map) data.get("spring");
57 | if (spring == null) continue;
58 |
59 | @SuppressWarnings("unchecked")
60 | Map datasource = (Map) spring.get("datasource");
61 | if (datasource == null) continue;
62 |
63 | // 第一优先:spring.datasource 配置
64 | String url = String.valueOf(datasource.get("url"));
65 | String username = String.valueOf(datasource.get("username"));
66 | String password = String.valueOf(datasource.get("username"));
67 |
68 | if (StringUtils.notBlankAndNotNullStr(url) && StringUtils.notBlankAndNotNullStr(username)) {
69 | dbConfig = new DbConfig(url, username, password);
70 | break;
71 | }
72 |
73 | // 第二优先:druid.master 配置
74 | @SuppressWarnings("unchecked")
75 | Map druid = (Map) datasource.get("druid");
76 | if (druid == null) continue;
77 |
78 | @SuppressWarnings("unchecked")
79 | Map master = (Map) druid.get("master");
80 | if (master == null) continue;
81 |
82 | url = String.valueOf(master.get("url"));
83 | username = String.valueOf(master.get("username"));
84 | password = String.valueOf(master.get("password"));
85 |
86 | if (StringUtils.notBlankAndNotNullStr(url) && StringUtils.notBlankAndNotNullStr(username)) {
87 | dbConfig = new DbConfig(url, username, password);
88 | saveToCacheAppend(project, dbConfig);
89 | break;
90 | }
91 | } catch (Exception ignored) {
92 | }
93 | }
94 |
95 |
96 | if (dbConfig != null) {
97 | saveToCacheAppend(project, dbConfig);
98 | saveToCache(project, dbConfig);
99 | }
100 |
101 | return dbConfig;
102 | }
103 |
104 |
105 | /**
106 | * 递归查找所有 application*.yml 配置文件(仅限 src/main/resources)
107 | */
108 | public static List findAllYmlFiles(File rootDir) {
109 | List result = new ArrayList<>();
110 | Queue queue = new LinkedList<>();
111 | queue.add(rootDir);
112 |
113 | while (!queue.isEmpty()) {
114 | File current = queue.poll();
115 | if (current == null || !current.exists()) continue;
116 | if (current.isDirectory()) {
117 | File[] children = current.listFiles();
118 | if (children != null) Collections.addAll(queue, children);
119 | } else if (isYmlFile(current)) {
120 | result.add(current);
121 | }
122 | }
123 | return result;
124 | }
125 |
126 | /**
127 | * 判断是否为 application(-xxx)?.yml 文件
128 | */
129 | public static boolean isYmlFile(File file) {
130 | String name = file.getName();
131 | return name.matches("application(-[\\w]+)?\\.ya?ml") &&
132 | file.getParentFile() != null &&
133 | file.getParentFile().getPath().replace("\\", "/").endsWith("src/main/resources");
134 | }
135 |
136 |
137 | /**
138 | * 弹出异步输入框,直到用户输入完整数据库 URL 和用户名或取消
139 | *
140 | * @param project 当前项目
141 | * @param callback 用户输入完成回调
142 | */
143 | public static void promptUserInput(Project project, Consumer callback) {
144 | promptUserInputInternal(project, callback);
145 | }
146 |
147 | /**
148 | * 新增数据源
149 | *
150 | * @param project 当前项目
151 | * @param callback 用户输入完成回调
152 | */
153 | public static void promptUserAdd(Project project, Consumer callback) {
154 | promptUserAddInternal(project, callback);
155 | }
156 |
157 | /**
158 | * 配置默认数据源
159 | *
160 | * @param project 当前项目
161 | * @param callback 用户输入完成回调
162 | */
163 | public static void promptUserInputWithDbType(Project project, Consumer callback) {
164 | ApplicationManager.getApplication().invokeLater(() -> {
165 | List configs = new ArrayList<>();
166 | Path path = Paths.get(project.getBasePath(), CONFIG_PATH_ALL);
167 |
168 | // 读取配置文件
169 | if (Files.exists(path)) {
170 | try {
171 | String json = Files.readString(path, StandardCharsets.UTF_8);
172 | if (json != null && !json.isBlank()) {
173 | configs = objectMapper.readValue(json, new TypeReference>() {
174 | });
175 | }
176 | } catch (IOException e) {
177 | Messages.showErrorDialog(project, "读取数据库配置文件失败: " + e.getMessage(), "错误");
178 | }
179 | }
180 |
181 | // 分组
182 | Map> typeToConfigs = configs.stream()
183 | .filter(c -> c.getUrl() != null)
184 | .collect(Collectors.groupingBy(c -> parseDbType(c.getUrl())));
185 |
186 | if (typeToConfigs.isEmpty()) {
187 | typeToConfigs.put("mysql", List.of(new DbConfig(DEFAULT_MYSQL, "", "")));
188 | }
189 |
190 | // UI组件
191 | String[] dbTypes = typeToConfigs.keySet().toArray(new String[0]);
192 | JComboBox dbTypeComboBox = new JComboBox<>(dbTypes);
193 | JComboBox configComboBox = new JComboBox<>();
194 | JTextField urlField = new JTextField();
195 | JTextField usernameField = new JTextField();
196 | JPasswordField passwordField = new JPasswordField();
197 |
198 | // 删除按钮(➖)
199 | JButton deleteButton = new JButton("➖");
200 | deleteButton.setToolTipText("删除当前数据源");
201 | deleteButton.setPreferredSize(new Dimension(45, 24));
202 |
203 | // 刷新配置列表下拉框
204 | Consumer refreshConfigList = (isInit) -> {
205 | configComboBox.removeAllItems();
206 | String selectedType = (String) dbTypeComboBox.getSelectedItem();
207 | if (selectedType != null && typeToConfigs.containsKey(selectedType)) {
208 | List list = typeToConfigs.get(selectedType);
209 | for (DbConfig cfg : list) {
210 | configComboBox.addItem(cfg.getUrl());
211 | }
212 |
213 | if (!list.isEmpty()) {
214 | if (isInit) {
215 | DbConfig dbConfig = loadFromCache(project);
216 | if (dbConfig != null) {
217 | configComboBox.setSelectedItem(dbConfig.getUrl());
218 | urlField.setText(dbConfig.getUrl());
219 | usernameField.setText(dbConfig.getUsername());
220 | passwordField.setText(dbConfig.getPassword());
221 | }
222 | } else {
223 | configComboBox.setSelectedIndex(0);
224 | DbConfig first = list.get(0);
225 | urlField.setText(first.getUrl());
226 | usernameField.setText(first.getUsername());
227 | passwordField.setText(first.getPassword());
228 | }
229 | }
230 | }
231 | };
232 |
233 | // 删除按钮逻辑
234 | deleteButton.addActionListener(e -> {
235 | String selectedType = (String) dbTypeComboBox.getSelectedItem();
236 | int selectedIndex = configComboBox.getSelectedIndex();
237 |
238 | if (selectedType != null && selectedIndex >= 0 && typeToConfigs.containsKey(selectedType)) {
239 | List list = typeToConfigs.get(selectedType);
240 | if (selectedIndex >= list.size()) return;
241 | DbConfig toRemove = list.get(selectedIndex);
242 |
243 | int confirm = JOptionPane.showConfirmDialog(null,
244 | "确认删除该数据源配置?\n" + toRemove.getUrl(),
245 | "确认删除", JOptionPane.YES_NO_OPTION);
246 |
247 | if (confirm == JOptionPane.YES_OPTION) {
248 | list.remove(selectedIndex);
249 |
250 | // 写回 JSON 文件
251 | try {
252 | List allConfigs = typeToConfigs.values().stream()
253 | .flatMap(Collection::stream).collect(Collectors.toList());
254 | String newJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(allConfigs);
255 | Files.writeString(path, newJson, StandardCharsets.UTF_8);
256 | Messages.showInfoMessage(project, DELETE_SUCCESS_MESSAGE, DELETE_SUCCESS_TITLE);
257 | } catch (IOException ex) {
258 | Messages.showErrorDialog(project, "写入配置文件失败: " + ex.getMessage(), SQL_ERROR_TITLE);
259 | }
260 |
261 | refreshConfigList.accept(false);
262 | }
263 | }
264 | });
265 |
266 | // 监听选择变化
267 | dbTypeComboBox.addActionListener(e -> refreshConfigList.accept(false));
268 | configComboBox.addActionListener(e -> {
269 | String selectedType = (String) dbTypeComboBox.getSelectedItem();
270 | int idx = configComboBox.getSelectedIndex();
271 | if (selectedType != null && idx >= 0 && typeToConfigs.containsKey(selectedType)) {
272 | List list = typeToConfigs.get(selectedType);
273 | if (idx < list.size()) {
274 | DbConfig selected = list.get(idx);
275 | urlField.setText(selected.getUrl());
276 | usernameField.setText(selected.getUsername());
277 | passwordField.setText(selected.getPassword());
278 | }
279 | }
280 | });
281 |
282 | // 初始化
283 | DbConfig dbConfig = loadFromCache(project);
284 | if(dbConfig == null) {
285 | dbConfig= configs.isEmpty() ? null :
286 | configs.get(new Random().nextInt(configs.size()));
287 | saveToCache(project, dbConfig);
288 | }
289 | if (dbConfig == null) {
290 | JOptionPane.showMessageDialog(null, "请先添加数据源!", "提示", JOptionPane.WARNING_MESSAGE);
291 | }
292 | String dataBase = parseDbType(dbConfig.getUrl());
293 | dbTypeComboBox.setSelectedItem(dataBase);
294 | refreshConfigList.accept(true);
295 |
296 |
297 | // 构造 UI 面板
298 | JPanel panel = new JPanel(new GridLayout(0, 1));
299 | panel.setPreferredSize(new Dimension(500, 300));
300 | panel.add(new JLabel("数据库类型:"));
301 | panel.add(dbTypeComboBox);
302 | panel.add(new JLabel("具体配置:"));
303 |
304 | // 下拉框和删除按钮放一行
305 | JPanel configLine = new JPanel(new BorderLayout());
306 | configLine.add(configComboBox, BorderLayout.CENTER);
307 | configLine.add(deleteButton, BorderLayout.EAST);
308 | panel.add(configLine);
309 |
310 | panel.add(new JLabel("数据库 URL:"));
311 | panel.add(urlField);
312 | panel.add(new JLabel("用户名:"));
313 | panel.add(usernameField);
314 | panel.add(new JLabel("密码:"));
315 | panel.add(passwordField);
316 |
317 | // 循环弹窗
318 | while (true) {
319 | int result = JOptionPane.showConfirmDialog(
320 | null,
321 | panel,
322 | "数据库配置",
323 | JOptionPane.OK_CANCEL_OPTION,
324 | JOptionPane.PLAIN_MESSAGE
325 | );
326 |
327 | if (result != JOptionPane.OK_OPTION) break;
328 |
329 | String dbType = ((String) dbTypeComboBox.getSelectedItem()).toLowerCase();
330 | String url = urlField.getText().trim();
331 | String username = usernameField.getText().trim();
332 | String password = new String(passwordField.getPassword()).trim();
333 |
334 | List errorList = new ArrayList<>();
335 | if (!StringUtils.notBlankAndNotNullStr(url)) errorList.add("数据库 URL");
336 | if (!StringUtils.notBlankAndNotNullStr(username)) errorList.add("用户名");
337 | if (!StringUtils.notBlankAndNotNullStr(dbType)) errorList.add("数据库类型");
338 |
339 | if (errorList.isEmpty()) {
340 | callback.accept(new DbConfig(url, username, password));
341 | break;
342 | } else {
343 | Messages.showErrorDialog(project,
344 | "以下字段不能为空:\n" + String.join("、", errorList),
345 | "输入不完整");
346 | }
347 | }
348 | });
349 | }
350 |
351 |
352 | private static void promptUserInputInternal(Project project, Consumer callback) {
353 | ApplicationManager.getApplication().invokeLater(() -> {
354 | JTextField urlField = new JTextField(DEFAULT_MYSQL);
355 | JTextField usernameField = new JTextField();
356 | JPasswordField passwordField = new JPasswordField();
357 |
358 | JPanel panel = new JPanel(new GridLayout(0, 1));
359 | panel.setPreferredSize(new Dimension(300, 200));
360 | panel.add(new JLabel("数据库 URL:"));
361 | panel.add(urlField);
362 | panel.add(new JLabel("用户名:"));
363 | panel.add(usernameField);
364 | panel.add(new JLabel("密码:"));
365 | panel.add(passwordField);
366 |
367 |
368 | int result = JOptionPane.showConfirmDialog(
369 | null,
370 | panel,
371 | "请输入数据库配置",
372 | JOptionPane.OK_CANCEL_OPTION,
373 | JOptionPane.PLAIN_MESSAGE
374 | );
375 |
376 |
377 | if (result != JOptionPane.OK_OPTION) {
378 | // 用户点击了“取消”或关闭了窗口,直接返回,不继续执行下面的逻辑
379 | return;
380 | }
381 | String url = urlField.getText().trim();
382 | String username = usernameField.getText().trim();
383 | String password = new String(passwordField.getPassword()).trim();
384 |
385 | List errorList = new ArrayList<>();
386 | if (!StringUtils.notBlankAndNotNullStr(url)) {
387 | errorList.add("数据库 URL");
388 | }
389 | if (!StringUtils.notBlankAndNotNullStr(username)) {
390 | errorList.add("用户名");
391 | }
392 |
393 | if (errorList.isEmpty()) {
394 | url += appendUrlFix(parseDbType(url), url);
395 | callback.accept(new DbConfig(url, username, password));
396 | saveToCache(project, new DbConfig(url, username, password));
397 | saveToCacheAppend(project, new DbConfig(url, username, password));
398 | } else {
399 | Messages.showErrorDialog(project,
400 | "以下字段不能为空:\n" + String.join("、", errorList),
401 | "输入不完整");
402 | }
403 |
404 | });
405 | }
406 |
407 |
408 | private static void promptUserAddInternal(Project project, Consumer callback) {
409 | ApplicationManager.getApplication().invokeLater(() -> {
410 | JTextField urlField = new JTextField(DEFAULT_MYSQL);
411 | JTextField usernameField = new JTextField();
412 | JPasswordField passwordField = new JPasswordField();
413 |
414 | JPanel panel = new JPanel(new GridLayout(0, 1));
415 | panel.setPreferredSize(new Dimension(300, 200));
416 | panel.add(new JLabel("数据库 URL:"));
417 | panel.add(urlField);
418 | panel.add(new JLabel("用户名:"));
419 | panel.add(usernameField);
420 | panel.add(new JLabel("密码:"));
421 | panel.add(passwordField);
422 |
423 | int result = JOptionPane.showConfirmDialog(
424 | null,
425 | panel,
426 | "请输入数据库配置",
427 | JOptionPane.OK_CANCEL_OPTION,
428 | JOptionPane.PLAIN_MESSAGE
429 | );
430 | if (result != JOptionPane.OK_OPTION) {
431 | // 用户点击了“取消”或关闭了窗口,直接返回,不继续执行下面的逻辑
432 | return;
433 | }
434 | String url = urlField.getText().trim();
435 | String username = usernameField.getText().trim();
436 | String password = new String(passwordField.getPassword()).trim();
437 |
438 | List errorList = new ArrayList<>();
439 | if (!StringUtils.notBlankAndNotNullStr(url)) {
440 | errorList.add("数据库 URL");
441 | }
442 | if (!StringUtils.notBlankAndNotNullStr(username)) {
443 | errorList.add("用户名");
444 | }
445 |
446 | if (errorList.isEmpty()) {
447 | url += appendUrlFix(parseDbType(url), url);
448 | callback.accept(new DbConfig(url, username, password));
449 | saveToCache(project, new DbConfig(url, username, password));
450 | saveToCacheAppend(project, new DbConfig(url, username, password));
451 | } else {
452 | Messages.showErrorDialog(project,
453 | "以下字段不能为空:\n" + String.join("、", errorList),
454 | "输入不完整");
455 | }
456 |
457 | });
458 | }
459 |
460 |
461 | /**
462 | * 弹出同步阻塞输入框,直到用户输入完整数据库 URL 和用户名或取消
463 | *
464 | * @param project 当前项目
465 | * @return 用户输入的数据库配置,用户取消返回 null
466 | */
467 | public static DbConfig promptUserInputSync(Project project) {
468 | JTextField urlField = new JTextField(DEFAULT_MYSQL);
469 | JTextField usernameField = new JTextField();
470 | JPasswordField passwordField = new JPasswordField();
471 |
472 | JPanel panel = new JPanel(new GridLayout(0, 1));
473 | panel.setPreferredSize(new Dimension(300, 200));
474 | panel.add(new JLabel("数据库 URL:"));
475 | panel.add(urlField);
476 | panel.add(new JLabel("用户名:"));
477 | panel.add(usernameField);
478 | panel.add(new JLabel("密码:"));
479 | panel.add(passwordField);
480 |
481 | while (true) {
482 | int result = JOptionPane.showConfirmDialog(
483 | null,
484 | panel,
485 | "请输入数据库配置",
486 | JOptionPane.OK_CANCEL_OPTION,
487 | JOptionPane.PLAIN_MESSAGE
488 | );
489 |
490 | if (result != JOptionPane.OK_OPTION) {
491 | return null; // 用户取消
492 | }
493 |
494 | String url = urlField.getText().trim();
495 | String username = usernameField.getText().trim();
496 | String password = new String(passwordField.getPassword()).trim();
497 |
498 | List errorList = new ArrayList<>();
499 | if (!StringUtils.notBlankAndNotNullStr(url)) {
500 | errorList.add("数据库 URL");
501 | }
502 | if (!StringUtils.notBlankAndNotNullStr(username)) {
503 | errorList.add("用户名");
504 | }
505 |
506 | if (errorList.isEmpty()) {
507 | url += appendUrlFix(parseDbType(url), url);
508 | saveToCache(project, new DbConfig(url, username, password));
509 | saveToCacheAppend(project, new DbConfig(url, username, password));
510 | return new DbConfig(url, username, password);
511 | } else {
512 | Messages.showErrorDialog(project,
513 | "以下字段不能为空:\n" + String.join("、", errorList),
514 | "输入不完整");
515 | }
516 | }
517 | }
518 |
519 |
520 | /**
521 | * 保存数据库配置到缓存文件,写成 JSON 格式,并测试数据库连接有效性
522 | *
523 | * @param project 当前项目
524 | * @param config 数据库配置对象
525 | */
526 | public static boolean saveToCache(Project project, DbConfig config) {
527 | Path path = Paths.get(project.getBasePath(), CONFIG_PATH);
528 |
529 | try {
530 | // 优先判断:如果已有缓存且内容一致,则跳过所有操作,提升性能
531 | if (Files.exists(path)) {
532 | String oldJson = Files.readString(path, StandardCharsets.UTF_8);
533 | if (oldJson == null || oldJson.trim().isEmpty()) {
534 | // 直接写入新的配置,跳过解析
535 | String newJson = objectMapper.writeValueAsString(config);
536 | Files.writeString(path, newJson, StandardCharsets.UTF_8);
537 | return true;
538 | }
539 | DbConfig oldConfig = objectMapper.readValue(oldJson, DbConfig.class);
540 | if (Objects.equals(oldConfig, config)) {
541 | String dbType = parseDbType(config.getUrl());
542 | if (DB_TYPE_MYSQL.equalsIgnoreCase(dbType)) {
543 | String yamlTemplate = SoarYamlUtil.readYamlTemplate("soar.yaml");
544 | String replacedYaml = SoarYamlUtil.replaceDbConfigInYaml(config, yamlTemplate);
545 | Path ideaDir = Paths.get(project.getBasePath(), ".idea");
546 | SoarYamlUtil.copySoarExeToDir(ideaDir);
547 | String userProjectPath = project.getBasePath();
548 | SoarYamlUtil.writeYamlToProjectIdea(userProjectPath, replacedYaml);
549 | }
550 | // 配置一致,无需保存
551 | return true;
552 | }
553 | }
554 |
555 | // 配置有变化,先测试连接
556 | if (config != null && !JdbcTableInfoUtil.testConnection(config)) {
557 | return false;
558 | }
559 |
560 | // 写入新配置
561 | Files.createDirectories(path.getParent());
562 | String json = objectMapper.writeValueAsString(config);
563 | Files.writeString(path, json, StandardCharsets.UTF_8);
564 | String dbType = parseDbType(config.getUrl());
565 | if (DB_TYPE_MYSQL.equalsIgnoreCase(dbType)) {
566 | String yamlTemplate = SoarYamlUtil.readYamlTemplate("soar.yaml");
567 | String replacedYaml = SoarYamlUtil.replaceDbConfigInYaml(config, yamlTemplate);
568 | Path ideaDir = Paths.get(project.getBasePath(), ".idea");
569 | SoarYamlUtil.copySoarExeToDir(ideaDir);
570 | String userProjectPath = project.getBasePath();
571 | SoarYamlUtil.writeYamlToProjectIdea(userProjectPath, replacedYaml);
572 | }
573 | return true;
574 |
575 | } catch (Exception e) {
576 |
577 | return false;
578 | }
579 | }
580 | public static void updateYamlConfig(String yamlPath, DbConfig config) throws IOException {
581 | String yaml = Files.readString(Paths.get(yamlPath));
582 |
583 | // 假设你能从 config 中拿到 url, user, password
584 | String url = config.getUrl();
585 | String user = config.getUsername();
586 | String password = config.getPassword();
587 |
588 | // 解析出 addr 和 schema(示例逻辑)
589 | String addr = url.substring(url.indexOf("//") + 2, url.indexOf("/", url.indexOf("//") + 2));
590 | String schema = url.substring(url.indexOf("/", url.indexOf("//") + 2) + 1,
591 | url.contains("?") ? url.indexOf("?") : url.length());
592 |
593 | // 替换 YAML 内容(保留缩进)
594 | yaml = yaml.replaceAll("(?m)^([ \\t]*)addr:\\s*.*", "$1addr: " + addr);
595 | yaml = yaml.replaceAll("(?m)^([ \\t]*)schema:\\s*.*", "$1schema: " + schema);
596 | yaml = yaml.replaceAll("(?m)^([ \\t]*)user:\\s*.*", "$1user: " + user);
597 | yaml = yaml.replaceAll("(?m)^([ \\t]*)password:\\s*.*", "$1password: " + password);
598 |
599 | // 写回文件
600 | Files.writeString(Paths.get(yamlPath), yaml);
601 | }
602 |
603 | public static boolean saveToCacheAppend(Project project, DbConfig newConfig) {
604 | Path path = Paths.get(project.getBasePath(), CONFIG_PATH_ALL);
605 | try {
606 | List configList = new ArrayList<>();
607 |
608 | // 读取已有配置列表
609 | if (Files.exists(path)) {
610 | String json = Files.readString(path, StandardCharsets.UTF_8);
611 | if (json != null && !json.isBlank()) {
612 | // 反序列化成列表
613 | configList = objectMapper.readValue(json, new TypeReference>() {
614 | });
615 | }
616 | }
617 |
618 | // 判断是否已有相同配置,避免重复添加(可根据需要定义相等逻辑)
619 | boolean exists = configList.stream().anyMatch(c -> c.equals(newConfig));
620 | if (exists) {
621 | return true; // 已存在,不重复添加
622 | }
623 |
624 | // 测试新配置连接
625 | if (!JdbcTableInfoUtil.testConnection(newConfig)) {
626 | return false;
627 | }
628 |
629 | // 添加新配置
630 | configList.add(newConfig);
631 |
632 | // 保存回文件
633 | Files.createDirectories(path.getParent());
634 | String newJson = objectMapper.writeValueAsString(configList);
635 | Files.writeString(path, newJson, StandardCharsets.UTF_8);
636 |
637 | return true;
638 | } catch (Exception e) {
639 | return false;
640 | }
641 | }
642 |
643 |
644 | /**
645 | * 从缓存文件中读取数据库配置(JSON格式)
646 | *
647 | * @param project 当前项目
648 | * @return 配置对象,读取失败或文件不存在返回 null
649 | */
650 | public static DbConfig loadFromCache(Project project) {
651 | try {
652 | Path path = Paths.get(project.getBasePath(), CONFIG_PATH);
653 | if (!Files.exists(path)) return null;
654 | String json = Files.readString(path, StandardCharsets.UTF_8);
655 | if (json.isEmpty()) {
656 | return null;
657 | }
658 | return objectMapper.readValue(json, DbConfig.class);
659 | } catch (Exception ignored) {
660 | ignored.printStackTrace();
661 | }
662 | return null;
663 | }
664 |
665 | public static String parseDbType(String url) {
666 | if (url == null || !url.startsWith("jdbc:")) {
667 | throw new IllegalArgumentException("无效的 JDBC URL: " + url);
668 | }
669 | return url.split(":")[1].toLowerCase();
670 | }
671 | public static String removeUrlFix(String url) {
672 | int idx = url.indexOf("?");
673 | return (idx != -1) ? url.substring(0, idx) : url;
674 | }
675 | }
676 |
--------------------------------------------------------------------------------