Add AI SQL generation and update project docs

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 14:17:22 +08:00
parent c07f843587
commit 1fde63c752
16 changed files with 982 additions and 90 deletions
+53 -36
View File
@@ -4,7 +4,7 @@
可行性分析用于判断 HyperSql 在当前技术条件、开发时间、经济成本和实际使用场景下是否能够顺利完成。
HyperSql 是一个基于 Java 24、JavaFX、Maven 和 SQLite 的轻量级数据库图形化管理工具,主要面向学生和 SQLite 初学者。目前项目已经完成了核心数据库浏览、表管理、数据行编辑SQLite 支持范围内的表结构编辑功能,后续将继续扩展备份恢复和 AI 辅助 SQL 生成。
HyperSql 是一个基于 Java 24、JavaFX、Maven 和 SQLite 的轻量级数据库图形化管理工具,主要面向学生和 SQLite 初学者。目前项目已经完成了核心数据库浏览、表管理、数据行编辑SQLite 支持范围内的表结构编辑、数据库备份恢复和 AI 辅助 SQL 生成功能
本项目从以下四个方面进行分析:
@@ -20,11 +20,13 @@ HyperSql 采用以下技术栈:
| 技术 | 作用 | 当前使用情况 |
|---|---|---|
| Java 24 | 主要开发语言 | 已用于项目主要代码开发 |
| JavaFX | 构建图形化用户界面 | 已实现主界面、表格、菜单、工具栏、创建表对话框、数据行编辑结构编辑操作入口 |
| Maven | 项目构建与依赖管理 | 已完成项目构建配置 |
| JavaFX | 构建图形化用户界面 | 已实现主界面、表格、菜单、工具栏、创建表对话框、数据行编辑结构编辑、备份恢复入口和 AI 设置/生成入口 |
| Maven | 项目构建与依赖管理 | 已完成项目构建配置,并管理 JavaFX、SQLite JDBC、Jackson 等依赖 |
| SQLite | 本地轻量级数据库 | 作为系统管理对象,并通过原生 ALTER TABLE 支持部分表结构编辑 |
| SQLite JDBC | Java 程序访问 SQLite 数据库 | 已实现数据库连接、元数据读取、SQL 执行、数据行写入表结构变更 |
| AI API | 根据表结构和用户需求生成 SQL | 作为后续扩展功能,当前尚未实现 |
| SQLite JDBC | Java 程序访问 SQLite 数据库 | 已实现数据库连接、元数据读取、SQL 执行、数据行写入表结构变更、备份前检查点等操作 |
| JDK HttpClient | 调用 AI API | 已用于请求 OpenAI 兼容接口和 Anthropic Claude Messages API |
| Jackson | JSON 构造与解析 | 已用于构造 AI 请求体和解析 AI 返回结果 |
| AI API | 根据表结构和用户需求生成 SQL | 已支持 OpenAI 兼容接口和 Anthropic ClaudeAPI Key 由用户在 UI 中输入并仅保存在本次运行内存中 |
### 技术可行性说明
@@ -33,16 +35,16 @@ HyperSql 采用以下技术栈:
- 当前项目已经通过 Maven 编译验证,说明技术环境可正常运行。
2. **JavaFX 能满足 GUI 需求**
- JavaFX 提供菜单栏、按钮、标签、文本框、表格、分页按钮、弹窗和 FXML 等组件。
- 当前项目已经实现左侧表/视图列表,右侧表数据、表结构和 SQL 执行区域,并提供数据行新增/删除按钮、可编辑单元格表结构编辑按钮,能够满足数据库管理工具的基本界面需求
- JavaFX 提供菜单栏、按钮、标签、文本框、密码输入框、下拉框、表格、分页按钮、弹窗和 FXML 等组件。
- 当前项目已经实现左侧表/视图列表,右侧表数据、表结构和 SQL 执行区域,并提供数据行新增/删除按钮、可编辑单元格表结构编辑按钮、备份恢复菜单和 AI SQL 生成入口
3. **SQLite 集成难度低**
- SQLite 是文件型数据库,不需要单独部署数据库服务器,适合课程项目和本地数据库管理。
- 当前项目已经支持打开、新建、关闭删除 SQLite 数据库文件。
- 当前项目已经支持打开、新建、关闭删除、备份和恢复 SQLite 数据库文件。
4. **SQLite JDBC 访问方式成熟**
- 通过 JDBC 可以完成数据库连接、查询、执行 SQL、读取表结构等操作。
- 当前项目已经实现表/视图列表读取、字段信息读取、表数据分页查询、SQL 执行、数据行插入/更新/删除部分表结构变更。
- 当前项目已经实现表/视图列表读取、字段信息读取、表数据分页查询、SQL 执行、数据行插入/更新/删除部分表结构变更和备份恢复前后的连接管理
5. **表管理和结构编辑功能可以通过 SQL 和 JavaFX UI 实现**
- 当前项目已经通过独立创建表对话框实现了表名输入、字段定义、字段类型选择、主键/非空设置、默认值填写和 SQL 预览。
@@ -53,20 +55,26 @@ HyperSql 采用以下技术栈:
- 当前项目已经通过主键或 SQLite `rowid` 定位数据行,使用参数化 SQL 完成新增、修改和删除。
- SQL 返回结果只对可识别的单表查询启用编辑,复杂查询保持只读,降低误操作风险。
7. **AI SQL 生成功能技术上可行,但需要控制范围**
- 后续可以读取当前数据库表结构,结合用户自然语言描述构造提示词,然后调用 AI API 生成 SQL
- 该功能涉及 API Key 管理、网络请求、调用成本和生成结果校验,因此适合作为扩展功能
7. **数据库备份恢复功能技术上可行**
- SQLite 数据库本质上是本地文件,因此可以通过文件复制完成备份
- 当前项目在备份前执行检查点操作,并在恢复前关闭数据库连接,使用临时文件替换目标数据库,恢复后重新打开数据库并刷新界面
- 恢复操作前会弹出确认提示,避免误覆盖当前数据库。
8. **AI SQL 生成功能技术上可行**
- 当前项目已经能够读取数据库表和视图结构,并将结构信息与用户自然语言需求组合成提示词。
- 系统通过 JDK HttpClient 调用 OpenAI 兼容接口或 Anthropic Claude Messages API,并用 Jackson 处理 JSON 请求和响应。
- API Key 只保存在运行内存中,不写入本地文件;AI 生成的 SQL 只填入 SQL 输入框,不自动执行,降低安全风险。
### 技术可行性结论
HyperSql 所采用的核心技术已经在当前项目中得到验证。数据库连接、表结构读取、表数据分页显示、SQL 执行、数据库文件管理、表管理、数据行编辑SQLite 安全结构编辑功能均已实现,因此项目核心功能在技术上可行。AI SQL 生成属于后续扩展,技术上可实现,但需要在安全性和调用成本方面进行控制
HyperSql 所采用的核心技术已经在当前项目中得到验证。数据库连接、表结构读取、表数据分页显示、SQL 执行、数据库文件管理、表管理、数据行编辑SQLite 安全结构编辑、备份恢复和 AI SQL 生成均已实现,因此项目核心功能和主要扩展功能在技术上可行
## 3. 经济可行性
HyperSql 的开发成本较低,主要体现在以下方面:
1. **开发工具成本低**
- Java、JavaFX、Maven、SQLiteSQLite JDBC 均可免费使用。
- Java、JavaFX、Maven、SQLiteSQLite JDBC 和 Jackson 均可免费使用。
- 可使用 IntelliJ IDEA Community、VS Code 等免费开发工具。
2. **运行环境成本低**
@@ -78,12 +86,13 @@ HyperSql 的开发成本较低,主要体现在以下方面:
- 当前已完成的功能不涉及商业授权费用。
4. **AI API 成本可控**
- AI SQL 生成尚未实现,可以作为后续扩展功能
- 若后续实现,可限制调用次数,或只在演示场景使用少量 API 调用,避免高额费用。
- AI SQL 生成功能由用户自行输入 API Key,系统本身不内置付费密钥
- 该功能只在用户主动点击生成时调用 API,不会自动后台调用。
- 生成 SQL 时只发送数据库结构和用户需求,不发送表数据,减少请求内容规模。
### 经济可行性结论
HyperSql 不需要额外硬件和商业软件投入,当前已实现功能均基于免费技术完成整体开发与运行成本较低,经济上可行。
HyperSql 不需要额外硬件和商业软件投入,当前主要功能均基于免费技术完成。AI 功能可能产生的调用费用由用户配置的 API 服务决定,并且只在用户主动使用时发生,因此整体开发与运行成本较低,经济上可行。
## 4. 时间可行性
@@ -104,21 +113,22 @@ HyperSql 采用增量迭代模型,可以按照功能优先级逐步完成,
| 交互修复 | 修复选择表不同步、刷新表列表保留当前表、SQL 结果区启动空状态 | 已完成 |
| 数据行编辑 | 表数据界面新增、修改、删除数据行;可编辑单表 SQL 结果 | 已完成 |
| 表结构安全编辑 | 重命名表、新增字段、重命名字段 | 已完成 |
| 备份恢复 | 数据库备份和恢复 | 待实现 |
| AI SQL 生成 | 根据表结构和自然语言生成 SQL | 待实现 |
| 备份恢复 | 数据库备份和从备份恢复 | 已完成 |
| 界面优化 | 整理菜单和工具栏,降低界面拥挤程度 | 已完成 |
| AI SQL 生成 | 根据表结构和自然语言生成 SQL,支持 OpenAI 兼容接口和 Anthropic Claude | 已完成 |
| 测试优化 | 功能测试、界面优化、最终演示准备 | 持续进行 |
### 时间可行性说明
1. 当前项目已经完成数据库管理工具的核心可运行版本,可以满足基本演示要求。
2. 数据行编辑和 SQLite 支持范围内的表结构编辑已经完成,软件实用性进一步提高。
3. 后续开发可以优先完成备份恢复,因为它与数据库管理工具的数据安全性直接相关
4. AI SQL 生成可以作为亮点功能,根据剩余时间决定实现深度
5. 采用增量迭代方式后,即使扩展功能未全部完成,系统仍然具备可运行、可演示的核心功能
3. 备份恢复功能已经完成,增强了数据库文件管理和数据安全能力
4. AI SQL 生成功能已经完成基础版本,可以作为课程项目亮点功能展示
5. 后续主要工作集中在测试、演示数据准备、界面细节优化和文档完善,风险相对较低
### 时间可行性结论
从当前进展看,HyperSql 的核心功能和主要编辑功能已经完成,课程周期内完成一个可演示版本具有较高可行性。后续需要合理控制扩展功能范围,避免 AI 调用和复杂数据库结构重建功能占用过多开发时间
从当前进展看,HyperSql 的核心功能和主要扩展功能已经完成,课程周期内形成可运行、可演示版本具有较高可行性。后续只需继续进行测试和演示准备即可
## 5. 操作可行性
@@ -139,34 +149,41 @@ HyperSql 面向学生和 SQLite 初学者,软件操作流程较为简单。
在 SQLite 支持范围内编辑表结构
在 SQL 执行区输入并执行 SQL
根据需要备份或恢复数据库
根据需要创建表、删除表或刷新表列表
在 SQL 执行区输入 SQL 或使用 AI 生成 SQL
检查 SQL 后手动执行
```
### 操作可行性说明
1. **界面符合用户习惯**
- 采用类似数据库管理工具的布局:左侧显示表/视图列表,右侧显示表数据、表结构和 SQL 执行区域。
- 菜单和工具栏经过整理,高频操作更明显,危险或低频操作放在菜单中,界面更清晰。
2. **降低数据库操作门槛**
- 用户可以通过图形界面打开数据库、浏览表结构、查看表数据、新增修改删除数据行、创建表、删除表进行部分表结构编辑,不必完全依赖命令行工具。
- 用户可以通过图形界面打开数据库、浏览表结构、查看表数据、新增修改删除数据行、创建表、删除表进行部分表结构编辑、备份恢复数据库,不必完全依赖命令行工具。
3. **分页降低操作压力**
- 表数据和 SQL 查询结果都使用分页显示,每页 100 行,避免一次显示大量数据导致界面卡顿或不便查看。
4. **错误提示清晰**
- 当未连接数据库、数据库连接失败、SQL 执行错误、创建表失败删除表失败时,系统会给出提示信息。
- 当未连接数据库、数据库连接失败、SQL 执行错误、创建表失败删除表失败、备份恢复失败或 AI 调用失败时,系统会给出提示信息。
5. **危险操作有确认流程**
- 删除数据库文件、删除表删除数据行都属于破坏性操作,系统在执行前会弹出确认提示。
- 删除数据库文件、删除表删除数据行、恢复数据库和结构变更等操作都属于破坏性操作,系统在执行前会弹出确认提示。
6. **当前限制清晰**
- 当前版本已经支持创建表、删除普通表、数据行编辑和 SQLite 原生支持的部分结构编辑,但尚未实现数据库备份恢复和 AI SQL 生成
6. **AI 生成 SQL 使用方式安全**
- 用户在 UI 中设置 API Key、Base URL 和模型
- API Key 只在本次运行中保存。
- 系统只将数据库结构和用户需求发送给 AI,不发送表数据。
- AI 生成的 SQL 只填入 SQL 输入框,不自动执行,用户可以检查后再手动运行。
- 如果生成内容包含可能修改数据或结构的关键词,系统会提示用户谨慎检查。
### 操作可行性结论
HyperSql 当前功能界面直观,基本操作流程清晰,已经支持常见数据浏览、数据编辑部分结构编辑操作,适合学生和 SQLite 初学者使用。随着后续备份恢复功能完善,软件的数据安全性会进一步提高。
HyperSql 当前功能界面直观,基本操作流程清晰,已经支持常见数据浏览、数据编辑部分结构编辑、备份恢复和 AI 辅助 SQL 生成操作,适合学生和 SQLite 初学者使用。
## 6. 可行性分析总结
@@ -174,11 +191,11 @@ HyperSql 当前功能界面直观,基本操作流程清晰,已经支持常
| 分析方面 | 结论 |
|---|---|
| 技术可行性 | JavaFX、SQLite JDBC 和 Maven 已经支撑当前核心功能、数据编辑结构编辑实现,AI API 可作为后续扩展 |
| 经济可行性 | 开发工具和数据库免费,当前功能无额外运行成本 |
| 时间可行性 | 核心功能和主要编辑功能已经完成,后续扩展可按优先级继续迭代 |
| 操作可行性 | 面向学生和初学者,界面简单直观,危险操作有确认提示 |
| 技术可行性 | JavaFX、SQLite JDBC、Maven、JDK HttpClient 和 Jackson 已经支撑当前核心功能、数据编辑结构编辑、备份恢复和 AI SQL 生成实现 |
| 经济可行性 | 开发工具和数据库免费,当前功能无额外固定运行成本AI 调用成本由用户自行配置和控制 |
| 时间可行性 | 核心功能和主要扩展功能已经完成,后续重点是测试和演示准备 |
| 操作可行性 | 面向学生和初学者,界面简单直观,危险操作有确认提示AI 生成 SQL 不自动执行 |
### 总体结论
HyperSql 的技术方案成熟,开发成本低,核心功能和主要编辑功能已经形成可运行版本,适合作为软件工程课程大作业项目继续完善。后续应优先补充数据库备份恢复功能,再根据时间实现 AI 辅助 SQL 生成。
HyperSql 的技术方案成熟,开发成本低,核心功能和主要扩展功能已经形成可运行版本,适合作为软件工程课程大作业项目继续完善和演示。当前项目已经具备数据库浏览、SQL 执行、数据编辑、结构编辑、备份恢复和 AI 辅助 SQL 生成等功能,整体可行性较高。
+5
View File
@@ -37,6 +37,11 @@
<artifactId>slf4j-simple</artifactId>
<version>2.0.17</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.18.3</version>
</dependency>
</dependencies>
<build>
+58 -30
View File
@@ -11,10 +11,11 @@ HyperSql 是一个面向学生和 SQLite 初学者的轻量级数据库图形化
3. 数据库表结构和表数据需要以更直观的方式展示。
4. 大表或较多查询结果需要分页显示,避免界面卡顿和阅读困难。
5. 创建数据库、创建表、删除表等常见操作需要简洁清晰的图形界面。
6. 删除数据库文件、删除表删除数据行等危险操作需要确认流程,降低误操作风险。
6. 删除数据库文件、删除表删除数据行、恢复数据库等危险操作需要确认流程,降低误操作风险。
7. 表结构修改需要尊重 SQLite 原生能力,只提供安全可控的结构编辑入口。
8. 用户希望通过 AI 根据当前数据库结构和自然语言需求辅助生成 SQL,但生成结果不应自动执行。
当前版本已经完成了数据库文件管理、表/视图浏览、表结构查看、表数据分页查看、SQL 执行、SQL 结果分页、创建表、删除表、数据行编辑SQLite 支持范围内的表结构编辑等核心功能。数据库备份恢复和 AI 辅助 SQL 生成作为后续扩展功能继续开发
当前版本已经完成了数据库文件管理、表/视图浏览、表结构查看、表数据分页查看、SQL 执行、SQL 结果分页、创建表、删除表、数据行编辑SQLite 支持范围内的表结构编辑数据库备份恢复和 AI 辅助 SQL 生成等核心与扩展功能
## 2. 用户需求分析
@@ -39,7 +40,11 @@ HyperSql 是一个面向学生和 SQLite 初学者的轻量级数据库图形化
9. 用户通过图形界面新增、修改或删除表中的数据行。
10. 用户在 SQLite 支持范围内重命名表、新增字段或重命名字段。
11. 用户刷新表列表,并希望保持当前选中的表不变。
12. 后续版本中,用户可以备份恢复数据库,或使用 AI 辅助生成 SQL
12. 用户将当前数据库备份为另一个文件
13. 用户从备份文件恢复当前数据库,并在恢复前得到确认提示。
14. 用户在 UI 中配置 AI Provider、API Key、Base URL 和模型。
15. 用户输入自然语言需求,由 AI 根据当前数据库结构生成 SQL。
16. 用户检查 AI 生成的 SQL,然后手动点击执行。
## 3. 功能性需求
@@ -56,8 +61,9 @@ HyperSql 的功能性需求主要包括以下模块:
| 表结构安全编辑 | 支持重命名表、新增字段、重命名字段 | 已实现 |
| 状态与错误提示 | 显示连接状态、执行结果和错误信息 | 已实现 |
| 数据编辑 | 支持新增、修改、删除表数据行,并支持可识别单表 SQL 结果编辑 | 已实现 |
| 备份与恢复 | 支持数据库文件备份和从备份文件恢复 | 实现 |
| AI SQL 生成 | 根据表结构和用户自然语言需求生成 SQL | 实现 |
| 备份与恢复 | 支持数据库文件备份和从备份文件恢复 | 实现 |
| AI SQL 生成 | 根据表结构和用户自然语言需求生成 SQL,支持 OpenAI 兼容接口和 Anthropic Claude | 实现 |
| UI 优化 | 整理菜单、工具栏和侧边栏操作入口,降低界面拥挤程度 | 已实现 |
## 4. 核心功能需求说明
@@ -160,36 +166,53 @@ HyperSql 的功能性需求主要包括以下模块:
### 5.2 数据库备份与恢复
- 系统后续应支持将当前数据库复制为备份文件。
- 系统后续应支持用户选择备份文件进行恢复。
- 系统应支持将当前数据库复制为备份文件。
- 系统应支持用户选择备份文件进行恢复。
- 系统应在恢复前提示用户确认,避免覆盖当前数据库。
- 系统应避免将当前数据库文件本身作为备份目标或恢复来源。
- 系统应在恢复前关闭当前数据库连接,恢复后重新打开数据库并刷新界面。
- 系统应显示备份或恢复的执行结果。
当前实现状态:实现。
当前实现状态:实现。
### 5.3 AI 辅助 SQL 生成
- 系统后续应读取当前数据库的表结构信息。
- 系统后续应根据表结构自动生成 AI 提示词。
- 系统应读取当前数据库的表结构和视图结构信息。
- 系统应根据表结构自动生成 AI 提示词。
- 用户可以输入自然语言需求。
- 系统支持 OpenAI 兼容接口和 Anthropic Claude。
- 用户应能在 UI 中设置 Provider、API Key、Base URL、模型和超时时间。
- API Key 只应保存在本次运行内存中,不应写入本地配置文件。
- 系统调用 AI API 生成 SQL 语句。
- 用户可以检查并确认生成的 SQL 后再执行。
- AI 生成的 SQL 不应直接自动执行,应由用户确认后手动执行。
- 系统不应向 AI 发送表数据,只发送数据库结构和用户需求。
- 若 AI 调用失败,系统应显示错误提示。
- AI 生成 SQL 不应直接自动执行,应由用户确认后执行
- 生成 SQL 包含可能修改数据或结构的关键词,系统应提示用户谨慎检查
当前实现状态:实现。
当前实现状态:实现。
### 5.4 界面整理优化
- 系统菜单应包含完整功能入口。
- 工具栏应保留高频操作,避免按钮过多导致界面拥挤。
- 表相关操作应放在侧边栏附近,方便用户针对当前表操作。
- 危险或低频操作应主要放在菜单中,减少误触。
当前实现状态:已实现。
## 6. 非功能性需求
| 需求类型 | 说明 | 当前体现 |
|---|---|---|
| 易用性 | 界面清晰,操作流程简单,适合初学者使用 | 已采用菜单、工具栏、表格、创建表对话框、数据行编辑按钮结构编辑按钮 |
| 可靠性 | 数据库连接、SQL 执行、数据编辑表操作失败时应有错误提示 | 已实现基础错误提示 |
| 安全性 | 删除数据库、删除表、删除数据行和结构变更等操作前应确认 | 已实现确认流程 |
| 易用性 | 界面清晰,操作流程简单,适合初学者使用 | 已采用菜单、工具栏、侧边栏、表格、创建表对话框、数据行编辑按钮结构编辑按钮和 AI 生成入口 |
| 可靠性 | 数据库连接、SQL 执行、数据编辑表操作、备份恢复和 AI 调用失败时应有错误提示 | 已实现基础错误提示 |
| 安全性 | 删除数据库、删除表、删除数据行、恢复数据库和结构变更等操作前应确认;AI 生成 SQL 不自动执行 | 已实现确认流程和 AI 生成后手动执行机制 |
| 性能 | 对常见小型 SQLite 数据库能够快速打开和查询 | 表数据采用数据库侧分页,降低大表加载压力 |
| 可维护性 | 采用模块化设计,便于后续扩展和维护 | 已划分数据库连接、元数据读取、SQL 执行、行数据服务、结构编辑服务、工具类和控制器 |
| 隐私性 | AI 功能不应发送表数据,API Key 不应持久化到本地文件 | AI 提示只包含表/视图结构和用户需求,API Key 仅保存在运行内存中 |
| 可维护性 | 采用模块化设计,便于后续扩展和维护 | 已划分数据库连接、元数据读取、SQL 执行、行数据服务、结构编辑服务、备份恢复逻辑、AI 客户端和工具类 |
| 兼容性 | 支持常见 SQLite 数据库文件,适配主流桌面系统 | 基于 JavaFX 和 SQLite JDBC,具备跨平台基础 |
| 可扩展性 | 后续可扩展备份恢复和 AI SQL 生成 | 已保留 SQL 执行、元数据读取、行数据服务和结构编辑服务基础能力 |
| 可扩展性 | 后续可扩展更多 AI Provider、SQL 历史记录和界面美化 | 已提供 AI Provider 适配层和独立 AI SQL 生成服务 |
## 7. 需求优先级
@@ -202,8 +225,9 @@ HyperSql 的功能性需求主要包括以下模块:
| 中 | 创建表、删除表、删除数据库文件 | 已实现 |
| 中 | 数据行新增、修改、删除 | 已实现 |
| 中 | SQLite 支持范围内的表结构编辑 | 已实现 |
| 中 | 数据库备份与恢复 | 实现 |
| 低 | AI 辅助 SQL 生成、SQL 历史记录、界面进一步美化 | 实现 |
| 中 | 数据库备份与恢复 | 实现 |
| 低 | AI 辅助 SQL 生成、界面进一步美化 | 实现 |
| 低 | SQL 历史记录、更多数据库类型支持 | 后续可扩展 |
### 优先级说明
@@ -213,23 +237,24 @@ HyperSql 的功能性需求主要包括以下模块:
2. **中优先级需求**
- 提升软件实用性和数据安全性。
- 当前已经完成创建表、删除表、删除数据库文件、数据行编辑表结构安全编辑,后续应继续完成备份恢复。
- 当前已经完成创建表、删除表、删除数据库文件、数据行编辑表结构安全编辑备份恢复。
3. **低优先级需求**
- 作为项目亮点和扩展功能。
- 可根据开发进度调整实现程度
- 当前已经完成 AI 辅助 SQL 生成和基础界面整理,后续可继续扩展 SQL 历史记录或更多界面美化
## 8. 当前版本限制
当前 HyperSql 仍存在以下限制:
1. 还没有实现数据库备份与恢复功能
2. 还没有实现 AI 辅助 SQL 生成功能
3. SQL 查询结果分页属于客户端分页,查询结果会先全部读取到内存中,不适合特别大的查询结果
4. SQL 结果编辑只支持可识别的单表查询,JOIN、聚合、表达式等复杂结果保持只读
5. 表结构编辑只支持 SQLite 原生安全支持的重命名表、新增字段和重命名字段,不支持需要重建表的复杂结构修改
6. 当前删除表功能只支持删除普通表,不支持删除视图
7. 当前主要面向 SQLite,不支持 MySQL、PostgreSQL 等远程数据库
1. SQL 查询结果分页属于客户端分页,查询结果会先全部读取到内存中,不适合特别大的查询结果
2. SQL 结果编辑只支持可识别的单表查询,JOIN、聚合、表达式等复杂结果保持只读
3. 表结构编辑只支持 SQLite 原生安全支持的重命名表、新增字段和重命名字段,不支持需要重建表的复杂结构修改
4. 当前删除表功能只支持删除普通表,不支持删除视图
5. 当前主要面向 SQLite,不支持 MySQL、PostgreSQL 等远程数据库
6. AI 生成 SQL 的质量取决于用户配置的模型和 API 服务,生成结果仍需要用户检查后再执行
7. API Key 只在本次运行中保存,关闭软件后需要重新输入
8. 当前未实现 SQL 历史记录功能。
## 9. 需求分析总结
@@ -246,6 +271,9 @@ HyperSql 的功能性需求主要包括以下模块:
7. 删除普通表。
8. 数据行新增、修改和删除。
9. SQLite 支持范围内的表结构安全编辑。
10. 状态提示、错误提示和危险操作确认
10. 数据库备份和从备份恢复
11. AI 辅助 SQL 生成。
12. 状态提示、错误提示和危险操作确认。
13. 菜单、工具栏和侧边栏入口整理。
后续开发应优先完成数据库备份恢复,使系统从“可浏览、可执行 SQL、可编辑数据、可管理表结构”进一步发展为“可完整管理 SQLite 数据库”的工具。在时间允许的情况下,再实现 AI 辅助 SQL 生成作为项目亮点。
当前 HyperSql 已经从“可浏览、可执行 SQL、可编辑数据、可管理表结构”进一步发展为“可完整管理 SQLite 数据库,并提供 AI 辅助 SQL 生成”的轻量级数据库管理工具。后续可以围绕测试、演示体验、SQL 历史记录和更细致的界面美化继续迭代。
+18 -10
View File
@@ -6,7 +6,7 @@
增量迭代模型是指将软件系统划分为多个相对独立的功能增量,每个增量都经过需求分析、设计、编码、测试和反馈优化等过程,最终逐步形成完整的软件系统。
HyperSql 是一个基于 Java 24、JavaFX 和 Maven 的轻量级 SQLite 图形化管理工具。项目开发过程中,先完成数据库连接、表结构浏览、表数据查看和 SQL 执行等核心功能,再逐步增加数据库文件管理、表管理、分页显示、数据行编辑表结构安全编辑等增强功能,最后根据时间继续扩展备份恢复和 AI 辅助 SQL 生成。
HyperSql 是一个基于 Java 24、JavaFX 和 Maven 的轻量级 SQLite 图形化管理工具。项目开发过程中,先完成数据库连接、表结构浏览、表数据查看和 SQL 执行等核心功能,再逐步增加数据库文件管理、表管理、分页显示、数据行编辑表结构安全编辑、备份恢复、界面优化和 AI 辅助 SQL 生成等增强功能
## 2. 选择增量迭代模型的原因
@@ -20,6 +20,7 @@ HyperSql 选择增量迭代模型,主要有以下原因:
3. **降低开发风险**
- 先完成基础功能,再开发复杂功能,可以避免一开始实现过多内容导致项目失控。
- 对 SQLite 表结构编辑和 AI SQL 生成这类有安全边界的功能,可以在后续迭代中逐步控制范围。
4. **便于阶段性展示**
- 每个迭代版本都能形成可运行的软件,方便课程汇报、阶段检查和最终演示。
@@ -38,12 +39,13 @@ HyperSql 选择增量迭代模型,主要有以下原因:
| V1.3 | 分页显示优化 | 表数据按每页 100 行分页显示;SQL 查询结果区按每页 100 行分页显示 | 已完成 |
| V1.4 | 数据库文件管理增强 | 新建 SQLite 数据库文件、删除当前数据库文件 | 已完成 |
| V1.5 | 表管理功能 | 通过简洁 UI 创建表;删除选中普通表;删除前进行确认 | 已完成 |
| V1.6 | 交互与缺陷修复 | 修复表/视图选择时数据与结构不同步问题;刷新表列表时保留当前选中表 | 已完成 |
| V1.6 | 交互与缺陷修复 | 修复表/视图选择时数据与结构不同步问题;刷新表列表时保留当前选中表SQL 结果区启动时保持空状态 | 已完成 |
| V1.7 | 数据行增删改查 | 在表数据界面新增、修改、删除数据行;在可识别单表 SQL 结果中支持编辑 | 已完成 |
| V1.8 | 表结构安全编辑 | 支持 SQLite 原生安全的重命名表、新增字段、重命名字段 | 已完成 |
| V1.9 | 备份与恢复 | 数据库文件备份、从备份恢复数据库 | 待实现 |
| V2.0 | AI 辅助 SQL 生成 | 根据表结构和用户自然语言需求调用 AI API 生成 SQL | 待实现 |
| V2.1 | 测试与优化 | 完善异常处理、界面优化、系统测试和最终演示准备 | 持续进行 |
| V1.9 | 备份与恢复 | 数据库文件备份、从备份恢复数据库,恢复前进行确认并重新打开数据库 | 已完成 |
| V2.0 | 界面整理优化 | 整理菜单和工具栏,将高频操作保留在工具栏,将危险或低频操作放入菜单 | 已完成 |
| V2.1 | AI 辅助 SQL 生成 | 根据表结构和用户自然语言需求调用 OpenAI 兼容接口或 Anthropic Claude 生成 SQL | 已完成 |
| V2.2 | 测试与优化 | 编译验证、启动验证、功能回归和最终演示准备 | 持续进行 |
## 4. 单次迭代流程
@@ -69,15 +71,18 @@ HyperSql 选择增量迭代模型,主要有以下原因:
1. **需求细化**
- 明确本次迭代要完成的功能和边界。例如创建表功能要求有简洁易用的 UI,而不是只让用户手写 SQL。
- 对 AI SQL 生成明确了 API Key 只在本次运行中保存,同时支持 OpenAI 兼容接口和 Anthropic Claude。
2. **界面与功能设计**
- 设计 JavaFX 界面布局和功能交互流程。例如主界面采用左侧表/视图列表、右侧表数据/表结构/SQL 执行区域的布局。
- 对菜单和工具栏进行整理,保留常用操作入口,减少界面拥挤。
3. **编码实现**
- 使用 Java 24、JavaFX、SQLite JDBC 和 Maven 完成功能开发。
- 使用 JDK 自带 HttpClient 和 Jackson 实现 AI API 调用与 JSON 解析。
4. **功能测试**
- 测试数据库连接、表结构读取、分页显示、SQL 执行、创建数据库、删除数据库、创建表删除表等功能是否正确。
- 测试数据库连接、表结构读取、分页显示、SQL 执行、创建数据库、删除数据库、创建表删除表、备份恢复、AI 设置和 AI SQL 生成等功能是否正确。
5. **问题修复**
- 根据测试结果修复异常、界面错误和逻辑问题。例如修复刷新表列表后错误切换到第一张表的问题。
@@ -107,11 +112,14 @@ HyperSql 选择增量迭代模型,主要有以下原因:
5. **逐步扩展编辑能力**
- 在只读浏览功能完成后,继续实现了数据行新增、修改、删除,并基于 SQLite 原生 `ALTER TABLE` 能力实现了重命名表、新增字段、重命名字段。
6. **保留后续扩展空间**
- 数据库备份恢复和 AI SQL 生成尚未完成,后续可作为新的增量继续实现
6. **完善数据安全能力**
- 数据库文件管理基础上增加备份恢复功能,恢复前进行确认,并在恢复后重新打开数据库,降低数据丢失风险
7. **增加智能辅助能力**
- 在 SQL 执行功能稳定后,增加 AI 辅助 SQL 生成。系统只发送数据库结构,不发送表数据;生成的 SQL 只填入输入框,不自动执行,由用户检查后手动运行。
## 6. 总结
HyperSql 采用增量迭代模型进行开发是合适的。当前项目已经完成了 SQLite 数据库管理工具的核心可运行版本,包括数据库文件管理、表/视图浏览、表结构查看、表数据分页查看、SQL 执行分页显示、创建表、删除表、数据行增删改查SQLite 支持范围内的表结构安全编辑等功能。
HyperSql 采用增量迭代模型进行开发是合适的。当前项目已经完成了 SQLite 数据库管理工具的核心可运行版本,包括数据库文件管理、表/视图浏览、表结构查看、表数据分页查看、SQL 执行分页显示、创建表、删除表、数据行增删改查SQLite 支持范围内的表结构安全编辑、数据库备份恢复和 AI 辅助 SQL 生成等功能。
后续开发继续沿用增量迭代方式,优先完善数据库备份恢复等实用功能,再根据课程进度实现 AI 辅助 SQL 生成等扩展功能。
后续开发继续沿用增量迭代方式,重点进行功能测试、异常场景验证、界面细节优化和最终课程演示材料整理。
@@ -0,0 +1,11 @@
package com.hypersql.ai;
public class AiGenerationException extends Exception {
public AiGenerationException(String message) {
super(message);
}
public AiGenerationException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,17 @@
package com.hypersql.ai;
public enum AiProvider {
OPENAI_COMPATIBLE("OpenAI 兼容"),
ANTHROPIC("Anthropic Claude");
private final String displayName;
AiProvider(String displayName) {
this.displayName = displayName;
}
@Override
public String toString() {
return displayName;
}
}
@@ -0,0 +1,29 @@
package com.hypersql.ai;
public record AiSettings(
AiProvider provider,
String apiKey,
String baseUrl,
String model,
int timeoutSeconds
) {
public AiSettings {
if (provider == null) {
throw new IllegalArgumentException("请选择 AI Provider。");
}
apiKey = normalize(apiKey);
baseUrl = normalize(baseUrl);
model = normalize(model);
if (timeoutSeconds <= 0) {
throw new IllegalArgumentException("超时时间必须大于 0 秒。");
}
}
public boolean hasApiKey() {
return apiKey != null && !apiKey.isBlank();
}
private static String normalize(String value) {
return value == null ? "" : value.trim();
}
}
@@ -0,0 +1,73 @@
package com.hypersql.ai;
import com.hypersql.db.SchemaDescriptionService;
import java.sql.Connection;
import java.sql.SQLException;
public final class AiSqlGenerationService {
private final SchemaDescriptionService schemaDescriptionService = new SchemaDescriptionService();
private final LlmClient openAiClient = new OpenAiCompatibleClient();
private final LlmClient anthropicClient = new AnthropicMessagesClient();
private final GeneratedSqlSanitizer sanitizer = new GeneratedSqlSanitizer();
public String generateSql(Connection connection, AiSettings settings, String naturalLanguagePrompt) throws AiGenerationException {
validate(settings, naturalLanguagePrompt);
try {
String schemaDescription = schemaDescriptionService.describe(connection);
String rawSql = client(settings.provider()).generateSql(systemPrompt(), userPrompt(schemaDescription, naturalLanguagePrompt), settings);
return sanitizer.sanitize(rawSql);
} catch (SQLException e) {
throw new AiGenerationException("读取数据库结构失败。", e);
}
}
public boolean containsDestructiveKeyword(String sql) {
return sanitizer.containsDestructiveKeyword(sql);
}
private void validate(AiSettings settings, String naturalLanguagePrompt) throws AiGenerationException {
if (settings == null || !settings.hasApiKey()) {
throw new AiGenerationException("请先在 AI 设置中输入 API Key。");
}
if (settings.baseUrl().isBlank()) {
throw new AiGenerationException("请先设置 AI Base URL。");
}
if (settings.model().isBlank()) {
throw new AiGenerationException("请先设置 AI Model。");
}
if (naturalLanguagePrompt == null || naturalLanguagePrompt.isBlank()) {
throw new AiGenerationException("请输入自然语言需求。");
}
}
private LlmClient client(AiProvider provider) {
return switch (provider) {
case OPENAI_COMPATIBLE -> openAiClient;
case ANTHROPIC -> anthropicClient;
};
}
private String systemPrompt() {
return """
你是 SQLite SQL 生成助手。
只能根据提供的数据库结构生成 SQLite SQL。
只返回一条 SQL 语句。
不要使用 Markdown 代码块。
不要解释。
除非用户明确要求修改数据或结构,否则优先生成 SELECT 查询。
""";
}
private String userPrompt(String schemaDescription, String naturalLanguagePrompt) {
return """
当前数据库结构如下:
%s
用户需求:
%s
请根据上述结构生成一条 SQLite SQL。生成结果会展示给用户检查,不会自动执行。
""".formatted(schemaDescription, naturalLanguagePrompt.trim());
}
}
@@ -0,0 +1,96 @@
package com.hypersql.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public final class AnthropicMessagesClient implements LlmClient {
private static final int MAX_TOKENS = 1200;
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public String generateSql(String systemPrompt, String userPrompt, AiSettings settings) throws AiGenerationException {
try {
String requestBody = buildRequestBody(systemPrompt, userPrompt, settings);
HttpRequest request = HttpRequest.newBuilder(endpoint(settings.baseUrl()))
.timeout(Duration.ofSeconds(settings.timeoutSeconds()))
.header("x-api-key", settings.apiKey())
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(settings.timeoutSeconds()))
.build()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new AiGenerationException("AI 调用失败,HTTP 状态码:" + response.statusCode());
}
return parseResponse(response.body());
} catch (IOException e) {
throw new AiGenerationException("AI 返回内容解析失败。", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new AiGenerationException("AI 调用已中断。", e);
} catch (IllegalArgumentException e) {
throw new AiGenerationException(e.getMessage(), e);
}
}
private String buildRequestBody(String systemPrompt, String userPrompt, AiSettings settings) throws IOException {
ObjectNode root = objectMapper.createObjectNode();
root.put("model", settings.model());
root.put("max_tokens", MAX_TOKENS);
root.put("system", systemPrompt);
ArrayNode messages = root.putArray("messages");
messages.addObject()
.put("role", "user")
.put("content", userPrompt);
return objectMapper.writeValueAsString(root);
}
private String parseResponse(String responseBody) throws IOException, AiGenerationException {
JsonNode root = objectMapper.readTree(responseBody);
JsonNode content = root.path("content");
StringBuilder text = new StringBuilder();
if (content.isArray()) {
for (JsonNode item : content) {
if ("text".equals(item.path("type").asText()) && item.path("text").isTextual()) {
text.append(item.path("text").asText()).append('\n');
}
}
}
String result = text.toString().trim();
if (result.isBlank()) {
throw new AiGenerationException("AI 没有返回可用的 SQL 内容。");
}
return result;
}
private URI endpoint(String baseUrl) {
String normalized = trimTrailingSlash(baseUrl);
if (normalized.endsWith("/v1")) {
normalized += "/messages";
} else if (!normalized.endsWith("/v1/messages")) {
normalized += "/v1/messages";
}
return URI.create(normalized);
}
private String trimTrailingSlash(String value) {
String result = value == null ? "" : value.trim();
while (result.endsWith("/")) {
result = result.substring(0, result.length() - 1);
}
return result;
}
}
@@ -0,0 +1,67 @@
package com.hypersql.ai;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class GeneratedSqlSanitizer {
private static final Pattern FENCED_BLOCK = Pattern.compile("(?is)```(?:sql)?\\s*(.*?)\\s*```");
private static final Pattern SQL_START = Pattern.compile("(?is)\\b(SELECT|WITH|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|REPLACE|PRAGMA)\\b");
private static final Pattern DESTRUCTIVE_KEYWORD = Pattern.compile("(?is)\\b(DROP|DELETE|UPDATE|INSERT|ALTER|CREATE|REPLACE)\\b");
public String sanitize(String rawContent) throws AiGenerationException {
if (rawContent == null || rawContent.isBlank()) {
throw new AiGenerationException("AI 没有返回可用的 SQL 内容。");
}
String text = extractCodeBlock(rawContent.trim()).trim();
Matcher startMatcher = SQL_START.matcher(text);
if (startMatcher.find()) {
text = text.substring(startMatcher.start()).trim();
}
text = firstStatement(text).trim();
if (text.isBlank()) {
throw new AiGenerationException("AI 生成的 SQL 为空。");
}
return text;
}
public boolean containsDestructiveKeyword(String sql) {
if (sql == null) {
return false;
}
return DESTRUCTIVE_KEYWORD.matcher(sql.toUpperCase(Locale.ROOT)).find();
}
private String extractCodeBlock(String text) {
Matcher matcher = FENCED_BLOCK.matcher(text);
if (matcher.find()) {
return matcher.group(1);
}
return text;
}
private String firstStatement(String text) {
boolean inSingleQuote = false;
boolean inDoubleQuote = false;
for (int i = 0; i < text.length(); i++) {
char current = text.charAt(i);
if (current == '\'' && !inDoubleQuote) {
if (inSingleQuote && i + 1 < text.length() && text.charAt(i + 1) == '\'') {
i++;
} else {
inSingleQuote = !inSingleQuote;
}
} else if (current == '"' && !inSingleQuote) {
if (inDoubleQuote && i + 1 < text.length() && text.charAt(i + 1) == '"') {
i++;
} else {
inDoubleQuote = !inDoubleQuote;
}
} else if (current == ';' && !inSingleQuote && !inDoubleQuote) {
return text.substring(0, i + 1);
}
}
return text;
}
}
@@ -0,0 +1,5 @@
package com.hypersql.ai;
public interface LlmClient {
String generateSql(String systemPrompt, String userPrompt, AiSettings settings) throws AiGenerationException;
}
@@ -0,0 +1,84 @@
package com.hypersql.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public final class OpenAiCompatibleClient implements LlmClient {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public String generateSql(String systemPrompt, String userPrompt, AiSettings settings) throws AiGenerationException {
try {
String requestBody = buildRequestBody(systemPrompt, userPrompt, settings);
HttpRequest request = HttpRequest.newBuilder(endpoint(settings.baseUrl()))
.timeout(Duration.ofSeconds(settings.timeoutSeconds()))
.header("Authorization", "Bearer " + settings.apiKey())
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(settings.timeoutSeconds()))
.build()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new AiGenerationException("AI 调用失败,HTTP 状态码:" + response.statusCode());
}
return parseResponse(response.body());
} catch (IOException e) {
throw new AiGenerationException("AI 返回内容解析失败。", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new AiGenerationException("AI 调用已中断。", e);
} catch (IllegalArgumentException e) {
throw new AiGenerationException(e.getMessage(), e);
}
}
private String buildRequestBody(String systemPrompt, String userPrompt, AiSettings settings) throws IOException {
ObjectNode root = objectMapper.createObjectNode();
root.put("model", settings.model());
ArrayNode messages = root.putArray("messages");
messages.addObject()
.put("role", "system")
.put("content", systemPrompt);
messages.addObject()
.put("role", "user")
.put("content", userPrompt);
return objectMapper.writeValueAsString(root);
}
private String parseResponse(String responseBody) throws IOException, AiGenerationException {
JsonNode root = objectMapper.readTree(responseBody);
JsonNode content = root.path("choices").path(0).path("message").path("content");
if (!content.isTextual() || content.asText().isBlank()) {
throw new AiGenerationException("AI 没有返回可用的 SQL 内容。");
}
return content.asText();
}
private URI endpoint(String baseUrl) {
String normalized = trimTrailingSlash(baseUrl);
if (!normalized.endsWith("/chat/completions")) {
normalized += "/chat/completions";
}
return URI.create(normalized);
}
private String trimTrailingSlash(String value) {
String result = value == null ? "" : value.trim();
while (result.endsWith("/")) {
result = result.substring(0, result.length() - 1);
}
return result;
}
}
@@ -1,5 +1,9 @@
package com.hypersql.controller;
import com.hypersql.ai.AiGenerationException;
import com.hypersql.ai.AiProvider;
import com.hypersql.ai.AiSettings;
import com.hypersql.ai.AiSqlGenerationService;
import com.hypersql.db.DatabaseConnectionManager;
import com.hypersql.db.DatabaseMetadataService;
import com.hypersql.db.SqlExecutionService;
@@ -17,6 +21,7 @@ import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.concurrent.Task;
import javafx.fxml.FXMLLoader;
import javafx.scene.Node;
import javafx.scene.Scene;
@@ -26,6 +31,7 @@ import javafx.scene.control.ComboBox;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.ListView;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TabPane;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
@@ -43,8 +49,10 @@ import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
@@ -57,6 +65,7 @@ import java.util.stream.Collectors;
public class MainController {
private static final int TABLE_PAGE_SIZE = 100;
private static final int SQL_PAGE_SIZE = 100;
private static final int DEFAULT_AI_TIMEOUT_SECONDS = 30;
private static final List<String> ALLOWED_COLUMN_TYPES = List.of(
"INTEGER", "TEXT", "REAL", "BLOB", "NUMERIC", "BOOLEAN", "DATE", "DATETIME"
);
@@ -134,12 +143,14 @@ public class MainController {
private final SqlExecutionService sqlExecutionService = new SqlExecutionService();
private final RowDataService rowDataService = new RowDataService();
private final SchemaEditService schemaEditService = new SchemaEditService();
private final AiSqlGenerationService aiSqlGenerationService = new AiSqlGenerationService();
private TableInfo currentTable;
private long currentTableTotalRows;
private long currentTablePageIndex;
private EditableQueryResult currentTableResult;
private QueryResult currentSqlResult;
private EditableQueryResult currentEditableSqlResult;
private AiSettings aiSettings;
private String currentSqlText;
private long currentSqlPageIndex;
private boolean selectingTableProgrammatically;
@@ -168,10 +179,7 @@ public class MainController {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("新建 SQLite 数据库");
fileChooser.setInitialFileName("new-database.db");
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("SQLite 数据库", "*.db", "*.sqlite", "*.sqlite3"),
new FileChooser.ExtensionFilter("所有文件", "*.*")
);
addSqliteExtensionFilters(fileChooser);
File selectedFile = fileChooser.showSaveDialog(currentWindow());
if (selectedFile == null) {
@@ -202,10 +210,7 @@ public class MainController {
private void handleOpenDatabase() {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("打开 SQLite 数据库");
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("SQLite 数据库", "*.db", "*.sqlite", "*.sqlite3"),
new FileChooser.ExtensionFilter("所有文件", "*.*")
);
addSqliteExtensionFilters(fileChooser);
File selectedFile = fileChooser.showOpenDialog(currentWindow());
if (selectedFile == null) {
@@ -224,6 +229,102 @@ public class MainController {
}
}
@FXML
private void handleBackupCurrentDatabase() {
Optional<Path> currentDatabasePath = currentDatabasePathOrAlert();
if (currentDatabasePath.isEmpty()) {
return;
}
Path sourcePath = currentDatabasePath.get();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("备份 SQLite 数据库");
fileChooser.setInitialFileName(sourcePath.getFileName() + ".backup.db");
addSqliteExtensionFilters(fileChooser);
File selectedFile = fileChooser.showSaveDialog(currentWindow());
if (selectedFile == null) {
status("已取消备份数据库");
return;
}
Path backupPath = selectedFile.toPath().toAbsolutePath().normalize();
try {
if (isSamePathOrFile(sourcePath, backupPath)) {
DialogUtils.showError("备份数据库失败", "备份目标不能是当前数据库文件本身。");
status("备份数据库失败");
return;
}
checkpointCurrentDatabase();
Files.copy(sourcePath, backupPath, StandardCopyOption.REPLACE_EXISTING);
DialogUtils.showInfo("备份数据库成功", "已备份数据库:\n" + sourcePath + "\n\n备份文件:\n" + backupPath);
status("已备份数据库");
} catch (IOException | SQLException e) {
DialogUtils.showError("备份数据库失败", e.getMessage());
status("备份数据库失败");
}
}
@FXML
private void handleRestoreCurrentDatabase() {
Optional<Path> currentDatabasePath = currentDatabasePathOrAlert();
if (currentDatabasePath.isEmpty()) {
return;
}
Path targetPath = currentDatabasePath.get();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("选择数据库备份文件");
addSqliteExtensionFilters(fileChooser);
File selectedFile = fileChooser.showOpenDialog(currentWindow());
if (selectedFile == null) {
status("已取消还原数据库");
return;
}
Path backupPath = selectedFile.toPath().toAbsolutePath().normalize();
try {
if (!Files.isRegularFile(backupPath) || !Files.isReadable(backupPath)) {
DialogUtils.showError("还原数据库失败", "备份文件不存在或不可读:\n" + backupPath);
status("还原数据库失败");
return;
}
if (isSamePathOrFile(targetPath, backupPath)) {
DialogUtils.showError("还原数据库失败", "备份文件不能是当前数据库文件本身。");
status("还原数据库失败");
return;
}
} catch (IOException e) {
DialogUtils.showError("还原数据库失败", e.getMessage());
status("还原数据库失败");
return;
}
boolean confirmed = DialogUtils.confirm(
"确认还原数据库",
"将关闭当前连接,并用以下备份文件覆盖当前数据库:\n\n"
+ backupPath
+ "\n\n当前数据库:\n"
+ targetPath
+ "\n\n此操作不可撤销,是否继续?"
);
if (!confirmed) {
status("已取消还原数据库");
return;
}
connectionManager.close();
try {
restoreFromBackup(backupPath, targetPath);
reopenDatabaseAndRefresh(targetPath);
DialogUtils.showInfo("还原数据库成功", "已从备份还原数据库:\n" + backupPath + "\n\n当前数据库:\n" + targetPath);
status("已还原数据库");
} catch (IOException | SQLException e) {
handleRestoreFailure(targetPath, e);
}
}
@FXML
private void handleDeleteCurrentDatabase() {
Optional<Path> currentDatabasePath = connectionManager.getCurrentDatabasePath();
@@ -469,6 +570,50 @@ public class MainController {
}
}
@FXML
private void handleAiSettings() {
Optional<AiSettings> settings = showAiSettingsDialog();
if (settings.isEmpty()) {
return;
}
aiSettings = settings.get();
status("AI 设置已更新");
}
@FXML
private void handleGenerateSql() {
if (!ensureConnected()) {
return;
}
if (aiSettings == null || !aiSettings.hasApiKey()) {
DialogUtils.showError("未设置 AI", "请先在“执行 > AI 设置”中输入 API Key。");
status("未设置 AI");
return;
}
Optional<String> prompt = showAiPromptDialog();
if (prompt.isEmpty()) {
return;
}
AiSettings settingsSnapshot = aiSettings;
Connection connection = connection();
String naturalLanguagePrompt = prompt.get();
Task<String> task = new Task<>() {
@Override
protected String call() throws AiGenerationException {
return aiSqlGenerationService.generateSql(connection, settingsSnapshot, naturalLanguagePrompt);
}
};
sqlMessageLabel.setText("AI 正在生成 SQL...");
status("AI 正在生成 SQL");
task.setOnSucceeded(_ -> applyGeneratedSql(task.getValue()));
task.setOnFailed(_ -> handleAiGenerationFailure(task.getException()));
Thread thread = new Thread(task, "hypersql-ai-sql-generation");
thread.setDaemon(true);
thread.start();
}
@FXML
private void handleExecuteSql() {
if (!ensureConnected()) {
@@ -836,6 +981,139 @@ public class MainController {
}
}
private Optional<AiSettings> showAiSettingsDialog() {
Dialog<AiSettings> dialog = new Dialog<>();
dialog.setTitle("AI 设置");
dialog.initOwner(currentWindow());
dialog.getDialogPane().getButtonTypes().addAll(javafx.scene.control.ButtonType.OK, javafx.scene.control.ButtonType.CANCEL);
ComboBox<AiProvider> providerComboBox = new ComboBox<>(FXCollections.observableArrayList(AiProvider.values()));
PasswordField apiKeyField = new PasswordField();
TextField baseUrlField = new TextField();
TextField modelField = new TextField();
TextField timeoutField = new TextField(String.valueOf(DEFAULT_AI_TIMEOUT_SECONDS));
AiSettings currentSettings = aiSettings;
if (currentSettings == null) {
providerComboBox.getSelectionModel().select(AiProvider.OPENAI_COMPATIBLE);
baseUrlField.setText(defaultBaseUrl(AiProvider.OPENAI_COMPATIBLE));
modelField.setText("gpt-4o-mini");
} else {
providerComboBox.getSelectionModel().select(currentSettings.provider());
apiKeyField.setText(currentSettings.apiKey());
baseUrlField.setText(currentSettings.baseUrl());
modelField.setText(currentSettings.model());
timeoutField.setText(String.valueOf(currentSettings.timeoutSeconds()));
}
providerComboBox.valueProperty().addListener((_, _, provider) -> {
if (provider == null) {
return;
}
baseUrlField.setText(defaultBaseUrl(provider));
modelField.setText(defaultModel(provider));
});
GridPane gridPane = new GridPane();
gridPane.setHgap(8);
gridPane.setVgap(8);
gridPane.add(new Label("Provider"), 0, 0);
gridPane.add(providerComboBox, 1, 0);
gridPane.add(new Label("API Key"), 0, 1);
gridPane.add(apiKeyField, 1, 1);
gridPane.add(new Label("Base URL"), 0, 2);
gridPane.add(baseUrlField, 1, 2);
gridPane.add(new Label("Model"), 0, 3);
gridPane.add(modelField, 1, 3);
gridPane.add(new Label("超时秒数"), 0, 4);
gridPane.add(timeoutField, 1, 4);
dialog.getDialogPane().setContent(gridPane);
dialog.setResultConverter(buttonType -> {
if (!javafx.scene.control.ButtonType.OK.equals(buttonType)) {
return null;
}
try {
int timeoutSeconds = Integer.parseInt(timeoutField.getText().trim());
return new AiSettings(
providerComboBox.getValue(),
apiKeyField.getText(),
baseUrlField.getText(),
modelField.getText(),
timeoutSeconds
);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("超时时间必须是数字。");
}
});
try {
return dialog.showAndWait();
} catch (IllegalArgumentException e) {
DialogUtils.showError("AI 设置无效", e.getMessage());
return Optional.empty();
}
}
private Optional<String> showAiPromptDialog() {
Dialog<String> dialog = new Dialog<>();
dialog.setTitle("AI 生成 SQL");
dialog.initOwner(currentWindow());
dialog.getDialogPane().getButtonTypes().addAll(javafx.scene.control.ButtonType.OK, javafx.scene.control.ButtonType.CANCEL);
TextArea promptArea = new TextArea();
promptArea.setPromptText("例如:查询每个用户的订单数量");
promptArea.setPrefRowCount(6);
promptArea.setWrapText(true);
dialog.getDialogPane().setContent(promptArea);
dialog.setResultConverter(buttonType -> {
if (!javafx.scene.control.ButtonType.OK.equals(buttonType)) {
return null;
}
return promptArea.getText() == null ? "" : promptArea.getText().trim();
});
Optional<String> result = dialog.showAndWait();
if (result.isPresent() && result.get().isBlank()) {
DialogUtils.showError("需求不能为空", "请输入希望 AI 生成的 SQL 需求。");
return Optional.empty();
}
return result;
}
private void applyGeneratedSql(String generatedSql) {
sqlTextArea.setText(generatedSql);
mainTabPane.getSelectionModel().select(2);
String message = "AI 已生成 SQL,请检查后再手动执行。";
if (aiSqlGenerationService.containsDestructiveKeyword(generatedSql)) {
message += " 检测到可能修改数据或结构的关键词,请特别谨慎。";
}
sqlMessageLabel.setText(message);
status("AI 已生成 SQL");
}
private void handleAiGenerationFailure(Throwable throwable) {
String message = throwable == null ? "AI 生成 SQL 失败。" : throwable.getMessage();
if (message == null || message.isBlank()) {
message = "AI 生成 SQL 失败。";
}
sqlMessageLabel.setText(message);
DialogUtils.showError("AI 生成 SQL 失败", message);
status("AI 生成 SQL 失败");
}
private String defaultBaseUrl(AiProvider provider) {
return switch (provider) {
case OPENAI_COMPATIBLE -> "https://api.openai.com/v1";
case ANTHROPIC -> "https://api.anthropic.com";
};
}
private String defaultModel(AiProvider provider) {
return switch (provider) {
case OPENAI_COMPATIBLE -> "gpt-4o-mini";
case ANTHROPIC -> "claude-opus-4-7";
};
}
private Optional<Map<String, Object>> showRowInputDialog(List<ColumnInfo> columns) {
Dialog<Map<String, Object>> dialog = new Dialog<>();
dialog.setTitle("新增数据行");
@@ -1140,6 +1418,73 @@ public class MainController {
return value == null ? "NULL" : String.valueOf(value);
}
private Optional<Path> currentDatabasePathOrAlert() {
Optional<Path> currentDatabasePath = connectionManager.getCurrentDatabasePath();
if (currentDatabasePath.isPresent()) {
return currentDatabasePath;
}
DialogUtils.showError("未连接数据库", "请先打开或新建一个 SQLite 数据库文件。");
status("未连接数据库");
return Optional.empty();
}
private void addSqliteExtensionFilters(FileChooser fileChooser) {
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("SQLite 数据库", "*.db", "*.sqlite", "*.sqlite3"),
new FileChooser.ExtensionFilter("所有文件", "*.*")
);
}
private boolean isSamePathOrFile(Path first, Path second) throws IOException {
Path normalizedFirst = first.toAbsolutePath().normalize();
Path normalizedSecond = second.toAbsolutePath().normalize();
if (normalizedFirst.equals(normalizedSecond)) {
return true;
}
return Files.exists(normalizedFirst)
&& Files.exists(normalizedSecond)
&& Files.isSameFile(normalizedFirst, normalizedSecond);
}
private void checkpointCurrentDatabase() throws SQLException {
try (Statement statement = connection().createStatement()) {
statement.execute("PRAGMA wal_checkpoint(FULL)");
}
}
private void restoreFromBackup(Path backupPath, Path targetPath) throws IOException {
Path parent = targetPath.getParent();
Path tempPath = Files.createTempFile(parent, targetPath.getFileName().toString(), ".restore.tmp");
try {
Files.copy(backupPath, tempPath, StandardCopyOption.REPLACE_EXISTING);
Files.move(tempPath, targetPath, StandardCopyOption.REPLACE_EXISTING);
} finally {
Files.deleteIfExists(tempPath);
}
}
private void reopenDatabaseAndRefresh(Path databasePath) throws SQLException {
connectionManager.open(databasePath);
databasePathLabel.setText(databasePath.toString());
refreshTables();
}
private void handleRestoreFailure(Path targetPath, Exception cause) {
try {
if (Files.exists(targetPath)) {
reopenDatabaseAndRefresh(targetPath);
DialogUtils.showError("还原数据库失败", "还原失败,已重新打开当前数据库:\n" + cause.getMessage());
status("还原数据库失败");
return;
}
} catch (SQLException e) {
cause.addSuppressed(e);
}
updateDisconnectedUi();
DialogUtils.showError("还原数据库失败", "还原失败,且当前数据库无法重新打开:\n" + cause.getMessage());
status("还原数据库失败");
}
private boolean ensureConnected() {
if (connectionManager.isConnected()) {
return true;
@@ -0,0 +1,84 @@
package com.hypersql.db;
import com.hypersql.model.ColumnInfo;
import com.hypersql.model.TableInfo;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
public final class SchemaDescriptionService {
private final DatabaseMetadataService metadataService = new DatabaseMetadataService();
public String describe(Connection connection) throws SQLException {
List<TableInfo> objects = metadataService.listTables(connection);
if (objects.isEmpty()) {
return "当前数据库没有用户表或视图。";
}
StringBuilder description = new StringBuilder();
for (TableInfo object : objects) {
if ("view".equalsIgnoreCase(object.type())) {
appendView(connection, description, object);
} else {
appendTable(connection, description, object);
}
description.append('\n');
}
return description.toString().trim();
}
private void appendTable(Connection connection, StringBuilder description, TableInfo table) throws SQLException {
description.append("表:").append(table.name()).append('\n');
List<ColumnInfo> columns = metadataService.listColumns(connection, table.name());
for (ColumnInfo column : columns) {
description.append(" - ")
.append(column.name())
.append(" ")
.append(column.type() == null || column.type().isBlank() ? "未声明类型" : column.type());
if (column.primaryKey()) {
description.append(" PRIMARY KEY");
}
if (column.notNull()) {
description.append(" NOT NULL");
}
if (column.defaultValue() != null) {
description.append(" DEFAULT ").append(column.defaultValue());
}
description.append('\n');
}
}
private void appendView(Connection connection, StringBuilder description, TableInfo view) throws SQLException {
description.append("视图:").append(view.name()).append('\n');
String sql = viewDefinition(connection, view.name());
if (sql != null && !sql.isBlank()) {
description.append(" 定义:").append(sql).append('\n');
}
List<ColumnInfo> columns = metadataService.listColumns(connection, view.name());
for (ColumnInfo column : columns) {
description.append(" - ")
.append(column.name())
.append(" ")
.append(column.type() == null || column.type().isBlank() ? "未声明类型" : column.type())
.append('\n');
}
}
private String viewDefinition(Connection connection, String viewName) throws SQLException {
String sql = """
SELECT sql
FROM sqlite_master
WHERE type = 'view'
AND name = ?
""";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, viewName);
try (ResultSet resultSet = statement.executeQuery()) {
return resultSet.next() ? resultSet.getString("sql") : null;
}
}
}
}
+19 -6
View File
@@ -8,6 +8,7 @@
<?import javafx.scene.control.MenuBar?>
<?import javafx.scene.control.MenuItem?>
<?import javafx.scene.control.Separator?>
<?import javafx.scene.control.SeparatorMenuItem?>
<?import javafx.scene.control.SplitPane?>
<?import javafx.scene.control.Tab?>
<?import javafx.scene.control.TabPane?>
@@ -26,27 +27,33 @@
<Menu text="文件">
<MenuItem onAction="#handleCreateDatabase" text="新建数据库..." />
<MenuItem onAction="#handleOpenDatabase" text="打开数据库..." />
<MenuItem onAction="#handleDeleteCurrentDatabase" text="删除当前数据库文件..." />
<MenuItem onAction="#handleCloseDatabase" text="关闭数据库" />
<SeparatorMenuItem />
<MenuItem onAction="#handleBackupCurrentDatabase" text="备份当前数据库..." />
<MenuItem onAction="#handleRestoreCurrentDatabase" text="从备份还原当前数据库..." />
<SeparatorMenuItem />
<MenuItem onAction="#handleDeleteCurrentDatabase" text="删除当前数据库文件..." />
<SeparatorMenuItem />
<MenuItem onAction="#handleExit" text="退出" />
</Menu>
<Menu text="表">
<MenuItem onAction="#handleCreateTable" text="创建表..." />
<MenuItem onAction="#handleDeleteSelectedTable" text="删除选中表..." />
<SeparatorMenuItem />
<MenuItem onAction="#handleRefreshTables" text="刷新表列表" />
</Menu>
<Menu text="执行">
<MenuItem onAction="#handleAiSettings" text="AI 设置..." />
<MenuItem onAction="#handleGenerateSql" text="AI 生成 SQL..." />
<SeparatorMenuItem />
<MenuItem onAction="#handleExecuteSql" text="执行 SQL" />
</Menu>
</MenuBar>
<ToolBar>
<ToolBar styleClass="app-toolbar">
<Button onAction="#handleCreateDatabase" text="新建数据库" />
<Button onAction="#handleOpenDatabase" text="打开数据库" />
<Button onAction="#handleDeleteCurrentDatabase" text="删除数据库" />
<Button onAction="#handleCloseDatabase" text="关闭数据库" />
<Button onAction="#handleBackupCurrentDatabase" text="备份数据库" />
<Separator />
<Button onAction="#handleCreateTable" text="创建表" />
<Button onAction="#handleDeleteSelectedTable" text="删除表" />
<Button onAction="#handleRefreshTables" text="刷新表列表" />
<Button onAction="#handleExecuteSql" text="执行 SQL" />
</ToolBar>
@@ -63,6 +70,11 @@
<Label styleClass="section-title" text="当前数据库" />
<Label fx:id="databasePathLabel" maxWidth="Infinity" text="未连接数据库" wrapText="true" />
<Label styleClass="section-title" text="表 / 视图列表" />
<HBox spacing="6" styleClass="sidebar-actions">
<Button onAction="#handleCreateTable" text="创建" />
<Button onAction="#handleDeleteSelectedTable" text="删除" />
<Button onAction="#handleRefreshTables" text="刷新" />
</HBox>
<ListView fx:id="tableListView" VBox.vgrow="ALWAYS" />
</VBox>
<TabPane fx:id="mainTabPane">
@@ -118,6 +130,7 @@
<Label styleClass="section-title" text="SQL 输入" />
<TextArea fx:id="sqlTextArea" prefRowCount="7" promptText="请输入 SQL 语句" VBox.vgrow="NEVER" />
<HBox alignment="CENTER_LEFT" spacing="8">
<Button onAction="#handleGenerateSql" text="AI 生成 SQL" />
<Button onAction="#handleExecuteSql" text="执行 SQL" />
<Label fx:id="sqlMessageLabel" text="等待执行 SQL" HBox.hgrow="ALWAYS" />
</HBox>
+10
View File
@@ -7,6 +7,16 @@
-fx-background-color: #f7f8fa;
}
.app-toolbar {
-fx-background-color: #fafafa;
-fx-border-color: #dcdcdc transparent #dcdcdc transparent;
-fx-padding: 6 8 6 8;
}
.sidebar-actions {
-fx-padding: 0 0 2 0;
}
.section-title {
-fx-font-weight: bold;
}