diff --git a/feasibility_analysis.md b/feasibility_analysis.md index 8148716..aa76675 100644 --- a/feasibility_analysis.md +++ b/feasibility_analysis.md @@ -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 Claude,API 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、SQLite 和 SQLite JDBC 均可免费使用。 + - Java、JavaFX、Maven、SQLite、SQLite 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 生成。 \ No newline at end of file +HyperSql 的技术方案成熟,开发成本低,核心功能和主要扩展功能已经形成可运行版本,适合作为软件工程课程大作业项目继续完善和演示。当前项目已经具备数据库浏览、SQL 执行、数据编辑、结构编辑、备份恢复和 AI 辅助 SQL 生成等功能,整体可行性较高。 diff --git a/pom.xml b/pom.xml index 3520dee..c3ea66a 100644 --- a/pom.xml +++ b/pom.xml @@ -37,6 +37,11 @@ slf4j-simple 2.0.17 + + com.fasterxml.jackson.core + jackson-databind + 2.18.3 + diff --git a/requirements_analysis.md b/requirements_analysis.md index 09c1a1c..0e2afa9 100644 --- a/requirements_analysis.md +++ b/requirements_analysis.md @@ -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 生成作为项目亮点。 \ No newline at end of file +当前 HyperSql 已经从“可浏览、可执行 SQL、可编辑数据、可管理表结构”进一步发展为“可较完整管理 SQLite 数据库,并提供 AI 辅助 SQL 生成”的轻量级数据库管理工具。后续可以围绕测试、演示体验、SQL 历史记录和更细致的界面美化继续迭代。 diff --git a/software_process_model.md b/software_process_model.md index 1f1cc98..3990966 100644 --- a/software_process_model.md +++ b/software_process_model.md @@ -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 生成等扩展功能。 \ No newline at end of file +后续开发可继续沿用增量迭代方式,重点进行功能测试、异常场景验证、界面细节优化和最终课程演示材料整理。 diff --git a/src/main/java/com/hypersql/ai/AiGenerationException.java b/src/main/java/com/hypersql/ai/AiGenerationException.java new file mode 100644 index 0000000..8e9b253 --- /dev/null +++ b/src/main/java/com/hypersql/ai/AiGenerationException.java @@ -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); + } +} diff --git a/src/main/java/com/hypersql/ai/AiProvider.java b/src/main/java/com/hypersql/ai/AiProvider.java new file mode 100644 index 0000000..0cccf41 --- /dev/null +++ b/src/main/java/com/hypersql/ai/AiProvider.java @@ -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; + } +} diff --git a/src/main/java/com/hypersql/ai/AiSettings.java b/src/main/java/com/hypersql/ai/AiSettings.java new file mode 100644 index 0000000..9897c3c --- /dev/null +++ b/src/main/java/com/hypersql/ai/AiSettings.java @@ -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(); + } +} diff --git a/src/main/java/com/hypersql/ai/AiSqlGenerationService.java b/src/main/java/com/hypersql/ai/AiSqlGenerationService.java new file mode 100644 index 0000000..225256b --- /dev/null +++ b/src/main/java/com/hypersql/ai/AiSqlGenerationService.java @@ -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()); + } +} diff --git a/src/main/java/com/hypersql/ai/AnthropicMessagesClient.java b/src/main/java/com/hypersql/ai/AnthropicMessagesClient.java new file mode 100644 index 0000000..b305773 --- /dev/null +++ b/src/main/java/com/hypersql/ai/AnthropicMessagesClient.java @@ -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 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; + } +} diff --git a/src/main/java/com/hypersql/ai/GeneratedSqlSanitizer.java b/src/main/java/com/hypersql/ai/GeneratedSqlSanitizer.java new file mode 100644 index 0000000..3313ec2 --- /dev/null +++ b/src/main/java/com/hypersql/ai/GeneratedSqlSanitizer.java @@ -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; + } +} diff --git a/src/main/java/com/hypersql/ai/LlmClient.java b/src/main/java/com/hypersql/ai/LlmClient.java new file mode 100644 index 0000000..3926a80 --- /dev/null +++ b/src/main/java/com/hypersql/ai/LlmClient.java @@ -0,0 +1,5 @@ +package com.hypersql.ai; + +public interface LlmClient { + String generateSql(String systemPrompt, String userPrompt, AiSettings settings) throws AiGenerationException; +} diff --git a/src/main/java/com/hypersql/ai/OpenAiCompatibleClient.java b/src/main/java/com/hypersql/ai/OpenAiCompatibleClient.java new file mode 100644 index 0000000..2d3534b --- /dev/null +++ b/src/main/java/com/hypersql/ai/OpenAiCompatibleClient.java @@ -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 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; + } +} diff --git a/src/main/java/com/hypersql/controller/MainController.java b/src/main/java/com/hypersql/controller/MainController.java index cd4e859..8864d7f 100644 --- a/src/main/java/com/hypersql/controller/MainController.java +++ b/src/main/java/com/hypersql/controller/MainController.java @@ -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 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 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 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 currentDatabasePath = connectionManager.getCurrentDatabasePath(); @@ -469,6 +570,50 @@ public class MainController { } } + @FXML + private void handleAiSettings() { + Optional 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 prompt = showAiPromptDialog(); + if (prompt.isEmpty()) { + return; + } + + AiSettings settingsSnapshot = aiSettings; + Connection connection = connection(); + String naturalLanguagePrompt = prompt.get(); + Task 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 showAiSettingsDialog() { + Dialog dialog = new Dialog<>(); + dialog.setTitle("AI 设置"); + dialog.initOwner(currentWindow()); + dialog.getDialogPane().getButtonTypes().addAll(javafx.scene.control.ButtonType.OK, javafx.scene.control.ButtonType.CANCEL); + + ComboBox 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 showAiPromptDialog() { + Dialog 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 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> showRowInputDialog(List columns) { Dialog> dialog = new Dialog<>(); dialog.setTitle("新增数据行"); @@ -1140,6 +1418,73 @@ public class MainController { return value == null ? "NULL" : String.valueOf(value); } + private Optional currentDatabasePathOrAlert() { + Optional 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; diff --git a/src/main/java/com/hypersql/db/SchemaDescriptionService.java b/src/main/java/com/hypersql/db/SchemaDescriptionService.java new file mode 100644 index 0000000..ea98c0b --- /dev/null +++ b/src/main/java/com/hypersql/db/SchemaDescriptionService.java @@ -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 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 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 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; + } + } + } +} diff --git a/src/main/resources/com/hypersql/main-view.fxml b/src/main/resources/com/hypersql/main-view.fxml index 67ce22f..a303f26 100644 --- a/src/main/resources/com/hypersql/main-view.fxml +++ b/src/main/resources/com/hypersql/main-view.fxml @@ -8,6 +8,7 @@ + @@ -26,27 +27,33 @@ - + + + + + + + + + + - +